92059f319e
Per the v0.18.0 email + webhook hygiene proposal (~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md §1), this is the shared envelope builder every send path will call in Slice 2. No call-site changes yet — Slice 2 migrates email_otc / email_invite / email._deliver / email._send_bundle to use it. The helper centralizes the deliverability-critical headers (Date, Message-ID, Auto-Submitted) and exposes per-kind unsubscribe semantics (none for OTC, mailto: for invites, full one-click for watcher notifications) as explicit kwargs rather than buried in each call site. 15 new unit tests covering: always-present headers, Auto-Submitted toggle, the three List-Unsubscribe shapes, plain-only vs multipart/alternative body. Full suite: 267 passed (was 252).
174 lines
6.6 KiB
Python
174 lines
6.6 KiB
Python
"""Unit tests for `app.email_envelope.build_envelope` (v0.18.0 Slice 1).
|
|
|
|
These tests don't spin up the FastAPI app or touch the DB — they
|
|
exercise the helper directly. The integration tests in
|
|
test_otc_vertical / test_admin_create_user_invite_vertical /
|
|
test_notifications_vertical exercise the helper's *use* via the
|
|
shared `_SENT` buffer (the send path appends the envelope dict
|
|
before invoking the helper).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from email.utils import parsedate_to_datetime
|
|
|
|
from app.email_envelope import build_envelope
|
|
|
|
|
|
def _base_kwargs(**overrides):
|
|
base = dict(
|
|
to_address="recipient@example.com",
|
|
from_address="notifications@ohm.wiggleverse.org",
|
|
from_name="OHM",
|
|
subject="A test subject",
|
|
body_plain="Hello, world.\n",
|
|
)
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Always-present headers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_envelope_sets_from_to_subject():
|
|
msg = build_envelope(**_base_kwargs())
|
|
assert msg["To"] == "recipient@example.com"
|
|
assert msg["Subject"] == "A test subject"
|
|
# `From` is the display-form: "OHM <notifications@ohm.wiggleverse.org>".
|
|
assert "OHM" in msg["From"]
|
|
assert "<notifications@ohm.wiggleverse.org>" in msg["From"]
|
|
|
|
|
|
def test_envelope_sets_date_header_parseable():
|
|
msg = build_envelope(**_base_kwargs())
|
|
raw = msg["Date"]
|
|
assert raw, "Date header must be set"
|
|
# parsedate_to_datetime raises ValueError on malformed input.
|
|
dt = parsedate_to_datetime(raw)
|
|
assert dt is not None
|
|
|
|
|
|
def test_envelope_sets_message_id_with_from_domain_by_default():
|
|
msg = build_envelope(**_base_kwargs())
|
|
mid = msg["Message-ID"]
|
|
assert mid, "Message-ID must be set"
|
|
# Shape per RFC 5322 / make_msgid: <random@domain>
|
|
assert mid.startswith("<") and mid.endswith(">")
|
|
assert "@ohm.wiggleverse.org>" in mid
|
|
|
|
|
|
def test_envelope_message_id_domain_override():
|
|
msg = build_envelope(**_base_kwargs(msgid_domain="example.test"))
|
|
assert "@example.test>" in msg["Message-ID"]
|
|
|
|
|
|
def test_envelope_message_id_falls_back_to_localhost_if_from_has_no_at():
|
|
# Defensive: a malformed from_address shouldn't crash the helper.
|
|
msg = build_envelope(**_base_kwargs(from_address="bare-no-at-sign"))
|
|
assert "@localhost>" in msg["Message-ID"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auto-Submitted (RFC 3834)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_envelope_sets_auto_submitted_for_transactional_default():
|
|
msg = build_envelope(**_base_kwargs())
|
|
assert msg["Auto-Submitted"] == "auto-generated"
|
|
|
|
|
|
def test_envelope_omits_auto_submitted_when_transactional_is_false():
|
|
msg = build_envelope(**_base_kwargs(is_transactional=False))
|
|
assert msg["Auto-Submitted"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reply-To
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_envelope_sets_reply_to_when_provided():
|
|
msg = build_envelope(**_base_kwargs(reply_to="ohm@wiggleverse.org"))
|
|
assert msg["Reply-To"] == "ohm@wiggleverse.org"
|
|
|
|
|
|
def test_envelope_omits_reply_to_when_absent():
|
|
msg = build_envelope(**_base_kwargs())
|
|
assert msg["Reply-To"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# List-Unsubscribe (the headers RFC 8058 / Gmail-Yahoo care about)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_envelope_no_list_unsubscribe_when_neither_given():
|
|
"""OTC mail: the recipient explicitly requested the code; no
|
|
unsubscribe semantics. The header MUST be absent (presence would
|
|
imply OHM has the recipient on a list, which it doesn't)."""
|
|
msg = build_envelope(**_base_kwargs())
|
|
assert msg["List-Unsubscribe"] is None
|
|
assert msg["List-Unsubscribe-Post"] is None
|
|
|
|
|
|
def test_envelope_mailto_only_list_unsubscribe():
|
|
"""Admin invite / per-RFC invite: `mailto:` form only, no URL.
|
|
The recipient isn't a user yet, so there's no per-user opt-out
|
|
URL to flip; the operator handles ad-hoc opt-outs manually."""
|
|
msg = build_envelope(**_base_kwargs(
|
|
unsubscribe_mailto="ohm@wiggleverse.org?subject=remove",
|
|
))
|
|
assert msg["List-Unsubscribe"] == "<mailto:ohm@wiggleverse.org?subject=remove>"
|
|
# NO List-Unsubscribe-Post when only a mailto is present — the
|
|
# one-click semantic requires a URL the MUA can POST to.
|
|
assert msg["List-Unsubscribe-Post"] is None
|
|
|
|
|
|
def test_envelope_full_one_click_list_unsubscribe():
|
|
"""Watcher notification / bundle: `mailto:` + signed-URL +
|
|
`List-Unsubscribe-Post: List-Unsubscribe=One-Click`. Gmail and
|
|
Yahoo enforce this for bulk-adjacent mail per RFC 8058."""
|
|
msg = build_envelope(**_base_kwargs(
|
|
unsubscribe_mailto="ohm@wiggleverse.org?subject=remove",
|
|
unsubscribe_url="https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc",
|
|
))
|
|
lu = msg["List-Unsubscribe"]
|
|
assert "<mailto:ohm@wiggleverse.org?subject=remove>" in lu
|
|
assert "<https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc>" in lu
|
|
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
|
|
|
|
|
|
def test_envelope_url_only_list_unsubscribe_still_sets_post():
|
|
msg = build_envelope(**_base_kwargs(
|
|
unsubscribe_url="https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc",
|
|
))
|
|
assert msg["List-Unsubscribe"] == "<https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc>"
|
|
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Body shape — plain-only vs multipart/alternative
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_envelope_plain_only_body_is_text_plain():
|
|
msg = build_envelope(**_base_kwargs())
|
|
# No HTML alternative -> single-part text/plain.
|
|
assert msg.get_content_type() == "text/plain"
|
|
assert msg.get_content().strip() == "Hello, world."
|
|
|
|
|
|
def test_envelope_with_html_is_multipart_alternative():
|
|
msg = build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
|
|
assert msg.get_content_type() == "multipart/alternative"
|
|
# Two parts: text/plain first (so plain-text clients picking the
|
|
# first part get the readable text), text/html second.
|
|
parts = list(msg.iter_parts())
|
|
assert len(parts) == 2
|
|
assert parts[0].get_content_type() == "text/plain"
|
|
assert parts[1].get_content_type() == "text/html"
|
|
assert "Hello, world." in parts[0].get_content()
|
|
assert "<b>world</b>" in parts[1].get_content()
|