Skip to content

Commit a0070f7

Browse files
Backend/emails: Support custom SMTP headers for attachments (#8665)
1 parent 85a283a commit a0070f7

6 files changed

Lines changed: 118 additions & 50 deletions

File tree

app/backend/proto/internal/jobs.proto

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ 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;
17+
reserved 9; // Previous "attachments" field, which had mime_type+filename subfields.
18+
repeated EmailAttachmentV2 attachments = 10;
1819
}
1920

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;
21+
message EmailAttachmentV2 {
22+
bytes data = 1;
23+
string content_disposition = 2; // The Content-Disposition header, including parameters
24+
string content_type = 3; // The Content-Type header, including parameters
2425
}
2526

2627
message HandleNotificationPayload {

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

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from email.headerregistry import Address
2-
from typing import cast
32

43
from ics import Calendar, Event # type: ignore[import-untyped]
54
from ics.grammar.parse import ContentLine # type: ignore[import-untyped]
@@ -8,27 +7,27 @@
87
from couchers.config import config
98
from couchers.email.rendering import get_emails_i18next
109
from couchers.i18n import LocalizationContext
11-
from couchers.proto.internal.jobs_pb2 import EmailAttachment
10+
from couchers.proto.internal.jobs_pb2 import EmailAttachmentV2
1211
from couchers.proto.requests_pb2 import HostRequest
1312

1413
HOST_REQUEST_ICS_FILENAME = "host_request.ics"
1514

1615

1716
def create_host_request_attachment(
1817
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)
18+
) -> EmailAttachmentV2:
19+
calendar = create_host_request_calendar(host_request, other_name, hosting, loc_context)
20+
return calendar_to_attachment(calendar, HOST_REQUEST_ICS_FILENAME)
2221

2322

24-
def create_host_request_ics(
23+
def create_host_request_calendar(
2524
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
26-
) -> str:
25+
) -> Calendar:
2726
event = create_host_request_event(host_request, other_name, hosting, loc_context)
2827

2928
# METHOD:PUBLISH means this is part of a stream of calendar event information.
3029
# It allows for later cancellation, and doesn't expose accept/decline functionality.
31-
return event_to_ics(event, "PUBLISH", loc_context)
30+
return event_to_calendar(event, "PUBLISH", loc_context)
3231

3332

3433
def create_host_request_event(
@@ -68,14 +67,14 @@ def create_host_request_event(
6867

6968
def create_host_request_cancellation_attachment(
7069
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
71-
) -> EmailAttachment:
72-
ics = create_host_request_cancellation_ics(host_request, other_name, hosting, loc_context)
73-
return ics_to_attachment(ics, HOST_REQUEST_ICS_FILENAME)
70+
) -> EmailAttachmentV2:
71+
calendar = create_host_request_cancellation_calendar(host_request, other_name, hosting, loc_context)
72+
return calendar_to_attachment(calendar, HOST_REQUEST_ICS_FILENAME)
7473

7574

76-
def create_host_request_cancellation_ics(
75+
def create_host_request_cancellation_calendar(
7776
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
78-
) -> str:
77+
) -> Calendar:
7978
event = create_host_request_event(host_request, other_name, hosting, loc_context, sequence=1)
8079
event.name = get_emails_i18next().localize(
8180
"calendar_events.title_cancelled", loc_context.locale, {"title": event.name}
@@ -85,24 +84,28 @@ def create_host_request_cancellation_ics(
8584
# METHOD:PUBLISH means this is part of a stream of calendar event information.
8685
# Gmail™ will immediately remove the event from the user's calendar.
8786
# METHOD:CANCEL might leave the event in cancelled state or not work.
88-
return event_to_ics(event, "PUBLISH", loc_context)
87+
return event_to_calendar(event, "PUBLISH", loc_context)
8988

9089

91-
def event_to_ics(event: Event, method: str | None, loc_context: LocalizationContext) -> str:
90+
def event_to_calendar(event: Event, method: str | None, loc_context: LocalizationContext) -> Calendar:
9291
# PRODID is mandatory and generally follows "-//[Organization]//[Product Name]//[Language]"
9392
calendar = Calendar(creator=f"-//Couchers.org//Couchers//{loc_context.locale.upper()}")
9493
if method:
9594
calendar.method = method
9695
calendar.events.add(event)
97-
return cast(str, calendar.serialize())
96+
return calendar
9897

9998

100-
def ics_to_attachment(ics: str, filename: str) -> EmailAttachment:
101-
return EmailAttachment(
102-
filename=filename,
103-
mime_type="text/calendar",
104-
data=ics.encode("utf-8"),
105-
)
99+
def calendar_to_attachment(calendar: Calendar, filename: str) -> EmailAttachmentV2:
100+
data = calendar.serialize().encode("utf-8")
101+
content_disposition = f'attachment; filename="{filename}"'
102+
content_type = 'text/calendar; charset="utf-8"'
103+
if calendar.method:
104+
# The SMTP Content-Type "method" parameter must match the value in the ics file.
105+
# AI recommends avoiding quotes on this parameter for backwards compatibility with old email clients.
106+
content_type += f"; method={calendar.method}"
107+
108+
return EmailAttachmentV2(data=data, content_disposition=content_disposition, content_type=content_type)
106109

107110

108111
def get_host_request_event_uid(host_request_id: int) -> str:

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

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,12 @@ def make_cid(sender_email: str) -> tuple[str, str]:
1919
return cid, without_tag
2020

2121

22-
def send_smtp_email(payload: jobs_pb2.SendEmailPayload) -> Email:
23-
"""
24-
Sends out the email through SMTP, settings from config.
25-
26-
Returns a models.Email object that can be straight away added to the database.
27-
"""
28-
message_id = random_hex()
22+
def email_proto_to_message(payload: jobs_pb2.SendEmailPayload, couchers_id: str) -> tuple[EmailMessage, str | None]:
2923
msg = EmailMessage()
3024
msg["Subject"] = payload.subject
3125
msg["From"] = Address(payload.sender_name, addr_spec=payload.sender_email)
3226
msg["To"] = Address(addr_spec=payload.recipient)
33-
msg["X-Couchers-ID"] = message_id
27+
msg["X-Couchers-ID"] = couchers_id
3428

3529
if payload.list_unsubscribe_header:
3630
msg["List-Unsubscribe"] = payload.list_unsubscribe_header
@@ -41,7 +35,7 @@ def send_smtp_email(payload: jobs_pb2.SendEmailPayload) -> Email:
4135

4236
msg.set_content(payload.plain)
4337

44-
updated_html = payload.html
38+
updated_html: str | None = payload.html
4539
if updated_html:
4640
# for any png files in attachment_imgs/, goes through and replaces instances of the filename with attachment
4741
used_attachments = []
@@ -58,15 +52,31 @@ def send_smtp_email(payload: jobs_pb2.SendEmailPayload) -> Email:
5852
msg.add_alternative(updated_html, subtype="html")
5953

6054
for cid, mime_type, mime_subtype, data in used_attachments:
61-
payloads = cast(list[MIMEPart], msg.get_payload())
62-
payloads[1].add_related(data, mime_type, mime_subtype, cid=cid)
55+
html_part = cast(list[MIMEPart], msg.get_payload())[-1]
56+
html_part.add_related(data, mime_type, mime_subtype, cid=cid)
6357

6458
if payload.attachments:
6559
for attachment in payload.attachments:
66-
mime_maintype, mime_subtype = attachment.mime_type.split("/")
60+
# Create with generic Content-Type/Content-Disposition headers,
61+
# then overwrite them with the headers specified by the caller.
6762
msg.add_attachment(
68-
attachment.data, maintype=mime_maintype, subtype=mime_subtype, filename=attachment.filename
63+
attachment.data, maintype="application", subtype="octet-stream", disposition="attachment"
6964
)
65+
attachment_part = cast(list[MIMEPart], msg.get_payload())[-1]
66+
_replace_header_verbatim(attachment_part, "Content-Type", attachment.content_type)
67+
_replace_header_verbatim(attachment_part, "Content-Disposition", attachment.content_disposition)
68+
69+
return msg, updated_html
70+
71+
72+
def send_smtp_email(payload: jobs_pb2.SendEmailPayload) -> Email:
73+
"""
74+
Sends out the email through SMTP, settings from config.
75+
76+
Returns a models.Email object that can be straight away added to the database.
77+
"""
78+
message_id = random_hex()
79+
msg, updated_html = email_proto_to_message(payload, message_id)
7080

7181
with smtplib.SMTP(config["SMTP_HOST"], config["SMTP_PORT"]) as server:
7282
server.ehlo()
@@ -84,7 +94,24 @@ def send_smtp_email(payload: jobs_pb2.SendEmailPayload) -> Email:
8494
recipient=payload.recipient,
8595
subject=payload.subject,
8696
plain=payload.plain,
87-
html=updated_html,
97+
html=updated_html or "",
8898
list_unsubscribe_header=payload.list_unsubscribe_header,
8999
source_data=payload.source_data,
90100
)
101+
102+
103+
def _replace_header_verbatim(part: MIMEPart, name: str, value: str) -> None:
104+
# MIMEPart.replace_header will parse the value and reformat it,
105+
# resulting in additional quoting for an .ics "method=PUBLISH" parameter,
106+
# which are not as backwards compatible with older email clients.
107+
108+
if hasattr(part, "_headers"):
109+
# Replace the header in the internal data structure to avoid reformatting.
110+
header_index = next((i for i, val in enumerate(part._headers) if val[0] == name), None)
111+
if isinstance(header_index, int):
112+
part._headers[header_index] = (name, value)
113+
else:
114+
part._headers.append((name, value))
115+
else:
116+
# Non-verbatim fallback, in case the internals change
117+
part.replace_header(name, value)

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
generate_unsub_topic_key,
2323
)
2424
from couchers.proto import api_pb2
25-
from couchers.proto.internal.jobs_pb2 import EmailAttachment
25+
from couchers.proto.internal.jobs_pb2 import EmailAttachmentV2
2626
from couchers.templating import Jinja2Template, template_folder
2727
from couchers.utils import now, to_aware_datetime
2828

@@ -36,7 +36,7 @@ class RenderedEmailNotification:
3636
body_html: str | None
3737
source_data: str | None
3838
list_unsubscribe_header: str | None
39-
attachments: list[EmailAttachment] = field(default_factory=list)
39+
attachments: list[EmailAttachmentV2] = field(default_factory=list)
4040

4141

4242
def render_email_notification(
@@ -450,7 +450,7 @@ def _get_custom_templated_email(notification: Notification, loc_context: Localiz
450450
raise NotImplementedError(f"Unknown topic-action: {notification.topic}:{notification.action}")
451451

452452

453-
def get_ics_attachment(notification: Notification, loc_context: LocalizationContext) -> EmailAttachment | None:
453+
def get_ics_attachment(notification: Notification, loc_context: LocalizationContext) -> EmailAttachmentV2 | None:
454454
data = notification.topic_action.data_type.FromString(notification.data) # type: ignore[attr-defined]
455455
if notification.topic_action == NotificationTopicAction.host_request__accept:
456456
# Caveat: The surfer technically still hasn't confirmed, but when they do they don't receive an email,

app/backend/src/tests/test_calendar_events.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import ics
55
import pytest
66

7-
from couchers.email.calendar_events import create_host_request_cancellation_ics, create_host_request_ics
7+
from couchers.email.calendar_events import create_host_request_calendar, create_host_request_cancellation_calendar
88
from couchers.i18n.context import LocalizationContext
99
from couchers.proto import conversations_pb2, requests_pb2
1010
from couchers.proto.requests_pb2 import HostRequest
@@ -25,9 +25,9 @@ def test_initial_ics_content():
2525
host_request_id=42, from_date="2000-01-01", to_date="2000-01-02", hosting_city="New York"
2626
)
2727

28-
ics = create_host_request_ics(
28+
ics: str = create_host_request_calendar(
2929
host_request, other_name="Bob", hosting=True, loc_context=LocalizationContext.en_utc()
30-
)
30+
).serialize()
3131
assert _normalize_ics(ics) == _normalize_ics("""
3232
BEGIN:VCALENDAR
3333
VERSION:2.0
@@ -52,9 +52,9 @@ def test_cancellation_ics_content():
5252
host_request_id=42, from_date="2000-01-01", to_date="2000-01-02", hosting_city="New York"
5353
)
5454

55-
ics = create_host_request_cancellation_ics(
55+
ics: str = create_host_request_cancellation_calendar(
5656
host_request, other_name="Bob", hosting=True, loc_context=LocalizationContext.en_utc()
57-
)
57+
).serialize()
5858
assert _normalize_ics(ics) == _normalize_ics("""
5959
BEGIN:VCALENDAR
6060
VERSION:2.0
@@ -216,8 +216,9 @@ def test_host_request_attachments_disabled(db, feature_flags, moderator: Moderat
216216
def _get_email_ics_attachment_calendar_event(e) -> ics.Event:
217217
assert len(e.attachments or []) == 1
218218
ics_attachment = e.attachments[0]
219-
assert ics_attachment.filename.endswith(".ics")
219+
assert ics_attachment.content_type.startswith("text/calendar")
220220
ics_calendar = ics.Calendar(ics_attachment.data.decode("utf-8"))
221+
assert f"method={ics_calendar.method}" in ics_attachment.content_type
221222
assert len(ics_calendar.events) == 1
222223
return next(iter(ics_calendar.events))
223224

app/backend/src/tests/test_smtp.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from couchers.email.smtp import email_proto_to_message
2+
from couchers.proto.internal.jobs_pb2 import EmailAttachmentV2, SendEmailPayload
3+
4+
5+
def test_verbatim_attachment_headers():
6+
"""
7+
Test that specified Content-Type/Content-Disposition are preserved verbatim (with original quoting).
8+
This ensures we can create attachments compatible with older email clients.
9+
"""
10+
11+
send_email_payload = SendEmailPayload(
12+
sender_name="alice",
13+
sender_email="alice@couchers.org",
14+
recipient="bob@couchers.org",
15+
subject="greeting",
16+
plain="hello",
17+
attachments=[
18+
EmailAttachmentV2(
19+
data=bytes([0, 255]), # Force base64 encoding
20+
content_type='maintype/subtype; quoted-header="value"; unquoted-header=value',
21+
content_disposition="attachment",
22+
)
23+
],
24+
)
25+
26+
email_message, _ = email_proto_to_message(send_email_payload, couchers_id="42")
27+
smtp_str = email_message.as_string()
28+
29+
expected_snippet = """
30+
Content-Type: maintype/subtype; quoted-header="value"; unquoted-header=value
31+
Content-Transfer-Encoding: base64
32+
Content-Disposition: attachment
33+
MIME-Version: 1.0
34+
""".strip()
35+
36+
assert expected_snippet in smtp_str

0 commit comments

Comments
 (0)