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 typing import Optional, Union
|
||||||
|
|
||||||
from email.message import EmailMessage
|
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
|
from .utils import is_bool
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-few-public-methods
|
# pylint: disable=too-few-public-methods, too-many-instance-attributes
|
||||||
class EmailSender:
|
class EmailSender:
|
||||||
"""SMTP Email Sender"""
|
"""SMTP Email Sender"""
|
||||||
|
|
||||||
@ -22,31 +26,57 @@ class EmailSender:
|
|||||||
smtp_server: Optional[str]
|
smtp_server: Optional[str]
|
||||||
smtp_port: int
|
smtp_port: int
|
||||||
smtp_use_tls: bool
|
smtp_use_tls: bool
|
||||||
|
support_email: str
|
||||||
|
templates: Jinja2Templates
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.sender = os.environ.get("EMAIL_SENDER") or "Browsertrix admin"
|
self.sender = os.environ.get("EMAIL_SENDER") or "Browsertrix admin"
|
||||||
self.password = os.environ.get("EMAIL_PASSWORD") or ""
|
self.password = os.environ.get("EMAIL_PASSWORD") or ""
|
||||||
self.reply_to = os.environ.get("EMAIL_REPLY_TO") or self.sender
|
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_server = os.environ.get("EMAIL_SMTP_HOST")
|
||||||
self.smtp_port = int(os.environ.get("EMAIL_SMTP_PORT", 587))
|
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.smtp_use_tls = is_bool(os.environ.get("EMAIL_SMTP_USE_TLS"))
|
||||||
|
|
||||||
self.default_origin = os.environ.get("APP_ORIGIN")
|
self.default_origin = os.environ.get("APP_ORIGIN")
|
||||||
|
|
||||||
def _send_encrypted(self, receiver, subject, message) -> None:
|
self.templates = Jinja2Templates(
|
||||||
"""Send Encrypted SMTP Message"""
|
directory=os.path.join(os.path.dirname(__file__), "email-templates")
|
||||||
print(message, flush=True)
|
)
|
||||||
|
|
||||||
|
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:
|
if not self.smtp_server:
|
||||||
print("Email: No SMTP Server, not sending", flush=True)
|
print("Email: No SMTP Server, not sending", flush=True)
|
||||||
return
|
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 = EmailMessage()
|
||||||
msg["Subject"] = subject
|
msg.set_content(text.strip())
|
||||||
|
|
||||||
|
msg["Subject"] = subject.strip()
|
||||||
msg["From"] = self.reply_to
|
msg["From"] = self.reply_to
|
||||||
msg["To"] = receiver
|
msg["To"] = receiver
|
||||||
msg["Reply-To"] = msg["From"]
|
msg["Reply-To"] = msg["From"]
|
||||||
msg.set_content(message)
|
|
||||||
|
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
|
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
|
||||||
@ -76,38 +106,26 @@ class EmailSender:
|
|||||||
|
|
||||||
origin = self.get_origin(headers)
|
origin = self.get_origin(headers)
|
||||||
|
|
||||||
message = f"""
|
self._send_encrypted(receiver_email, "validate", origin=origin, token=token)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# pylint: disable=too-many-arguments
|
# pylint: disable=too-many-arguments
|
||||||
def send_new_user_invite(
|
def send_new_user_invite(self, invite: InvitePending, org_name: str, headers=None):
|
||||||
self, receiver_email, sender, org_name, token, headers=None
|
|
||||||
):
|
|
||||||
"""Send email to invite new user"""
|
"""Send email to invite new user"""
|
||||||
|
|
||||||
origin = self.get_origin(headers)
|
origin = self.get_origin(headers)
|
||||||
|
|
||||||
message = f"""
|
receiver_email = invite.email or ""
|
||||||
You are invited by {sender} to join their organization, "{org_name}" on Browsertrix Cloud!
|
|
||||||
|
|
||||||
You can join by clicking here: {origin}/join/{token}?email={receiver_email}
|
invite_url = f"{origin}/join/{invite.id}?email={receiver_email}"
|
||||||
|
|
||||||
The invite token is: {token}"""
|
|
||||||
|
|
||||||
self._send_encrypted(
|
self._send_encrypted(
|
||||||
receiver_email,
|
receiver_email,
|
||||||
f'You\'ve been invited to join "{org_name}" on Browsertrix Cloud',
|
"invite",
|
||||||
message,
|
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
|
# pylint: disable=too-many-arguments
|
||||||
@ -117,29 +135,28 @@ The invite token is: {token}"""
|
|||||||
"""Send email to invite new user"""
|
"""Send email to invite new user"""
|
||||||
origin = self.get_origin(headers)
|
origin = self.get_origin(headers)
|
||||||
|
|
||||||
message = f"""
|
invite_url = f"{origin}/invite/accept/{token}?email={receiver_email}"
|
||||||
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}"""
|
|
||||||
|
|
||||||
self._send_encrypted(
|
self._send_encrypted(
|
||||||
receiver_email,
|
receiver_email,
|
||||||
f'You\'ve been invited to join "{org_name}" on Browsertrix Cloud',
|
"invite",
|
||||||
message,
|
invite_url=invite_url,
|
||||||
|
is_new=False,
|
||||||
|
sender=sender,
|
||||||
|
org_name=org_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
def send_user_forgot_password(self, receiver_email, token, headers=None):
|
def send_user_forgot_password(self, receiver_email, token, headers=None):
|
||||||
"""Send password reset email with token"""
|
"""Send password reset email with token"""
|
||||||
origin = self.get_origin(headers)
|
origin = self.get_origin(headers)
|
||||||
|
|
||||||
message = f"""
|
self._send_encrypted(
|
||||||
We received your password reset request. Please click here: {origin}/reset-password?token={token}
|
receiver_email,
|
||||||
to create a new password
|
"password_reset",
|
||||||
"""
|
origin=origin,
|
||||||
|
token=token,
|
||||||
self._send_encrypted(receiver_email, "Password Reset", message)
|
support_email=self.support_email,
|
||||||
|
)
|
||||||
|
|
||||||
def send_background_job_failed(
|
def send_background_job_failed(
|
||||||
self,
|
self,
|
||||||
@ -149,21 +166,6 @@ to create a new password
|
|||||||
receiver_email: str,
|
receiver_email: str,
|
||||||
):
|
):
|
||||||
"""Send background job failed email to superuser"""
|
"""Send background job failed email to superuser"""
|
||||||
message = f"""
|
self._send_encrypted(
|
||||||
Failed Background Job
|
receiver_email, "failed_bg_job", job=job, org=org, finished=finished
|
||||||
---------------------
|
)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
@ -11,7 +11,8 @@ from pymongo.errors import AutoReconnect
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from .pagination import DEFAULT_PAGE_SIZE
|
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
|
from .utils import is_bool
|
||||||
|
|
||||||
|
|
||||||
@ -70,13 +71,7 @@ class InviteOps:
|
|||||||
|
|
||||||
await self.invites.insert_one(new_user_invite.to_dict())
|
await self.invites.insert_one(new_user_invite.to_dict())
|
||||||
|
|
||||||
self.email.send_new_user_invite(
|
self.email.send_new_user_invite(new_user_invite, org_name, headers)
|
||||||
new_user_invite.email,
|
|
||||||
new_user_invite.inviterEmail,
|
|
||||||
org_name,
|
|
||||||
new_user_invite.id,
|
|
||||||
headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_valid_invite(self, invite_token: UUID, email):
|
async def get_valid_invite(self, invite_token: UUID, email):
|
||||||
"""Retrieve a valid invite data from db, or throw if invalid"""
|
"""Retrieve a valid invite data from db, or throw if invalid"""
|
||||||
@ -118,8 +113,8 @@ class InviteOps:
|
|||||||
async def invite_user(
|
async def invite_user(
|
||||||
self,
|
self,
|
||||||
invite: InviteRequest,
|
invite: InviteRequest,
|
||||||
user,
|
user: User,
|
||||||
user_manager,
|
user_manager: UserManager,
|
||||||
org=None,
|
org=None,
|
||||||
allow_existing=False,
|
allow_existing=False,
|
||||||
headers: Optional[dict] = None,
|
headers: Optional[dict] = None,
|
||||||
@ -148,6 +143,7 @@ class InviteOps:
|
|||||||
# URL decode email address just in case
|
# URL decode email address just in case
|
||||||
email=urllib.parse.unquote(invite.email),
|
email=urllib.parse.unquote(invite.email),
|
||||||
inviterEmail=user.email,
|
inviterEmail=user.email,
|
||||||
|
fromSuperuser=user.is_superuser,
|
||||||
)
|
)
|
||||||
|
|
||||||
other_user = await user_manager.get_by_email(invite.email)
|
other_user = await user_manager.get_by_email(invite.email)
|
||||||
|
|||||||
@ -48,6 +48,7 @@ class InvitePending(BaseMongoModel):
|
|||||||
|
|
||||||
created: datetime
|
created: datetime
|
||||||
inviterEmail: str
|
inviterEmail: str
|
||||||
|
fromSuperuser: Optional[bool]
|
||||||
oid: Optional[UUID]
|
oid: Optional[UUID]
|
||||||
role: Optional[UserRole] = UserRole.VIEWER
|
role: Optional[UserRole] = UserRole.VIEWER
|
||||||
email: Optional[str]
|
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:
|
configMap:
|
||||||
name: app-templates
|
name: app-templates
|
||||||
|
|
||||||
|
- name: email-templates
|
||||||
|
configMap:
|
||||||
|
name: email-templates
|
||||||
|
|
||||||
containers:
|
containers:
|
||||||
- name: api
|
- name: api
|
||||||
image: {{ .Values.backend_image }}
|
image: {{ .Values.backend_image }}
|
||||||
@ -68,6 +72,9 @@ spec:
|
|||||||
- name: app-templates
|
- name: app-templates
|
||||||
mountPath: /app/btrixcloud/templates/
|
mountPath: /app/btrixcloud/templates/
|
||||||
|
|
||||||
|
- name: email-templates
|
||||||
|
mountPath: /app/btrixcloud/email-templates/
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: {{ .Values.backend_memory }}
|
memory: {{ .Values.backend_memory }}
|
||||||
@ -139,6 +146,9 @@ spec:
|
|||||||
- name: app-templates
|
- name: app-templates
|
||||||
mountPath: /app/btrixcloud/templates/
|
mountPath: /app/btrixcloud/templates/
|
||||||
|
|
||||||
|
- name: email-templates
|
||||||
|
mountPath: /app/btrixcloud/email-templates/
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: {{ .Values.backend_memory }}
|
memory: {{ .Values.backend_memory }}
|
||||||
|
|||||||
@ -135,3 +135,13 @@ metadata:
|
|||||||
|
|
||||||
data:
|
data:
|
||||||
{{ (.Files.Glob "app-templates/*.yaml").AsConfig | indent 2 }}
|
{{ (.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_REPLY_TO: "{{ .Values.email.reply_to }}"
|
||||||
EMAIL_PASSWORD: "{{ .Values.email.password }}"
|
EMAIL_PASSWORD: "{{ .Values.email.password }}"
|
||||||
EMAIL_SMTP_USE_TLS: "{{ .Values.email.use_tls }}"
|
EMAIL_SMTP_USE_TLS: "{{ .Values.email.use_tls }}"
|
||||||
|
EMAIL_SUPPORT: "{{ .Values.email.support_email }}"
|
||||||
|
|
||||||
SUPERUSER_EMAIL: "{{ .Values.superuser.email }}"
|
SUPERUSER_EMAIL: "{{ .Values.superuser.email }}"
|
||||||
SUPERUSER_PASSWORD: "{{ .Values.superuser.password }}"
|
SUPERUSER_PASSWORD: "{{ .Values.superuser.password }}"
|
||||||
|
|||||||
@ -293,6 +293,7 @@ email:
|
|||||||
password: password
|
password: password
|
||||||
reply_to_email: example@example.com
|
reply_to_email: example@example.com
|
||||||
use_tls: True
|
use_tls: True
|
||||||
|
support_email: support@example.com
|
||||||
|
|
||||||
|
|
||||||
# Deployment options
|
# Deployment options
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user