From a90979a46c47c6110e1b816d5c1571279b76d5d6 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Tue, 12 May 2026 11:13:11 -0400 Subject: [PATCH] MPS-23: single warm sending identity for transactional mail All transactional mail now sends From app.email.sender (default no-reply@origin.makepostsell.com) instead of per-shop no-reply@, with the shop name (or email.from_name) as the display name. The origin identity is DKIM-signed (d=makepostsell.com) and SPF-authorized and relays via mx1's warm IP, so operator custom-domain shops stop getting spam-foldered. format_from_header() builds the From; send_email() gained a from_name kwarg. Reply-To / per-shop contact email still TODO. --- CLAUDE.md | 4 ++ development.ini | 9 ++++ docs/tickets/mps-23.md | 16 ++++++- make_post_sell/lib/mail.py | 25 ++++++++++- make_post_sell/tests/test_models.py | 65 +++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f698e4b..a8d884f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,10 @@ Files are NEVER streamed through uwsgi. Our server only generates presigned URLs **NEVER** use `request.app["bucket.secure_uploads"]`, `request.app["bucket.secure_uploads.get_endpoint"]`, or `request.secure_uploads_client` directly in views or templates. These are only used internally by `request_methods.py` as fallbacks. +### Transactional Email (lib/mail.py) + +All transactional mail (OTP login codes, receipts, sale/offer notifications, gift cards, invites) sends from **one warm sending identity**: `app.email.sender` (default `no-reply@origin.makepostsell.com`, overridable via `MPS_EMAIL_SENDER`) — *not* per-shop `no-reply@`. The recipient-facing name is the **shop name** when in shop context, else `app.email.from_name` (default `Make Post Sell`); `lib/mail.py:format_from_header()` builds the `From:` header. Why a single identity: operator custom-domain shops (e.g. `shop.unturf.com`) have no DKIM key MPS controls and don't authorize MPS's sending IPs in SPF, so per-domain `From:` lands in spam. `origin.makepostsell.com` is DKIM-signed by opendkim on the origin box (`d=makepostsell.com`, selector `20190727`) and SPF-authorized (`v=spf1 a a:mx1.foxhop.net -all`), and outbound is relayed through `mx1.foxhop.net` (warm IP, see `foxhop-pillar/postfix/makepostsell.sls` → `postfix_relayhost`). Reply-To / per-shop contact email is still TODO — see `docs/tickets/mps-23.md`. + ### Karaoke Pipeline (lib/karaoke.py) Disk-backed vocal isolation pipeline using spectral mid-side Wiener masking diff --git a/development.ini b/development.ini index ccccda3..a590e72 100644 --- a/development.ini +++ b/development.ini @@ -51,6 +51,15 @@ app.make_post_sell.root_domain_owner_email = ${MAKE_POST_SELL_DOMAIN_OWNER_EMAIL # app.email.relay = localhost:8025 +# Single warm sending identity for ALL transactional mail (OTP codes, receipts, +# sale/offer notifications, gift cards, invites) — regardless of which shop or +# custom domain it's for. origin.makepostsell.com is DKIM-signed (d=makepostsell.com) +# and SPF-authorized (relayed via mx1.foxhop.net). See docs/tickets/mps-23.md. +# The shop name (when in shop context) becomes the From display name; this is the +# fallback display name for platform mail with no shop context. +app.email.sender = ${MPS_EMAIL_SENDER:-no-reply@origin.makepostsell.com} +app.email.from_name = ${MPS_EMAIL_FROM_NAME:-Make Post Sell} + # TODO: these values should be moved to environment variables & we should create an # example app.env environment file. app.bucket.secure_uploads = ${MPS_APP_MAIN_BUCKET} diff --git a/docs/tickets/mps-23.md b/docs/tickets/mps-23.md index ba15cc2..92faa0a 100644 --- a/docs/tickets/mps-23.md +++ b/docs/tickets/mps-23.md @@ -1,6 +1,20 @@ # MPS-23 — Consolidated transactional sender identity + shop contact email -**Status:** Open (DNS prep done; needs a decision on the Reply-To / shop-contact-email approach before code lands) +**Status:** Core shipped (2026-05-12) — single sending identity + shop display +name + DNS are live. **Remaining:** the Reply-To / shop-contact-email decision +(see "Open question" below), and the optional `d=origin.makepostsell.com` DKIM +key for author-domain-exact alignment. + +## Shipped +- `app.email.sender` defaults to `no-reply@origin.makepostsell.com` (overridable + via `MPS_EMAIL_SENDER`); `app.email.from_name` defaults to `Make Post Sell`. +- `lib/mail.py`: `format_from_header()` helper; `send_pyramid_email()` sets the + From display name to `request.shop.name` when in shop context, else + `email.from_name`. `send_email()` gained a `from_name` kwarg. +- DNS: `origin.makepostsell.com TXT "v=spf1 a a:mx1.foxhop.net -all"` (in + `proxy.unturf.com/ingress/pdns-init.sh`). opendkim on the origin already signs + `*@*.makepostsell.com` with `d=makepostsell.com`, relayed via mx1's warm IP. +- Tests: `TestMailFromHeader` in `test_models.py`. ## Problem diff --git a/make_post_sell/lib/mail.py b/make_post_sell/lib/mail.py index d048026..1154454 100644 --- a/make_post_sell/lib/mail.py +++ b/make_post_sell/lib/mail.py @@ -28,6 +28,7 @@ import dkim import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from email.utils import formataddr # Catch socket errors when postfix isn't running... from socket import error as socket_error @@ -36,6 +37,17 @@ import logging log = logging.getLogger(__name__) +def format_from_header(from_name, sender_email): + """Build the From header value. + + With a display name -> '"Acme Shop" '. + Without -> the bare address. formataddr handles RFC-2047 encoding/quoting. + """ + if from_name: + return formataddr((from_name, sender_email)) + return sender_email + + def send_email( to_email, sender_email, @@ -47,6 +59,7 @@ def send_email( dkim_selector="", dkim_signature_algorithm="ed25519-sha256", debug_mode=False, + from_name="", ): # The `email` library assumes it is working with string objects. # The `dkim` library assumes it is working with byte objects. @@ -65,7 +78,7 @@ def send_email( msg.attach(MIMEText(message_text, "plain")) msg.attach(MIMEText(message_html, "html")) msg["To"] = to_email - msg["From"] = sender_email + msg["From"] = format_from_header(from_name, sender_email) msg["Subject"] = subject try: @@ -151,6 +164,15 @@ def send_pyramid_email(request, to_email, subject, message_text, message_html): "email.dkim_signature_algorithm", "ed25519-sha256" ) + # Recipient-facing sender name: the shop (when in shop context) so that one + # warm sending address (email.sender) can carry mail for every shop without + # the recipient seeing a generic no-reply@. Falls back to a configured + # platform name, then to nothing (bare address). + shop = getattr(request, "shop", None) + from_name = (getattr(shop, "name", "") if shop else "") or request.app.get( + "email.from_name", "" + ) + send_email( to_email, sender_email, @@ -162,6 +184,7 @@ def send_pyramid_email(request, to_email, subject, message_text, message_html): dkim_selector, dkim_signature_algorithm, request.debug_mode, + from_name, ) diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py index a138ca8..fe2a667 100644 --- a/make_post_sell/tests/test_models.py +++ b/make_post_sell/tests/test_models.py @@ -4817,6 +4817,71 @@ class TestAuctionQuantity(unittest.TestCase): self.assertTrue(MpsAuction.is_lot_auction.fget(a)) +class TestMailFromHeader(unittest.TestCase): + """MPS-23: From header carries a display name (the shop, or a configured + platform fallback) in front of the single warm sending address.""" + + def test_format_from_header_bare(self): + from ..lib.mail import format_from_header + + self.assertEqual( + format_from_header("", "no-reply@origin.makepostsell.com"), + "no-reply@origin.makepostsell.com", + ) + + def test_format_from_header_with_name(self): + from ..lib.mail import format_from_header + + self.assertEqual( + format_from_header("Acme Shop", "no-reply@origin.makepostsell.com"), + "Acme Shop ", + ) + + def test_format_from_header_quotes_special(self): + from ..lib.mail import format_from_header + + # formataddr quotes a display name containing a comma. + self.assertEqual( + format_from_header("Acme, Inc.", "no-reply@origin.makepostsell.com"), + '"Acme, Inc." ', + ) + + @mock.patch("make_post_sell.lib.mail.send_email") + def test_send_pyramid_email_uses_shop_name(self, mock_send_email): + from ..lib.mail import send_pyramid_email + from types import SimpleNamespace + + request = mock.Mock() + request.domain = "shop.example" + request.debug_mode = True + request.shop = SimpleNamespace(name="Acme Shop") + request.app = {"email.sender": "no-reply@origin.makepostsell.com"} + + send_pyramid_email(request, "buyer@example.com", "Hi", "text", "

html

") + # from_name is the final positional arg of send_email(). + _, kwargs = mock_send_email.call_args + args = mock_send_email.call_args[0] + self.assertEqual(args[-1], "Acme Shop") + self.assertEqual(args[1], "no-reply@origin.makepostsell.com") + + @mock.patch("make_post_sell.lib.mail.send_email") + def test_send_pyramid_email_falls_back_to_configured_name(self, mock_send_email): + from ..lib.mail import send_pyramid_email + + request = mock.Mock() + request.domain = "my.makepostsell.com" + request.debug_mode = True + request.shop = None + request.app = { + "email.sender": "no-reply@origin.makepostsell.com", + "email.from_name": "Make Post Sell", + } + + send_pyramid_email(request, "u@example.com", "Hi", "text", "

html

") + args = mock_send_email.call_args[0] + self.assertEqual(args[-1], "Make Post Sell") + + class TestEmailNotificationContent(unittest.TestCase): """MPS-20 + MPS-21: email helpers format templates with the right fields. Mocks the underlying send_pyramid_email to verify the call."""