Skip to content

Commit 315fdef

Browse files
Backend/requests: Support .ics attachments (#8471)
* Backend/requests: Support .ics attachments * Fix tests and mypy * Fix mypy * Added caveat comments * PR feedback * Formatting
1 parent d252d30 commit 315fdef

13 files changed

Lines changed: 354 additions & 4 deletions

File tree

app/backend.dev.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ NOTIFICATION_EMAIL_SENDER=Couchers.org
3838
NOTIFICATION_EMAIL_ADDRESS=notify@couchers.org.invalid
3939
NOTIFICATION_PREFIX='[DEV] '
4040
ENABLE_NOTIFICATION_TRANSLATIONS=1
41+
ENABLE_EMAIL_ICS_ATTACHMENTS=1
4142

4243
REPORTS_EMAIL_RECIPIENT=reports@couchers.org.invalid
4344
CONTRIBUTOR_FORM_EMAIL_RECIPIENT=forms@couchers.org.invalid

app/backend/proto/internal/jobs.proto

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ message SendEmailPayload {
1414
string list_unsubscribe_header = 7;
1515
// source data as to where this email came from
1616
string source_data = 8;
17+
repeated EmailAttachment attachments = 9;
18+
}
19+
20+
message EmailAttachment {
21+
string filename = 1;
22+
string mime_type = 2; // Both the type and subtype, e.g. "application/pdf"
23+
bytes data = 3;
1724
}
1825

1926
message HandleNotificationPayload {

app/backend/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencies = [
1313
"geoip2>=5.1.0",
1414
"grpcio>=1.76.0",
1515
"http-ece>=1.2.1",
16+
"ics>=0.7.3",
1617
"Jinja2>=3.1.6",
1718
"luhn>=0.2.0",
1819
"markdown-it-py>=4.0.0",

app/backend/src/couchers/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
# An optional prefix for email subject, e.g. [STAGING]
7171
("NOTIFICATION_PREFIX", str, ""),
7272
("ENABLE_NOTIFICATION_TRANSLATIONS", bool),
73+
("ENABLE_EMAIL_ICS_ATTACHMENTS", bool),
7374
# Address to send emails about reported users
7475
("REPORTS_EMAIL_RECIPIENT", str),
7576
# Address to send contributor forms when users sign up/fill the form
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from email.headerregistry import Address
2+
from typing import cast
3+
4+
from ics import Calendar, Event # type: ignore[import-untyped]
5+
from ics.grammar.parse import ContentLine # type: ignore[import-untyped]
6+
7+
from couchers import urls
8+
from couchers.config import config
9+
from couchers.email.rendering import get_emails_i18next
10+
from couchers.i18n import LocalizationContext
11+
from couchers.proto.internal.jobs_pb2 import EmailAttachment
12+
from couchers.proto.requests_pb2 import HostRequest
13+
14+
HOST_REQUEST_ICS_FILENAME = "host_request.ics"
15+
16+
17+
def create_host_request_attachment(
18+
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
19+
) -> EmailAttachment:
20+
ics = create_host_request_ics(host_request, other_name, hosting, loc_context)
21+
return ics_to_attachment(ics, HOST_REQUEST_ICS_FILENAME)
22+
23+
24+
def create_host_request_ics(
25+
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
26+
) -> str:
27+
event = create_host_request_event(host_request, other_name, hosting, loc_context)
28+
return event_to_ics(event, loc_context)
29+
30+
31+
def create_host_request_event(
32+
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
33+
) -> Event:
34+
"""Creates an ics event for a host request."""
35+
36+
event = Event()
37+
event.uid = get_host_request_event_uid(host_request.host_request_id)
38+
39+
if hosting:
40+
event.name = get_emails_i18next().localize(
41+
"calendar_events.host_requests.title_host", loc_context.locale, {"name": other_name}
42+
)
43+
else:
44+
event.name = get_emails_i18next().localize(
45+
"calendar_events.host_requests.title_surfer", loc_context.locale, {"name": other_name}
46+
)
47+
48+
# Our to_date is inclusive, iCalendar's DTEND is exclusive (for full-day events)
49+
# make_all_day will adjust the end date by one day accordingly.
50+
event.begin = host_request.from_date
51+
event.end = host_request.to_date
52+
event.make_all_day()
53+
54+
event.location = host_request.hosting_city
55+
event.url = urls.host_request(host_request_id=str(host_request.host_request_id))
56+
57+
# Google Calendar™ will hide the URL if there is a location, so also include it in the description
58+
event.description = event.url
59+
60+
return event
61+
62+
63+
def create_host_request_cancellation_attachment(
64+
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
65+
) -> EmailAttachment:
66+
ics = create_host_request_cancellation_ics(host_request, other_name, hosting, loc_context)
67+
return ics_to_attachment(ics, HOST_REQUEST_ICS_FILENAME)
68+
69+
70+
def create_host_request_cancellation_ics(
71+
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
72+
) -> str:
73+
event = create_host_request_event(host_request, other_name, hosting, loc_context)
74+
event.name = get_emails_i18next().localize(
75+
"calendar_events.title_cancelled", loc_context.locale, {"title": event.name}
76+
)
77+
event.status = "CANCELLED"
78+
79+
# Cancellation is sequenced after creation (which defaults to sequence number 0)
80+
event.extra.append(ContentLine(name="SEQUENCE", value="1"))
81+
82+
return event_to_ics(event, loc_context)
83+
84+
85+
def event_to_ics(event: Event, loc_context: LocalizationContext) -> str:
86+
# PRODID is mandatory and generally follows "-//[Organization]//[Product Name]//[Language]"
87+
calendar = Calendar(creator=f"-//Couchers.org//Couchers//{loc_context.locale.upper()}")
88+
if event.status == "CANCELLED":
89+
calendar.method = "CANCEL"
90+
calendar.events.add(event)
91+
return cast(str, calendar.serialize())
92+
93+
94+
def ics_to_attachment(ics: str, filename: str) -> EmailAttachment:
95+
return EmailAttachment(
96+
filename=filename,
97+
mime_type="text/calendar",
98+
data=ics.encode("utf-8"),
99+
)
100+
101+
102+
def get_host_request_event_uid(host_request_id: int) -> str:
103+
uid_domain = Address(addr_spec=config["NOTIFICATION_EMAIL_ADDRESS"]).domain
104+
return f"host_request.{host_request_id}@{uid_domain}"

app/backend/src/couchers/email/locales/en.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
"generic": {
33
"greeting_line": "Hi {{name}},"
44
},
5+
"calendar_events": {
6+
"title_cancelled": "Cancelled: {{ title }}",
7+
"host_requests": {
8+
"title_host": "Hosting {{ name }}",
9+
"title_surfer": "Surfing with {{ name }}"
10+
}
11+
},
512
"plaintext_formats": {
613
"action": "{{text}}: {{url}}",
714
"user": "{{name}}, {{age}}, {{city}}"

app/backend/src/couchers/email/smtp.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@ def send_smtp_email(payload: jobs_pb2.SendEmailPayload) -> Email:
4545
if updated_html:
4646
# for any png files in attachment_imgs/, goes through and replaces instances of the filename with attachment
4747
used_attachments = []
48-
for attachment in (template_base / "attachment_imgs").glob("*.png"):
49-
attachment_html_path = str(attachment.relative_to(template_base))
48+
for attachment_full_path in (template_base / "attachment_imgs").glob("*.png"):
49+
attachment_html_path = str(attachment_full_path.relative_to(template_base))
5050
if attachment_html_path not in updated_html:
5151
continue
5252
# it's used in this template, so attach and replace it
53-
data = attachment.read_bytes()
53+
data = attachment_full_path.read_bytes()
5454
cid, wcid = make_cid(payload.sender_email)
5555
updated_html = updated_html.replace(attachment_html_path, f"cid:{wcid}")
5656
used_attachments.append((cid, "image", "png", data))
@@ -61,6 +61,13 @@ def send_smtp_email(payload: jobs_pb2.SendEmailPayload) -> Email:
6161
payloads = cast(list[MIMEPart], msg.get_payload())
6262
payloads[1].add_related(data, mime_type, mime_subtype, cid=cid)
6363

64+
if payload.attachments:
65+
for attachment in payload.attachments:
66+
mime_maintype, mime_subtype = attachment.mime_type.split("/")
67+
msg.add_attachment(
68+
attachment.data, maintype=mime_maintype, subtype=mime_subtype, filename=attachment.filename
69+
)
70+
6471
with smtplib.SMTP(config["SMTP_HOST"], config["SMTP_PORT"]) as server:
6572
server.ehlo()
6673
if not config["DEV"]:

app/backend/src/couchers/notifications/background.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def _send_email_notification(session: Session, user: User, notification: Notific
5858
html=rendered.body_html,
5959
source_data=rendered.source_data,
6060
list_unsubscribe_header=rendered.list_unsubscribe_header,
61+
attachments=rendered.attachments,
6162
),
6263
)
6364

app/backend/src/couchers/notifications/render_email.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import logging
2-
from dataclasses import dataclass
2+
from dataclasses import dataclass, field
33
from typing import Any
44

55
from couchers import urls
66
from couchers.config import config
7+
from couchers.email.calendar_events import create_host_request_attachment, create_host_request_cancellation_attachment
78
from couchers.email.rendering import EmailFooter, UnsubscribeInfo, UnsubscribeLink
89
from couchers.i18n import LocalizationContext
910
from couchers.i18n.localize import format_phone_number
@@ -16,6 +17,7 @@
1617
generate_unsub_topic_key,
1718
)
1819
from couchers.proto import api_pb2, notification_data_pb2
20+
from couchers.proto.internal.jobs_pb2 import EmailAttachment
1921
from couchers.templating import Jinja2Template, template_folder
2022
from couchers.utils import now, to_aware_datetime
2123

@@ -29,6 +31,7 @@ class RenderedEmailNotification:
2931
body_html: str | None
3032
source_data: str | None
3133
list_unsubscribe_header: str | None
34+
attachments: list[EmailAttachment] = field(default_factory=list)
3235

3336

3437
def render_email_notification(
@@ -64,12 +67,18 @@ def render_email_notification(
6467
list_unsubscribe_header = get_list_unsubscribe_header(notification)
6568
source_data = config["VERSION"] + f"/{custom_templated.template_name}"
6669

70+
if config.get("ENABLE_EMAIL_ICS_ATTACHMENTS"):
71+
attachment = get_ics_attachment(notification, loc_context)
72+
else:
73+
attachment = None
74+
6775
return RenderedEmailNotification(
6876
subject=custom_templated.subject,
6977
body_plaintext=plain,
7078
body_html=html,
7179
source_data=source_data,
7280
list_unsubscribe_header=list_unsubscribe_header,
81+
attachments=[attachment] if attachment else [],
7382
)
7483

7584

@@ -761,6 +770,28 @@ def _get_custom_templated_email(notification: Notification, loc_context: Localiz
761770
raise NotImplementedError(f"Unknown topic-action: {notification.topic}:{notification.action}")
762771

763772

773+
def get_ics_attachment(notification: Notification, loc_context: LocalizationContext) -> EmailAttachment | None:
774+
data = notification.topic_action.data_type.FromString(notification.data) # type: ignore[attr-defined]
775+
if notification.topic_action == NotificationTopicAction.host_request__accept:
776+
# Caveat: The surfer technically still hasn't confirmed, but when they do they don't receive an email,
777+
# so the accept notification is our last opportunity to provide them with a calendar event.
778+
return create_host_request_attachment(
779+
data.host_request, other_name=data.host.name, hosting=False, loc_context=loc_context
780+
)
781+
elif notification.topic_action == NotificationTopicAction.host_request__confirm:
782+
return create_host_request_attachment(
783+
data.host_request, other_name=data.surfer.name, hosting=True, loc_context=loc_context
784+
)
785+
elif notification.topic_action == NotificationTopicAction.host_request__cancel:
786+
# Caveat: only the party getting cancelled receives this notification,
787+
# we have no opportunity to provide the cancelling party with a cancelled ics attachment.
788+
return create_host_request_cancellation_attachment(
789+
data.host_request, other_name=data.surfer.name, hosting=True, loc_context=loc_context
790+
)
791+
else:
792+
return None
793+
794+
764795
def get_list_unsubscribe_header(notification: Notification) -> str | None:
765796
if notification.topic_action.is_critical:
766797
return None

app/backend/src/tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ def testconfig():
172172
config["REPORTS_EMAIL_RECIPIENT"] = "reports@couchers.org.invalid"
173173
config["CONTRIBUTOR_FORM_EMAIL_RECIPIENT"] = "forms@couchers.org.invalid"
174174
config["MODS_EMAIL_RECIPIENT"] = "mods@couchers.org.invalid"
175+
config["ENABLE_EMAIL_ICS_ATTACHMENTS"] = True
175176

176177
config["ENABLE_DONATIONS"] = False
177178
config["STRIPE_API_KEY"] = ""

0 commit comments

Comments
 (0)