Email Templates (#1375)
- Emails are now processed from Jinja2 templates found in `charts/email-templates`, to support easier updates via helm chart in the future. - The available templates are: `invite`, `password_reset`, `validate` and `failed_bg_job`. - Each template can be text only or also include HTML. The format of the template is: ``` subject ~~~ <html content> ~~~ text ``` - A new `support_email` field is also added to the email block in values.yaml Invite Template: - Currently, only the invite template includes an HTML version, other templates are text only. - The same template is used for new and existing users, with slightly different text if adding user to an existing org. - If user is invited by the superadmin, the invited by field is not included, otherwise it also includes 'You have been invited by X to join Y'
This commit is contained in:
parent
7d985a9688
commit
b23eed5003
@ -7,12 +7,16 @@ import ssl
|
||||
from typing import Optional, Union
|
||||
|
||||
from email.message import EmailMessage
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from fastapi import HTTPException
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .models import CreateReplicaJob, DeleteReplicaJob, Organization
|
||||
from .models import CreateReplicaJob, DeleteReplicaJob, Organization, InvitePending
|
||||
from .utils import is_bool
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
# pylint: disable=too-few-public-methods, too-many-instance-attributes
|
||||
class EmailSender:
|
||||
"""SMTP Email Sender"""
|
||||
|
||||
@ -22,31 +26,57 @@ class EmailSender:
|
||||
smtp_server: Optional[str]
|
||||
smtp_port: int
|
||||
smtp_use_tls: bool
|
||||
support_email: str
|
||||
templates: Jinja2Templates
|
||||
|
||||
def __init__(self):
|
||||
self.sender = os.environ.get("EMAIL_SENDER") or "Browsertrix admin"
|
||||
self.password = os.environ.get("EMAIL_PASSWORD") or ""
|
||||
self.reply_to = os.environ.get("EMAIL_REPLY_TO") or self.sender
|
||||
self.support_email = os.environ.get("EMAIL_SUPPORT") or self.reply_to
|
||||
self.smtp_server = os.environ.get("EMAIL_SMTP_HOST")
|
||||
self.smtp_port = int(os.environ.get("EMAIL_SMTP_PORT", 587))
|
||||
self.smtp_use_tls = is_bool(os.environ.get("EMAIL_SMTP_USE_TLS"))
|
||||
|
||||
self.default_origin = os.environ.get("APP_ORIGIN")
|
||||
|
||||
def _send_encrypted(self, receiver, subject, message) -> None:
|
||||
"""Send Encrypted SMTP Message"""
|
||||
print(message, flush=True)
|
||||
self.templates = Jinja2Templates(
|
||||
directory=os.path.join(os.path.dirname(__file__), "email-templates")
|
||||
)
|
||||
|
||||
def _send_encrypted(self, receiver: str, name: str, **kwargs) -> None:
|
||||
"""Send Encrypted SMTP Message using given template name"""
|
||||
|
||||
full = self.templates.env.get_template(name).render(kwargs)
|
||||
parts = full.split("~~~")
|
||||
if len(parts) == 3:
|
||||
subject, html, text = parts
|
||||
elif len(parts) == 2:
|
||||
subject, text = parts
|
||||
html = None
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="invalid_email_template")
|
||||
|
||||
print(full, flush=True)
|
||||
|
||||
if not self.smtp_server:
|
||||
print("Email: No SMTP Server, not sending", flush=True)
|
||||
return
|
||||
|
||||
msg: Union[EmailMessage, MIMEMultipart]
|
||||
|
||||
if html:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg.attach(MIMEText(text.strip(), "plain"))
|
||||
msg.attach(MIMEText(html.strip(), "html"))
|
||||
else:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(text.strip())
|
||||
|
||||
msg["Subject"] = subject.strip()
|
||||
msg["From"] = self.reply_to
|
||||
msg["To"] = receiver
|
||||
msg["Reply-To"] = msg["From"]
|
||||
msg.set_content(message)
|
||||
|
||||
context = ssl.create_default_context()
|
||||
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
|
||||
@ -76,38 +106,26 @@ class EmailSender:
|
||||
|
||||
origin = self.get_origin(headers)
|
||||
|
||||
message = f"""
|
||||
Please verify your registration for Browsertrix Cloud for {receiver_email}
|
||||
|
||||
You can verify by clicking here: {origin}/verify?token={token}
|
||||
|
||||
The verification token is: {token}"""
|
||||
|
||||
self._send_encrypted(
|
||||
receiver_email,
|
||||
"Welcome to Browsertrix Cloud, Verify your Registration",
|
||||
message,
|
||||
)
|
||||
self._send_encrypted(receiver_email, "validate", origin=origin, token=token)
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def send_new_user_invite(
|
||||
self, receiver_email, sender, org_name, token, headers=None
|
||||
):
|
||||
def send_new_user_invite(self, invite: InvitePending, org_name: str, headers=None):
|
||||
"""Send email to invite new user"""
|
||||
|
||||
origin = self.get_origin(headers)
|
||||
|
||||
message = f"""
|
||||
You are invited by {sender} to join their organization, "{org_name}" on Browsertrix Cloud!
|
||||
receiver_email = invite.email or ""
|
||||
|
||||
You can join by clicking here: {origin}/join/{token}?email={receiver_email}
|
||||
|
||||
The invite token is: {token}"""
|
||||
invite_url = f"{origin}/join/{invite.id}?email={receiver_email}"
|
||||
|
||||
self._send_encrypted(
|
||||
receiver_email,
|
||||
f'You\'ve been invited to join "{org_name}" on Browsertrix Cloud',
|
||||
message,
|
||||
"invite",
|
||||
invite_url=invite_url,
|
||||
is_new=True,
|
||||
sender=invite.inviterEmail if not invite.fromSuperuser else "",
|
||||
org_name=org_name,
|
||||
support_email=self.support_email,
|
||||
)
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
@ -117,29 +135,28 @@ The invite token is: {token}"""
|
||||
"""Send email to invite new user"""
|
||||
origin = self.get_origin(headers)
|
||||
|
||||
message = f"""
|
||||
You are invited by {sender} to join their organization, "{org_name}" on Browsertrix Cloud!
|
||||
|
||||
You can join by clicking here: {origin}/invite/accept/{token}?email={receiver_email}
|
||||
|
||||
The invite token is: {token}"""
|
||||
invite_url = f"{origin}/invite/accept/{token}?email={receiver_email}"
|
||||
|
||||
self._send_encrypted(
|
||||
receiver_email,
|
||||
f'You\'ve been invited to join "{org_name}" on Browsertrix Cloud',
|
||||
message,
|
||||
"invite",
|
||||
invite_url=invite_url,
|
||||
is_new=False,
|
||||
sender=sender,
|
||||
org_name=org_name,
|
||||
)
|
||||
|
||||
def send_user_forgot_password(self, receiver_email, token, headers=None):
|
||||
"""Send password reset email with token"""
|
||||
origin = self.get_origin(headers)
|
||||
|
||||
message = f"""
|
||||
We received your password reset request. Please click here: {origin}/reset-password?token={token}
|
||||
to create a new password
|
||||
"""
|
||||
|
||||
self._send_encrypted(receiver_email, "Password Reset", message)
|
||||
self._send_encrypted(
|
||||
receiver_email,
|
||||
"password_reset",
|
||||
origin=origin,
|
||||
token=token,
|
||||
support_email=self.support_email,
|
||||
)
|
||||
|
||||
def send_background_job_failed(
|
||||
self,
|
||||
@ -149,21 +166,6 @@ to create a new password
|
||||
receiver_email: str,
|
||||
):
|
||||
"""Send background job failed email to superuser"""
|
||||
message = f"""
|
||||
Failed Background Job
|
||||
---------------------
|
||||
|
||||
Organization: {org.name} ({job.oid})
|
||||
Job type: {job.type}
|
||||
|
||||
Job ID: {job.id}
|
||||
Started: {job.started.isoformat(sep=" ", timespec="seconds")}Z
|
||||
Finished: {finished.isoformat(sep=" ", timespec="seconds")}Z
|
||||
|
||||
Object type: {job.object_type}
|
||||
Object ID: {job.object_id}
|
||||
File path: {job.file_path}
|
||||
Replica storage name: {job.replica_storage.name}
|
||||
"""
|
||||
|
||||
self._send_encrypted(receiver_email, "Failed Background Job", message)
|
||||
self._send_encrypted(
|
||||
receiver_email, "failed_bg_job", job=job, org=org, finished=finished
|
||||
)
|
||||
|
@ -11,7 +11,8 @@ from pymongo.errors import AutoReconnect
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .pagination import DEFAULT_PAGE_SIZE
|
||||
from .models import UserRole, InvitePending, InviteRequest
|
||||
from .models import UserRole, InvitePending, InviteRequest, User
|
||||
from .users import UserManager
|
||||
from .utils import is_bool
|
||||
|
||||
|
||||
@ -70,13 +71,7 @@ class InviteOps:
|
||||
|
||||
await self.invites.insert_one(new_user_invite.to_dict())
|
||||
|
||||
self.email.send_new_user_invite(
|
||||
new_user_invite.email,
|
||||
new_user_invite.inviterEmail,
|
||||
org_name,
|
||||
new_user_invite.id,
|
||||
headers,
|
||||
)
|
||||
self.email.send_new_user_invite(new_user_invite, org_name, headers)
|
||||
|
||||
async def get_valid_invite(self, invite_token: UUID, email):
|
||||
"""Retrieve a valid invite data from db, or throw if invalid"""
|
||||
@ -118,8 +113,8 @@ class InviteOps:
|
||||
async def invite_user(
|
||||
self,
|
||||
invite: InviteRequest,
|
||||
user,
|
||||
user_manager,
|
||||
user: User,
|
||||
user_manager: UserManager,
|
||||
org=None,
|
||||
allow_existing=False,
|
||||
headers: Optional[dict] = None,
|
||||
@ -148,6 +143,7 @@ class InviteOps:
|
||||
# URL decode email address just in case
|
||||
email=urllib.parse.unquote(invite.email),
|
||||
inviterEmail=user.email,
|
||||
fromSuperuser=user.is_superuser,
|
||||
)
|
||||
|
||||
other_user = await user_manager.get_by_email(invite.email)
|
||||
|
@ -48,6 +48,7 @@ class InvitePending(BaseMongoModel):
|
||||
|
||||
created: datetime
|
||||
inviterEmail: str
|
||||
fromSuperuser: Optional[bool]
|
||||
oid: Optional[UUID]
|
||||
role: Optional[UserRole] = UserRole.VIEWER
|
||||
email: Optional[str]
|
||||
|
16
chart/email-templates/failed_bg_job
Normal file
16
chart/email-templates/failed_bg_job
Normal file
@ -0,0 +1,16 @@
|
||||
Failed Background Job
|
||||
~~~
|
||||
Failed Background Job
|
||||
---------------------
|
||||
|
||||
Organization: {{ org.name }} ({{ job.oid }})
|
||||
Job type: {{ job.type }}
|
||||
|
||||
Job ID: {{ job.id }}
|
||||
Started: {{ job.started.isoformat(sep=" ", timespec="seconds") }}Z
|
||||
Finished: {{ finished.isoformat(sep=" ", timespec="seconds") }}Z
|
||||
|
||||
Object type: {{ job.object_type }}
|
||||
Object ID: {{ job.object_id }}
|
||||
File path: {{ job.file_path }}
|
||||
Replica storage name: {{ job.replica_storage.name }}
|
64
chart/email-templates/invite
Normal file
64
chart/email-templates/invite
Normal file
@ -0,0 +1,64 @@
|
||||
Welcome to Browsertrix Cloud!
|
||||
~~~
|
||||
<html>
|
||||
<body>
|
||||
<p>Hello!</p>
|
||||
|
||||
<p>Welcome to Browsertrix Cloud!</p>
|
||||
|
||||
{% if sender %}
|
||||
<p>You have been invited by {{ sender }} to join "{{ org_name }}" on Browsertrix Cloud!
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if is_new %}
|
||||
<p>You can now set up your account using the link below.</p>
|
||||
|
||||
<p style="font-weight: bold; padding: 12px; background-color: lightgrey"><a href="{{ invite_url }}">Click here to create an account.</a></p>
|
||||
{% else %}
|
||||
<p style="font-weight: bold; padding: 12px; background-color: lightgrey"><a href="{{ invite_url }}">Click here to accept this invite.</a></p>
|
||||
{% endif %}
|
||||
|
||||
<p>When you first access your account, you’ll be directed to your Dashboard. It contains information you may want to view frequently including: Storage Usage, Crawling Info, Collections, and Monthly Usage History. From there, you can click <i>+ Create New</i> to create your first Crawl Workflow!
|
||||
|
||||
|
||||
<p>For more info, check out the <b><a href="https://docs.browsertrix.cloud/user-guide/">Browsertrix Cloud User Guide</a></b></p>
|
||||
|
||||
|
||||
<p>
|
||||
We want you to get the most from your Browsertrix Cloud experience!
|
||||
</p>
|
||||
|
||||
<p>Let us know if you need any questions or feedback.</p>
|
||||
You can connect with our team at <a href="mailto:{{ support_email }}">{{ support_email }}</a></p>
|
||||
</p>
|
||||
|
||||
<p><i>The Webrecorder Team</i></p>
|
||||
</body>
|
||||
</html>
|
||||
~~~
|
||||
Hello!
|
||||
|
||||
Welcome to Browsertrix Cloud!
|
||||
|
||||
{% if sender %}
|
||||
You have been invited by {{ sender }} to join their organization, "{{ org_name }}" on Browsertrix Cloud!
|
||||
|
||||
{% else %}
|
||||
|
||||
You can join by clicking here: {{ invite_url }}
|
||||
{% endif %}
|
||||
|
||||
When you first access your account, you’ll be directed to your Dashboard. It contains information you may want to view frequently including: Storage Usage, Crawling Info, Collections, and Monthly Usage History.
|
||||
|
||||
For more info, check out Browsertrix Cloud User Guide at: https://docs.browsertrix.cloud/user-guide/
|
||||
|
||||
|
||||
If you ever need to reset your password, go here: {{ origin }}/log-in/forgot-password
|
||||
|
||||
|
||||
We want you to get the most from your Browsertrix Cloud experience. Let us know if you need any questions or feedback.
|
||||
You can connect with our team at {{ support_email }}.
|
||||
|
||||
|
||||
|
10
chart/email-templates/password_reset
Normal file
10
chart/email-templates/password_reset
Normal file
@ -0,0 +1,10 @@
|
||||
Password Reset
|
||||
~~~
|
||||
We received your password reset request.
|
||||
|
||||
If you were locked out of your account, this request is sent automatically.
|
||||
|
||||
If you did not attempt to log in and did not request this email, please let us know immediately at:
|
||||
{{ support_email }}
|
||||
|
||||
Please click here: {{ origin }}/reset-password?token={{ token }} to create a new password.
|
7
chart/email-templates/validate
Normal file
7
chart/email-templates/validate
Normal file
@ -0,0 +1,7 @@
|
||||
Welcome to Browsertrix Cloud, Verify your Registration.
|
||||
~~~
|
||||
Please verify your registration for Browsertrix Cloud for {{ receiver_email }}
|
||||
|
||||
You can verify by clicking here: {{ origin }}/verify?token={{ token }}
|
||||
|
||||
The verification token is: {{ token }}
|
@ -45,6 +45,10 @@ spec:
|
||||
configMap:
|
||||
name: app-templates
|
||||
|
||||
- name: email-templates
|
||||
configMap:
|
||||
name: email-templates
|
||||
|
||||
containers:
|
||||
- name: api
|
||||
image: {{ .Values.backend_image }}
|
||||
@ -68,6 +72,9 @@ spec:
|
||||
- name: app-templates
|
||||
mountPath: /app/btrixcloud/templates/
|
||||
|
||||
- name: email-templates
|
||||
mountPath: /app/btrixcloud/email-templates/
|
||||
|
||||
resources:
|
||||
limits:
|
||||
memory: {{ .Values.backend_memory }}
|
||||
@ -139,6 +146,9 @@ spec:
|
||||
- name: app-templates
|
||||
mountPath: /app/btrixcloud/templates/
|
||||
|
||||
- name: email-templates
|
||||
mountPath: /app/btrixcloud/email-templates/
|
||||
|
||||
resources:
|
||||
limits:
|
||||
memory: {{ .Values.backend_memory }}
|
||||
|
@ -135,3 +135,13 @@ metadata:
|
||||
|
||||
data:
|
||||
{{ (.Files.Glob "app-templates/*.yaml").AsConfig | indent 2 }}
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: email-templates
|
||||
namespace: {{ .Release.Namespace }}
|
||||
|
||||
data:
|
||||
{{ (.Files.Glob "email-templates/*").AsConfig | indent 2 }}
|
||||
|
@ -15,6 +15,7 @@ stringData:
|
||||
EMAIL_REPLY_TO: "{{ .Values.email.reply_to }}"
|
||||
EMAIL_PASSWORD: "{{ .Values.email.password }}"
|
||||
EMAIL_SMTP_USE_TLS: "{{ .Values.email.use_tls }}"
|
||||
EMAIL_SUPPORT: "{{ .Values.email.support_email }}"
|
||||
|
||||
SUPERUSER_EMAIL: "{{ .Values.superuser.email }}"
|
||||
SUPERUSER_PASSWORD: "{{ .Values.superuser.password }}"
|
||||
|
@ -293,6 +293,7 @@ email:
|
||||
password: password
|
||||
reply_to_email: example@example.com
|
||||
use_tls: True
|
||||
support_email: support@example.com
|
||||
|
||||
|
||||
# Deployment options
|
||||
|
Loading…
Reference in New Issue
Block a user