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:
Ilya Kreymer 2023-11-15 15:22:12 -08:00 committed by GitHub
parent 7d985a9688
commit b23eed5003
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 190 additions and 72 deletions

View File

@ -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
)

View File

@ -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)

View File

@ -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]

View 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 }}

View 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, youll 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, youll 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 }}.

View 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.

View 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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}"

View File

@ -293,6 +293,7 @@ email:
password: password
reply_to_email: example@example.com
use_tls: True
support_email: support@example.com
# Deployment options