browsertrix/backend/btrixcloud/emailsender.py
Emma Segal-Grossman 8db0e44843
Feat: New email templating system & service (#2712)
Co-authored-by: Ilya Kreymer <ikreymer@gmail.com>
2025-08-01 17:00:24 -04:00

216 lines
7.0 KiB
Python

"""Basic Email Sending Support"""
from datetime import datetime
import os
import smtplib
import ssl
from uuid import UUID
from typing import Optional, Union
from email.message import EmailMessage
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import aiohttp
from fastapi import HTTPException
from .models import CreateReplicaJob, DeleteReplicaJob, Organization, InvitePending
from .utils import is_bool, get_origin
# 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
survey_url: str
email_template_endpoint: str
log_sent_emails: bool
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.survey_url = os.environ.get("USER_SURVEY_URL") or ""
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.log_sent_emails = is_bool(os.environ.get("LOG_SENT_EMAILS"))
email_template_endpoint = os.environ.get("EMAIL_TEMPLATE_ENDPOINT")
if not email_template_endpoint:
raise ValueError(
"Environment variable EMAIL_TEMPLATE_ENDPOINT is required but not set"
)
self.email_template_endpoint = email_template_endpoint
async def _send_encrypted(self, receiver: str, name: str, **kwargs) -> None:
"""Send Encrypted SMTP Message using given template name"""
try:
async with aiohttp.ClientSession() as session:
async with session.post(
self.email_template_endpoint + "/" + name,
json=kwargs,
) as resp:
if resp.status != 200:
raise HTTPException(
status_code=resp.status,
detail=await resp.text(),
)
json = await resp.json()
html = json["html"]
text = json["plainText"]
subject = json["subject"]
if self.log_sent_emails:
print(text, flush=True)
if not self.smtp_server:
print(
f'Email: created "{name}" msg for "{receiver}", '
f"but not sent (no SMTP server set)",
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)
# pylint: disable=broad-exception-caught
except Exception as exc:
print("Error fetching email template", exc)
raise exc
async def send_user_validation(
self, receiver_email: str, token: str, headers: Optional[dict] = None
):
"""Send email to validate registration email address"""
origin = get_origin(headers)
await self._send_encrypted(
receiver_email,
"verifyEmail",
origin=origin,
token=token,
receiver_email=receiver_email,
)
# pylint: disable=too-many-arguments
async def send_user_invite(
self,
invite: InvitePending,
token: UUID,
org_name: str,
is_new: bool,
headers: Optional[dict] = None,
):
"""Send email to invite new user"""
origin = get_origin(headers)
receiver_email = invite.email or ""
invite_url = (
f"{origin}/join/{token}?email={receiver_email}"
if is_new
else f"{origin}/invite/accept/{token}?email={receiver_email}"
)
await self._send_encrypted(
receiver_email,
"invite",
invite_url=invite_url,
is_new=is_new,
sender=invite.inviterEmail if not invite.fromSuperuser else "",
org_name=org_name,
support_email=self.support_email,
)
async def send_user_forgot_password(self, receiver_email, token, headers=None):
"""Send password reset email with token"""
origin = get_origin(headers)
await self._send_encrypted(
receiver_email,
"passwordReset",
origin=origin,
token=token,
support_email=self.support_email,
)
async def send_background_job_failed(
self,
job: Union[CreateReplicaJob, DeleteReplicaJob],
finished: datetime,
receiver_email: str,
org: Optional[Organization] = None,
):
"""Send background job failed email to superuser"""
await self._send_encrypted(
receiver_email,
"failedBgJob",
job=job,
org=str(org.id) if org else None,
finished=finished.isoformat(),
)
async def send_subscription_will_be_canceled(
self,
cancel_date: datetime,
user_name: str,
receiver_email: str,
org: Organization,
headers=None,
):
"""Send email indicating subscription is cancelled and all org data will be deleted"""
origin = get_origin(headers)
org_url = f"{origin}/orgs/{org.slug}/"
await self._send_encrypted(
receiver_email,
"subscriptionCancel",
org_url=org_url,
user_name=user_name,
org_name=org.name,
cancel_date=cancel_date.isoformat(),
support_email=self.support_email,
survey_url=self.survey_url,
)