A full email-marketing stack: templates, campaigns, multi-provider routing, domain & IP warming, reputation monitoring, tracking, preference center, automation. The infrastructure layer is shared with transactional email — what's described here is the campaign/marketing surface that sits on top.
Architecture#
┌─────────────────────────────────────────────────────────────────────┐
│ Email Marketing System │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
│ │ Visual │───▶│ Template │───▶│ Campaign │ │
│ │ Builder │ │ Service │ │ Service │ │
│ └─────────────┘ └──────────────┘ └──────────┬──────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────▼──────────┐ │
│ │ Routing Service │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Domain │ │ Warming │ │ Health │ │ ISP │ │ │
│ │ │ Service │ │ Service │ │ Monitor │ │ Router │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────▼───────────────────────────────┐ │
│ │ Provider Service │ │
│ │ ┌──────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ ┌────┐ │ │
│ │ │ SES │ │ SendGrid │ │ Mailgun │ │ Postmark │ │SMTP│ │ │
│ │ └──────┘ └──────────┘ └─────────┘ └──────────┘ └────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
│ │ Tracking │───▶│ Analytics │───▶│ Preference │ │
│ │ Service │ │ Service │ │ Center │ │
│ └─────────────┘ └──────────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Templates#
EmailTemplate model (models/email_marketing.py:111-194):
| Field | Note |
|---|---|
name, slug, category, template_type | Identity |
subject_template, body_html, body_text, preview_text | Content |
variables (JSONB) | Declared template variables |
visual_definition (JSONB) | The design a visual template is rendered from (see Visual builder) |
is_active, version | Lifecycle |
Enums (email_marketing.py:31-44):
EmailTemplateType:CODE(hand-coded HTML/Jinja),VISUAL(the block builder) orMJMLEmailTemplateCategory:GENERAL,MARKETING,ONBOARDING,RELEASE,TRANSACTIONAL,NEWSLETTER
Templates use Jinja2 for dynamic content:
<h1>Welcome, {{ recipient_name }}!</h1>
{% if has_trial %}
<p>Your trial ends on {{ trial_end_date }}</p>
{% endif %}
{% for feature in features %}
<li>{{ feature.name }}: {{ feature.description }}</li>
{% endfor %}
Campaigns#
EmailCampaign (models/email_marketing.py:200-354):
| Field | Note |
|---|---|
name, description | |
campaign_type (CampaignType) | ONE_TIME / RECURRING / TRIGGERED |
status (CampaignStatus) | DRAFT / SCHEDULED / SENDING / SENT / PAUSED / CANCELLED |
scheduled_at | When to start |
list_id | Targeting — required to schedule or send (see Audience) |
audience_filters (JSONB) | Not supported yet; the API refuses a non-empty value |
send_window (JSONB) | {start, end, timezone} — per-day send window |
from_name, from_email, reply_to | Headers |
template_context (JSONB) | Per-campaign template variable overrides |
sending_pool_id, sending_identity_id | Infrastructure |
routing_config (JSONB) | {strategy, preferred_providers, fallback_enabled} |
| Stats | total_recipients, sent_count, delivered_count, open_count, unique_open_count, click_count, unique_click_count, bounce_count, unsubscribe_count, complaint_count |
| Lifecycle | started_at, completed_at, created_at, updated_at |
Lifecycle:
DRAFT → SCHEDULED → SENDING → SENT
↓ ↓
PAUSED PAUSED → CANCELLED
CampaignRecipient (email_marketing.py:356-400+):
| Field | Note |
|---|---|
campaign_id, record_id, subscriber_id | Refs |
email, recipient_name | |
context (JSONB) | Per-recipient personalization context |
status (RecipientStatus) | PENDING / QUEUED / SENDING / SENT / DELIVERED / OPENED / CLICKED / BOUNCED / UNSUBSCRIBED / FAILED |
claimed_at | When the current claim (QUEUED / SENDING) was taken |
Audience#
A campaign goes to a CRM list (list_id), a segment (segment_id), or the
list's entries that match the segment (both set). Without either it cannot be
scheduled or started. Each record's address is read from the first of
email, work_email, personal_email, contact_email that holds a value,
lower-cased; records without one, second records with an address already
taken, and suppressed addresses are left out.
require_consent (default true for new campaigns) also leaves out anyone
without an active, unexpired ContactConsent for the record or its address.
A double opt-in counts only once confirmed. Campaigns that existed before
0.56.0 were migrated with require_consent = false, so nothing already
scheduled silently shrank.
GET /campaigns/{id}/audience-count returns
{count, total_records, missing_email, duplicates, suppressed, no_consent},
which is what the campaign page's Audience panel shows. The audience is read
500 records at a time, with no upper limit.
Filter-based ("smart") CRM lists hold no entries, so a campaign sent to one reaches nobody; use a segment.
Sending#
populate_recipients creates one PENDING row per address and is safe to run
again. send_campaign then claims up to 50 at a time (PENDING → QUEUED,
with a conditional UPDATE … RETURNING, so two batches cannot claim the same
row). It dispatches one send_campaign_email per claim with the workflow id
campaign-email-{recipient_id}. Each send takes its row QUEUED → SENDING
before contacting the provider, so a redelivered activity does not send twice.
check_scheduled_campaigns, every minute, also sweeps sending campaigns:
- a claim still
QUEUEDafter 15 minutes goes back toPENDING; - a row
SENDINGfor 15 minutes is markedFAILED("Send outcome unknown"), because it may have been sent and a duplicate is worse than a gap; - a campaign with
PENDINGrows and nothing in flight is restarted; - a campaign with nothing left is completed.
A campaign completes exactly once: a conditional update moves it to SENT
when no row is PENDING, QUEUED or SENDING. That fires the final stats
run, the notification and the campaign.sent automation trigger.
Subject A/B tests#
campaign.ab_test (services/campaign_ab_test.py) holds two to four subjects, test_fraction
(0.1–0.5, or 1 for a plain split), metric (opens / clicks) and wait_hours (1–72); it can
only be set on a draft. When sending starts, assign splits the pending recipients by a hash of
campaign and recipient: the sample (ceil(audience × fraction), at least one per subject) is dealt
round-robin between the subjects, and everyone else is held.
decide_due (the scheduled campaign check) waits for the sample to finish sending, then for
wait_hours after its last send, and picks the subject with the best rate. The original subject
(A) goes instead — decided_by says why — when:
- any subject has fewer than
MIN_SENDS_PER_VARIANT(10) sends, or nobody opened (or clicked) anything (auto:inconclusive); - none of the sample could be sent (
auto:no_data).
Held recipients then get the chosen subject. POST /campaigns/{id}/ab-test/winner picks one by hand
while recipients are still held. The setup panel reads the audience count and warns when each
subject's share of the sample is under 10, suggesting the smallest sample that reaches it.
Compliance footer#
Settings → Email marketing → Compliance holds the sender's company name and
postal address (GET/PUT /email-marketing/settings, stored in
workspace.settings["email_marketing"]). Without an address a campaign cannot
start, and a test send is refused. Every campaign and test email gets a footer
with the address, an unsubscribe link and an email-preferences link, added at
send time, even if the template has its own.
Every campaign email also carries List-Unsubscribe with the one-click URL
and List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058), as Gmail
and Yahoo require for bulk mail. The link is /api/v1/t/u/{token}: GET
shows a confirmation, POST unsubscribes. Templates can place it themselves
with {{unsubscribe_url}} and {{preferences_url}}. The unsubscribe link is
not rewritten for click tracking, so unsubscribing never counts as a click.
Marketing funnel emails carry the footer too (see Funnels). Other automation emails carry the header but not yet the footer.
Multi-domain infrastructure#
models/email_infrastructure.py introduces the infrastructure objects used by every send.
EmailProvider (line 94-191)#
| Field | Note |
|---|---|
name, provider_type | SES / SENDGRID / MAILGUN / POSTMARK / SMTP |
credentials (JSONB, encrypted) | Provider-specific |
settings (JSONB) | webhook_signing_key, sandbox_mode, tracking_enabled |
max_sends_per_second, max_sends_per_day, current_daily_sends, daily_sends_reset_at | Rate limits |
priority, is_default | Routing prefs |
last_check_at, last_check_status, last_error | Health |
SendingDomain (line 197-337)#
| Field | Note |
|---|---|
domain, subdomain | |
status | PENDING / VERIFYING / VERIFIED / FAILED / PAUSED / WARMING / ACTIVE |
dns_records (JSONB) | {spf, dkim, dmarc, return_path} |
warming_status, warming_schedule_id, warming_started_at, warming_day | Warming state |
daily_limit, daily_sent, daily_reset_at | Per-day caps |
default_from_name, default_reply_to | Headers |
health_score (0-100), health_status | EXCELLENT / GOOD / FAIR / POOR / CRITICAL |
is_default |
SendingIdentity (line 343-402)#
A specific (domain, email, display_name) combination. A workspace can have multiple identities on one domain (e.g. marketing@, support@, noreply@).
DedicatedIP (line 408-501)#
For workspaces on dedicated infrastructure. Same warming state shape as SendingDomain plus ip_address, hostname (PTR), and blacklist_status (JSONB array of RBL checks).
Sending pools#
A pool groups domains + IPs into a logical lane. Campaigns route through a pool; the routing service inside the pool picks the best domain/identity for each send based on health and warming status.
Domain & IP warming#
Three built-in schedules (email_infrastructure.py:508-547):
| Schedule | Duration | Day 1 | Day 7 | Day 14 | Day 21 |
|---|---|---|---|---|---|
| CONSERVATIVE | 21 days | 50 | 1,000 | 15,000 | 100,000 |
| MODERATE | 14 days | 100 | 7,500 | 100,000 | — |
| AGGRESSIVE | 7 days | 200 | 15,000 | 100,000 | — |
Each schedule is a list of {day: int, volume: int} entries defining the per-day cap.
Auto-pause thresholds (warming_service.py:34-37):
- Bounce rate > 5% (
DEFAULT_MAX_BOUNCE_RATE = 0.05), or the schedule'smax_bounce_rate - Complaint rate > 0.1% (
DEFAULT_MAX_COMPLAINT_RATE = 0.001), or the schedule'smax_complaint_rate
A day's numbers are counted from provider_event_logs: every send through a domain logs a send
event (campaigns, funnel emails and test sends alike) and the provider webhooks log deliveries,
bounces and complaints against the domain. Each is counted once per message and recipient (providers
retry webhooks), and a delivery, bounce or complaint counts toward the day its email was sent — a
bounce arriving this morning for yesterday's send is yesterday's. check_warming_thresholds (hourly) pauses the domain as
soon as today's rates cross the limits, once at least 20 emails have gone (MIN_THRESHOLD_SAMPLE;
fewer is noise). A paused domain stays paused until someone resumes it.
Daily progression: process_warming_day runs hourly and moves each warming domain to its next day
once its current day (UTC) has ended — once per day however often it runs, and one day at a time after
an outage. It sets daily_limit from the schedule and completes warm-up (status active) after the
last step. The job is the backstop: a warming domain's first reservation of a new UTC day advances it
first (send_capacity.reserve_domain), so the sends that resume at midnight get the new day's volume
rather than yesterday's. The domain row is locked while it advances, so the job and a send racing each
other move it one day, not two.
Daily limits (services/send_capacity.py): daily_sent counts the UTC day named by
daily_reset_at; an older or missing day reads as zero, so the count resets itself at midnight UTC
without a job (reset_daily_volumes only zeroes old counts for display). The provider's
max_sends_per_day works the same way on current_daily_sends. A send reserves its slot with one
conditional UPDATE before the provider is called and gives it back if the provider refuses, so a
50-wide campaign batch cannot overshoot the limit. Before 0.59.0 nothing reset these counts: a domain
that reached its limit once stayed blocked, and warm-up never left day one.
What happens at the limit:
- Campaign — it may still be scheduled and started (
sender.waiting/sender.resumes_atsay it will wait). Batches are no bigger than what the sender has left; a recipient over the limit goes back topendingwithdeferred_until, and the campaign recordsresumes_at/waiting_reason. The scheduled sweep restarts it after the reset. (It used to fail every remaining recipient.) A campaign sending through a pool first tries the pool's other members — skipping any that are full or whose provider has hit its own cap — and waits only when every member is out for the day. - Funnel email — the step waits until the reset, spread over the first 30 minutes of the day, then sends. Another domain problem (paused, critical health) is retried hourly.
- Test send — refused for that address with the reason.
Health scoring (reputation_service.py:102-112):
- Weighted: bounce (35%) + complaint (35%) + delivery (15%) + engagement (15%)
- Tiers: EXCELLENT ≥90, GOOD 70-89, FAIR 50-69, POOR 30-49, CRITICAL <30
Smart routing#
The routing service selects domain + identity + provider per recipient based on:
- Health score — prefer high-health domains
- ISP affinity — route Gmail-bound mail to Gmail-optimized domains, etc.
- Daily limits — only domains with allowance left today; with
prefer_warming_complete, warmed domains take the traffic while any can - Failover — switch providers if the primary errors out
- Pool routing strategy — round-robin / weighted / priority — from
routing_config.strategy
ISPMetrics (referenced in reputation_service.py:14) tracks inbox placement metrics per major ISP and feeds the affinity decision.
Tracking#
Open tracking#
api/email_tracking.py (prefix /t, line 14):
GET /t/p/{pixel_id}.gif(line 56) — returns a 1×1 transparent GIF and records an open event in a background taskpixel_idis a signed identifier mapping to aCampaignRecipient— no raw IDs in URLs
Click tracking#
Outbound links are rewritten through GET /t/c/{click_token}:
- The token is signed and maps to
(campaign_id, recipient_id, original_url) - Records click event + redirects (302) to the original URL
Both pixel and click handlers go through the SUS pixel-tracking flow without exposing PII — recipients can't enumerate IDs by guessing.
Inbound webhooks#
api/email_webhooks.py (prefix /webhooks/email, line 31):
POST /ses— AWS SES via SNSPOST /sendgrid— SendGrid eventsPOST /mailgun— Mailgun eventsPOST /postmark— Postmark events
Each handler processes provider events into a normalized EventType set (email_infrastructure.py:76-87):
SEND, DELIVERY, BOUNCE, COMPLAINT, REJECT,
OPEN, CLICK, UNSUBSCRIBE,
RENDERING_FAILURE, DELIVERY_DELAY
Events cascade to:
ProviderEventLog— raw auditCampaignRecipient.status— DELIVERED, OPENED, CLICKED, BOUNCED, UNSUBSCRIBEDWarmingProgress— feeds warming metricsReputationService— domain health scoring + auto-pause
See webhooks.md for inbound webhook signing patterns. Each provider's webhook_signing_key is stored in EmailProvider.settings and used to verify HMAC on inbound POSTs.
Preference center#
Public, unauthenticated, token-based.
Subscriber model#
| Field | Note |
|---|---|
email, name | |
status (SubscriberStatus) | ACTIVE / UNSUBSCRIBED / BOUNCED / COMPLAINED |
preference_token | Signed identifier — drives /preferences/{token} URL |
| Per-category preferences | Subscribed yes/no per category + frequency |
Categories#
Workspace-defined. Example:
categories = [
"product_updates", # New features, releases
"marketing", # Promotions, offers
"tips_and_tricks", # Educational content
"community", # Events, newsletters
]
Frequency options#
SubscriptionFrequency: IMMEDIATE / DAILY / WEEKLY / MONTHLY (email_marketing.py:99-104).
Unsubscribe sources#
UnsubscribeSource (email_marketing.py:90-96): LINK, PREFERENCE_CENTER, API, COMPLAINT, BOUNCE — useful for retention analytics.
The page itself is /preferences/{token} on the frontend
(app/public/preferences/[token], reached through a rewrite). It lists the
categories with a switch each and has an "Unsubscribe from everything" button.
The backend's old /preferences/{token}/page redirects there.
Endpoints#
GET /preferences/{token} fetch
POST /preferences/{token} update
POST /preferences/{token}/unsubscribe unsubscribe from all
All three are unauthenticated; the token is the capability.
Leads: one contact, many sources#
Every way a person arrives — a form, a CSV import, a subscriber import, a
booking, a product signup, Gmail sync, a website aexy.identify() call, the
intake API or the signup embed — goes through
ContactIngestService.upsert (services/contact_ingest.py). It:
- finds the workspace's Person record whose
email,work_email,personal_emailorcontact_emailmatches (case-insensitive; the oldest if several do), under a per-address lock, or creates one; - fills only the fields that are blank on an existing record — a lead source never overwrites what is there;
- sets first-touch attribution once:
lead_source,utm_source,utm_medium,utm_campaign(created as Person attributes on first use); - writes a
contact_touchesrow for every arrival (source, detail, all UTMs, referrer, consent basis) — the full history behind the first touch; - records consent and sets up the subscriber according to the source's consent basis;
- adds the record to the source's list, if it has one;
- fires
record.created(new) orrecord.updated(fields filled); - after the transaction commits, emits
contact.capturedfor every arrival, new or returning, andemail.subscribedfor an opt-in (emit_contact_events: their automations and webhooks never run inside the capture's transaction, nor for a capture that rolled back), and routes and scores new contacts and sends the double opt-in email (follow_up_captured_contacts). A double opt-in signup is routed and scored when it is confirmed, not before.
Consent basis#
| Basis | Subscriber | Consent row | email.subscribed | Used by |
|---|---|---|---|---|
explicit_opt_in | active | yes | yes | forms (default), intake API (default), embed |
double_opt_in | pending until the link is confirmed | on confirm | on confirm | signup embed (default) |
legitimate_interest | active | yes | no | product signups |
imported_attested | active | yes | no | CSV / subscriber imports, when the importer attests it |
none | not created (an existing one is linked) | no | no | bookings, Gmail sync, identify(), imports by default |
A lead source never re-subscribes someone who unsubscribed, bounced or
complained, and records no consent for them. Only the person can come back, by
confirming a double opt-in — and never after a bounce or complaint. Until they
confirm, no other source (a form, an import, the intake API) can make them
active: anyone could have typed their address into the double opt-in form.
pending subscribers are suppressed on every send path until they confirm.
Lead sources#
Settings → Email marketing → Lead sources (/email-marketing/lead-sources):
-
Intake API — a key for Zapier, Make, ad lead forms or anything that can POST JSON. Shown once; stored as a hash; rotatable.
curl -X POST https://aexy.example/api/v1/intake/contacts \ -H "X-Aexy-Intake-Key: aexy_lead_…" -H "Content-Type: application/json" \ -d '{"email":"[email protected]","first_name":"Lea","utm_source":"facebook","source_detail":"Q4 lead ad"}'Named fields (
first_name,last_name,name,phone,company_name,title),fieldsand any other top-level key become record values;utm_*may be top-level or underutm. -
Signup embed — a newsletter form for any web page:
<div data-aexy-signup="ls_…" data-api="https://aexy.example/api/v1"></div> <script src="https://aexy.example/aexy-signup.js" async></script>Double opt-in by default (or explicit opt-in). Protected by an origin allowlist, per-IP and per-source rate limits and a honeypot, and it answers the same whether or not the address was already subscribed. An explicit opt-in embed must list its allowed websites; a double opt-in one may be left open to any site. The widget posts JSON as
text/plainso browsers send it without a CORS preflight. The confirmation email links to/subscribe/confirm/{token}, a page with a Confirm button (mail scanners follow links, so opening the link alone confirms nothing). The link expires 7 days after the latest confirmation email; signing up again sends a new one.
Forms can be framed from /embed/forms/{token}; a form's Person connection
has its own consent basis (explicit opt-in for a new connection; none for one
saved before consent settings existed) and optional list.
CSV imports (/gtm/import) take a column mapping, a consent basis, a list and
a source detail. /gtm/import takes up to 1,000 rows; larger files (up to
10MB) run in the background (/gtm/import/async, then
GET /gtm/import/async/{workflow_id} for the result). The file and its
per-row report are kept on a contact_import_jobs row; the status lists up to
500 rows, problem rows first, and rows_omitted counts the rest. Imports
commit every 250 rows, and a retried background import resumes after its last
committed chunk.
Website aexy.identify() calls come through the public tracking endpoint,
whose key is the workspace id, so they are trusted only so far: of their
properties only names are kept, and at most 200 new contacts an hour per
workspace are created from them (linking a known contact is not limited).
Segments#
A segment is a saved question about contacts, asked again each time it is
used: "came in through the signup embed, scored 40 or more, opened something
in the last 30 days". Email marketing → Segments. Model ContactSegment
(models/contact_segment.py); rules are compiled to one SQL condition over
the workspace's Person records by services/segment_service.py, and that one
condition is what campaigns, the member list, the count, the preview and the
membership sync use.
match is all or any; up to 25 rules, each {field, operator, value}:
| Field | Operators | Value |
|---|---|---|
attribute (+ attribute slug) | equals, not_equals, contains, not_contains, starts_with, ends_with, gt/gte/lt/lte (a value that isn't a number doesn't match), is_empty, is_not_empty, in, not_in, has/has_not (multi-select, e.g. tags; a list or a comma-separated value) | text, number or list. On a number field equals compares numbers (10 = 10.0); on a checkbox, equals false includes contacts never ticked |
source (+ optional detail) | is / is_not (any arrival, from contact_touches), first_is (the lead_source first touch) | a source: form, import, signup, signup_embed, intake_api, booking, website, email_sync, subscriber_import |
list | in, not_in | list id (422 if not an id) |
lead_score | gt, gte, lt, lte, between | number, or [low, high]; never scored = 0 |
lead_stage | in, not_in | anonymous … sql, opportunity, customer; the contact's stage across ICP templates (services/lead_stage.py: an opportunity/customer on any row, else the best-scoring row's); never scored = anonymous |
subscription | in, not_in | active, pending, unsubscribed, bounced, complained, none (no subscriber) |
consent | has, has_not | optional list of bases |
category | subscribed, not_subscribed | category id (no opt-out row = subscribed, as can_send_to) |
engagement (+ days) | sent, not_sent, opened, not_opened, clicked, not_clicked | campaign recipients, tracking pixels (campaign and automation mail) and link clicks |
created (+ days) | within_days, older_than_days |
Endpoints under /workspaces/{ws}/email-marketing/segments: list, create,
get, PATCH, DELETE (409, naming them, while an unfinished campaign —
draft included — or an active segment.entered / segment.exited
automation uses it),
POST /preview (count and newest ten for unsaved rules), GET /{id}/members,
GET /options (attributes, sources seen, lists, categories for the builder).
Funnels from segments: segment.entered / segment.exited#
Every 15 minutes (sync_segments, schedule sync-contact-segments) each
segment's members are compared with the snapshot in contact_segment_members
and the CRM triggers segment.entered / segment.exited fire for the
difference (narrow with segment_id). The first sync of a new segment, and
the first after its rules change, only records the baseline: an "entered"
automation is for people who join from then on (saving the same rules again,
as a rename does, keeps the baseline). At most 2,000 events per segment per
sync; the rest are picked up by the next one. Segments are synced least
recently synced first, each under a row lock an overlapping run skips, and an
event fires only for a snapshot row that run inserted or deleted; a run stops
starting segments after 10 minutes and heartbeats, so it is never retried on
top of itself.
Lead score and stage#
score_lead now counts sign-ups, form submissions and email engagement as
activity, so a contact who never visited the tracked site no longer decays to
0. Stale scores are recomputed daily (rescore_leads), so decay applies to
contacts who have gone quiet. opportunity and customer are never
overwritten by a rescore. A change in the contact's stage — the one the
lead_stage rule reads, not one ICP template's row — fires
lead.stage_changed (narrow with from_stage / to_stage).
List and tag actions#
add_to_list / remove_from_list are offered in the builder: both run paths
go through CRMListService (the list must be in the record's workspace and
object; entry_count and list_entry.* events follow). add_record_tag /
remove_record_tag keep a multi-select tags value on the record, creating
the Tags attribute on first use.
Funnels#
A funnel is an automation canvas with timing in it: a wait, a condition, a
branch, or a funnel email. It runs durably on Temporal
(temporal/workflows/crm_workflow.py), one run per contact, and that run
(CRMAutomationRun) is the contact's enrollment: where they are
(current_node_id), what they are waiting for and until when (waiting_for,
wait_until), whether they are paused, and why they left (exit_reason:
completed, goal:<trigger>, manual, removed, reply:<category>,
timeout, failed). The workflow starts once the trigger's transaction
commits, so its first step sees the contact and consent the trigger wrote.
Sequences became funnels#
Since 0.58.0 there are no sequences. GTM outreach sequences (/gtm/sequences)
and the older CRM sequences (/crm/sequences) were both replaced by funnels.
Each sequence becomes a funnel with the trigger contact.enrolled, a straight
canvas of waits and emails, exit goals (a GTM sequence's come from its
exit-on-reply, bounce and unsubscribe settings; a CRM sequence's are reply and
unsubscribe), and re-entry "not while active".
GTM → Funnels, imports and replies
has the details: what each step becomes, and what happens to contacts who were
part-way through.
CRM sequences never had anything running them, so none of their contacts was ever sent anything. They are converted turned off, and their contacts are not started.
The conversion is a script an operator runs once after deploying, first with
--dry-run. It needs Temporal and is safe to run again. Each sequence is
committed as it goes (and each contact moved, once their old workflow is
stopped), so a sequence that fails is reported, the others stay converted, and
the next run carries on with it. A contact whose step has since been removed
from the funnel's canvas is left where they are, with a note, rather than
started from the top. Moved contacts are mailed at the address their
enrolment had, in its timezone (trigger.enrolled_email /
trigger.enrolled_timezone, used by the funnel's emails before the contact's
own). Until the script has run, a step that still enrols in or removes from a
sequence fails saying so:
docker exec aexy-backend python scripts/migrate_sequences_to_funnels.py --dry-run
docker exec aexy-backend python scripts/migrate_sequences_to_funnels.py
What starts a funnel#
Any CRM trigger. Three matter most for marketing:
contact.enrolled— only when a contact is enrolled: by another funnel's step, by a contact import, or by hand from the funnel's Enrollments tab. The run'strigger.enrolled_by_kindsays which (funnel,import,member, ormigrationfor a converted sequence). This is the trigger for a funnel that should run only when you put someone in it.contact.captured— a new or returning lead. Narrow it to lead sources with the trigger'ssourcesetting:signup,signup_embed,form,intake_api,import,subscriber_import,booking,website,email_sync. With none chosen it fires for every source.segment.entered— see Funnels from segments.
Starter funnels#
Four complete funnels to start from, in the gallery when you create a new automation. Each comes with its trigger, emails, exit goals and re-entry rule, and is created turned off so you can read it through first. The copy is a starting point.
| Starter | Starts on | What it does | Ends when | Again? |
|---|---|---|---|---|
| Signup nurture | contact.captured from signup | Welcome → 2 days → first steps → waits up to 4 days for a click (clicked: tag engaged; not: "need a hand?") → 4 days → offer a walkthrough | meeting booked, becomes a customer, unsubscribes | once per contact |
| Newsletter welcome | contact.captured from signup_embed | Welcome → 3 days → best of | unsubscribes | once per contact |
| Demo request follow-up | form.submitted (you pick the form) | Transactional confirmation → task for the owner → waits up to 48 hours for a booking (booked: tag demo_booked; not: nudge → 3 days → last follow-up) | meeting booked, replies, unsubscribes | again after 30 days |
| Re-engagement | segment.entered (you pick the segment) | "It's been a while" → waits 7 days for a click → "should we stop?" → waits 7 days for a click → tag re_engaged or dormant | unsubscribes | again after 90 days |
In Signup nurture both paths meet again, so contacts tagged engaged also get
the walkthrough offer. The demo follow-ups are sent without a marketing consent,
because the contact asked to be contacted; an unsubscribe still stops them.
GET /workspaces/{ws}/automations/starter-funnels
POST /workspaces/{ws}/automations/starter-funnels/{key} {name?, form_id?, segment_id?}
The response's needs lists what still has to be chosen on the trigger (a form
or a segment) before it can be turned on. The canvas templates API offers the
same four canvases, plus "Deal Stage Alert".
Funnel steps#
Besides emails, waits and branches, a funnel can act on the contact's other
funnels and on LinkedIn (services/funnel_actions.py). A step that is turned
away is skipped, not failed, and the funnel carries on.
| Action | Settings | What it does |
|---|---|---|
enroll_in_funnel | automation_id | Starts that funnel for this contact. The target's re-entry policy applies; a funnel that is off, or that turns them away, skips the step. A funnel can't enrol a contact in itself. |
remove_from_funnel | automation_id, or empty | Takes the contact out of that funnel, or out of every other funnel they are in (exit reason removed). |
linkedin_view_profile | — | Visits their LinkedIn profile. |
linkedin_connect | message (optional note, 300 characters) | Sends a connection request. |
linkedin_message | message (required) | Sends a LinkedIn message. |
LinkedIn steps need a LinkedIn automation provider (PhantomBuster) set up under
GTM providers. The profile link comes from the contact field in
profile_url_field (default linkedin). When that is empty, the step looks the
profile up by email through the contact-enrichment provider and saves it to the
contact, so the next step doesn't look again. A contact whose address is
suppressed is skipped. A retried step never repeats an action that already
reached LinkedIn.
Funnel email#
A send_email step with a category is sent inside the step itself
(services/funnel_mailer.py) and reports sent, skipped (with why; the
funnel carries on), deferred (the step sleeps and runs again) or failed.
Marketing (marketing or a category slug) | Transactional (transactional) | |
|---|---|---|
| Who | an active subscriber (not awaiting double opt-in), not opted out of the category, with an active consent unless require_consent: false | anyone not bounced, complained or on the suppression list |
| Footer and List-Unsubscribe | yes (needs the workspace postal address) | no |
| Weekly cap | counts, and waits for it | no |
| Sender | sending_identity_id or the workspace default identity, through its domain's provider — never the platform mailer | the same, or the platform mailer when none is set up |
Content is a template (template_id, optional email_subject override) or an
inline email_subject + email_body (plain text becomes paragraphs). Merge
tags are Jinja: {{ contact.first_name }}, {{ contact.email }},
{{ record.values.<field> }}, a bare {{ first_name }}, {{ trigger.* }},
{{ unsubscribe_url }}, {{ preferences_url }}, {{ company_name }}.
thread_with_previous: true sends the email as a reply in the same
conversation as the last email this enrollment sent to the contact. Leave the
subject blank to reuse the previous subject as "Re: …". If the enrollment has
sent them nothing yet, the email goes as a new one, and a blank subject fails
the step.
Deferrals: send_window ({days: [0..6], start_hour, end_hour, timezone},
Monday is 0; timezone: "contact" uses the contact's timezone value), the
weekly cap (workspace setting funnel_weekly_cap, default 3 marketing emails
per contact per 7 days across all funnels, counting a send already under
way; 0 turns it off — two funnels mailing the same contact at once take turns
at the count), and a sending domain at its daily limit (waits for the reset at
midnight UTC; any other domain problem is retried hourly). After 20 deferrals
the step fails — before 0.59.0 it was recorded completed although nothing had
been sent.
Every email is a row in automation_email_messages, claimed as
<run>:<node> before the provider is called: a retried step never sends
twice, and one left mid-send by a crash is reported, not resent. The row keeps
the Message-ID we set, the provider's id, the pixel, and when it was opened,
clicked and replied to. Links in funnel mail are minted per email and carry the
contact and the pixel, so a click is the contact's and the email's.
Waits and paths#
- Wait for an event (
wait_type: "event"):wait_for_event,timeout_hours, optionallyemail_node_id("this email": opens and clicks by its pixel, replies by its Message-ID) andevent_filter. The wait owns aWorkflowEventSubscriptionfor the run's contact. The node has two handles,receivedandtimeout; with nothing ontimeout,on_timeoutsayscontinue(default) orexit. A timeout is a path, not a failure. When "this email" was never sent (skipped for consent, suppressed, gave up deferring) there is nothing to open, and the wait takes the timeout path at once rather than wake on some other email. - Events that wake waits: every CRM trigger (
email.opened,email.clicked,email.replied,form.submitted,record.updated,segment.entered,lead.stage_changed, …),email.unsubscribed,email.bounced,email.complained,meeting.scheduled(Aexy booking, or the meeting webhook), and the inbound workflow-event webhooks. One hook matches them:services/funnel_runtime.contact_event. The signals it owes — and those for pause, resume and exit — are written tofunnel_signalsin the event's transaction, sent once it commits, and retried every minute (deliver-funnel-signals) while Temporal can't be reached. Taking out a contact whose workflow no longer exists closes their run. A wait whose event arrived but whose signal didn't still takes thereceivedpath when its timer ends. - A/B split: a branch node with
mode: "split"and aweightper path. The pick is a hash of the run and node, so it is stable across replays. - Where two paths meet again, the node still runs (before, everything below the untaken path was skipped, including the join).
Funnel rules#
On the automation (PATCH /workspaces/{ws}/automations/{id}):
reentry_policy:always(default),once,not_while_active, orcooldownwithreentry_cooldown_days. Checked before the run takes a slot of the monthly allowance.exit_goals: up to 20{trigger, config}— any CRM trigger, narrowed with its own settings ({"trigger": "lead.stage_changed", "config": {"to_stage": "customer"}}), plusemail.unsubscribed,email.bounced,email.complained,meeting.scheduled. When one happens to an enrolled contact, their run ends withexit_reason: goal:<trigger>, even mid-wait.
Replies#
services/reply_detection.py reads inbound mail (the inbound webhook and Gmail
sync) and matches In-Reply-To / References against our Message-ID and the
provider's id (SES replaces ours; its id is matched by the part before the
@). Without those headers, mail from the contact to our from or reply-to
address within 30 days counts — in the workspace whose sending domain or
identity received it, and never when the candidates span workspaces. The
sender must be the contact. The first reply
stamps replied_at and fires email.replied.
To act on what a reply says, classify it with
POST /workspaces/{ws}/gtm/replies/classify (message_id is the funnel email
answered, plus reply_text). An LLM picks interested, ooo,
not_interested, wrong_person, unsubscribe, question or referral. At
0.7 confidence or more, interested, not_interested, wrong_person and
referral take the contact out of every funnel (exit reason
reply:<category>); unsubscribe unsubscribes them from all marketing mail
and takes them out too; ooo and question are only recorded. Classifying is not automatic; see
GTM → Replies for the rest of the API.
Enrollments API#
GET /workspaces/{ws}/automations/{id}/enrollments?state=&node_id=— items,counts(active, paused, completed, exited, failed) andby_node(active contacts per step);POST /workspaces/{ws}/automations/{id}/enrollments{record_ids?, list_id?}— start the funnel for chosen contacts or a CRM list's people, up to 500 at a time ("Enrol contacts" on the Enrollments tab). The funnel must be on, and its re-entry policy applies. Returns{enrolled, skipped, failed}: skipped were turned away by re-entry or the monthly run limit, failed are not contacts in this workspace;POST …/enrollments/{run}/pause | resume | exit,POST …/enrollments/pause-all | resume-all— pause holds before the next step (a wait already running keeps its timer);GET /workspaces/{ws}/crm/records/{record}/enrollments— the funnels a contact is in or has been through.
A contact import (GTM → Import) can also start a funnel for every contact it
brings in: pick the funnel on the import (automation_id).
Release announcements#
A release is announced with a campaign, not a funnel: there is no per-contact
release event. Announce by email on a release
(POST /workspaces/{ws}/releases/{id}/announce) drafts an email campaign.
Nothing is sent until someone reviews it and sends it.
- Audience: a segment (
segment_id) or a CRM list (list_id). By default, the "Release updates" segment: subscribers who haven't opted out of thereleasescategory. The segment and the category are created the first time they're needed. - Sender:
sending_identity_idorsending_pool_id, or the workspace's default identity. - Content: the "Release announcement" template, or
template_id. It fills in the release name, version, notes (escaped) and an optionalrelease_url(http://orhttps://only).
It needs the email marketing app, and admin rights. Sending goes through the
normal campaign path: suppression, consent, opt-outs and the footer. The
campaign belongs to the releases category (subscription_category_id), so
whatever the audience — a list included — a contact who opted out of release
emails is skipped at send.
Suppression enforcement#
An address is suppressed when its EmailSubscriber.status is UNSUBSCRIBED,
BOUNCED or COMPLAINED, or when it (or its domain, for a domain_block) is
on the workspace's SuppressionList. It is checked twice:
- when the audience is built — the address gets no recipient row and is
counted under
suppressedin the audience breakdown; - again just before each send — someone who unsubscribed while the
campaign was going out is marked
UNSUBSCRIBED("Suppressed: …") and not sent.
Delivery webhooks from all four providers feed it. A hard bounce sets the
subscriber to BOUNCED and adds a bounce suppression; a complaint sets
COMPLAINED and adds a complaint suppression (a later bounce does not
overwrite a complaint); a soft bounce changes nothing. Each then fires the
email.bounced / email.complained automation trigger.
Category preferences are not yet checked by campaigns (campaigns have no
category); a segment can filter on them with the category rule.
Onboarding & automation#
Onboarding flows#
OnboardingFlow:
- name: "New User Welcome"
- steps: [
{ type: "email", delay: 0, template: "welcome" },
{ type: "wait", delay: 86400 }, # 1 day
{ type: "email", delay: 0, template: "getting_started" },
{ type: "milestone", slug: "first_project" },
{ type: "email", delay: 0, template: "project_tips" },
]
Per-user state is UserOnboardingProgress (current step, completed_at, decisions). Execution runs as a Temporal workflow — see Onboarding.
CRM triggers#
Hook into the CRM automation engine (see crm.md). Relevant triggers:
| Trigger | Description |
|---|---|
user.first_login | User's first authentication |
user.profile_completed | Profile setup finished |
user.integration_connected | Connected GitHub/Slack/etc. |
user.milestone_reached | Hit a usage milestone |
release.published | New release announcement |
Workflow actions to dispatch email-marketing work from any automation:
{ action: "send_campaign", campaign_id: "..." }
{ action: "trigger_onboarding", flow_slug: "new_user" }
{ action: "complete_onboarding_step", step_id: "..." }
Visual builder#
A Visual template is built from blocks instead of HTML: pick Visual on Templates → New, or open an existing visual template. The editor has a block palette, the email as a list of blocks (select one to edit it; reorder with the arrows; sections take blocks per column), an inspector for the selected block, email-wide settings, a merge-tag menu on every text field, a live preview at desktop and mobile width, Send test (it sends the saved template, so it waits until your changes are saved), and a library of saved designs to start from.
Blocks (every one renders real markup): columns (a section of 1–3
columns, stacked on phones), heading, text (bold, italic, links, lists), image
(src, alt, link, width), button, divider, spacer, hero, social links, footer.
Design JSON — stored on the design (saved_email_designs.design_json) and
on the template (email_templates.visual_definition):
{
"version": 1,
"settings": { "background": "#f4f4f5", "content_background": "#ffffff", "content_width": 600,
"font_family": "Arial, Helvetica, sans-serif", "text_color": "#1f2937",
"link_color": "#2563eb", "preheader": "Shown after the subject" },
"blocks": [
{ "id": "b1", "type": "heading", "props": { "text": "Hi {{ contact.first_name }}" } },
{ "id": "b2", "type": "section", "props": { "columns": 2 }, "children": [
{ "id": "c1", "type": "column", "children": [ { "id": "b3", "type": "text", "props": { "text": "Left" } } ] },
{ "id": "c2", "type": "column", "children": [] } ] }
]
}
One renderer. services/email_design_renderer.py turns a design into
email-client HTML (presentation tables, inline styles, a centred column) plus a
plain-text body. The result is the template's body_html/body_text, rendered
again at send time with the recipient's context like any other template.
Merge tags and escaping. Everything typed into a block is HTML-escaped.
Merge tags of the form {{ contact.first_name }} (optionally
| default('there')) are kept for the send-time render; any other brace is
written as an entity, so {% ... %} or {{ ''.__class__ }} typed into a block
prints as text and never runs. URLs must be http(s), mailto:, tel: or
start with one of the links the platform supplies ({{ unsubscribe_url }},
{{ preferences_url }}, {{ release_url }}); anything else is dropped — a
contact's own value ({{ website_url }}) can follow a real scheme but never
lead one. The
text block's rich text is rebuilt from an allowlist (strong, em, u, s,
a href, p, lists, br).
Designs and templates stay in step. Saving a design linked to a template
re-renders that template, so a send always uses the latest design. Converting a
design updates its linked template rather than creating a second one; opening a
visual template in the builder returns the design behind it exactly. A visual
template created or patched with a visual_definition has its body rendered
from it — a body_html sent alongside is ignored.
Preview and test send. Previews (the builder's and the templates pages')
are shown only in <iframe sandbox=""> — no scripts, no access to the app.
POST /templates/{id}/test sends a [TEST] copy to at most five addresses
from a sending identity, a sending pool or a From address on a verified domain,
through the same sender checks, compliance gate and footer as a campaign send
(services/email_test_send.py, shared with the campaign test send).
POST /workspaces/{ws}/visual-builder/render { design_json, context? }
GET /workspaces/{ws}/visual-builder/block-types the built-in blocks
GET|POST /workspaces/{ws}/visual-builder/designs
GET|PATCH|DELETE /workspaces/{ws}/visual-builder/designs/{id} PATCH re-renders a linked template
POST /workspaces/{ws}/visual-builder/designs/{id}/convert-to-template { template_name?, subject?, category? }
POST /workspaces/{ws}/visual-builder/templates/{template_id}/edit the design behind a visual template
POST /workspaces/{ws}/email-marketing/templates/{id}/test { to_emails, from_email | sending_identity_id | sending_pool_id, context? }
The visual-builder API is part of email marketing: switching the email
marketing app off closes it. Workspace custom blocks (visual_template_blocks)
are used as {"type": "custom", "props": {"block_id": ...}} and render in a
sandboxed Jinja environment with escaped props.
API endpoints#
Templates (api/email_marketing.py:64+)#
GET /workspaces/{ws}/email-marketing/templates
POST /workspaces/{ws}/email-marketing/templates
GET /workspaces/{ws}/email-marketing/templates/{template_id}
PATCH /workspaces/{ws}/email-marketing/templates/{template_id}
DELETE /workspaces/{ws}/email-marketing/templates/{template_id}
POST /workspaces/{ws}/email-marketing/templates/{template_id}/preview render with context
POST /workspaces/{ws}/email-marketing/templates/{template_id}/test send a test (1-5 addresses)
Campaigns#
GET /workspaces/{ws}/email-marketing/campaigns
POST /workspaces/{ws}/email-marketing/campaigns
GET /workspaces/{ws}/email-marketing/campaigns/{campaign_id}
POST /workspaces/{ws}/email-marketing/campaigns/{campaign_id}/schedule
POST /workspaces/{ws}/email-marketing/campaigns/{campaign_id}/send start now
POST /workspaces/{ws}/email-marketing/campaigns/{campaign_id}/pause
POST /workspaces/{ws}/email-marketing/campaigns/{campaign_id}/test send a test
GET /workspaces/{ws}/email-marketing/campaigns/{campaign_id}/recipients
Analytics#
GET /workspaces/{ws}/email-marketing/campaigns/{id}/stats
GET /workspaces/{ws}/email-marketing/campaigns/{id}/timeline
GET /workspaces/{ws}/email-marketing/campaigns/{id}/links
GET /workspaces/{ws}/email-marketing/campaigns/{id}/devices
GET /workspaces/{ws}/email-marketing/overview workspace summary
Email infrastructure (api/email_infrastructure.py:59+)#
# Providers
POST /workspaces/{ws}/email-infrastructure/providers
GET /workspaces/{ws}/email-infrastructure/providers
GET /workspaces/{ws}/email-infrastructure/providers/{id}
POST /workspaces/{ws}/email-infrastructure/providers/{id}/test verify credentials
# Domains
POST /workspaces/{ws}/email-infrastructure/domains
GET /workspaces/{ws}/email-infrastructure/domains
POST /workspaces/{ws}/email-infrastructure/domains/{id}/verify
GET /workspaces/{ws}/email-infrastructure/domains/{id}/health
POST /workspaces/{ws}/email-infrastructure/domains/{id}/warming/start
GET /workspaces/{ws}/email-infrastructure/domains/{id}/warming/progress
# Warming schedules
POST /workspaces/{ws}/email-infrastructure/warming-schedules
GET /workspaces/{ws}/email-infrastructure/warming-schedules
# Pools, identities
POST /workspaces/{ws}/email-infrastructure/pools
POST /workspaces/{ws}/email-infrastructure/identities
Preferences (public, unauthenticated)#
GET /preferences/{token}
POST /preferences/{token}
POST /preferences/{token}/unsubscribe
Visual builder#
GET /workspaces/{ws}/visual-builder/blocks
POST /workspaces/{ws}/visual-builder/blocks custom block
GET /workspaces/{ws}/visual-builder/designs
POST /workspaces/{ws}/visual-builder/designs
POST /workspaces/{ws}/visual-builder/render render design to HTML
POST /workspaces/{ws}/visual-builder/designs/{id}/convert-to-template
Temporal activities & schedules#
Activities live in temporal/activities/email.py and warming.py; periodic schedules in temporal/schedules.py.
Campaign#
| Activity | Trigger | Retry / Timeout |
|---|---|---|
send_campaign | Dispatched on send | STANDARD, 30m |
send_campaign_email | Per-recipient fan-out, workflow id campaign-email-{recipient_id} | STANDARD, 2m |
check_scheduled_campaigns | Every minute — starts due campaigns and sweeps sending ones | STANDARD |
update_campaign_stats | Post-send aggregation | STANDARD, 5m |
Warming#
| Activity | Schedule ID | Cadence |
|---|---|---|
process_warming_day | process-warming-day | Hourly (advances each domain once per UTC day) |
check_warming_thresholds | check-warming-thresholds | Hourly |
reset_daily_volumes | reset-daily-volumes-email | Hourly (display only — counts reset themselves) |
Analytics#
| Activity | Schedule ID |
|---|---|
aggregate_email_analytics | aggregate-email-analytics |
Onboarding#
| Activity | Trigger |
|---|---|
start_user_onboarding | Dispatched when user enters a flow |
process_onboarding_step | Continues a user through the flow |
check_due_onboarding_steps | Scheduled — drives time-based step transitions |
Inspect schedules and run history in the Temporal UI at http://localhost:8080.
Email infrastructure lives in the backend#
Everything below the campaign layer is backend code — there is no separate email service:
- Domain registration, DKIM/SPF/DMARC records and DNS verification —
services/domain_service.py - Warming schedules (custom schedules, pause, bounce-adjusted) —
services/warming_service.py - Domain health and reputation —
services/health_scoring_service.py,services/reputation_service.py - Providers (SES, SendGrid, Mailgun, Postmark, SMTP), daily caps and delivery —
services/provider_service.py - Pools and domain failover —
services/routing_service.py - Bounce/complaint/unsubscribe webhooks, with per-provider signature checks —
api/email_webhooks.py,services/email_webhook_verify.py - Inbound mail for agent inboxes and Service Desk mailboxes —
POST /api/v1/webhooks/email/inbound
The models (EmailProvider, SendingDomain, SendingIdentity, DedicatedIP) are the source of truth. A mailagent/ microservice used to sit alongside with its own copies of domains, warming, health and providers; it was never deployed, its versions were weaker than these, and it has been removed (scripts/migrate_2026_09_29b_drop_mailagent.sql drops its tables).
See aexy-email-infrastructure.md for the operational guide.
Configuration#
# Default SMTP fallback
SMTP_HOST=email-smtp.us-east-1.amazonaws.com
SMTP_PORT=587
SMTP_USERNAME=...
SMTP_PASSWORD=...
[email protected]
EMAIL_FROM_NAME=Aexy
# Tracking
EMAIL_TRACKING_DOMAIN=track.aexy.io
EMAIL_IMAGE_CDN_URL=https://cdn.aexy.io/images
# Rate limits
CAMPAIGN_SEND_RATE_PER_MINUTE=100
Production deployments configure providers per workspace via the EmailProvider table — these env vars are dev defaults.
Frontend#
/frontend/src/app/(app)/email-marketing/:
| Route | Purpose |
|---|---|
/campaigns | Campaign list |
/campaigns/[id] | Campaign editor + analytics |
/campaigns/new | Creation flow |
/templates | Template list |
/templates/[id] | Template editor (visual builder when template_type=VISUAL) |
/templates/new | Creation |
/settings | Domains, providers, warming config |
Best practices#
Deliverability#
- Warm new domains gradually — use a CONSERVATIVE schedule unless you have established reputation
- Monitor health scores daily — pause domains below 80
- Segment by engagement — send to recent openers first; cold lists kill reputation
- Clean lists aggressively — remove BOUNCED and COMPLAINED subscribers immediately
Template design#
- Keep it simple — fewer images, more text gets through filters
- Mobile-first — 60%+ of opens are mobile
- One primary CTA per email
- Test rendering — use the preview API across email clients before sending
Compliance#
- Unsubscribe and address are added for you — every campaign email gets the compliance footer and the one-click header; set the postal address first
- Honor opt-outs in real time —
UNSUBSCRIBEDstatus takes effect at the next send check, which runs continuously - Log everything —
ProviderEventLogandUnsubscribeSourceare the audit trail - Respect frequency settings — over-sending is the fastest path to complaints
Common pitfalls#
- Template category vs campaign type confusion:
EmailTemplateCategoryis what the template is about (MARKETING,TRANSACTIONAL,NEWSLETTER, …).CampaignTypeis how it's sent (ONE_TIME,RECURRING,TRIGGERED). AMARKETINGtemplate can be sent in aTRIGGEREDcampaign — they're orthogonal. - Warming pause is sticky: when
check_warming_thresholdsauto-pauses a domain, it stays paused until manually resumed. There's no auto-resume after metrics recover — that's intentional, since the operator needs to investigate. - Provider rate limits aren't enforced by the provider: the backend tracks
current_daily_sendsagainstmax_sends_per_day. If you bypass the campaign service (e.g. raw provider call), you'll exceed the limit and the provider will start rejecting — but Aexy won't know to slow down. - Suppression doesn't cross workspaces: an email opted out of workspace A can still be sent from workspace B. This is by design (each workspace's reputation is its own), but counter-intuitive.
SENTvsCOMPLETED: the campaign lifecycle ends atSENT— there's noCOMPLETEDstate. Earlier docs incorrectly listedSent → Completed; correct flow ends atSENT.- Visual builder JSON in
EmailTemplate.visual_definition: editing the template HTML directly drifts from the visual definition. Either always edit through the builder, or settemplate_type=CODEto lock the visual definition out. - Pixel/click tokens are signed: re-issuing tokens (e.g. on URL regeneration) invalidates old tracking links. If a campaign already sent, don't rotate the signing key — you'll lose open/click attribution.
Related#
- Aexy email infrastructure (operations) — inbound mail, DNS setup
- Webhooks — inbound webhook signing
- CRM — automations that dispatch campaigns
- Workflows & automations — visual automation builder
- AI Agents — agents that send/draft email
- GTM — imports, reply classification, and how GTM sequences became funnels