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