Email tracking, analytics, and AWS SES integration for PhoenixKit. Drop-in email logging, delivery event tracking, engagement metrics, templating, rate limiting, and an admin dashboard.
- Email logging — comprehensive logging of all outgoing emails with optional body/header storage
- AWS SES integration — SQS-based event polling for delivery, bounce, complaint, open, and click events
- Analytics & metrics — system stats, engagement rates, campaign performance, and provider comparison
- Email templates — file-based now (
phoenix_kit_templates); this package keeps only the one-time export of legacy database rows - Rate limiting — configurable protection against abuse and spam via Hammer
- Blocklist — manage blocked email addresses
- Queue management — view and manage outgoing email queue
- Archival — automatic cleanup, body compression, and S3 archival of old email data
- Webhook processing — process incoming delivery/bounce/complaint webhooks
- CSV export — export email logs via controller endpoint
- Email open/click tracking — pixel tracking and link rewriting for engagement metrics
- Admin dashboard — LiveView pages for logs, metrics, queue, blocklist, and settings
- Auto-discovery — implements
PhoenixKit.Modulebehaviour; PhoenixKit finds it at startup with zero config
Address verification is no longer part of this package. The Verification
API and workers, SMTP settings section, runner download route and machine-API
tools (emails.claim_smtp_jobs and emails.report_smtp_results) are removed.
Newsletters uses a host-configured verification engine instead.
If you used this feature, prepare the host while the old version is still running, before updating this dependency:
- Stop starting verification batches and stop external SMTP runners. In
Settings → Email Sending → Mailbox verification (SMTP), switch the SMTP mode
off; this settles queued SMTP checks. Remove any verification worker from
Oban.Plugins.Cron, especiallyPhoenixKit.Modules.Emails.Verification.QueueSweepWorker. A cron entry for a removed worker prevents Oban from starting after the upgrade. - Keep the
email_verificationqueue configured while in-flight batches finish. Wait for executing jobs to finish and for remaining batch work to settle. Scheduled sweep/runner jobs can remain after that. Pause the verification queue on every node, wait for executing jobs to finish, then cancel the remainingavailable,scheduledandretryablejobs for these workers through the host's Oban instance:PhoenixKit.Modules.Emails.Verification.BatchStartWorker,PhoenixKit.Modules.Emails.Verification.VerifyEmailWorker,PhoenixKit.Modules.Emails.Verification.QueueSweepWorkerandPhoenixKit.Modules.Emails.Verification.LocalRunnerWorker. - Remove
email_verificationfrom the host's Oban queues, then update the dependency. Leaving jobs behind makes them fail as unknown workers if the queue still runs, or leaves them unprocessed if the queue is removed. - Revoke runner API keys with scope
emails.smtp_runner:run. If the machine API was enabled only for the runner, disable it under Settings → API. Remove host calls toPhoenixKit.Modules.Emails.Verificationand its submodules; they have no replacement API in this package.
Hosts that never used verification can update directly; remove any configured verification cron entries or queue as well.
Existing verification tables and their rows are preserved, including batch,
domain-cache, SMTP-queue and runner records. Migrations V4 and V5 retain their
version numbers but create nothing; fresh installs get no verification tables,
and repair no longer manages them. Old email_verify_* settings and
:phoenix_kit_emails :verification_* configuration keys are no longer read.
Message templates no longer live in the database. They are files a host owns in
its own repository — version-controlled and reviewable — that core reads through
phoenix_kit_templates, over the defaults core and each module ship. The
template editor, the database layer and the phoenix_kit_email_templates table
are retired: a fresh install never creates the table.
Before upgrading from core 2.x, export your edits. Core 3.0 no longer reads the table, so an edit that is only in the database stops taking effect:
mix phoenix_kit_emails.templates.export # --dry-run to preview firstReview the diff and commit the files. Nothing is deleted and no row is touched (this package never drops the table), so an export that turns out wrong costs nothing but the files. On a database without the table the task stops and says so.
The task exports only what you actually edited. A template still identical to what this package ships is skipped, because core supplies those itself now, translated into every shipped locale. On an install that never opened the template editor it writes nothing at all, and you can stop reading here.
Newsletter layouts (is_system: false, picked per broadcast) are not affected
— those are authored at runtime by people who cannot open a pull request, so
they keep a table and an editor.
The template name becomes a directory; each part is a file, and any locale that is not the fallback carries its code:
priv/phoenix_kit_templates/
└── register/
├── subject.txt # the fallback locale — what everyone falls through to
├── subject.uk.txt
├── text.txt
└── html.html
A stored html_body is a whole HTML document with its own container, header,
footer and <style>. Core wraps an email built from a file in a shared layout
(PhoenixKit.Email.Layout) but never wraps a document, so a document exported
verbatim keeps its old chrome and the layout never applies. Where core has the
layout, the export writes the body fragment only: what sits between the
template's header and footer, with the styles the body needs (the button, the
notice boxes) inlined. The email's <h1> title, a footer that carries a
placeholder (the "copy this link" fallback, billing's company details) and
anything outside the header and footer blocks are content, not decoration, and
stay in the body. Only the decorative wrapping goes; any text it held is listed
in the output so it can be moved into your own _layout.
mix phoenix_kit_emails.templates.export --html document # the stored document, as before--html is auto by default: body when the loaded core has the layout,
document when it does not (a fragment on such a core would be sent bare). One
known gap: billing's {{{line_items_html}}} inserts rows styled by classes from
the removed <style>, so they render unstyled until billing styles its own rows;
the task warns about it.
A database template follows the same rules as a phoenix_kit_templates file:
{{name}}is HTML-escaped in the HTML body;{{{name}}}is inserted raw, for a value that is already rendered HTML. Subject and text body are plain text and never escaped.{{ name }}is{{name}}. A name is an identifier —{{user.name}}or an unclosed{{nameis literal text — and an unbound placeholder is left as written.- A value is a string, number, atom or a
String.Charsstruct (Date,Decimal, ...). A map or list used by a placeholder raisesArgumentError.
When upgrading:
mix phoenix_kit.updateruns this package's V3 migration, which rewrites billing's{{line_items_html}}and{{line_items_table_html}}to the raw form in stored HTML bodies. Those two names are inserted raw in either form anyway.- A template you wrote yourself that passes ready-made HTML in another variable
needs
{{{name}}}now — otherwise the markup arrives escaped. - A list or charlist value that a placeholder uses is an
ArgumentErrornow; before, it was silently turned into text. - A name with a dot or a dash (
{{user.name}},{{a-b}}) is literal text, not a variable. - Code up to 0.5.4 renders
{{{x}}}as{+ value +}. If you roll back,mix phoenix_kit.updateto a version below V3 restores the double braces for the two billing names, but a{{{name}}}you wrote yourself stays as written.
Add phoenix_kit_emails to your dependencies in mix.exs:
def deps do
[
{:phoenix_kit_emails, "~> 0.2"}
]
endThen fetch dependencies:
mix deps.getNote: For development or if not yet published to Hex, you can use:
{:phoenix_kit_emails, github: "BeamLabEU/phoenix_kit_emails"}
PhoenixKit auto-discovers the module at startup — no additional configuration needed.
- Add the dependency to
mix.exs - Run
mix deps.get - Enable the module in admin settings (
email_enabled: true) - Configure your AWS SES configuration set name in settings
- Email logs and analytics are available at
/admin/emails
alias PhoenixKit.Modules.Emails
# Check if system is enabled
Emails.enabled?()
# Enable/disable the system
Emails.enable_system()
Emails.disable_system()
# Get current configuration
config = Emails.get_config()# Create a log entry
{:ok, log} = Emails.create_log(%{
to: "user@example.com",
from: "noreply@example.com",
subject: "Welcome!",
status: "sent"
})
# List logs with filters
logs = Emails.list_logs(%{status: "sent", page: 1})
# Get a specific log
log = Emails.get_log!(log_uuid)# Get system statistics
stats = Emails.get_system_stats(:last_30_days)
# => %{total_sent: 5000, delivered: 4850, bounce_rate: 2.5, open_rate: 23.4}
# Get campaign performance
campaign_stats = Emails.get_campaign_stats("newsletter_2024")
# => %{total_sent: 1000, delivery_rate: 98.5, open_rate: 25.2, click_rate: 4.8}
# Get engagement metrics
metrics = Emails.get_engagement_metrics(:last_7_days)
# Get provider performance comparison
performance = Emails.get_provider_performance(:last_30_days)# Process an incoming webhook from AWS SES
{:ok, event} = Emails.process_webhook_event(webhook_data)
# List events for a specific log
events = Emails.list_events_for_log(log_uuid)# Clean up old logs (by retention days)
{deleted_count, _} = Emails.cleanup_old_logs(90)
# Compress old email bodies to save storage
Emails.compress_old_bodies(30)
# Archive old data to S3
Emails.archive_to_s3(180)Settings are managed through the PhoenixKit Settings API and can be configured via the admin UI at /admin/settings/email-sending.
| Key | Type | Default | Description |
|---|---|---|---|
email_enabled |
boolean | false |
Enable/disable the email system |
email_save_body |
boolean | false |
Save full email body (vs preview only) |
email_save_headers |
boolean | false |
Save email headers |
email_ses_events |
boolean | false |
Manage AWS SES delivery events |
email_retention_days |
integer | 90 |
Days to keep email logs |
aws_ses_configuration_set |
string | — | AWS SES configuration set name |
email_compress_body |
integer | — | Compress body after N days |
email_archive_to_s3 |
boolean | false |
Enable S3 archival |
email_s3_bucket |
string | — | S3 bucket archives are written to |
email_s3_integration |
string | — | Integrations UUID that signs uploads (unset = ExAws chain) |
email_sampling_rate |
integer | — | Percentage of emails to fully log |
email_create_placeholder_logs |
boolean | false |
Create placeholder logs for orphaned events |
The module declares permissions via permission_metadata/0:
"emails"— access to email admin dashboard and all sub-pages
Use Scope.has_module_access?/2 to check permissions in your application.
lib/
mix/tasks/
phoenix_kit_emails.install.ex # Install mix task
phoenix_kit/modules/emails/
emails.ex # Context + PhoenixKit.Module behaviour
application_integration.ex # Application-level integration
archiver.ex # S3 archival logic
archive_worker.ex # Hourly Oban cron that calls the archiver
email_log_data.ex # Email log data struct
event.ex # Delivery/bounce/complaint event schema
interceptor.ex # Email interceptor for logging
log.ex # Email log schema
metrics.ex # Analytics and metrics queries
paths.ex # Centralized URL path helpers
provider.ex # Provider behaviour
rate_limiter.ex # Rate limiting via Hammer
sqs_polling_job.ex # Oban job for SQS polling
sqs_polling_manager.ex # SQS polling lifecycle management
sqs_processor.ex # SQS message processing
supervisor.ex # OTP Supervisor
table_columns.ex # Admin table column definitions
template.ex # Legacy template row (read-only, for the export)
templates.ex # send_email/4 facade + legacy defaults/rows for the export
utils.ex # Shared utilities
web/
blocklist.ex # Blocklist admin LiveView
details.ex # Email log detail LiveView
emails.ex # Email logs admin LiveView
email_tracking.ex # Open/click tracking
export_controller.ex # CSV export controller
metrics.ex # Metrics dashboard LiveView
queue.ex # Queue management LiveView
routes.ex # Route definitions
settings.ex # Settings admin LiveView
webhook_controller.ex # Webhook endpoint controller
| Table | Description |
|---|---|
phoenix_kit_email_logs |
Email log records (UUIDv7 PK) |
phoenix_kit_email_events |
Delivery/bounce/complaint/open/click events |
phoenix_kit_email_templates |
Retired: no longer created; hosts upgraded from core 2.x keep it (read only by the export) |
mix deps.get # Install dependencies
mix test # Run tests
mix format # Format code
mix credo --strict # Static analysis (strict mode)
mix dialyzer # Type checking
mix docs # Generate documentation
mix precommit # Compile + format + credo + dialyzer
mix quality # Format + credo + dialyzer- Verify
email_enabledistruein settings - Ensure the module is listed as a dependency in the parent app's
mix.exs
- Verify
email_ses_eventsis enabled in settings - Check that
aws_ses_configuration_setis configured - Ensure SQS queue permissions allow the application to poll
- Review Oban dashboard for failed SQS polling jobs
- Confirm emails are being logged (check
/admin/emails) - Verify the date range filter matches when emails were sent
MIT — see LICENSE for details.