Email engine

Every email Arc CMS sends goes through one engine: pick a provider, set a brand kit, edit templates in a block editor, and read what happened to each message in the email log.

What it is

The engine is the part every other email feature sends through. It has these pieces:

PieceWhereWhat it does
Provider settingsAdmin, Settings, Email Settings (/admin/settings/email)Which service sends the mail, the sender identity, rate limits, log retention, and a switch per kind of email
Brand kitAdmin, Email, Brand KitLogo, colors, font, footer and social links wrapped around every block-built email
ComposerAdmin, Email, ComposerA block editor for every email template, with a test send
Transactional emailsCloud FunctionsSign-up codes, the welcome email, payment receipts, notification emails, the admin digest
Queue and retriesCloud FunctionsChecks each send against the switches, consent and suppression, sends it, and retries failures
Email logAdmin, Email, Email Logs (/admin/email-logs)One record per email, including the ones that were blocked
Unsubscribe and preferencesPublic links in each emailOne-click unsubscribe and a page to turn marketing email off or back on

The Email menu is named Email + SMS while the SMS feature is on.

Every email has a category. transactional email (codes, receipts) ignores marketing consent. marketing email needs the recipient to be subscribed and must contain an unsubscribe link. Broadcasts, drip sequences and announcements are Email marketing; form emails are Signup forms. Both send through this engine.

Turn it on or off

The engine is always on: it is core, not a switchable feature, and every app has it. What you control is at run time, on Admin, Settings, Email Settings:

SwitchStops
Waitlist emailsSignup form codes and welcome emails
Account emailsThe sign-up code and the welcome email for new accounts
Payment emailsReceipts, failed payments, subscription changes, trial and updates reminders
Notification emailsThe email copy of in-app notifications, and the admin digest
BroadcastsBroadcast sends
Drip campaignsDrip sequence steps
Admin alertsThe notifications sent to every admin on events such as a new sign-up, a payment or new feedback

Test sends and emails sent by Automations have no switch of their own under Email Features; the master switch still stops them.

Set it up

  1. Choose a provider. Open Admin, Settings, Email Settings and pick one:

    ProviderWhat it needsDefault limits
    SMTPHost, port (587 by default), secure on or off, user, password1 per second
    GmailA Gmail address and a Google app password1 per second, 500 a day
    ResendAn API key2 per second, 100 a day
    Debug Provider (Log Only)NothingNone

    For a real provider, click Send a Test Email and give an address: the test sends a real message through the provider, and Save Settings stays disabled until it passes. The Debug Provider needs no test.

  2. Fill in the sender. Sender name and email are copied into the default templates when they are created, and used for test sends. Reply-to applies to every send; BCC (a copy of every email) applies with SMTP and Gmail. Change the per-second, per-hour and per-day limits if your plan allows more. A send over the limit waits and is tried again later.

  3. Save. Choosing a provider (or clicking Configure Email) and then saving also turns email on, so the master switch needs no separate click. Switch it off with the toggle at the top of the page; saving after that leaves it off.

  4. Set the public address for email links, if you use your own domain. Unsubscribe and preference links are built on liveUrl in the Settings/email document, or, when that is empty, on live_url in functions/src/constant.ts, or, when that is empty too, on the install's Hosting address, https://<hosting site>.web.app/. So a new install needs nothing here until it has a custom domain. The admin page has no field for it: add liveUrl (for example https://example.com/) to the document in the Firebase console. Saving the page keeps it. With Hosting turned off and nothing set, the links come out relative and do not work.

  5. Set the brand kit on Admin, Email, Brand Kit: logo URL and width, colors, font, footer text, postal address and social links. The preview updates as you type.

Templates need no setup step. The default templates are created the first time something needs one (the first sign-up, the first notification email, the first digest), and opening Email, Announcements creates any that are missing. Creating them never overwrites a template that exists.

Provider secrets (the SMTP or Gmail password, the Resend API key) are stored in the Settings/email document, which only admins can read. The secret that signs unsubscribe links is created on first use in _system/unsubscribe_secret, which no browser can read.

Use the Debug Provider (Log Only) while you build and test: every email is composed in full and recorded in the email log, and nothing is sent. The sign-up page shows the sign-up code on screen, so you (or a test script) can sign up without an inbox, and so can anyone with any address. Codes for adding an email to an account stay in the email log, and so do password reset codes, unless you turn on Show password reset codes on screen, which appears beside the Debug Provider: Forgot password then shows the code on the sign-in page (off by default; anyone who knows an email could then reset its password; see Forgot password). The admin dashboard shows a banner while it is active. Switch to a real provider, and test the connection, before real people sign up.

Use it

Edit templates in the composer

  1. Open Admin, Email, Composer. It lists one template per type. Templates of a feature that is off (form or payment emails) are not listed.

  2. Pick a template, or click New email. A new email gets a unique type such as custom_1759200000000; the Type column shows it as "Custom 1759200000000".

  3. Set the title, subject and category, then build the body from blocks: heading, paragraph, image, button, divider, spacer, columns, social links and raw HTML.

  4. Type # in a text field to pick a merge tag such as ##NAME##. The tags offered depend on the template type; the full list is in Email merge tags.

  5. Save. The composer stores the block design and the finished HTML, wrapped in the brand kit.

  6. Click Send test to send the compiled email to an address. Its subject starts with [TEST], and it is logged like any other email.

A marketing template cannot be saved without ##UNSUBSCRIBE_LINK##. The brand kit footer carries it by default.

A template made with New email, here or from a drip step, has no sender of its own (only the default templates carry one), and drip steps and Automations send with the template's sender. The composer has no sender field: before using such a template there, add senderName and senderEmail to its EmailTemplate document in the Firebase console.

The brand kit is applied when a template is saved, not when it is sent. After changing the brand kit, open each block template in the composer and save it again, or its emails keep the old look.

The default templates are plain HTML and open read-only in the composer. Upgrade to blocks starts a new starter block design (a heading and a paragraph); it does not convert the old content. Copy the text you want to keep before you upgrade.

Transactional emails

Template typeSent whenCategory
signup_otp_emailSomeone asks for a sign-up or email change codetransactional
password_reset_otp_emailSomeone taps Forgot password (see Forgot password)transactional
signup_welcome_emailA user record is createdmarketing
notification_generic_emailAn in-app notification is also sent by email (see Notifications)the notification type's
admin_digest_emailThe daily admin digest, when turned ontransactional
payment_succeeded_email, payment_failed_email, subscription_lifecycle_email, trial_ending_email, updates_ending_emailPayment events and reminders (see Payments)transactional
waitlist_verify_otp_email, waitlist_welcome_emailSignup form codes and welcomes, one pair per form (see Signup forms)transactional, marketing

To stop one of these emails without touching code, turn its template off: payment emails have a switch each on Settings, Payments, and form emails on the form's Email Templates page. For the others, set the template's isActive field to false in the Firebase console. Its sends are then logged as skipped, reason template_inactive.

The admin digest emails every admin a count of new sign-ups, successful payments and failed emails from the last 24 hours. It has no switch on the admin page: set adminDigest to { enabled: true, hourUtc: 8 } in the Settings/email document. The job runs every hour and sends in that hour (UTC).

How a send works

Every email is queued by one function, queueEmail() in functions/src/email-core/queueEmail.ts. It checks, in order: the master switch, the email's feature switch, whether the template is active, whether an admin disabled the contact (with Audience on), marketing consent (marketing only), and the suppression list. A send that fails a check is still written to the log, with the reason. A send that passes is written as pending, and a trigger, onEmailLogCreate, sends it. An email queued with sendNow: true, such as a sign-up code, is sent by the call that queued it instead, which saves the seconds the trigger can take to start. The trigger still runs and sends it only if that call did not.

A failed send is retried with growing delays (10, then 20 minutes) up to 3 attempts, then marked failed. A send over the rate limit is marked deferred and tried again after 15 minutes. The retryPendingEmails job picks both up every 5 minutes.

Read the email log

Admin, Email, Email Logs lists every email with its status. Open one to see the exact subject and HTML that were sent, the merge tags used, and any tag that had no value.

StatusMeans
pendingQueued, about to be sent
successThe provider accepted it (with the Debug Provider: recorded, not sent)
retryingA send failed; another attempt is scheduled
deferredOver the rate limit; waiting
failedEvery attempt failed; the error is on the record
skippedBlocked before sending: email_disabled, feature_disabled, template_inactive, contact_disabled or unsubscribed
suppressedThe address is on the suppression list
delivered, bounced, complainedSet later by the provider webhook, when one is connected

Logs older than 60 days are deleted every night at 02:00 UTC. Change the number of days, or turn this off, under Auto-Purge Email Logs on the Email Settings page. Purge Old Logs on the log page deletes them now.

Unsubscribe and preferences

The brand kit footer carries two links by default: ##UNSUBSCRIBE_LINK## and ##PREFERENCES_LINK##. Each carries a token signed with the site's secret, so nobody can build one for someone else's address.

A suppressed address gets no marketing email and still gets transactional email, such as codes and receipts. With Audience on, the contact's consent changes as well, and with Email marketing on, an unsubscribe ends their drip sequences. A bounce or complaint reported by the provider webhook is recorded on the email log only; to stop mailing an address that bounces, suppress its contact in Audience.

Both links open on the site's own address. The shipped firebase.json has Hosting rewrites that send the exact paths /unsubscribe and /email-preferences to the arccms-handleUnsubscribe and arccms-handleEmailPreferences functions, ahead of the rewrite that sends every other path to the app. The links therefore do not depend on the functions' own cloudfunctions.net or run.app addresses, which differ per project and region. The rewrites take effect when Hosting is deployed, and they reach functions in the same project. The older /unsubscribe/:waitlistId/:userId pages are not affected. Never set liveUrl to a function address. Before launch, click both links in a real email.

Extend it

Send email from your own Cloud Functions (in functions/src/custom/) through queueEmail(), never by writing to EmailLogs: a test fails if any file outside functions/src/email-core/ creates an email log. Create the template in the composer, copy its type, and load it by that type:

import { db } from '../init.js';
import { queueEmail } from '../email-core/queueEmail.js';

export async function sendShippedEmail(toEmail: string, tracking: string) {
  const snap = await db.collection('EmailTemplate').where('type', '==', 'custom_1759200000000').limit(1).get();
  if (snap.empty) return;
  const t = snap.docs[0].data();
  const settings = (await db.collection('Settings').doc('email').get()).data() ?? {};
  await queueEmail({
    source: 'event',
    category: 'transactional',
    toEmail,
    senderEmail: t.senderEmail || settings.senderEmail,
    senderName: t.senderName || settings.senderName,
    subject: t.subject,
    template: t.template,
    type: t.type,
    templateIsActive: t.isActive !== false,
    data: { TRACKING: tracking },   // fills ##TRACKING##
  });
}

A template created with New email has no sender of its own, and queueEmail() sends with exactly the sender you pass: fall back to the sender on Email Settings, as above.

Where your functions go and how they deploy: Write your own Cloud Functions.

What it stores

WhereHolds
Settings/emailProvider and its secrets, sender, limits, retention, feature switches, liveUrl, adminDigest
Settings/email_statusA public copy of the master switch, the sign-up verification switch, and whether the Debug Provider is active (no secrets)
Settings/email_brandThe brand kit
EmailTemplateTemplates: type, category, subject, HTML, block design, isActive
EmailLogsOne record per email, sent or blocked
SuppressionAddresses that get no marketing email, keyed by a hash of the address, with the reason
_email_countersHourly and daily send counts per provider, for the rate limits
_system/unsubscribe_secretThe secret that signs unsubscribe and preference links

Nothing is kept in Storage. The Cloud Functions, each deployed as arccms-<name>:

FunctionKindDoes
onEmailLogCreateFirestore triggerSends each new pending log. A sendNow log only if the call that queued it did not send it
retryPendingEmailsEvery 5 minutesRetries retrying and deferred logs
scheduledPurgeEmailLogsDaily, 02:00 UTCDeletes old logs
purgeEmailLogsCallable, adminDeletes logs older than a number of days
sendTestEmailCallable, adminThe composer's test send
seedEmailTemplatesCallable, adminCreates missing default templates, notification types, event mappings and system lists
dedupeEmailTemplatesCallable, adminRemoves duplicate templates of the same type
testSmtpConfigConnection, testProviderConnectionCallableThe provider connection test
onUserCreateWelcomeEmailFirestore triggerQueues the welcome email for a new user
sendAdminDigestEvery hourSends the daily digest in its hour
handleUnsubscribe, handleEmailPreferencesHTTPThe unsubscribe and preference pages
unsubscribeLegacyLinkCallableUnsubscribes from older form links of the shape /unsubscribe/:waitlistId/:userId
handleEmailWebhookHTTPRecords delivery, bounce and complaint events from a provider on the log
trackEmailOpenHTTPThe open-tracking image endpoint. Emails carry no tracking image unless trackingPixelUrl is set in Settings/email

All collections: Data model. All functions: Cloud Functions.

Who can do what

WhoCan
AdminRead and write Settings/email, Settings/email_brand, EmailTemplate and EmailLogs; read Suppression; call the admin functions above
Anyone, signed in or notRead Settings/email_status; open an unsubscribe or preference link with a valid token
Nobody from a browserWrite Suppression; read or write _email_counters or _system. Only Cloud Functions do.

Roles and how they are checked: Roles and claims.

Troubleshooting

You seeWhy, and what to do
Log says success but nothing arrivesThe Debug Provider is active (the log has logOnly). Pick a real provider.
skipped, email_disabledThe master switch is off, or no provider is set.
skipped, feature_disabledThat kind of email is off under Email Features.
skipped, template_inactiveThe template is turned off.
skipped, contact_disabledAn admin disabled the contact in Audience. A disabled contact gets no email at all, not even a sign-up code.
skipped with unsubscribed, or suppressedThe person opted out of marketing, or the address is on the suppression list.
Many deferred logsThe provider's daily or hourly limit is used up. They go out when it resets, or raise the limit on Email Settings.
failedRead errorMessage on the log, fix the provider settings, and test the connection again.
An empty gap where a merge tag should beThe tag had no value. The log lists it under unmapped tags.
Unsubscribe link opens the not-found page, or links are missingDeploy Hosting so the rewrites in firebase.json are live (see Unsubscribe and preferences), and check liveUrl: it must be the site's own address, not a function address.
New brand colors do not showSave each block template again in the composer.
Nothing happens after a code change to a functionFunctions run in your Firebase project, not locally: deploy the changed function (see Deploy only what changed).