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@<domain>,
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.
This commit is contained in:
russell@unturf.com 2026-05-12 11:13:11 -04:00
parent 0f19da5ba8
commit a90979a46c
No known key found for this signature in database
5 changed files with 117 additions and 2 deletions

View file

@ -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. **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@<shop-domain>`. 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) ### Karaoke Pipeline (lib/karaoke.py)
Disk-backed vocal isolation pipeline using spectral mid-side Wiener masking Disk-backed vocal isolation pipeline using spectral mid-side Wiener masking

View file

@ -51,6 +51,15 @@ app.make_post_sell.root_domain_owner_email = ${MAKE_POST_SELL_DOMAIN_OWNER_EMAIL
# app.email.relay = localhost:8025 # 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 # TODO: these values should be moved to environment variables & we should create an
# example app.env environment file. # example app.env environment file.
app.bucket.secure_uploads = ${MPS_APP_MAIN_BUCKET} app.bucket.secure_uploads = ${MPS_APP_MAIN_BUCKET}

View file

@ -1,6 +1,20 @@
# MPS-23 — Consolidated transactional sender identity + shop contact email # 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 ## Problem

View file

@ -28,6 +28,7 @@ import dkim
import smtplib import smtplib
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import formataddr
# Catch socket errors when postfix isn't running... # Catch socket errors when postfix isn't running...
from socket import error as socket_error from socket import error as socket_error
@ -36,6 +37,17 @@ import logging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def format_from_header(from_name, sender_email):
"""Build the From header value.
With a display name -> '"Acme Shop" <no-reply@origin.makepostsell.com>'.
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( def send_email(
to_email, to_email,
sender_email, sender_email,
@ -47,6 +59,7 @@ def send_email(
dkim_selector="", dkim_selector="",
dkim_signature_algorithm="ed25519-sha256", dkim_signature_algorithm="ed25519-sha256",
debug_mode=False, debug_mode=False,
from_name="",
): ):
# The `email` library assumes it is working with string objects. # The `email` library assumes it is working with string objects.
# The `dkim` library assumes it is working with byte 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_text, "plain"))
msg.attach(MIMEText(message_html, "html")) msg.attach(MIMEText(message_html, "html"))
msg["To"] = to_email msg["To"] = to_email
msg["From"] = sender_email msg["From"] = format_from_header(from_name, sender_email)
msg["Subject"] = subject msg["Subject"] = subject
try: try:
@ -151,6 +164,15 @@ def send_pyramid_email(request, to_email, subject, message_text, message_html):
"email.dkim_signature_algorithm", "ed25519-sha256" "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( send_email(
to_email, to_email,
sender_email, sender_email,
@ -162,6 +184,7 @@ def send_pyramid_email(request, to_email, subject, message_text, message_html):
dkim_selector, dkim_selector,
dkim_signature_algorithm, dkim_signature_algorithm,
request.debug_mode, request.debug_mode,
from_name,
) )

View file

@ -4817,6 +4817,71 @@ class TestAuctionQuantity(unittest.TestCase):
self.assertTrue(MpsAuction.is_lot_auction.fget(a)) 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 <no-reply@origin.makepostsell.com>",
)
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." <no-reply@origin.makepostsell.com>',
)
@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", "<p>html</p>")
# 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", "<p>html</p>")
args = mock_send_email.call_args[0]
self.assertEqual(args[-1], "Make Post Sell")
class TestEmailNotificationContent(unittest.TestCase): class TestEmailNotificationContent(unittest.TestCase):
"""MPS-20 + MPS-21: email helpers format templates with the right """MPS-20 + MPS-21: email helpers format templates with the right
fields. Mocks the underlying send_pyramid_email to verify the call.""" fields. Mocks the underlying send_pyramid_email to verify the call."""