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:
| Piece | Where | What it does |
|---|---|---|
| Provider settings | Admin, 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 kit | Admin, Email, Brand Kit | Logo, colors, font, footer and social links wrapped around every block-built email |
| Composer | Admin, Email, Composer | A block editor for every email template, with a test send |
| Transactional emails | Cloud Functions | Sign-up codes, the welcome email, payment receipts, notification emails, the admin digest |
| Queue and retries | Cloud Functions | Checks each send against the switches, consent and suppression, sends it, and retries failures |
| Email log | Admin, Email, Email Logs (/admin/email-logs) | One record per email, including the ones that were blocked |
| Unsubscribe and preferences | Public links in each email | One-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:
- The master switch (Email Enabled) turns all sending on or off. It cannot be turned on until a provider is set up. While it is off, every send is written to the email log as
skippedwith the reasonemail_disabled. - Email Features turns one kind of email off and leaves the rest alone. A blocked send is logged as
skipped, reasonfeature_disabled. Admin alerts is the exception: while it is off, the alerts are not created at all, in the app or by email.
| Switch | Stops |
|---|---|
| Waitlist emails | Signup form codes and welcome emails |
| Account emails | The sign-up code and the welcome email for new accounts |
| Payment emails | Receipts, failed payments, subscription changes, trial and updates reminders |
| Notification emails | The email copy of in-app notifications, and the admin digest |
| Broadcasts | Broadcast sends |
| Drip campaigns | Drip sequence steps |
| Admin alerts | The 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
-
Choose a provider. Open Admin, Settings, Email Settings and pick one:
Provider What it needs Default limits SMTP Host, port (587 by default), secure on or off, user, password 1 per second Gmail A Gmail address and a Google app password 1 per second, 500 a day Resend An API key 2 per second, 100 a day Debug Provider (Log Only) Nothing None 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.
-
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.
-
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.
-
Set the public address for email links, if you use your own domain. Unsubscribe and preference links are built on
liveUrlin theSettings/emaildocument, or, when that is empty, onlive_urlinfunctions/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: addliveUrl(for examplehttps://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. -
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
Open Admin, Email, Composer. It lists one template per type. Templates of a feature that is off (form or payment emails) are not listed.
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".Set the title, subject and category, then build the body from blocks: heading, paragraph, image, button, divider, spacer, columns, social links and raw HTML.
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.Save. The composer stores the block design and the finished HTML, wrapped in the brand kit.
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 type | Sent when | Category |
|---|---|---|
signup_otp_email | Someone asks for a sign-up or email change code | transactional |
password_reset_otp_email | Someone taps Forgot password (see Forgot password) | transactional |
signup_welcome_email | A user record is created | marketing |
notification_generic_email | An in-app notification is also sent by email (see Notifications) | the notification type's |
admin_digest_email | The daily admin digest, when turned on | transactional |
payment_succeeded_email, payment_failed_email, subscription_lifecycle_email, trial_ending_email, updates_ending_email | Payment events and reminders (see Payments) | transactional |
waitlist_verify_otp_email, waitlist_welcome_email | Signup 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.
| Status | Means |
|---|---|
pending | Queued, about to be sent |
success | The provider accepted it (with the Debug Provider: recorded, not sent) |
retrying | A send failed; another attempt is scheduled |
deferred | Over the rate limit; waiting |
failed | Every attempt failed; the error is on the record |
skipped | Blocked before sending: email_disabled, feature_disabled, template_inactive, contact_disabled or unsubscribed |
suppressed | The address is on the suppression list |
delivered, bounced, complained | Set 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.
- Unsubscribe (
/unsubscribe?e=...&t=...) adds the address to the suppression list and shows a confirmation page. Marketing emails also carry theList-Unsubscribeheaders, so mail apps can offer their own one-click button. - Preferences (
/email-preferences?e=...&t=...) shows whether the person gets marketing email and lets them turn it off or back on.
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.
- The result says what happened:
{ status: 'pending' | 'skipped' | 'suppressed', skipReason }. - Add
sendNow: truefor an email someone is waiting for on screen, such as a code: your function sends it before it returns, not the trigger. Leave it off for everything else, so a burst of emails does not hold up your function. - Pass
dedupeKeyfrom a trigger that may run twice: the same type and key never queue a second email. - To let admins decide which email goes out when something happens in your app, emit an event instead and let them map it in Automations.
- To reach a signed-in user in the app and by email, create a notification (see Notifications).
Where your functions go and how they deploy: Write your own Cloud Functions.
What it stores
| Where | Holds |
|---|---|
Settings/email | Provider and its secrets, sender, limits, retention, feature switches, liveUrl, adminDigest |
Settings/email_status | A public copy of the master switch, the sign-up verification switch, and whether the Debug Provider is active (no secrets) |
Settings/email_brand | The brand kit |
EmailTemplate | Templates: type, category, subject, HTML, block design, isActive |
EmailLogs | One record per email, sent or blocked |
Suppression | Addresses that get no marketing email, keyed by a hash of the address, with the reason |
_email_counters | Hourly and daily send counts per provider, for the rate limits |
_system/unsubscribe_secret | The secret that signs unsubscribe and preference links |
Nothing is kept in Storage. The Cloud Functions, each deployed as arccms-<name>:
| Function | Kind | Does |
|---|---|---|
onEmailLogCreate | Firestore trigger | Sends each new pending log. A sendNow log only if the call that queued it did not send it |
retryPendingEmails | Every 5 minutes | Retries retrying and deferred logs |
scheduledPurgeEmailLogs | Daily, 02:00 UTC | Deletes old logs |
purgeEmailLogs | Callable, admin | Deletes logs older than a number of days |
sendTestEmail | Callable, admin | The composer's test send |
seedEmailTemplates | Callable, admin | Creates missing default templates, notification types, event mappings and system lists |
dedupeEmailTemplates | Callable, admin | Removes duplicate templates of the same type |
testSmtpConfigConnection, testProviderConnection | Callable | The provider connection test |
onUserCreateWelcomeEmail | Firestore trigger | Queues the welcome email for a new user |
sendAdminDigest | Every hour | Sends the daily digest in its hour |
handleUnsubscribe, handleEmailPreferences | HTTP | The unsubscribe and preference pages |
unsubscribeLegacyLink | Callable | Unsubscribes from older form links of the shape /unsubscribe/:waitlistId/:userId |
handleEmailWebhook | HTTP | Records delivery, bounce and complaint events from a provider on the log |
trackEmailOpen | HTTP | The 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
| Who | Can |
|---|---|
| Admin | Read and write Settings/email, Settings/email_brand, EmailTemplate and EmailLogs; read Suppression; call the admin functions above |
| Anyone, signed in or not | Read Settings/email_status; open an unsubscribe or preference link with a valid token |
| Nobody from a browser | Write Suppression; read or write _email_counters or _system. Only Cloud Functions do. |
Roles and how they are checked: Roles and claims.
Troubleshooting
| You see | Why, and what to do |
|---|---|
Log says success but nothing arrives | The Debug Provider is active (the log has logOnly). Pick a real provider. |
skipped, email_disabled | The master switch is off, or no provider is set. |
skipped, feature_disabled | That kind of email is off under Email Features. |
skipped, template_inactive | The template is turned off. |
skipped, contact_disabled | An admin disabled the contact in Audience. A disabled contact gets no email at all, not even a sign-up code. |
skipped with unsubscribed, or suppressed | The person opted out of marketing, or the address is on the suppression list. |
Many deferred logs | The provider's daily or hourly limit is used up. They go out when it resets, or raise the limit on Email Settings. |
failed | Read errorMessage on the log, fix the provider settings, and test the connection again. |
| An empty gap where a merge tag should be | The tag had no value. The log lists it under unmapped tags. |
| Unsubscribe link opens the not-found page, or links are missing | Deploy 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 show | Save each block template again in the composer. |
| Nothing happens after a code change to a function | Functions run in your Firebase project, not locally: deploy the changed function (see Deploy only what changed). |