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:
parent
0f19da5ba8
commit
a90979a46c
5 changed files with 117 additions and 2 deletions
|
|
@ -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@<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)
|
||||
|
||||
Disk-backed vocal isolation pipeline using spectral mid-side Wiener masking
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" <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(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <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):
|
||||
"""MPS-20 + MPS-21: email helpers format templates with the right
|
||||
fields. Mocks the underlying send_pyramid_email to verify the call."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue