Email Marketing

Campaigns, automation & infrastructure

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

FieldNote
name, slug, category, template_typeIdentity
subject_template, body_html, body_text, preview_textContent
variables (JSONB)Declared template variables
visual_definition (JSONB)The design a visual template is rendered from (see Visual builder)
is_active, versionLifecycle

Enums (email_marketing.py:31-44):

  • EmailTemplateType: CODE (hand-coded HTML/Jinja), VISUAL (the block builder) or MJML
  • EmailTemplateCategory: 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):

FieldNote
name, description
campaign_type (CampaignType)ONE_TIME / RECURRING / TRIGGERED
status (CampaignStatus)DRAFT / SCHEDULED / SENDING / SENT / PAUSED / CANCELLED
scheduled_atWhen to start
list_idTargeting — 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_toHeaders
template_context (JSONB)Per-campaign template variable overrides
sending_pool_id, sending_identity_idInfrastructure
routing_config (JSONB){strategy, preferred_providers, fallback_enabled}
Statstotal_recipients, sent_count, delivered_count, open_count, unique_open_count, click_count, unique_click_count, bounce_count, unsubscribe_count, complaint_count
Lifecyclestarted_at, completed_at, created_at, updated_at

Lifecycle:

DRAFT → SCHEDULED → SENDING → SENT
                 ↓     ↓
               PAUSED  PAUSED → CANCELLED

CampaignRecipient (email_marketing.py:356-400+):

FieldNote
campaign_id, record_id, subscriber_idRefs
email, recipient_name
context (JSONB)Per-recipient personalization context
status (RecipientStatus)PENDING / QUEUED / SENDING / SENT / DELIVERED / OPENED / CLICKED / BOUNCED / UNSUBSCRIBED / FAILED
claimed_atWhen 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 QUEUED after 15 minutes goes back to PENDING;
  • a row SENDING for 15 minutes is marked FAILED ("Send outcome unknown"), because it may have been sent and a duplicate is worse than a gap;
  • a campaign with PENDING rows 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.

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

FieldNote
name, provider_typeSES / 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_atRate limits
priority, is_defaultRouting prefs
last_check_at, last_check_status, last_errorHealth

SendingDomain (line 197-337)#

FieldNote
domain, subdomain
statusPENDING / VERIFYING / VERIFIED / FAILED / PAUSED / WARMING / ACTIVE
dns_records (JSONB){spf, dkim, dmarc, return_path}
warming_status, warming_schedule_id, warming_started_at, warming_dayWarming state
daily_limit, daily_sent, daily_reset_atPer-day caps
default_from_name, default_reply_toHeaders
health_score (0-100), health_statusEXCELLENT / 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):

ScheduleDurationDay 1Day 7Day 14Day 21
CONSERVATIVE21 days501,00015,000100,000
MODERATE14 days1007,500100,000—
AGGRESSIVE7 days20015,000100,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's max_bounce_rate
  • Complaint rate > 0.1% (DEFAULT_MAX_COMPLAINT_RATE = 0.001), or the schedule's max_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_at say it will wait). Batches are no bigger than what the sender has left; a recipient over the limit goes back to pending with deferred_until, and the campaign records resumes_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:

  1. Health score — prefer high-health domains
  2. ISP affinity — route Gmail-bound mail to Gmail-optimized domains, etc.
  3. Daily limits — only domains with allowance left today; with prefer_warming_complete, warmed domains take the traffic while any can
  4. Failover — switch providers if the primary errors out
  5. 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 task
  • pixel_id is a signed identifier mapping to a CampaignRecipient — 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 SNS
  • POST /sendgrid — SendGrid events
  • POST /mailgun — Mailgun events
  • POST /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 audit
  • CampaignRecipient.status — DELIVERED, OPENED, CLICKED, BOUNCED, UNSUBSCRIBED
  • WarmingProgress — feeds warming metrics
  • ReputationService — 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#

FieldNote
email, name
status (SubscriberStatus)ACTIVE / UNSUBSCRIBED / BOUNCED / COMPLAINED
preference_tokenSigned identifier — drives /preferences/{token} URL
Per-category preferencesSubscribed 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_email or contact_email matches (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_touches row 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) or record.updated (fields filled);
  • after the transaction commits, emits contact.captured for every arrival, new or returning, and email.subscribed for 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.
BasisSubscriberConsent rowemail.subscribedUsed by
explicit_opt_inactiveyesyesforms (default), intake API (default), embed
double_opt_inpending until the link is confirmedon confirmon confirmsignup embed (default)
legitimate_interestactiveyesnoproduct signups
imported_attestedactiveyesnoCSV / subscriber imports, when the importer attests it
nonenot created (an existing one is linked)nonobookings, 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), fields and any other top-level key become record values; utm_* may be top-level or under utm.

  • 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/plain so 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}:

FieldOperatorsValue
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
listin, not_inlist id (422 if not an id)
lead_scoregt, gte, lt, lte, betweennumber, or [low, high]; never scored = 0
lead_stagein, not_inanonymous … 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
subscriptionin, not_inactive, pending, unsubscribed, bounced, complained, none (no subscriber)
consenthas, has_notoptional list of bases
categorysubscribed, not_subscribedcategory id (no opt-out row = subscribed, as can_send_to)
engagement (+ days)sent, not_sent, opened, not_opened, clicked, not_clickedcampaign 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's trigger.enrolled_by_kind says which (funnel, import, member, or migration for 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's source setting: 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.

StarterStarts onWhat it doesEnds whenAgain?
Signup nurturecontact.captured from signupWelcome → 2 days → first steps → waits up to 4 days for a click (clicked: tag engaged; not: "need a hand?") → 4 days → offer a walkthroughmeeting booked, becomes a customer, unsubscribesonce per contact
Newsletter welcomecontact.captured from signup_embedWelcome → 3 days → best ofunsubscribesonce per contact
Demo request follow-upform.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, unsubscribesagain after 30 days
Re-engagementsegment.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 dormantunsubscribesagain 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.

ActionSettingsWhat it does
enroll_in_funnelautomation_idStarts 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_funnelautomation_id, or emptyTakes 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_connectmessage (optional note, 300 characters)Sends a connection request.
linkedin_messagemessage (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)
Whoan active subscriber (not awaiting double opt-in), not opted out of the category, with an active consent unless require_consent: falseanyone not bounced, complained or on the suppression list
Footer and List-Unsubscribeyes (needs the workspace postal address)no
Weekly capcounts, and waits for itno
Sendersending_identity_id or the workspace default identity, through its domain's provider — never the platform mailerthe 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, optionally email_node_id ("this email": opens and clicks by its pixel, replies by its Message-ID) and event_filter. The wait owns a WorkflowEventSubscription for the run's contact. The node has two handles, received and timeout; with nothing on timeout, on_timeout says continue (default) or exit. 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 to funnel_signals in 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 the received path when its timer ends.
  • A/B split: a branch node with mode: "split" and a weight per 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, or cooldown with reentry_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"}}), plus email.unsubscribed, email.bounced, email.complained, meeting.scheduled. When one happens to an enrolled contact, their run ends with exit_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) and by_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 the releases category. The segment and the category are created the first time they're needed.
  • Sender: sending_identity_id or sending_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 optional release_url (http:// or https:// 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 suppressed in 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:

TriggerDescription
user.first_loginUser's first authentication
user.profile_completedProfile setup finished
user.integration_connectedConnected GitHub/Slack/etc.
user.milestone_reachedHit a usage milestone
release.publishedNew 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#

ActivityTriggerRetry / Timeout
send_campaignDispatched on sendSTANDARD, 30m
send_campaign_emailPer-recipient fan-out, workflow id campaign-email-{recipient_id}STANDARD, 2m
check_scheduled_campaignsEvery minute — starts due campaigns and sweeps sending onesSTANDARD
update_campaign_statsPost-send aggregationSTANDARD, 5m

Warming#

ActivitySchedule IDCadence
process_warming_dayprocess-warming-dayHourly (advances each domain once per UTC day)
check_warming_thresholdscheck-warming-thresholdsHourly
reset_daily_volumesreset-daily-volumes-emailHourly (display only — counts reset themselves)

Analytics#

ActivitySchedule ID
aggregate_email_analyticsaggregate-email-analytics

Onboarding#

ActivityTrigger
start_user_onboardingDispatched when user enters a flow
process_onboarding_stepContinues a user through the flow
check_due_onboarding_stepsScheduled — 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/:

RoutePurpose
/campaignsCampaign list
/campaigns/[id]Campaign editor + analytics
/campaigns/newCreation flow
/templatesTemplate list
/templates/[id]Template editor (visual builder when template_type=VISUAL)
/templates/newCreation
/settingsDomains, providers, warming config

Best practices#

Deliverability#

  1. Warm new domains gradually — use a CONSERVATIVE schedule unless you have established reputation
  2. Monitor health scores daily — pause domains below 80
  3. Segment by engagement — send to recent openers first; cold lists kill reputation
  4. Clean lists aggressively — remove BOUNCED and COMPLAINED subscribers immediately

Template design#

  1. Keep it simple — fewer images, more text gets through filters
  2. Mobile-first — 60%+ of opens are mobile
  3. One primary CTA per email
  4. Test rendering — use the preview API across email clients before sending

Compliance#

  1. Unsubscribe and address are added for you — every campaign email gets the compliance footer and the one-click header; set the postal address first
  2. Honor opt-outs in real time — UNSUBSCRIBED status takes effect at the next send check, which runs continuously
  3. Log everything — ProviderEventLog and UnsubscribeSource are the audit trail
  4. Respect frequency settings — over-sending is the fastest path to complaints

Common pitfalls#

  • Template category vs campaign type confusion: EmailTemplateCategory is what the template is about (MARKETING, TRANSACTIONAL, NEWSLETTER, …). CampaignType is how it's sent (ONE_TIME, RECURRING, TRIGGERED). A MARKETING template can be sent in a TRIGGERED campaign — they're orthogonal.
  • Warming pause is sticky: when check_warming_thresholds auto-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_sends against max_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.
  • SENT vs COMPLETED: the campaign lifecycle ends at SENT — there's no COMPLETED state. Earlier docs incorrectly listed Sent → Completed; correct flow ends at SENT.
  • Visual builder JSON in EmailTemplate.visual_definition: editing the template HTML directly drifts from the visual definition. Either always edit through the builder, or set template_type=CODE to 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.