Admin REST API
Mounted under /api inside createAdminRouter(mailer). With the default mount path /admin/mailer, endpoints live at /admin/mailer/api/*. Gated by your host's auth middleware.
All requests/responses are JSON. Mutating endpoints write to mailer_audit_log automatically with actor resolved via getActor (default human:${req.user?.email ?? 'anonymous'}).
Identity
GET /api/me
→ { actor: string, permissions: { canPublish: boolean, canSendBroadcasts: boolean, canManageSuppressions: boolean } }Called by the SPA on boot. The permissions object hides UI buttons the user can't activate.
Dashboard
GET /api/dashboard
Aggregated KPIs + recent activity.
→ {
kpis: {
sends: { value: number, delta: number | null }
deliveredRate: { value: number, delta: number | null, bounced: number }
openRate: { value: number, delta: number | null, exclBots: boolean }
clickRate: { value: number, delta: number | null }
}
health: { status: 'healthy' | 'degraded' | 'tripped', rates: Record<string, number> }
queue: { inFlight: number, delayed: number, providerOk: boolean, providerName: string }
recentFlows: FlowDoc[]
recentSends: SendDoc[]
recentAudit: AuditLogDoc[]
}Events registry
GET /api/events
The full event registry — declared events with their dedupe policies, the event names seen in mailer_events that nobody registered, and per-name volume so a registered trigger can be checked against what the host really fires. A name in registered with no stats entry (or none in last30d) is a trigger nobody is reaching.
→ {
registered: { name: string; dedupePolicy: 'once-per-contact' | 'once-per-day' | 'every-time' }[]
seen: string[] // names in mailer_events that are not registered
stats: Record<string, { // keyed by event name, registered or not
total: number // rows ever
last7d: number
last30d: number
firstAt: string // ISO
lastAt: string
}>
}Flows
GET /api/flows
List all flows, newest-updated first.
GET /api/flows/:slug
Single flow's full definition.
POST /api/flows/:slug/pause
Set enabled: false. In-flight runs continue.
POST /api/flows/:slug/resume
Set enabled: true.
Templates
GET /api/templates
List all templates, newest-updated first.
GET /api/templates/:slug
Single template's full definition.
PATCH /api/templates/:slug/draft
Stage changes on the template's unpublished draft. Every field is optional; only the ones present are written, so the editor can save one field without round-tripping the rest. A template with no draft yet gets one seeded from its published values first.
Exactly one of html, mjml or editorJson describes the body, and publish resolves them in that authoring precedence (editorJson → mjml → raw html). Sending mjml: '' or editorJson: null clears that source, which is how a template changes authoring mode.
fromEmail and kind are validated against the senderDomains registry as a pair, whether one or both are supplied.
body: {
subject?: string
preheader?: string
html?: string
mjml?: string
editorJson?: Record<string, unknown> | null
notes?: string
name?: string
fromName?: string
fromEmail?: string
replyTo?: string | null
kind?: 'marketing' | 'transactional'
bodyFormat?: 'multipart' | 'text_only'
trackOpens?: boolean
trackClicks?: boolean
}
→ { ok: true }
→ 400 { error: 'sender_domain_invalid', code: string, message: string }Broadcasts
GET /api/broadcasts
List all broadcasts, newest-created first.
GET /api/broadcasts/:slug
Single broadcast.
Contacts
GET /api/contacts
Paginated list via the adapter.
?cursor=<string>&limit=<number> (limit max 200)
→ { contacts: Contact[], nextCursor?: string }GET /api/contacts/:externalId
Contact detail — adapter fields + subscription + recent events + recent sends + active flow runs.
→ {
contact: Contact
subscription: SubscriptionDoc | null
recentEvents: EventDoc[]
recentSends: SendDoc[]
activeRuns: FlowRunDoc[]
}Sends
GET /api/sends
?status=<SendStatus>&limit=<number> (limit max 500)
→ SendDoc[]GET /api/sends/:id
→ { send: SendDoc, webhookEvents: WebhookEventDoc[] }Suppressions
GET /api/suppressions
List, newest-added first (limit 500).
POST /api/suppressions
body: {
email: string
scope: 'all' | 'marketing' | 'transactional'
reason: 'unsubscribed' | 'hard_bounce' | 'complaint' | 'manual' | 'list_cleaning' | 'gdpr_forget'
source?: string
notes?: string
}
→ { ok: true }Audit log
GET /api/audit
List, newest-first (limit 200).
Vars schema
GET /api/vars-schema
The host's varsAdapter schema as JSON Schema (drives editor autocomplete and the unknown_variable lint rule), plus the built-in render-context keys.
→ { schema: JSONSchema | null, builtins: string[] }Templates — preview + test send
POST /api/templates/:slug/preview
Render the draft (or published body with useDraft: false). Pass contactId to render as a real contact — the contact is loaded through the adapter and the varsAdapter resolver runs (reason: 'preview'). Without it, a sample contact and empty host vars are used.
Pass html, mjml or editorJson to render that content instead of the stored draft, resolved in the usual authoring precedence. This is how the editor previews unsaved edits: a preview is a read, and making one should not write a draft — which is an audit-logged mutation — just to see the result.
Pass eventProperties to simulate a trigger event — both {{event.*}} and the resolver's info.eventProperties receive it.
body: {
useDraft?: boolean
contactId?: string
sampleContact?: Contact
vars?: Record<string, unknown>
eventProperties?: Record<string, unknown>
html?: string
mjml?: string
editorJson?: Record<string, unknown> | null
}
→ { subject, preheader, html, plainText, contact: { externalId, email } }
→ 404 { error: 'contact_not_found', contactId }
→ 409 { error: 'not_published', message }
→ 422 { error: 'compile_failed', message }
→ 502 { error: 'vars_resolve_failed', message }POST /api/templates/:slug/send-test
Send the published template to an operator-typed address. With contactId, the render uses that contact's data + resolved host vars (reason: 'test') while still delivering to to.
body: { to: string; contactId?: string; sampleData?: { contact?: Contact; vars?: Record<string, unknown> }; eventProperties?: Record<string, unknown> }
→ { ok: true, providerId: string }Templates — content linter + Mail-Tester
POST /api/templates/:slug/lint
Run the content linter against a draft. Body fields are all optional — missing fields fall back to the saved draft, then to the last published values. When the effective source resolves to raw HTML (see Templates → Hand-written HTML), the response's errors / warnings arrays also carry markup issues (unclosed tags, a <script> tag, a missing <html>/<body> wrapper, and the like) alongside the usual content rules.
body: {
subject?: string
preheader?: string
mjml?: string
html?: string
editorJson?: Record<string, unknown> | null
fromEmail?: string
kind?: 'marketing' | 'transactional'
}
→ {
errors: { rule: string; severity: 'error'; message: string; hint?: string }[]
warnings: { rule: string; severity: 'warning'; message: string; hint?: string }[]
infos: { rule: string; severity: 'info'; message: string; hint?: string }[]
compileFailed: boolean
}POST /api/templates/:slug/publish
Publish the saved draft — its content may live in html, mjml or editorJson, resolved in that authoring precedence (editorJson → mjml → raw html); an HTML-only draft is stored verbatim with plain text derived. Rejects with 422 if the linter has errors. Rejects with 422 mail_tester_blocked when a cached Mail-Tester score is below minScore (code: 'low_score'), or — with mailTester.requireScore enabled — when the content has no score at all (code: 'no_score', score: null). Pass { bypassMailTester: true } to override the Mail-Tester gate (still subject to the lint gate).
body?: { bypassMailTester?: boolean }
→ 200 { ok: true, version: number, warnings: CompilerWarning[], lint: LintResult }
// CompilerWarning ≈ { line?: number; message: string; tagName?: string; formattedMessage?: string }
// These come from the MJML / Maily compiler — formatting warnings that did
// not block compile (e.g. unknown attributes). Distinct from lint issues.
→ 422 { error: 'lint_failed', message: string, lint: LintResult }
→ 422 { error: 'mail_tester_blocked', message: string, score: MailTesterScoreDoc, hint: string }GET /api/templates/:slug/mail-tester
Mail-Tester status + the cached score for the current content fingerprint, if one exists.
→ {
configured: boolean
minScore: number
cacheHours: number
score: MailTesterScoreDoc | null
}POST /api/templates/:slug/mail-tester-check
Provision a Mail-Tester check, send the rendered draft to the test address via the default provider, return either a cached score or a pending checkId to poll.
→ {
cached: boolean
status: 'pending' | 'ready'
checkId?: string
contentKey?: string
score?: MailTesterScoreDoc
message?: string
}Returns 400 { error: 'not_configured' } when MailerConfig.mailTester is absent.
GET /api/templates/:slug/mail-tester-result?checkId=...&contentKey=...
Poll the in-progress check. Returns { status: 'pending', score: null } until Mail-Tester is ready, then the persisted score.
Health
GET /api/health
Per-(sender domain × kind) buckets plus the aggregate roll-up plus the configured trip thresholds. The top-level status is the worst bucket status (or null when no tick has run).
→ {
status: 'healthy' | 'degraded' | 'tripped' | null
rates: { bounceRate, hardBounceRate, complaintRate, failureRate } | null
counters: HealthCounters | null
aggregate: HealthDoc | null
buckets: HealthDoc[]
thresholds: {
hardBounceRatePctTrip: number
complaintRatePctTrip: number
combinedBounceRatePctTrip: number
failedToSendRatePctDegrade: number
}
}POST /api/health/resume
Manual reset. Pass { senderDomain, kind } to resume one bucket. Empty body resumes every tripped bucket.
body?: { senderDomain?: string; kind?: 'marketing' | 'transactional' }
→ { ok: true, resumed: number }Audit-logged.
GET /api/health/trips
The last 50 health.trip / health.resume audit-log rows.
DNSBL
GET /api/dnsbl
Latest scan results across all configured targets and lists.
→ {
checks: DnsblCheckDoc[]
latestRunAt: string | null
intervalHours: number
}POST /api/dnsbl/recheck
Force an immediate scan (ignores throttle). Audit-logged.
→ { ran: boolean; reason?: string; totalChecks?: number; listedCount?: number }Google Postmaster Tools
GET /api/postmaster
→ {
configured: boolean
intervalHours: number
domains: Array<{
domain: string
latest: PostmasterSnapshotDoc | null
history: PostmasterSnapshotDoc[] // up to 30
}>
}POST /api/postmaster/refresh
Force an immediate pull (ignores throttle). Audit-logged.
→ { ran: boolean; reason?: string; fetched?: number; trippedDomains?: string[] }Microsoft SNDS
GET /api/snds
→ {
configured: boolean
intervalHours: number
ips: Array<{
ip: string
latest: SndsSnapshotDoc | null
history: SndsSnapshotDoc[] // up to 30
}>
}POST /api/snds/refresh
Force an immediate pull (ignores throttle). Audit-logged.
→ { ran: boolean; reason?: string; rowsParsed?: number; rowsPersisted?: number }DMARC
GET /api/dmarc
→ {
domains: Array<{
domain: string
passCount: number
failCount: number
totalMessages: number
reportCount: number
latestRangeEnd: string | null
alignmentRate: number | null
currentPolicy: 'none' | 'quarantine' | 'reject' | null
currentPct: number | null
progression: { policy, pct, reason, current: { policy, pct } } | null
series: Array<{ day: string; alignmentRate: number | null }> // last 14 days
}>
sources: Array<{
sourceIp: string
domain: string
totalMessages: number
daysSeen: number
lastSeen: string
dkimResult: string
spfResult: string
dispositionApplied: string
label: string | null
ignored: boolean
tagSource: 'config' | 'db' | null
}>
recentReports: DmarcReportDoc[] // up to 30
retentionDays: number
}POST /api/dmarc/upload
Upload one .zip / .gz / .xml aggregate report as multipart/form-data with a file field. Audit-logged.
→ {
ok: true
reportId: string
domain: string
rangeStart: string
rangeEnd: string
totalMessages: number
passCount: number
failCount: number
duplicate: boolean
}Returns 400 for no_file / ingest_failed (with the parse error in message).
PUT /api/dmarc/sources/:ip
Tag a DMARC source IP as known.
body: { label: string; ignored?: boolean }
→ { ok: true }DELETE /api/dmarc/sources/:ip
Remove a DB-set tag (config-set tags can't be deleted via the API — edit MailerConfig.dmarc.knownSources).
→ { ok: true; deleted: number }List hygiene
GET /api/hygiene
→ {
totalSubscribers: number
totalWithSends: number
buckets: Array<{
label: string // "Engaged (last 30 days)", etc
minDays?: number | null
maxDays?: number | null
neverEngaged?: boolean
count: number
pctOfTotal: number
}>
sunsetCandidate: {
count: number
pctOfTotal: number
cohortLifetime: {
totalSends: number
bouncedRate: number
hardBouncedRate: number
complaintRate: number
} | null
projectedImpact: {
recentTotalSends: number
recentBounced: number
recentComplained: number
cohortRecentSends: number
cohortRecentBounced: number
cohortRecentComplained: number
currentOverallBounceRate: number
projectedBounceRate: number
currentOverallComplaintRate: number
projectedComplaintRate: number
} | null
}
computedAt: string
}Error shape
All errors return:
{ error: string, message?: string }Common error codes:
| Code | Status | Meaning |
|---|---|---|
not_found | 404 | Resource doesn't exist |
bad_id | 400 | Invalid ObjectId / slug format |
validation_failed | 400 | Request body failed Zod parse |
forbidden | 403 | Permission denied (when resolvePermissions is set) |
Pagination
Endpoints that paginate use opaque cursor strings. Pass ?cursor=<nextCursor> to fetch the next page. nextCursor is omitted on the last page.
Limits are bounded per endpoint (100-500 typical max).
Versioning
The admin API is internal — coupled to the SPA shipped in the same mailery package version. Endpoints may change between mailery releases without major version bumps. Don't build external integrations against /admin/mailer/api/* — use the Mailer class methods instead.