Email Delivery
PMFriend's email subsystem is designed around two ports that decouple business logic from any specific provider:
EmailSender— outbound transactional mail- inbound pipeline — SES → S3 → SNS → backend webhook
Multiple adapters can be wired behind each. Switching providers is a one- line config change, not a refactor.
Active providers
| Direction | Provider | Why |
|---|---|---|
| Outbound | Resend | AWS SES denied our sandbox-exit on first attempt without specifics — common for fresh accounts. Resend's transactional-only product approves immediately on domain-DNS verification, has a generous free tier, and shares the EmailSender port shape with SES so swapping back later is one line of config. |
| Inbound | AWS SES Inbound (ap-southeast-2) | Inbound has separate quotas from sandbox-exit so the email-to-maintenance feature is unaffected. See Email-to-maintenance Intake. |
The apex pmfriend.com is verified at Resend (DKIM + send subdomain). The
inbound.pmfriend.com subdomain is verified at SES with its own DKIM. Both
coexist with mailbox.org's apex MX (which serves human inbox + replies).
Provider switching
Set one environment variable on App Runner:
APP_EMAIL_PROVIDER=resend # current production
APP_EMAIL_PROVIDER=ses # if/when AWS approves us later
APP_EMAIL_PROVIDER=logging # local dev — prints to stdout
Each adapter is a Spring @Component activated by @ConditionalOnProperty.
The @Primary annotation on the active one wins the
EmailSender injection point.
Code locations:
infra/email/ResendEmailSender.javainfra/email/SesEmailSender.javainfra/email/LoggingEmailSender.java
What every adapter does
Every EmailSender implementation handles four cross-cutting concerns
identically — the contracts live on the port:
- Suppression-list check (
EmailSuppressionList.isSuppressed(...)) before the provider call. A recipient who has unsubscribed never gets another message regardless of which provider is active. - Multipart/alternative bodies — both HTML and plain-text are submitted; the provider composes the multipart MIME.
- Tags — passed through for per-template deliverability tracking (Resend dashboard / SES configuration set events).
- Failure semantics — never throws. Network errors / 4xx / 5xx /
timeout all degrade to
Result.failed(reason)so the calling use case can log + continue. The emailed-to row is the source of truth, not the email-was-sent flag.
Domain identity + DNS
| Subdomain | Purpose | DKIM | MX | SPF |
|---|---|---|---|---|
pmfriend.com (apex) | Human mail (mailbox.org) + outbound from-address (Resend) | mailbox.org + resend._domainkey | mailbox.org | v=spf1 include:mailbox.org include:amazonses.com ~all |
send.pmfriend.com | Resend bounce/return-path | (handled by Resend MX) | feedback-smtp.eu-west-1.amazonses.com | v=spf1 include:amazonses.com ~all |
inbound.pmfriend.com | SES inbound (email-to-maintenance) | 3 SES tokens | inbound-smtp.ap-southeast-2.amazonaws.com | (not needed — receive-only) |
mail.pmfriend.com | Reserved for SES outbound MAIL FROM | (configured) | (not needed) | v=spf1 include:amazonses.com ~all |
Resend uses send.pmfriend.com as bounce-handler, so the apex SPF doesn't
need include:_spf.resend.com — SPF check happens against the subdomain
during envelope-from validation.
DMARC is published at _dmarc.pmfriend.com with p=none (monitor only)
and rua=mailto:postmaster@pmfriend.com for aggregate reports. Tighten to
p=quarantine after two clean weeks of reports.
Current flow inventory
Brutally honest state — what actually fires email today:
Sends real email via Resend (production-active)
| Flow | Triggered by | Recipient | Template / Use case |
|---|---|---|---|
| Pilot application — confirmation | Public form on pmfriend.com | Applicant | PilotEmailTemplates.renderConfirmation |
| Pilot application — founder notification | Same form | support@pmfriend.com | PilotEmailTemplates.renderNotification |
| Password reset request | POST /auth/password-reset/request | The user | RequestPasswordReset (inline template) |
| Password reset confirmation | POST /auth/password-reset/confirm | The user | CompletePasswordReset (inline template) |
| Welcome email | New agency registration | New admin | RegisterAgencyAndAdmin (inline template) |
| Team invite | PM creates an invite | Invitee | CreateTeamInvite (inline template) |
| Owner digest | PM clicks "Send" on a draft | Owner | EmailDigestDispatcher (wraps plaintext in HTML) |
| Contractor magic-link | PM issues a contractor magic link | Contractor | IssueContractorMagicLink (inline template) |
| Owner approval request | Estimate exceeds owner-approval threshold during AssignContractor | Property's primary owner | AssignContractor#sendOwnerApprovalEmail (inline template) |
| Tenant — request acknowledged | PM triages a maintenance request | Reporting tenant | EmailTenantStatusNotifier#notifyAcknowledged |
| Tenant — contractor dispatched | Contractor assigned to WO sourced from a request | Reporting tenant | EmailTenantStatusNotifier#notifyDispatched |
| Tenant — work complete | WO transitions to COMPLETED | Reporting tenant | EmailTenantStatusNotifier#notifyCompleted |
Still gap (not yet built)
| Flow | Why it matters |
|---|---|
| Email verification at signup | Anyone can register with any email. Less critical while we invite pilots manually. |
| Bounce + complaint webhook | Resend webhook subscription not wired; hard-bounces don't auto-suppress. Manual handling for now. |
| Owner approval one-click URLs | The current owner-approval email is informational; owner replies to PM. A direct-approval portal is a roadmap item once we have a paid customer asking for it. |
Suppression list
Every EmailSender.send() consults EmailSuppressionList.isSuppressed(email)
before the provider call. Hits are skipped with an audit log line; the
caller's Result is failed("recipient on suppression list").
The suppression list is populated by:
- One-click unsubscribe — every outbound email's footer carries a
per-recipient HMAC-signed URL. Click → row inserted in
email_unsubscribes. RFC 8058List-Unsubscribe-Postis wired on the same endpoint so Gmail / Apple Mail surface a native Unsubscribe link in the inbox UI. - Future: bounce + complaint feedback — Resend webhook subscription to be wired so hard-bounces and ISP complaints auto-suppress. Today handled manually if it happens.
The list is global (no agency RLS) — a person who unsubscribed once should never receive PMFriend mail again, regardless of which agency might try to write to them later.
Privacy posture
- Outbound: only the message body is sent to Resend. Suppression list
- unsubscribe tokens never leave PMFriend. Sender display name resolves to the agency's name when relevant.
- Inbound: see Email-to-maintenance Intake for the privacy contract on what does (subject + plaintext body) and doesn't (sender, recipient, agency identifiers) reach Anthropic.
What's deferred
These are roadmap items captured in Product Roadmap §8a or the password-reset section of the same doc:
- Password reset — being built next
- Team-invite + contractor-magic-link auto-send — table-stakes for first paid pilot
- Welcome email after agency registration — first-impression polish
- Owner-digest migration to
EmailSender— replacesLoggingDigestDispatcher - Bounce + complaint webhook for automatic suppression-list updates
- Email verification at signup — currently anyone can register with any email; verification gate is a v2 concern once we're not inviting pilots manually
Configuration reference
app:
email:
provider: ${APP_EMAIL_PROVIDER:logging} # logging | ses | resend
region: ${APP_EMAIL_REGION:ap-southeast-2}
from-address: ${APP_EMAIL_FROM_ADDRESS:support@pmfriend.com}
from-name: ${APP_EMAIL_FROM_NAME:PMFriend}
configuration-set: ${APP_EMAIL_CONFIGURATION_SET:} # SES bounce/complaint events
resend:
api-key: ${APP_RESEND_API_KEY:} # required when provider=resend
http-timeout-ms: ${APP_RESEND_HTTP_TIMEOUT_MS:8000}
unsubscribe:
secret: ${APP_EMAIL_UNSUBSCRIBE_SECRET:} # HMAC signing key for unsubscribe URLs
public-base-url: ${APP_EMAIL_PUBLIC_BASE_URL:https://app.pmfriend.com}