From b23eed500341ffa0f45c3758161ad7ed3fc36d89 Mon Sep 17 00:00:00 2001
From: Ilya Kreymer
Date: Wed, 15 Nov 2023 15:22:12 -0800
Subject: [PATCH] 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
~~~
~~~
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'
---
backend/btrixcloud/emailsender.py | 126 ++++++++++++++-------------
backend/btrixcloud/invites.py | 16 ++--
backend/btrixcloud/models.py | 1 +
chart/email-templates/failed_bg_job | 16 ++++
chart/email-templates/invite | 64 ++++++++++++++
chart/email-templates/password_reset | 10 +++
chart/email-templates/validate | 7 ++
chart/templates/backend.yaml | 10 +++
chart/templates/configmap.yaml | 10 +++
chart/templates/secrets.yaml | 1 +
chart/values.yaml | 1 +
11 files changed, 190 insertions(+), 72 deletions(-)
create mode 100644 chart/email-templates/failed_bg_job
create mode 100644 chart/email-templates/invite
create mode 100644 chart/email-templates/password_reset
create mode 100644 chart/email-templates/validate
diff --git a/backend/btrixcloud/emailsender.py b/backend/btrixcloud/emailsender.py
index 3b7c6c6d..e097c32f 100644
--- a/backend/btrixcloud/emailsender.py
+++ b/backend/btrixcloud/emailsender.py
@@ -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 = EmailMessage()
- msg["Subject"] = subject
+ 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.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
+ )
diff --git a/backend/btrixcloud/invites.py b/backend/btrixcloud/invites.py
index a7885ee2..3b204f97 100644
--- a/backend/btrixcloud/invites.py
+++ b/backend/btrixcloud/invites.py
@@ -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)
diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py
index f973beed..2d03990c 100644
--- a/backend/btrixcloud/models.py
+++ b/backend/btrixcloud/models.py
@@ -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]
diff --git a/chart/email-templates/failed_bg_job b/chart/email-templates/failed_bg_job
new file mode 100644
index 00000000..7d825467
--- /dev/null
+++ b/chart/email-templates/failed_bg_job
@@ -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 }}
diff --git a/chart/email-templates/invite b/chart/email-templates/invite
new file mode 100644
index 00000000..61a2469d
--- /dev/null
+++ b/chart/email-templates/invite
@@ -0,0 +1,64 @@
+Welcome to Browsertrix Cloud!
+~~~
+
+
+Hello!
+
+Welcome to Browsertrix Cloud!
+
+{% if sender %}
+You have been invited by {{ sender }} to join "{{ org_name }}" on Browsertrix Cloud!
+
+{% endif %}
+
+{% if is_new %}
+You can now set up your account using the link below.
+
+Click here to create an account.
+{% else %}
+Click here to accept this invite.
+{% 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. From there, you can click + Create New to create your first Crawl Workflow!
+
+
+
For more info, check out the Browsertrix Cloud User Guide
+
+
+
+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 }}
+
+
+The Webrecorder Team
+
+
+~~~
+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 }}.
+
+
+
diff --git a/chart/email-templates/password_reset b/chart/email-templates/password_reset
new file mode 100644
index 00000000..7d92180e
--- /dev/null
+++ b/chart/email-templates/password_reset
@@ -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.
diff --git a/chart/email-templates/validate b/chart/email-templates/validate
new file mode 100644
index 00000000..51bc0fed
--- /dev/null
+++ b/chart/email-templates/validate
@@ -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 }}
diff --git a/chart/templates/backend.yaml b/chart/templates/backend.yaml
index 4a9c7624..c271c0e6 100644
--- a/chart/templates/backend.yaml
+++ b/chart/templates/backend.yaml
@@ -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 }}
diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml
index 023631d3..1152bf5b 100644
--- a/chart/templates/configmap.yaml
+++ b/chart/templates/configmap.yaml
@@ -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 }}
diff --git a/chart/templates/secrets.yaml b/chart/templates/secrets.yaml
index 9d511ad6..6f9f9361 100644
--- a/chart/templates/secrets.yaml
+++ b/chart/templates/secrets.yaml
@@ -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 }}"
diff --git a/chart/values.yaml b/chart/values.yaml
index ed2c1e9b..3cb99c14 100644
--- a/chart/values.yaml
+++ b/chart/values.yaml
@@ -293,6 +293,7 @@ email:
password: password
reply_to_email: example@example.com
use_tls: True
+ support_email: support@example.com
# Deployment options