DocumentationMailbar: e-mail campaignsMarkdown
Mailbar: e-mail campaigns
Anyone on the team, admins and agents alike, writes a campaign in the panel (Mailbar → Kampanjer), picks one or more contact lists and sends it now or at a set time. Snakkbar sends it through Resend to every subscribed contact with an address that is not on the suppression list, one mail per recipient, and follows up on what happens to each mail: delivered, opened, clicked, bounced, complained, unsubscribed.
In the panel Mailbar has three levels under its item in the rail: Kampanjer, Kontakter (everyone and the lists; a list opens its subscribers) and Innstillinger, with two tabs: Mal (the template) and Logo og bunntekst. Breadcrumbs show where you are («Mailbar › Kontakter › Nyhetsbrev»). The sending domains are the admins', in Settings → Mailbar.
Sending domains
A campaign is sent from a domain the tenant owns, e.g. nyhetsbrev@amatec.no.
- Settings → Mailbar (admins): add the domain (
amatec.no). Snakkbar registers it in Resend (EU region, sending only) with a tracking subdomain for clicks and opens (links.amatec.noby default). - Add the DNS records shown at the domain's DNS provider: DKIM (
resend._domainkey, TXT), SPF for the return path (send, MX and TXT) and tracking (links, CNAME). None of them touches the domain's own mail (its MX and SPF on the root stay as they are). - Wait until the domain shows as verified. Snakkbar picks up Resend's result by itself every 5 minutes; Sjekk på nytt fetches it at once, and asks Resend to check the DNS again only when Resend has not verified the domain and the last request was more than 10 minutes ago (a new check starts over, and the domain cannot send until it is done). DNS can take minutes to hours.
Until a domain is verified, campaigns go out from Snakkbar's shared campaign domain
(SNAKKBAR_CAMPAIGN_DOMAIN, e.g. amatec@post.snakkbar.app, with the tenant's name as sender
name). Replies cannot be received there, so such a campaign needs a reply-to address.
Also required before the first send: the footer (Mailbar → Innstillinger → Logo og bunntekst), the sender's name and postal address, which every campaign mail ends with.
Writing a campaign
A campaign has a name (internal), subject, preview line (preheader), sender name, the part
before @ (fromLocal, default nyhetsbrev), an optional reply-to address, the lists, and the
content as blocks:
| Block | Fields |
|---|---|
heading | text |
text | text: light markdown, **bold**, [label](https://…), a blank line starts a new paragraph |
button | label, url |
image | src (https), alt, optional url |
divider | — |
spacer | size: small, medium (default) or large |
cards | columns 2 (default) or 3, items (1–9): image (https), alt, title, text (light markdown), url, label (the link line, e.g. «Se produktet»). Side by side, stacked on phones |
social | links (1–7): network (facebook, instagram, linkedin, youtube, x, tiktok, web) and url, shown as small labels |
Every block can also have style: background (#rrggbb, a band edge to edge; adjacent blocks with
the same colour read as one section, and text turns light on a dark band), align (left or
center) and space (none, small, normal, large). In the template a block can have a
slot, the name of the section it belongs to («Toppsak», «Produkter»): Pixel fills the named
sections of a new newsletter and keeps the blocks without one (greeting, sign-off, social links)
exactly as they are.
The editor is the e-mail itself. Text is written in place, images and links open a small card, blocks move by their handle and have a style (the half-filled circle; the slot is set there in the template). The add bar offers headings, text, buttons and images; cards, room, social links, layout and design are asked of Pixel: «Mer med Pixel …», or the speech bubble on a block, which opens Pixel with a question about that block started in its composer. Without Pixel (an embed that leaves it out) the add bar offers every block.
Merge tags work in the subject, preview line and every block: {{firstName}},
{{lastName}}, {{email}}, {{company}}, with a fallback after |: Hei {{firstName|der}}.
Links to the tenant's own site (its site hosts and allowed origins) get
utm_source=snakkbar&utm_medium=email&utm_campaign=<campaign name as slug> and sb=<recipient token>, so a visit can be tied to the mail and the recipient. Existing UTM values are kept.
Links to other sites are left alone.
Every mail is rendered from MJML (server/campaigns-core.ts; tables and inline styles that hold
up in Outlook, Gmail and Apple Mail, 560 px wide, columns stacking on phones) and as plain text, and ends with the footer, a line saying why the recipient gets it, and an unsubscribe
link.
Visits from the mail
When a recipient clicks a link to the tenant's site, the widget (any page with the Snakkbar
script, or the SDK's startTracking) takes sb out of the address bar right away, so the token
is not shared or bookmarked, and reports the visit (POST /visitor/campaign-visit). The token is
never stored with the page address. Snakkbar then:
- marks the recipient as having visited (the campaign's Besøkte count, first visit per recipient), and stores every visit with its page;
- links the visitor to the contact, so their pages, chats and callback requests show up on the contact, unless the visitor already belongs to another contact (a forwarded mail);
- puts
site.visiton the contact's timeline.
This follows the tenant's consent setting: with consent: "required" the visit is reported only
after the visitor accepts, and not at all if they decline. It works whether or not live visitors
are on. Mail scanners do not count here, since they do not run the page's scripts.
Sending
- Send test: up to 5 addresses,
[Test]in the subject, filled in for the first subscriber (or sample values). Test mails have no recipient token and no unsubscribe link that works. - Send now or schedule (at least a minute ahead). Snakkbar first checks that the campaign has a subject, content, lists, a verified sender (or the shared domain with a reply-to address) and a footer; the panel shows what is missing.
- When sending starts, one row per recipient is put in a queue (duplicates across lists count once). A cron (every minute, and right away on Send now) sends 100 mails per Resend batch call. Two runs never send the same row (rows are claimed in the database); a rate limit or an outage puts the rows back in the queue.
- Stop: a scheduled campaign goes back to draft; a running one stops, and mails not yet handed to Resend are dropped.
Unsubscribing
Every mail has an unsubscribe link and the List-Unsubscribe / List-Unsubscribe-Post headers
(RFC 8058), so Gmail, Outlook and Apple Mail show their own unsubscribe button.
- The link opens a page with two buttons: unsubscribe from this campaign's lists, or from everything from the tenant. Opening the page changes nothing (mail scanners open links).
- The mail client's one-click unsubscribe unsubscribes from the campaign's lists.
- Both are logged as consent (
source: "unsubscribe_link"), firecontact.unsubscribed, and "everything" suppresses the address.
What comes back
Resend's webhook (POST /api/v1/webhooks/resend, the same endpoint as e-mail replies) reports
each mail's events. Snakkbar:
| Event | Effect |
|---|---|
email.delivered | Recipient → delivered |
email.opened | First open counted. Indicative only: Apple Mail and some others load the tracking pixel for every mail. |
email.clicked | Counted per person and per link, unless it was a mail scanner: a scanner or tool user agent, a click within 10 seconds of delivery, or several links clicked within 2 seconds. Scanner clicks are stored but left out of the numbers. |
email.bounced (permanent) | Recipient → bounced; the address is suppressed (bounced) |
email.complained | Recipient → complained; the contact is unsubscribed from everything (source: "complaint") |
email.failed | Recipient → failed, with Resend's reason |
Sent, opened, clicked (with the link), bounced and complained also land on the contact's timeline (contacts.md).
API
Anyone on the team (/operator…, used by the panel):
| Method and path | Body / result |
|---|---|
GET /operator/campaigns | → { campaigns: (Campaign & { stats })[] } |
POST /operator/campaigns | { name, …fields } → detail (201) |
GET /operator/campaigns/:id | → detail: { campaign, stats, sendProblem, sender: { from, shared } | null } |
PATCH /operator/campaigns/:id | any of name, subject, preheader, fromName, fromLocal, replyTo, sendingDomainId, listIds, content → detail. Drafts only. |
DELETE /operator/campaigns/:id | → 204. Drafts and stopped campaigns only. |
POST /operator/campaigns/:id/duplicate | → detail of the copy |
POST /operator/campaigns/:id/preview | { content?, subject?, preheader? } (unsaved changes) → { html, text, subject } |
GET /operator/campaigns/:id/audience | → { recipients }: how many would get it now |
POST /operator/campaigns/:id/test | { emails: [1–5] } → { sent } (30 per hour) |
POST /operator/campaigns/:id/send | { scheduledAt? } → detail. 409 with the reason when it cannot be sent. |
POST /operator/campaigns/:id/cancel | → detail |
GET /operator/campaigns/:id/recipients?filter=&offset=&limit= | → { recipients: CampaignRecipient[] }; filter: a status, opened, clicked or unsubscribed |
GET /operator/campaign-settings | → { footer, logoUrl, sharedDomain, domains: SendingDomain[] } |
PATCH /operator/campaign-settings | { footer } |
POST /operator/campaign-settings/logo | multipart file (PNG, JPEG or WebP, ≤ 2 MB) → { logoUrl }. The logo heads every mail; without one the company name does. |
DELETE /operator/campaign-settings/logo | → { logoUrl: null } |
GET /operator/campaign-template | → CampaignTemplate { design, content, updatedAt }: Mailbar's template (Mailbar → Innstillinger → Mal). design is how every mail looks (accent, text, background, card as #rrggbb; font and headingFont from system, helvetica, verdana, trebuchet, georgia, palatino; headingSize 18–36; header logo-left / logo-center / band; topBar; buttons rounded / pill / square; images rounded / square). content: the blocks a campaign created without content starts from. |
PUT /operator/campaign-template | { design?, content? } → CampaignTemplate. What is left out is kept. Every mail (preview, test, send) uses the design. |
POST /operator/campaign-images | multipart file (PNG, JPEG, WebP or GIF, ≤ 5 MB) → { url }: a public image for mails (the campaign-images bucket), also what the panel attaches to Pixel's messages. |
POST /operator/sending-domains | Admins. { domain, trackingSubdomain? } → { domain } with the DNS records (201) |
POST /operator/sending-domains/:id/verify | Admins. → { domain } after asking Resend to check again |
DELETE /operator/sending-domains/:id | Admins. → 204 (also removed from Resend) |
The customer's backend, with the campaigns scope (read only):
| Method and path | Result |
|---|---|
GET /server/campaigns | { campaigns: Campaign[] } |
GET /server/campaigns/:id | { campaign, stats: CampaignStats } |
GET /server/campaigns/:id/recipients?filter=&offset=&limit= | { recipients: CampaignRecipient[] } |
To act on clicks, unsubscribes and bounces in your own systems, use webhooks
(contact.unsubscribed) and the contact timeline (GET /server/contacts/:ref/activities).