Skip to main content

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

DirectionProviderWhy
OutboundResendAWS 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.
InboundAWS 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.java
  • infra/email/SesEmailSender.java
  • infra/email/LoggingEmailSender.java

What every adapter does

Every EmailSender implementation handles four cross-cutting concerns identically — the contracts live on the port:

  1. Suppression-list check (EmailSuppressionList.isSuppressed(...)) before the provider call. A recipient who has unsubscribed never gets another message regardless of which provider is active.
  2. Multipart/alternative bodies — both HTML and plain-text are submitted; the provider composes the multipart MIME.
  3. Tags — passed through for per-template deliverability tracking (Resend dashboard / SES configuration set events).
  4. 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

SubdomainPurposeDKIMMXSPF
pmfriend.com (apex)Human mail (mailbox.org) + outbound from-address (Resend)mailbox.org + resend._domainkeymailbox.orgv=spf1 include:mailbox.org include:amazonses.com ~all
send.pmfriend.comResend bounce/return-path(handled by Resend MX)feedback-smtp.eu-west-1.amazonses.comv=spf1 include:amazonses.com ~all
inbound.pmfriend.comSES inbound (email-to-maintenance)3 SES tokensinbound-smtp.ap-southeast-2.amazonaws.com(not needed — receive-only)
mail.pmfriend.comReserved 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)

FlowTriggered byRecipientTemplate / Use case
Pilot application — confirmationPublic form on pmfriend.comApplicantPilotEmailTemplates.renderConfirmation
Pilot application — founder notificationSame formsupport@pmfriend.comPilotEmailTemplates.renderNotification
Password reset requestPOST /auth/password-reset/requestThe userRequestPasswordReset (inline template)
Password reset confirmationPOST /auth/password-reset/confirmThe userCompletePasswordReset (inline template)
Welcome emailNew agency registrationNew adminRegisterAgencyAndAdmin (inline template)
Team invitePM creates an inviteInviteeCreateTeamInvite (inline template)
Owner digestPM clicks "Send" on a draftOwnerEmailDigestDispatcher (wraps plaintext in HTML)
Contractor magic-linkPM issues a contractor magic linkContractorIssueContractorMagicLink (inline template)
Owner approval requestEstimate exceeds owner-approval threshold during AssignContractorProperty's primary ownerAssignContractor#sendOwnerApprovalEmail (inline template)
Tenant — request acknowledgedPM triages a maintenance requestReporting tenantEmailTenantStatusNotifier#notifyAcknowledged
Tenant — contractor dispatchedContractor assigned to WO sourced from a requestReporting tenantEmailTenantStatusNotifier#notifyDispatched
Tenant — work completeWO transitions to COMPLETEDReporting tenantEmailTenantStatusNotifier#notifyCompleted

Still gap (not yet built)

FlowWhy it matters
Email verification at signupAnyone can register with any email. Less critical while we invite pilots manually.
Bounce + complaint webhookResend webhook subscription not wired; hard-bounces don't auto-suppress. Manual handling for now.
Owner approval one-click URLsThe 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:

  1. One-click unsubscribe — every outbound email's footer carries a per-recipient HMAC-signed URL. Click → row inserted in email_unsubscribes. RFC 8058 List-Unsubscribe-Post is wired on the same endpoint so Gmail / Apple Mail surface a native Unsubscribe link in the inbox UI.
  2. 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 — replaces LoggingDigestDispatcher
  • 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}