Skip to main content
Documentation / Newsletters

Newsletters

Newsletters let admins compose and send Markdown emails to your contacts. They use the same outbound mail transport as transactional email, the same contact database, and the same admin panel — no separate service needed.

Disabled by default. The newsletter feature is opt-in. When disabled, no routes, services, or admin pages register at boot. Re-enabling later is a no-op migration; your data is always preserved.

Quick start

  1. Enable the feature in your host app (see Enabling newsletters for the per-framework env var).
  2. Re-seed permissions so admins get newsletters.manage and newsletters.send.
  3. Schedule the dispatcher to run every minute (cron, scheduler, or wp_schedule_event).
  4. Visit Newsletters in the admin sidebar → Lists → create a static list → add contacts → back to NewslettersNew newsletter → compose → Send Test to MeSchedule or Send Now.

Enabling newsletters

The flag is read at boot, so restart the host app after flipping it.

Framework Enable flag Notes
Laravel ESCALATED_ENABLE_NEWSLETTERS=true in .env php artisan escalated:install --with-newsletters sets this and seeds permissions.
NestJS EscalatedModule.forRoot({ enableNewsletters: true }) Pass via module options.
Rails config.enable_newsletters = true in your initializer Register a Markdown renderer via config.newsletter_markdown_renderer.
Django ESCALATED = {"enable_newsletters": True} in settings.py Plug in a Markdown converter via ESCALATED["newsletter_markdown_renderer"].
Adonis new NewsletterDispatcher({ enableNewsletters: true }) Wire into your scheduler.
Symfony Pass enabled: true to NewsletterDispatcher in services.yaml Twig namespace EscalatedNewsletter for themes.
Phoenix config :escalated, newsletter_tracking_enabled: true etc. Register newsletter_markdown_renderer: &Earmark.as_html!/1.
Go newsletter.NewRenderer(newsletter.Config{ EnableNewsletters: true, ... }) Engine-level; full controllers in a follow-up.
.NET Instantiate NewsletterRenderer with options EF Core migration generated by host integrator.
Spring Inject NewsletterRenderer.Options{ ... } as a @Bean JPA entities auto-derived via Hibernate.
WordPress Set option escalated_newsletters_enabled to 1 Schema added on plugin activation.
Filament Inherits Laravel; just register the plugin Resources are gated on the Laravel feature flag.

Lists

A list is a named recipient bucket. There are two kinds:

  • Static lists — you add specific contacts to the list manually, or import them via CSV. Membership is fixed until you change it.
  • Dynamic lists — you save a filter (e.g. "all contacts with at least 3 tickets opened in the last 90 days") and the list re-evaluates against your current contacts each time you send.

Both kinds source from your existing escalated_contacts table. There's no separate subscriber list — newsletter recipients are people who already exist as contacts in your support system.

When a contact has unsubscribed (set via the one-click link in any newsletter footer), they're automatically excluded from every newsletter, on every list, regardless of which list they're added to. See Unsubscribes and opt-out.

Creating a static list

  1. Newsletters → Lists → New list
  2. Pick Static.
  3. Open the new list. Add contacts by email (autocomplete against your contacts table) or Import CSV — one email per line.

Creating a dynamic list

  1. Newsletters → Lists → New list
  2. Pick Dynamic.
  3. Build the filter using the same rule editor as saved contact views (field, operator, value rules). The page shows a live "matches X contacts" counter.

Templates

A template is a reusable Markdown body + theme + optional default subject. Templates let your team share consistent authoring without copy-pasting.

Templates do not pre-fill recipients or schedule. A campaign references a template (via the "Start from template" dropdown on the compose page) and may override the body or theme on the way out.

Authoring a newsletter

The Compose page is a three-pane layout:

  • Left — metadata: subject, From name/email, Reply-To, target list, optional template, schedule.
  • Center — Markdown editor with an Insert merge field dropdown above. Merge fields use {{ contact.first_name }} syntax.
  • Right — live preview iframe. Re-renders against a sample contact as you type.

Merge fields (allowed)

  • {{ contact.name }} — full name (or empty)
  • {{ contact.first_name }} — first space-separated word of name (or empty)
  • {{ contact.email }} — email address
  • {{ contact.metadata.<key> }} — arbitrary contact metadata key (returns empty if unset)
  • {{ unsubscribe_url }} — the recipient's unique unsubscribe link
  • {{ view_in_browser_url }} — the recipient's unique view-in-browser link

Unknown merge fields render as empty strings. There is no eval-style template interpretation of user content — themes are the only place the host template engine runs.

Buttons

  • Save draft — saves the campaign without sending. Permission: newsletters.manage.
  • Send Test to Me — sends a single test email to the current agent's address, marked is_test = true so it doesn't pollute analytics. Permission: newsletters.manage.
  • Schedule — saves the campaign with a future scheduled_at. The dispatcher will pick it up at that time. Permission: newsletters.send.
  • Send Now — immediate planning + dispatch. Permission: newsletters.send.

The Schedule and Send Now buttons are hidden when outbound mail is not configured at the host level — Escalated will show a setup banner instead.

Themes

A theme is a server-side template file in your host's templating language. Every newsletter is rendered through a theme to get a styled HTML email with a consistent header, footer, and unsubscribe link.

Two starter themes ship with every backend:

  • default — clean single-column layout with system font and AA-contrast text.
  • branded — adds a colored header bar using your configured brand accent and an optional logo URL.

Customize by dropping a new template file into the conventional theme directory:

Framework Theme directory File extension
Laravel resources/views/vendor/escalated/newsletters/themes/ .blade.php
NestJS configured via newsletters.themesDir .hbs
Rails app/views/escalated/newsletter_themes/ .html.erb
Django escalated/templates/escalated/newsletter_themes/ .html
Adonis resources/views/newsletter_themes/ .edge
Symfony templates/newsletter_themes/ (Twig namespace EscalatedNewsletter) .html.twig
Phoenix priv/templates/newsletter_themes/ .html.eex
Go configured via Config.ThemesDir .html (Go html/template)
.NET Views/NewsletterThemes/ .html
Spring resources/templates/escalated/newsletter_themes/ .html
WordPress templates/newsletter_themes/ of the plugin .php

Each theme receives subject, body (the pre-rendered, merge-field-resolved HTML), unsubscribe_url, view_in_browser_url, and brand.name|accent|logo_url|physical_address.

Scheduling

The dispatcher must run on a recurring schedule (we recommend every minute) for scheduled newsletters to actually send. Wire the dispatcher call into whichever scheduler your stack uses.

Framework How to schedule
Laravel $schedule->command('escalated:newsletters:dispatch')->everyMinute()->withoutOverlapping();
Rails Call Escalated::Newsletter::Dispatcher.new.dispatch_batch from sidekiq-cron, GoodJob, or cron.
Django Call NewsletterDispatcher().dispatch_batch() from Celery beat, django-q, or cron.
Symfony Symfony Messenger / cron / Scheduler component invoking NewsletterDispatcher::dispatchBatch().
Adonis Ace command or cron invoking new NewsletterDispatcher({ ... }).dispatchBatch().
Phoenix Mix task or Oban / Quantum invoking Escalated.Services.Newsletter.Dispatcher.dispatch_batch/0.
WordPress wp_schedule_event('escalated_every_minute', 'escalated_newsletters_dispatch').
.NET IHostedService or BackgroundService calling the dispatcher every minute.
Spring @Scheduled(fixedDelay = 60000) calling the dispatcher.
Go A goroutine + time.Ticker (or your host's scheduler).

When the feature flag is off, the dispatcher is a no-op — wiring it permanently is safe.

Tracking and privacy

By default, Escalated tracks opens and clicks on outbound newsletters using two complementary mechanisms:

  • Pixel tracking. A 1×1 transparent PNG appended to each email. When the recipient's mail client loads it (most do), the open is recorded.
  • Click rewriting. Every <a href> in the body (except the unsubscribe and view-in-browser links) is rewritten to a redirect URL. When clicked, Escalated records the click and 302s to the original destination. URLs use base64-url encoding — no plaintext destination in the redirect URL.

Plus, when you use a supported ESP (Postmark, Mailgun, SES, SendGrid), Escalated also receives bounce and spam-complaint events via webhook. Bounced contacts are added to an internal suppression list and skipped on future sends. Complained contacts are similarly suppressed.

Disabling tracking

For privacy-sensitive deployments, set newsletter.tracking_enabled = false (in escalated_settings via the Newsletter settings admin page). When off:

  • Pixel injection is skipped.
  • Click rewriting is skipped — <a href> URLs go directly to the destination.
  • ESP bounce/complaint webhooks still process — these are required for deliverability and aren't tracking-related.

CAN-SPAM / Gmail bulk-sender compliance

  • Outbound emails carry List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click headers (RFC 8058 / Gmail/Yahoo 2024+ bulk-sender rules).
  • One-click unsubscribe sets a contact-scoped opt-out timestamp — once unsubscribed, the contact is skipped on every future newsletter, regardless of which list they're on.
  • Themes render the configurable brand.physical_address in the footer. Configure this if you send to US recipients. The admin UI shows a banner when the address is unset.

Unsubscribes and opt-out

The unsubscribe link in every newsletter footer points to /escalated/n/u/{token} — a unique URL per recipient.

  • GET loads a themed confirmation page.
  • POST (clicked from the confirmation page, or fired automatically by Gmail's one-click button) sets contacts.marketing_opt_out_at = now().

Once set, this flag suppresses the contact from every future newsletter on every list. The flag is contact-scoped, not list-scoped — there's no per-list unsubscribe in v1.

Re-opt-in is admin-only: visit the contact's detail page and clear the marketing_opt_out_at field. There's no customer-facing preference center in v1.

Troubleshooting deliverability

If newsletters land in spam or fail to deliver:

  1. Confirm your domain is authenticated. Set up SPF, DKIM, and DMARC for your sending domain. Without these, deliverability against Gmail/Yahoo/Outlook is poor regardless of what Escalated does.
  2. Watch the bounce rate. Newsletters auto-pause if hard bounces exceed 5% within the first 100 deliveries. If you see this, your list quality is the issue — clean it before resuming.
  3. Use an ESP, not raw SMTP. Postmark, Mailgun, SES, and SendGrid have deliverability reputation that matters more than the message content for bulk sending.
  4. Configure your brand.physical_address. Gmail/Outlook penalize unbranded bulk mail without a footer address.
  5. Use a separate sending domain or subdomain if possible (e.g. news@updates.acme.com rather than news@acme.com) so transactional reputation isn't tied to marketing reputation.

If you suspect an outage on Escalated's side: check the Deliveries tab on a sent newsletter for failure_reason text. ESP webhook events also surface bounce reasons verbatim.

Out of scope (v1)

These are planned for v1.1+ — not yet supported:

  • A/B subject testing
  • Drip / sequence campaigns
  • Customer-facing preference center (one-click unsubscribe is the only opt-out path today)
  • Cold-list / non-contact imports (recipients must already exist as contacts)
  • Drag-and-drop visual builder
  • Engagement-based dynamic segments (e.g. "opened last 3 newsletters")
  • Multi-language template variants
  • API-driven external sends (the admin UI is the only authoring surface)