modules/email-operations.md
McRitchie Studio owns the cross-app email operating playbook. studio-engine
owns the shared Rails mechanics: Studio::MailTransport, Studio::Email.deliver,
the local agent inbox, SES helper tasks, and the engine outbox model.
Keep app-specific templates, copy, previews, and sender defaults in the owning
app. Keep provider selection, recovery rules, and cross-app sender inventory
here.
| Need | Canonical doc |
|---|---|
| Shared provider contract | studio-engine/docs/EMAIL_TRANSPORT.md |
| McRitchie implementation notes | mcritchie-studio/docs/email-delivery.md |
| Turf Monster implementation notes | turf-monster/docs/email-delivery.md |
| Credential item names | mcritchie-studio/docs/agents/modules/credential-inventory.md |
| Local ports and worktree stacks | mcritchie-studio/docs/agents/modules/ports-and-processes.md |
| App | Production app | Local URL | Local inbox | Transactional sender | Marketing sender | Durable outbox |
|---|---|---|---|---|---|---|
| McRitchie Studio | mcritchie-studio |
http://localhost:3000 |
http://localhost:3000/_studio/local_emails |
McRitchie Studio <team@mcritchie.studio> |
Alex McRitchie <alex@mcritchie.studio> |
studio_email_deliveries / Studio::EmailDelivery |
| Turf Monster | turf-monster-mainnet |
http://localhost:3100 |
http://localhost:3100/_studio/local_emails |
Turf Monster <team@turfmonster.media> |
Alex from Turf Monster <alex@turfmonster.media> |
email_deliveries / EmailDelivery |
| Future apps | TBD | reserve the app's hundred-block | http://localhost:<port>/_studio/local_emails |
App Name <team@app-domain> |
Alex McRitchie <alex@app-domain> or app-owned marketer |
prefer studio_email_deliveries |
Transactional auth/security/account emails should use the team@ convention.
Marketing, newsletter, or broadcast emails should use an alex@ sender when the
copy is intended to feel personal. Do not add a future app to real provider
delivery until its sender domain is recorded in this matrix.
Do not print secret values in docs, logs, or handoffs.
| Credential | Location | Used for |
|---|---|---|
agent.aws.mcritchie-ses |
agents 1Password vault |
Shared SES-scoped AWS credentials, region us-east-2 |
RESEND_API_KEY |
app env / Heroku config | Rollback provider only |
Runtime mail env:
MAIL_TRANSPORT=ses
SES_REGION=us-east-2
SES_SMTP_USERNAME=...
SES_SMTP_PASSWORD=...
MAILER_FROM="App Name <team@example.com>"
MARKETING_MAILER_FROM="Alex from App Name <alex@example.com>"
RESEND_MAILER_FROM="McRitchie Studio <team@mcritchie.studio>"
RESEND_API_KEY=... # rollback only
LOCAL_EMAIL_CAPTURE=1 # local/worktree proof mode
RESEND_MAILER_FROM is the shared fallback sender for apps that are waiting on
SES production access or have not completed their own SES setup yet. Keep
MAILER_FROM and MARKETING_MAILER_FROM app/domain-specific for the SES target
state; do not make future apps buy/verify extra Resend domains just to send
pre-SES auth mail.
The fallback sender is only valid if its domain is verified in the same Resend
account as RESEND_API_KEY. A successful app response ({"success":true} from
POST /magic_link) proves only that the app accepted the send intent; it does
not prove provider delivery. A provider smoke is complete only when the durable
outbox job finishes successfully and the provider accepts the message.
SES helper tasks use SES API credentials:
SES_AWS_ACCESS_KEY_ID=...
SES_AWS_SECRET_ACCESS_KEY=...
SES_REGION=us-east-2
The engine falls back to AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY for older
apps, but production should prefer SES_AWS_* so an app's S3/ImageCache IAM user
is not mistaken for the SES verification user. Runtime delivery uses the SES
SMTP username and password, which are a separate credential pair.
Last checked: 2026-06-15.
| Check | Result |
|---|---|
| SES region | us-east-2 |
| SES account sending | SendingEnabled=true |
| SES production access | ProductionAccessEnabled=false |
| SES enforcement | HEALTHY |
mcritchie.studio identity |
verified for sending, DKIM SUCCESS |
turfmonster.media identity |
verified for sending, DKIM SUCCESS |
| Resend fallback domain | mcritchie.studio verified in the Resend account backing production apps |
Conclusion: domain verification is ready, but production cutover is still
blocked. Do not set persistent MAIL_TRANSPORT=ses on production web dynos
until SES production access is approved and runtime SMTP credentials have passed
a one-off provider smoke test; sandbox mode can send only to verified recipient
identities/domains and would break normal user mail.
Live production check, 2026-06-15:
MAIL_TRANSPORT unset, so live
mail stays on Resend fallback.SES_AWS_ACCESS_KEY_ID / SES_AWS_SECRET_ACCESS_KEY are staged on both
Heroku apps from agent.aws.mcritchie-ses.bin/rails ses:check on both Heroku apps now uses
CredentialSource=SES_AWS_ACCESS_KEY_ID and returns HTTP 200 for
ses:GetAccount and ses:ListEmailIdentities.GetAccount reports SendingEnabled=true,
ProductionAccessEnabled=false, and Enforcement=HEALTHY.mcritchie.studio and turfmonster.media as VerificationStatus=SUCCESS
and SendingEnabled=true. The currently released helper may display those
identity-list rows as pending because it reads the older
VerifiedForSendingStatus field instead of VerificationStatus.MAIL_TRANSPORT unset while AWS production access remains unresolved.Production proof gaps:
studio-engine release before proofing a
provider cutover. Turf Monster release v93 proved the shared mail boot path
through Resend fallback; the remaining production proof is SES after sandbox
removal and SMTP credential staging.alex@mcritchie.studio and a Turf-approved inbox.Use real provider delivery for primary local stacks:
LOCAL_EMAIL_CAPTURE=0.RESEND_API_KEY and RESEND_MAILER_FROM are present while SES is
blocked by sandbox access.sent=true with no error.Use local capture for parallel worktree stacks or tasks that should not email
real recipients:
LOCAL_EMAIL_CAPTURE=1.Provider smoke tests are explicit, narrower tasks:
LOCAL_EMAIL_CAPTURE=0.RESEND_API_KEY plus a
verified RESEND_MAILER_FROM domain while SES is sandboxed, or
MAIL_TRANSPORT=ses plus SES SMTP env after cutover).bin/rails "email:smoke[approved-test-inbox@example.com]" from the app.The smoke task sends one direct ActionMailer message through the current
transport and refuses capture/test/file modes by default. Use
EMAIL_SMOKE_ALLOW_NON_EXTERNAL=1 only for explicit capture-mode proof, not for
provider proof.
Resend fallback verification, if delivery fails with "domain is not verified":
dig +short TXT resend._domainkey.mcritchie.studio
dig +short MX send.mcritchie.studio
dig +short TXT send.mcritchie.studio
Those public records must match Resend's domain page. Then trigger verification
in Resend and wait for the domain status to become verified before retrying
app email. Do not switch fallback senders to an app domain unless that domain is
also present and verified in the active Resend account.
Do not ask Mr. McRitchie to run terminal commands for these proofs. Ask for approval
or external access only when the agent cannot perform the check directly.
Run this once per sending domain.
us-east-2; sandbox mode can send only to
verified recipients.p=none is acceptable during
observation, but the record must exist.SES_SMTP_USERNAME, SES_SMTP_PASSWORD, SES_REGION, and the sender
env in the app's environment (MAILER_FROM for transactional mail, and
MARKETING_MAILER_FROM where the app sends marketing/newsletter mail).RESEND_API_KEY and RESEND_MAILER_FROM present during the migration
window.MAIL_TRANSPORT=ses.Useful app commands:
bin/rails ses:check
bin/rails "ses:verify_domain[example.com]"
bin/rails "email:smoke[approved-test-inbox@example.com]"
Marketing and broadcast surfaces reuse the same provider policy as
transactional email, but the product model stays app-owned. McRitchie Studio
owns Broadcast, Contact, BroadcastDelivery, unsubscribe routes,
engagement tracking, campaign templates, and S3-hosted email assets.
Minimum rules for broadcast surfaces:
LOCAL_EMAIL_CAPTURE=1.BROADCAST_HOST only for an intentional campaign-specific host override.If provider delivery fails but the app accepted the send intent, inspect the
durable outbox first.
McRitchie Studio:
bin/rails runner 'Studio::EmailDelivery.resend_unsent!'
Turf Monster:
bin/rails runner 'EmailDelivery.resend_unsent!'
If local capture looks empty, check LOCAL_EMAIL_CAPTURE, the app's route for
/_studio/local_emails, and whether the code path uses Studio::Email.deliver.
Rollback is intentionally simple while Resend remains available:
MAIL_TRANSPORT=resend
Unsetting MAIL_TRANSPORT also resumes Resend when RESEND_API_KEY is present.
Do not remove Resend env or dependencies until each production app has:
When those are true, open a cleanup issue or audit item before removing Resend
from code or infrastructure.
We emailed a one-tap sign-in link to . It expires shortly and can only be used once.
No email? Check spam, or close this and try again.