Documentation / Newsletters
Documentation
- Getting Started
- Installation
- Frontend Setup
- Theming
- User Model
- Authorization
- Configuration
- Routes
- Tickets
- Public Tickets
- Bulk Actions
- Conversations
- Statuses & Priorities
- SLAs & Escalation
- Macros
- Automation Types
- Custom Ticket Actions
- Automations
- Newsletters
- Workflows
- Followers
- Satisfaction Ratings
- Collaboration
- Keyboard Shortcuts
- Events
- Scheduling
- Notifications
- Inbound Email
- Importing Data
- Single Sign-On
- REST API
- Management Commands
- Mobile SDKs
- Contributing
- Plugin Development
- Compare
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
- Enable the feature in your host app (see Enabling newsletters for the per-framework env var).
- Re-seed permissions so admins get
newsletters.manageandnewsletters.send. - Schedule the dispatcher to run every minute (cron, scheduler, or
wp_schedule_event). - Visit Newsletters in the admin sidebar → Lists → create a static list → add contacts → back to Newsletters → New newsletter → compose → Send Test to Me → Schedule 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
- Newsletters → Lists → New list
- Pick Static.
- Open the new list. Add contacts by email (autocomplete against your contacts table) or Import CSV — one email per line.
Creating a dynamic list
- Newsletters → Lists → New list
- Pick Dynamic.
- Build the filter using the same rule editor as saved contact views (
field,operator,valuerules). 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 = trueso 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-UnsubscribeandList-Unsubscribe-Post: List-Unsubscribe=One-Clickheaders (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_addressin 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:
- 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.
- 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.
- Use an ESP, not raw SMTP. Postmark, Mailgun, SES, and SendGrid have deliverability reputation that matters more than the message content for bulk sending.
- Configure your
brand.physical_address. Gmail/Outlook penalize unbranded bulk mail without a footer address. - Use a separate sending domain or subdomain if possible (e.g.
news@updates.acme.comrather thannews@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)