Emails
Transactional emails
Open up email.ex and you will see a list of functions that generate Swoosh email structs. Eg:
def confirm_register_email(email, url) do
base_email()
|> to(email)
|> subject("Confirm instructions")
|> render_body("confirm_register_email.html", %{url: url})
|> premail()
end
If I run this function in IEX I can see the Swoosh struct:
A Swoosh.Email struct can be delivered to an email address by a Swoosh mailer (see mailer.ex). Eg:
MyApp.Email.confirm_register_email(user.email, url)
|> MyApp.Mailer.deliver()
So email.ex creates the Swoosh structs and the functions for actually delivering emails like the previous code example are in user_notifier.ex. Think of email.ex functions like view templates, and user_notifier.ex functions like controller actions.
The steps to creating a new email:
-
Create the function to generate a Swoosh struct in
email.ex -
Ensure it looks good by navigating to
http://localhost:4000/dev/emailsand creating a new action inemail_testing_controller.exthat renders thehtml_bodyvalue of the struct -
Create a new
user_notifier.exfunction for use in your application
To see a more detailed write up on creating an email — see this section in the “Complete web app” guide.
Email provider: Resend
Petal Pro v4 uses Resend as the sole transactional email provider via the Swoosh.Adapters.Resend adapter. AWS SES has been removed.
Configuration
Set the RESEND_API_KEY environment variable (get it from your Resend dashboard):
fly secrets set RESEND_API_KEY="re_..."
In runtime.exs the mailer is configured automatically when the key is present:
config :petal_pro, PetalPro.Mailer,
adapter: Swoosh.Adapters.Resend,
api_key: System.fetch_env!("RESEND_API_KEY")
No MAIL_PROVIDER env var is needed — Resend is the only option.
Email template components
Petal Pro ships a set of HEEx email layout components you can compose to build consistent, well-styled transactional emails. These live in lib/petal_pro_web/components/email_components.ex.
Configurable section gap
The gap between sections in the email body is configurable via a gap assign on the wrapper component:
<.email_body gap="32px">
<.email_section>First section</.email_section>
<.email_section>Second section</.email_section>
</.email_body>
Gray button variant
In addition to the default primary button, a gray variant is available for secondary actions (e.g. “Unsubscribe”, “View in browser”):
<.email_button href={@url} variant="gray">
Manage preferences
</.email_button>
Callout boxes
Use callout boxes to draw attention to important information — warnings, confirmation codes, or key instructions:
<.email_callout>
Your confirmation code is <strong>482901</strong>. It expires in 15 minutes.
</.email_callout>
Callout boxes render with a subtle background and border, making them stand out from surrounding body text.
Marketing emails with Resend
Marketing emails are emails you wish to send to your whole subscriber base or some segment of them to push a marketing or product update. They differ from transactional emails in that you don’t program them in code — you compose them in the Resend dashboard and hit send.
We use Resend for marketing because it has a clean API, generous free tier, and a good unsubscribe / deliverability story out of the box. Transactional email continues to go through Swoosh + AWS SES — these are two separate concerns.
How sync works
Contacts (your users) sync to Resend automatically:
-
On sign-in, profile update, notification settings change, onboarding completion, and membership expiry, an Oban job (
PetalPro.Workers.ContactSyncWorker) runsPetalPro.MarketingEmail.sync_contact/1 - That call upserts the user as a Resend contact (email, first name, unsubscribed flag) and reconciles their segment membership
- Suspended or deleted users are removed from Resend entirely
To backfill existing users (e.g. after first deploy), run:
# In iex
PetalPro.MarketingEmail.sync_all_contacts()
# Or as a Mix task
mix petal_pro.sync_to_resend
Each user gets its own Oban job with random 0-600s jitter to stay under Resend’s default 2 req/sec rate limit.
Segments
Resend slices contacts via segments (formerly “audiences”). We use three:
| Env var | Members |
|---|---|
RESEND_SEGMENT_ID_ALL_USERS |
All confirmed, non-suspended, non-deleted users with setting_notification_subscribe_newsletter = true |
RESEND_SEGMENT_ID_MEMBERS |
Active Petal Pro members |
RESEND_SEGMENT_ID_EXPIRED_MEMBERS |
Users whose membership has expired and who have no active membership |
A user can be in multiple segments simultaneously (a current member is in both ALL_USERS and MEMBERS). On every sync we read the contact’s current segments from Resend, diff against the desired set, and add/remove explicitly via POST/DELETE /contacts/:id/segments/:segment_id.
To add a new segment:
- Create the segment in the Resend dashboard, capture its ID
-
Add a config key in
config/runtime.exs -
Add a clause in
PetalPro.MarketingEmail.desired_segments/1
Inbound webhook
Resend sends events to POST /webhooks/resend (handled by PetalProWeb.ResendWebhookController). Signatures are verified via svix HMAC against RESEND_WEBHOOK_SECRET.
| Event | Action |
|---|---|
contact.updated with unsubscribed: true |
Flips setting_notification_subscribe_newsletter to false on the matching user |
email.bounced |
Logged to Logs for review |
email.complained |
Logged to Logs for review (spam complaints damage sender reputation) |
Required env vars
RESEND_API_KEY=re_...
RESEND_SEGMENT_ID_ALL_USERS=...
RESEND_SEGMENT_ID_MEMBERS=...
RESEND_SEGMENT_ID_EXPIRED_MEMBERS=...
RESEND_WEBHOOK_SECRET=whsec_...