Skip to content

Commit

Permalink
Feature/612 new emails (#614)
Browse files Browse the repository at this point in the history
* Swap old test mail server for mailpit

* WIP email redesign

* Re-work invite email to be similar to confirmation email

* Fix the chip!

* Remove test function

* xfail the tests

* fix xfail...
  • Loading branch information
MelissaAutumn authored Aug 8, 2024
1 parent 844464e commit 93845d8
Show file tree
Hide file tree
Showing 17 changed files with 379 additions and 96 deletions.
5 changes: 3 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ SUPPORT_EMAIL=
# Connection security: SSL|STARTTLS|NONE
SMTP_SECURITY=NONE
# Address and port of the SMTP server
SMTP_URL=localhost
SMTP_PORT=8050
SMTP_URL=mailpit
# Mailpit
SMTP_PORT=1025
# SMTP user credentials
SMTP_USER=
SMTP_PASS=
Expand Down
3 changes: 0 additions & 3 deletions backend/scripts/dev-entry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
run-command main setup
run-command main update-db

# Start up fake mail server
python -u -m smtpd -n -c DebuggingServer localhost:8050 &

# Start cron
service cron start

Expand Down
3 changes: 2 additions & 1 deletion backend/src/appointment/controller/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,8 @@ def send_vevent(
filename='AppointmentInvite.ics',
data=ics,
)
background_tasks.add_task(send_invite_email, to=attendee.email, attachment=invite)
date = slot.start.replace(tzinfo=timezone.utc).astimezone(zoneinfo.ZoneInfo(attendee.timezone))
background_tasks.add_task(organizer.name, organizer.email, send_invite_email, date=date, duration=slot.duration, to=attendee.email, attachment=invite)

@staticmethod
def available_slots_from_schedule(schedule: models.Schedule) -> list[schemas.SlotBase]:
Expand Down
142 changes: 108 additions & 34 deletions backend/src/appointment/controller/mailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,17 @@
Handle outgoing emails.
"""

import datetime
import logging
import os
import smtplib
import ssl
from email.message import EmailMessage

import jinja2
import validators

from html import escape
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from fastapi.templating import Jinja2Templates

from ..l10n import l10n
Expand All @@ -26,7 +23,10 @@ def get_jinja():

templates = Jinja2Templates(path)
# Add our l10n function
templates.env.trim_blocks = True
templates.env.lstrip_blocks = True
templates.env.globals.update(l10n=l10n)
templates.env.globals.update(homepage_url=os.getenv('FRONTEND_URL'))

return templates

Expand All @@ -38,7 +38,7 @@ def get_template(template_name) -> 'jinja2.Template':


class Attachment:
def __init__(self, mime: tuple[str], filename: str, data: str):
def __init__(self, mime: tuple[str,str], filename: str, data: str|bytes):
self.mime_main = mime[0]
self.mime_sub = mime[1]
self.filename = filename
Expand All @@ -61,6 +61,7 @@ def __init__(
self.body_html = html
self.body_plain = plain
self.attachments = attachments
print("Attachments -> ", self.attachments)

def html(self):
"""provide email body as html per default"""
Expand All @@ -71,31 +72,33 @@ def text(self):
# TODO: do some real html tag stripping and sanitizing here
return self.body_plain if self.body_plain != '' else escape(self.body_html)

def attachments(self):
def _attachments(self):
"""provide all attachments as list"""
return self.attachments

def build(self):
"""build email header, body and attachments"""
# create mail header
message = MIMEMultipart('alternative')

message = EmailMessage()
message['Subject'] = self.subject
message['From'] = self.sender
message['To'] = self.to

# add body as html and text parts
if self.text():
message.attach(MIMEText(self.text(), 'plain'))
if self.html():
message.attach(MIMEText(self.html(), 'html'))
message.set_content(self.text())
message.add_alternative(self.html(), subtype='html')

# add attachment(s) as multimedia parts
for a in self.attachments:
part = MIMEBase(a.mime_main, a.mime_sub)
part.set_payload(a.data)
encoders.encode_base64(part)
part.add_header('Content-Disposition', f'attachment; filename={a.filename}')
message.attach(part)
for a in self._attachments():
# Attach it to the html payload
message.get_payload()[1].add_related(
a.data,
a.mime_main,
a.mime_sub,
cid=f'<{a.filename}>',
filename=a.filename
)

return message.as_string()

Expand Down Expand Up @@ -137,17 +140,71 @@ def send(self):
server.quit()


class InvitationMail(Mailer):
class BaseBookingMail(Mailer):
def __init__(self, name, email, date, duration, *args, **kwargs):
"""Base class for emails with name, email, and event information"""
self.name = name
self.email = email
self.date = date
self.duration = duration
super().__init__(*args, **kwargs)

date_end = self.date + datetime.timedelta(minutes=self.duration)

self.time_range = ' - '.join([date.strftime('%I:%M%p'), date_end.strftime('%I:%M%p')])
self.timezone = ''
if self.date.tzinfo:
self.timezone += f'({date.strftime("%Z")})'
self.day = date.strftime('%A, %B %d %Y')

def _attachments(self):
"""We need these little icons for the message body"""
path = 'src/appointment/templates/assets/img/icons'

with open(f'{path}/calendar.png', 'rb') as fh:
calendar_icon = fh.read()
with open(f'{path}/clock.png', 'rb') as fh:
clock_icon = fh.read()

return [
Attachment(
mime=('image', 'png'),
filename='calendar.png',
data=calendar_icon,
),
Attachment(
mime=('image', 'png'),
filename='clock.png',
data=clock_icon,
),
*self.attachments,
]


class InvitationMail(BaseBookingMail):
def __init__(self, *args, **kwargs):
"""init Mailer with invitation specific defaults"""
default_kwargs = {
'subject': l10n('invite-mail-subject'),
'plain': l10n('invite-mail-plain'),
}
super(InvitationMail, self).__init__(*args, **default_kwargs, **kwargs)
super().__init__(*args, **default_kwargs, **kwargs)

def html(self):
return get_template('invite.jinja2').render()
print("->",self._attachments())
return get_template('invite.jinja2').render(
name=self.name,
email=self.email,
time_range=self.time_range,
timezone=self.timezone,
day=self.day,
duration=self.duration,
# Icon cids
calendar_icon_cid=self._attachments()[0].filename,
clock_icon_cid=self._attachments()[1].filename,
# Calendar ics cid
invite_cid=self._attachments()[2].filename,
)


class ZoomMeetingFailedMail(Mailer):
Expand All @@ -165,36 +222,53 @@ def text(self):
return l10n('zoom-invite-failed-plain', {'title': self.appointment_title})


class ConfirmationMail(Mailer):
def __init__(self, confirm_url, deny_url, attendee_name, attendee_email, date, *args, **kwargs):
class ConfirmationMail(BaseBookingMail):
def __init__(self, confirm_url, deny_url, name, email, date, duration, schedule_name, *args, **kwargs):
"""init Mailer with confirmation specific defaults"""
self.attendee_name = attendee_name
self.attendee_email = attendee_email
self.date = date
print("Init!")
self.confirmUrl = confirm_url
self.denyUrl = deny_url
default_kwargs = {'subject': l10n('confirm-mail-subject', {'attendee_name': self.attendee_name})}
super(ConfirmationMail, self).__init__(*args, **default_kwargs, **kwargs)
self.schedule_name = schedule_name
default_kwargs = {'subject': l10n('confirm-mail-subject', {'name': name})}
super().__init__(name=name, email=email, date=date, duration=duration, *args, **default_kwargs, **kwargs)

date_end = self.date + datetime.timedelta(minutes=self.duration)

self.time_range = ' - '.join([date.strftime('%I:%M%p'), date_end.strftime('%I:%M%p')])
self.timezone = ''
if self.date.tzinfo:
self.timezone += f'({date.strftime("%Z")})'
self.day = date.strftime('%A, %B %d %Y')

def text(self):
return l10n(
'confirm-mail-plain',
{
'attendee_name': self.attendee_name,
'attendee_email': self.attendee_email,
'date': self.date,
'name': self.name,
'email': self.email,
'day': self.day,
'duration': self.duration,
'time_range': self.time_range,
'timezone': self.timezone,
'confirm_url': self.confirmUrl,
'deny_url': self.denyUrl,
},
)

def html(self):
return get_template('confirm.jinja2').render(
attendee_name=self.attendee_name,
attendee_email=self.attendee_email,
date=self.date,
name=self.name,
email=self.email,
time_range=self.time_range,
timezone=self.timezone,
day=self.day,
duration=self.duration,
confirm=self.confirmUrl,
deny=self.denyUrl,
schedule_name=self.schedule_name,
# Icon cids
calendar_icon_cid=self._attachments()[0].filename,
clock_icon_cid=self._attachments()[1].filename,
)


Expand Down
34 changes: 28 additions & 6 deletions backend/src/appointment/l10n/en/email.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,30 @@
## General

-brand-name = Thunderbird Appointment
-brand-footer = This message is sent from {-brand-name}.
-brand-slogan = Plan less, do more.
-brand-sign-up-with-url = Sign up on appointment.day
-brand-sign-up-with-no-url = Sign up on
-brand-footer = This message was sent from:
{-brand-name}
{-brand-slogan} {-brand-sign-up-with-url}
mail-brand-footer = {-brand-footer}
mail-brand-footer = This message was sent from:
{-brand-name}
{-brand-slogan} {-brand-sign-up-with-no-url}
## Invitation

invite-mail-subject = Invitation sent from {-brand-name}
invite-mail-plain = {-brand-footer}
invite-mail-html = {-brand-footer}
invite-mail-html-heading-name = { $name }
invite-mail-html-heading-email = ({ $email })
invite-mail-html-heading-text = has accepted your booking:
invite-mail-html-time = { $duration } mins
invite-mail-html-invite-is-attached = You can download the calendar invite file below:
invite-mail-html-download = Download
## New Booking

# Variables
Expand Down Expand Up @@ -43,7 +57,11 @@ confirm-mail-subject = Action Required: Confirm booking request from { $attendee
# $date (String) - Date of the Appointment
# $confirm_url (String) - URL that when clicked will confirm the appointment
# $deny_url (String) - URL that when clicked will deny the appointment
confirm-mail-plain = { $attendee_name } ({ $attendee_email }) just requested this time slot from your schedule: { $date }
confirm-mail-plain = { $name } ({ $email }) is requesting to book a time slot in: { $schedule_name }
{ $duration } mins
{ $time_range } ({ $timezone })
{ $day }
Visit this link to confirm the booking request:
{ $confirm_url }
Expand All @@ -56,11 +74,15 @@ confirm-mail-plain = { $attendee_name } ({ $attendee_email }) just requested thi
# $attendee_name (String) - Name of the person who requested the appointment
# $appointment_email (String) - Email of the person who requested the appointment
# $date (String) - Date of the requested appointment
confirm-mail-html-heading = { $attendee_name } ({ $attendee_email }) just requested this time slot from your schedule: { $date }.
confirm-mail-html-heading-name = { $name }
confirm-mail-html-heading-email = ({ $email })
confirm-mail-html-heading-text = is requesting to book a time slot in { $schedule_name }:
confirm-mail-html-time = { $duration } mins
confirm-mail-html-confirm-text = Click here to confirm the booking request:
confirm-mail-html-confirm-action = Confirm Booking
confirm-mail-html-confirm-action = Confirm
confirm-mail-html-deny-text = Or here if you want to deny it:
confirm-mail-html-deny-action = Deny Booking
confirm-mail-html-deny-action = Decline
## Rejected Appointment

Expand Down
6 changes: 5 additions & 1 deletion backend/src/appointment/routes/api.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import datetime
import logging
import os
import secrets
import uuid

import requests.exceptions
import sentry_sdk
import tzlocal
from sentry_sdk import metrics
from redis import Redis, RedisCluster

Expand All @@ -12,6 +15,7 @@
from starlette.responses import HTMLResponse, JSONResponse

from .. import utils
from ..controller.mailer import Attachment
from ..database import repo, schemas

# authentication
Expand All @@ -26,7 +30,7 @@
from ..exceptions import validation
from ..exceptions.validation import RemoteCalendarConnectionError, APIException
from ..l10n import l10n
from ..tasks.emails import send_support_email
from ..tasks.emails import send_support_email, send_confirmation_email, send_invite_email

router = APIRouter()

Expand Down
5 changes: 2 additions & 3 deletions backend/src/appointment/routes/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,7 @@ def request_schedule_availability_slot(

# human readable date in subscribers timezone
# TODO: handle locale date representation
date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(subscriber.timezone)).strftime('%c')
date = f'{date}, {slot.duration} minutes ({subscriber.timezone})'
date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(subscriber.timezone))

# If bookings are configured to be confirmed by the owner for this schedule,
# send emails to owner for confirmation and attendee for information
Expand All @@ -321,7 +320,7 @@ def request_schedule_availability_slot(
# Sending confirmation email to owner
background_tasks.add_task(
send_confirmation_email, url=url, attendee_name=attendee.name, attendee_email=attendee.email, date=date,
to=subscriber.preferred_email
duration=slot.duration, to=subscriber.preferred_email, schedule_name=schedule.name
)

# Sending pending email to attendee
Expand Down
Loading

0 comments on commit 93845d8

Please sign in to comment.