browsertrix/backend/btrixcloud/emailsender.py
Ilya Kreymer b23eed5003
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'
2023-11-15 15:22:12 -08:00

172 lines
5.4 KiB
Python

""" Basic Email Sending Support"""
from datetime import datetime
import os
import smtplib
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, InvitePending
from .utils import is_bool
# pylint: disable=too-few-public-methods, too-many-instance-attributes
class EmailSender:
"""SMTP Email Sender"""
sender: str
password: str
reply_to: str
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")
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.set_content(text.strip())
msg["Subject"] = subject.strip()
msg["From"] = self.reply_to
msg["To"] = receiver
msg["Reply-To"] = msg["From"]
context = ssl.create_default_context()
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
if self.smtp_use_tls:
server.ehlo()
server.starttls(context=context)
server.ehlo()
if self.password:
server.login(self.sender, self.password)
server.send_message(msg)
# server.sendmail(self.sender, receiver, message)
def get_origin(self, headers):
"""Return origin of the received request"""
if not headers:
return self.default_origin
scheme = headers.get("X-Forwarded-Proto")
host = headers.get("Host")
if not scheme or not host:
return self.default_origin
return scheme + "://" + host
def send_user_validation(self, receiver_email, token, headers=None):
"""Send email to validate registration email address"""
origin = self.get_origin(headers)
self._send_encrypted(receiver_email, "validate", origin=origin, token=token)
# pylint: disable=too-many-arguments
def send_new_user_invite(self, invite: InvitePending, org_name: str, headers=None):
"""Send email to invite new user"""
origin = self.get_origin(headers)
receiver_email = invite.email or ""
invite_url = f"{origin}/join/{invite.id}?email={receiver_email}"
self._send_encrypted(
receiver_email,
"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
def send_existing_user_invite(
self, receiver_email, sender, org_name, token, headers=None
):
"""Send email to invite new user"""
origin = self.get_origin(headers)
invite_url = f"{origin}/invite/accept/{token}?email={receiver_email}"
self._send_encrypted(
receiver_email,
"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)
self._send_encrypted(
receiver_email,
"password_reset",
origin=origin,
token=token,
support_email=self.support_email,
)
def send_background_job_failed(
self,
job: Union[CreateReplicaJob, DeleteReplicaJob],
org: Organization,
finished: datetime,
receiver_email: str,
):
"""Send background job failed email to superuser"""
self._send_encrypted(
receiver_email, "failed_bg_job", job=job, org=org, finished=finished
)