phoenix_kit_newsletters
Newsletters module for PhoenixKit — email broadcasts and subscription management.
Installation
Add to your mix.exs:
def deps do
[
{:phoenix_kit_newsletters, "~> 0.2"}
# During development (before Hex publish):
# {:phoenix_kit_newsletters, github: "BeamLabEU/phoenix_kit_newsletters"}
]
end
Oban Setup
PhoenixKit Newsletters uses Oban for background email delivery. Add the
newsletters_delivery queue to your Oban configuration:
# In config/config.exs:
config :my_app, Oban,
repo: MyApp.Repo,
queues: [newsletters_delivery: 10]
The queue name must be exactly newsletters_delivery — that is the queue
PhoenixKit.Newsletters.Workers.DeliveryWorker enqueues into. A host that
names it anything else enqueues jobs nothing drains and never sends a single
email. mix phoenix_kit.install adds this queue for you.
Queue concurrency is the delivery-rate ceiling. Per-broadcast pacing is
configured separately, on the send profile (rate_per_hour, rate_per_day,
pause_seconds), and applies to one broadcast at a time.
See the Oban documentation for full configuration options.
How It Works
PhoenixKit Newsletters auto-registers with PhoenixKit on startup — no additional configuration needed.
- Run
mix deps.get - Recompile PhoenixKit routes:
mix deps.compile phoenix_kit --force - Restart your server
- Enable the module in Admin > Modules > Newsletters
This module owns and versions the future shape of its 2 tables
(phoenix_kit_newsletters_broadcasts, phoenix_kit_newsletters_deliveries)
through its own migration chain, PhoenixKitNewsletters.Migrations. Core's
V135 baseline still CREATEs both tables on every install — this chain's
V1 adopts that current shape (verbatim, including the five later core
migrations that layered send profiles, CRM-sourced recipients and
attachments on top) and stamps a version marker; it changes nothing else.
mix phoenix_kit.update in the host discovers and drives this chain the
same way it drives core's own.
Requirements
| Dependency | Version |
|---|---|
| Elixir | ~> 1.18 |
| PhoenixKit | ~> 2.52 |
| Phoenix LiveView | ~> 1.1 |
| Oban | ~> 2.20 |
| MDEx | ~> 0.13 |
Architecture
PhoenixKit Newsletters implements the PhoenixKit.Module behaviour and plugs into the host PhoenixKit app. It depends on the host for Repo, Mailer, Endpoint, Users, and Settings.
Core Schemas
All schemas use UUIDv7 primary keys.
| Schema | Description |
|---|---|
Broadcast |
Email content (Markdown → HTML); status lifecycle: draft → scheduled → sending → sent, plus cancelled/failed. Audience is either a CRM contact list (source_type "crm_list", crm_list_uuid) or a set of core roles (source_type "user_group", source_params["role_uuids"]). Up to 10 Storage file attachments. |
Layout |
An operator-authored wrapper a broadcast is sent inside. One row per layout; display_name, subject, html_body, text_body are maps keyed by language. Status active/archived. Broadcast.template_uuid is a foreign key to it. |
Delivery |
Per-recipient tracking record; status: pending → sent → delivered / opened / bounced / failed / blocked. Exactly one owner: user_uuid (role recipient) or crm_contact_uuid + recipient_email (CRM recipient) — enforced by a DB CHECK constraint. |
There is no List/ListMember model any more — the old newsletters-owned
mailing lists were replaced by CRM contact lists / core roles and dropped
from the schema by core's V156.
Broadcast Sending Pipeline
Broadcaster orchestrates sending:
- Validates the source (a
crm_listbroadcast's list must beactive), renders Markdown to HTML, flips the broadcast tosending - Resolves recipients once (deduplicated, opted-out/inactive excluded)
- Inserts
Deliveryrecords in batches of 500 viainsert_all, inside one transaction - Enqueues one
DeliveryWorkerOban job per delivery, paced by the send profile's rate limits
DeliveryWorker sends individual emails and tracks delivery status. Each email is
rendered in the recipient's language (RecipientLanguage: a user's preferred locale; a
CRM contact's locale, else the list's; else the site's content language):
- With a layout, the layout's HTML in that language (falling back to the base language,
the site's language, then any) is the document. The broadcast goes where it places
{{{content}}}(the older{{content}}still works), then one pass fills the variables:{{name}},{{email}},{{unsubscribe_url}},{{preferences_url}}, and core's email chrome —{{{header}}},{{{footer}}},{{logo_url}},{{accent_color}},{{site_name}},{{site_url}},{{subject}}.{{…}}values are HTML-escaped;{{{…}}}are inserted raw. - Without a layout, the broadcast is wrapped in core's standard email layout for the
newslettersgroup, so a host's_layout-newsletters/_header-newsletters/_footer-newsletterstemplate files (or the shared_layout/_header/_footer) apply. - A layout's subject, when it contains
{{subject}}, is a pattern around the broadcast's subject. The text part is never wrapped.
Layouts are managed under Newsletters → Layouts in the admin. Rows created before this table existed (operator-authored email templates, plus any system email a broadcast or the default-layout setting still pointed at, as archived) were carried over by migration V2 under their own uuids.
Modules
| Module | Role |
|---|---|
Newsletters |
Main context — CRUD for broadcasts, deliveries, scheduled processing |
PhoenixKitNewsletters.Migrations |
This module's own versioned migration chain (migration_module/0) |
Layouts |
Layout context — list/get/create/update/archive/restore, default layout |
Render |
Builds one email's subject, HTML and text (layout, language, core chrome) |
Broadcaster |
Validates, renders and sends a broadcast; enqueues Oban jobs |
DeliveryWorker |
Oban worker — sends individual emails, tracks delivery status |
CRMSource |
Soft-dependency bridge to phoenix_kit_crm contact lists |
UserGroupSource |
Role-based audience resolution and opt-out |
Paths |
Centralized path helpers — always use instead of hardcoding URLs |
Web.Broadcasts |
Admin LiveView — broadcasts index |
Web.BroadcastEditor |
Admin LiveView — create/edit broadcast with Markdown editor |
Web.BroadcastDetails |
Admin LiveView — delivery stats and recipient list |
Web.LayoutsIndex |
Admin LiveView — layouts list, archive/restore, default layout |
Web.LayoutEditor |
Admin LiveView — create/edit a layout per language, with preview |
Web.PreferenceCenterLive |
Public LiveView — self-service subscription preferences (token or login) |
Web.UnsubscribeController |
Public controller — confirm/unsubscribe/one-click flow |
Web.Routes |
Public route definitions via route_module/0 |
Settings
| Key | Default | Description |
|---|---|---|
newsletters_enabled |
false |
Enables/disables the module |
newsletters_default_template |
— | UUID of the layout new broadcasts start with (set from the Layouts page) |
from_email |
noreply@example.com |
Sender email address (shared with core, used by the legacy send path) |
from_name |
Newsletter |
Sender display name (shared with core, used by the legacy send path) |
Unsubscribe Flow
Tokens are Phoenix.Token, max_age 7 days, with three distinct salts —
never merge them, a per-list link must never open the full preference page:
| Salt | Payload | Used by |
|---|---|---|
"unsubscribe" |
%{contact_uuid, crm_list_uuid} |
Per-list CRM unsubscribe link |
"newsletters_user_optout" |
%{user_uuid} |
Role-recipient ("user_group") opt-out link |
"newsletters_preferences" |
%{contact_uuid} |
Preference-center link |
| Route | Behaviour |
|---|---|
GET /newsletters/unsubscribe?token= |
Confirm page only; never mutates |
POST /newsletters/unsubscribe (scope=list) |
Removes the contact from that CRM list |
POST /newsletters/unsubscribe (scope=all) |
Contact-level opt-out across every list |
POST /newsletters/unsubscribe (scope=role_optout) |
Records the role recipient's opt-out |
GET /newsletters/unsubscribe/one-click |
Redirects to the confirm page, never mutates |
POST /newsletters/unsubscribe/one-click |
RFC 8058 one-click unsubscribe, always answers 200 |
GET /newsletters/preferences[?token=] |
Preference center (token, or an authenticated user's linked contact) |
The UnsubscribeController verifies the token and performs the requested
action; only POST handlers ever mutate state — a mail client's automatic
link-scanning GET never unsubscribes anyone.
Removing this module
There is deliberately no automated uninstall.
PhoenixKitNewsletters.Migrations.down/1 never drops either table or a row
in them, for any target version — a host that merely removes this
dependency from mix.exs has not consented to deleting every broadcast's
history and per-recipient delivery/bounce tracking, and a migration whose
result depended on which packages happen to be compiled in would be
nondeterministic. Removing the data is therefore a deliberate, manual
operator step, in FK-safe order (children before parents):
-- Only after removing :phoenix_kit_newsletters from mix.exs, and only if
-- you actually want every broadcast and its delivery history gone for
-- good. Does NOT touch phoenix_kit_email_send_profiles — that table
-- belongs to core, not this module.
DROP TABLE phoenix_kit_newsletters_deliveries;
DROP TABLE phoenix_kit_newsletters_broadcasts;
Dropping phoenix_kit_newsletters_broadcasts last also removes the
pknl_schema:<N> version marker, which is a COMMENT on that table — no
separate step is needed.
If you want to keep the tables (e.g. you plan to reinstall the module later) but stop this chain from tracking them, clear the version marker instead:
COMMENT ON TABLE phoenix_kit_newsletters_broadcasts IS NULL;
Development
mix deps.get # Install dependencies
mix test # Run all tests
mix format # Format code
mix credo # Lint / code quality
mix dialyzer # Static type checking