Hopp til innholdet

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.

  1. 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.no by default).
  2. 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).
  3. 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:

BlockFields
headingtext
texttext: light markdown, **bold**, [label](https://…), a blank line starts a new paragraph
buttonlabel, url
imagesrc (https), alt, optional url
divider—
spacersize: small, medium (default) or large
cardscolumns 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
sociallinks (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.visit on 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"), fire contact.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:

EventEffect
email.deliveredRecipient → delivered
email.openedFirst open counted. Indicative only: Apple Mail and some others load the tracking pixel for every mail.
email.clickedCounted 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.complainedRecipient → complained; the contact is unsubscribed from everything (source: "complaint")
email.failedRecipient → 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 pathBody / 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/:idany 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/logomultipart 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-imagesmultipart 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-domainsAdmins. { domain, trackingSubdomain? } → { domain } with the DNS records (201)
POST /operator/sending-domains/:id/verifyAdmins. → { domain } after asking Resend to check again
DELETE /operator/sending-domains/:idAdmins. → 204 (also removed from Resend)

The customer's backend, with the campaigns scope (read only):

Method and pathResult
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).