Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 64 additions & 62 deletions backend/btrixcloud/emailsender.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@
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"""

Expand All @@ -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:
Expand Down Expand Up @@ -76,38 +106,26 @@ def send_user_validation(self, receiver_email, token, headers=None):

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
Expand All @@ -117,29 +135,28 @@ def send_existing_user_invite(
"""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,
Expand All @@ -149,21 +166,6 @@ def send_background_job_failed(
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
)
16 changes: 6 additions & 10 deletions backend/btrixcloud/invites.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
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


Expand Down Expand Up @@ -70,13 +71,7 @@ async def add_new_user_invite(

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"""
Expand Down Expand Up @@ -118,8 +113,8 @@ async def accept_user_invite(self, user, invite_token: str, user_manager):
async def invite_user(
self,
invite: InviteRequest,
user,
user_manager,
user: User,
user_manager: UserManager,
org=None,
allow_existing=False,
headers: Optional[dict] = None,
Expand Down Expand Up @@ -148,6 +143,7 @@ async def invite_user(
# 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)
Expand Down
1 change: 1 addition & 0 deletions backend/btrixcloud/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
16 changes: 16 additions & 0 deletions chart/email-templates/failed_bg_job
Original file line number Diff line number Diff line change
@@ -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 changes: 64 additions & 0 deletions chart/email-templates/invite
Original file line number Diff line number Diff line change
@@ -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 changes: 10 additions & 0 deletions chart/email-templates/password_reset
Original file line number Diff line number Diff line change
@@ -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 changes: 7 additions & 0 deletions chart/email-templates/validate
Original file line number Diff line number Diff line change
@@ -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 }}
10 changes: 10 additions & 0 deletions chart/templates/backend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ spec:
configMap:
name: app-templates

- name: email-templates
configMap:
name: email-templates

containers:
- name: api
image: {{ .Values.backend_image }}
Expand All @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand Down
10 changes: 10 additions & 0 deletions chart/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Loading