PhoenixKitEmails

Elixir License: MIT

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.

Features

⚠️ Email templates are files now (core 3.0)

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 first

Review 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.

What the files look like

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

The html part: a body, not a whole document

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.

Variables in a template

A database template follows the same rules as a phoenix_kit_templates file:

When upgrading:

Installation

Add phoenix_kit_emails to your dependencies in mix.exs:

def deps do
  [
    {:phoenix_kit_emails, "~> 0.2"}
  ]
end

Then fetch dependencies:

mix deps.get

Note: 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.

Quick Start

  1. Add the dependency to mix.exs
  2. Run mix deps.get
  3. Enable the module in admin settings (email_enabled: true)
  4. Configure your AWS SES configuration set name in settings
  5. Email logs and analytics are available at /admin/emails

Usage

System management

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()

Email logging

# 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)

Analytics

# 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)

Webhook processing

# 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)

Maintenance

# 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

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

Permissions

The module declares permissions via permission_metadata/0:

Use Scope.has_module_access?/2 to check permissions in your application.

Architecture

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

Database Tables

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)

Address verification

PhoenixKit.Modules.Emails.Verification checks e-mail addresses (syntax, word lists, DNS MX) and stores one verdict per address; Verification.start_batch/4 verifies a whole list in the background, with progress on a PubSub topic. Batches run on the Oban queue email_verification, which the host's Oban config must declare (core 2.28.0 and later adds it on install/update; with an older core, or an app that was set up before, add it by hand):

config :my_app, Oban, queues: [email_verification: 5, ...]

Verification.queue_configured?/0 tells whether it is there. The mailbox (SMTP) check is off by default (email_verify_smtp_mode is off). With the mode runner the server never opens a connection to port 25 itself: it queues the addresses and a runner on the user's own machine, authenticated by an API key of core's machine API (core 2.63.0 and later), takes the work and reports what the mail hosts said. With the mode local the site's own server does the checking, from its own IP address (a built-in runner on the same queue, with the same pauses and throttling); leave it off when other sites share that address. See docs/smtp-runner.md for the runner script.

Development

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

Troubleshooting

Emails not appearing in admin

SES events not processing

Metrics showing zero data

License

MIT — see LICENSE for details.