Skip to content

Notifications & messaging

Every application eventually has to interrupt somebody. An invoice needs an approval, an order shipped, an integration failed, a record the user follows changed. The platform’s job is to carry that interruption to the right people, on the channels they have agreed to, without leaking anything they are not allowed to see and without any one automation being able to bury them.

Three nouns carry the whole model. A notification type is deployable metadata: what raises it, who receives it, what it links to, which channels it may use. A message template is deployable metadata: the text of the message, with typed merge fields, per channel and per locale. A delivery is data: one row per recipient per channel, with a state, an attempt count, and a reason if it failed.

The load-bearing decision on this page: a notification is a read, and every access rule that governs a read governs it. The message is rendered once per recipient, inside that recipient’s access context, on the same enforced read path as any query — so field-level security applies to a merge field exactly as it applies to a column on a record page. A template that interpolates record.margin_pct does not put the margin in front of a recipient who cannot read the margin. This is not a lint rule on template authors and it is not a review checklist. It is the render path.

The alternative — render once against the record, then fan the identical bytes out to a recipient list — is what every incumbent does, and it is why “the email alert leaked the discount to the customer-facing user” is a permanent category of production incident. Rendering per recipient costs more. It is worth it.

Piece What it is Where it lives
Notification type Trigger, targeting, channels, collapse and digest policy Canonical component, notification_type
Message template Subject and body per channel, per locale, with typed merge fields Canonical component, message_template
Sending identity The verified domain, from-address, and reply policy for a channel Canonical component, sending_identity
Preference One user’s answer for one category or type on one channel Record data
Notification One raised event: type, subject record, resolved recipients, payload Record data
Delivery One recipient × one channel: state, attempts, provider id, failure reason Record data

The split matters for the same reason it matters everywhere else on this platform: components deploy between environments, record data does not. A notification type moves from a sandbox to production in a deploy. A user’s decision to mute it does not travel with it, and the deploy cannot silently re-enable a channel someone turned off.

Four, and the set is closed. Adding a fifth is a platform change, not a customer configuration, because each channel is a delivery adapter with its own identity, failure model, and payload constraints.

Channel Carries Failure model
in_app Rich body, the tray, read/unread, deep link Effectively none — the delivery row is the notification
email Rich body, subject, reply routing Bounces (hard and soft), suppression, reputation
push Plain title and short body, deep link Token revocation, device unregistration
chat Rich body posted into an external chat workspace Third-party rate limits and outages

One notification, several channels, one render per recipient per channel — the push body is not the email body truncated; it is its own template with its own merge fields, because a 40-character lock-screen line and a 600-word email are different documents.

in_app is the only channel that is always available and cannot be disabled at the org level, because it is the fallback of record: if every other channel is off, misconfigured, or suppressed, the notification is still in the tray and nothing has been lost.

A notification type resolves its recipients from a list of target rules, evaluated at raise time against the subject record. The rules union, then deduplicate by user.

Rule kind Resolves to
user One named user
role Every user holding that role, optionally plus every role beneath it
group Every member of a group, transitively — the same group closure the record-access plane uses
owner The subject record’s current owner, which may be a queue and therefore a set
field The users named by a user-lookup field on the record (record.assigned_billing_specialist)
expression A pure expression returning a user set

expression is written in the one typed language and is pure, so it is type-checked at author time and recorded in the dependency graph — the platform can answer “which notification types target users through the region field” before a deploy changes that field. That is the same property sharing rules get from having their coverage written as an expression rather than encoded in a rule type.

Targeting is not access. Resolving a user into the recipient set says nothing about whether that user may see the subject record. The two are checked separately: targeting decides who is told, the access planes decide what they are told. A recipient who cannot read the subject record still receives the notification, rendered down to whatever they may actually see — which, in the limiting case, is a title and no link. Suppressing the notification entirely would be worse: it would make notification delivery a side channel for probing record access.

A template binds to an object and a channel and carries a locale map. Merge fields are expressions in the one typed language, interpolated into text:

Invoice {{record.invoice_number}} needs your approval.
Submitted by {{record.submitted_by.name}} on {{format(record.submitted_at, "d MMM")}}.

Three consequences of merge fields being ordinary typed expressions rather than a separate merge dialect:

  • They type-check. {{record.total_price}} in a template bound to invoice compiles or it does not. A renamed field breaks the deploy, not the Tuesday-morning send.
  • They are in the dependency graph. Where-used, blast radius, and impact preview cover templates. Deleting a field tells you which templates read it.
  • There is one grammar. The same expression syntax as a formula field, a validation rule, and a sharing rule’s coverage. Nothing to learn twice, and no second merge language whose escaping rules differ.

Localization is a map on the one component, not a family of cloned components. default is required; each additional locale supplies a subject and body. Resolution is: the recipient’s locale, then the recipient’s language without region, then default. A missing locale is a fallback, never a failure — a notification that does not send because nobody translated it is a worse outcome than one that sends in English.

Every merge field is evaluated on the enforced read path, as the recipient. A field the recipient cannot read does not resolve to its value. What happens instead is declared on the template:

onUnreadable Behavior
redact The field renders as —. The rest of the message sends intact.
minimal (default) If any field listed in requires is unreadable, the whole body is replaced by the type’s minimal form: title, subject record name if readable, and the link.
suppress If any requires field is unreadable, no delivery is created for that recipient on that channel.

minimal is the default because it is the only one of the three that is both safe and honest: the recipient learns that something happened and can ask, rather than receiving a message with holes in it or receiving nothing at all. suppress exists for templates whose entire purpose is one sensitive number, where a contentless notification is noise.

The same rule covers the link. A deep link is a target record id, not a pre-authorized URL; access is resolved when the link is followed, not when the notification was raised.

A notification type, end to end:

{
"key": "notify_invoice_submitted",
"label": "Invoice submitted for approval",
"type": "notification_type",
"body": {
"object": "invoice",
"category": "approvals",
"severity": "normal",
"raise": {
"on": "update",
"when": "record.status == \"Pending Approval\"",
"changedToMeet": true
},
"targets": [
{ "kind": "expression", "users": "approversFor(record)" },
{ "kind": "field", "field": "invoice.account_manager" }
],
"link": { "object": "invoice", "record": "record.id" },
"channels": {
"in_app": { "template": "tpl_invoice_submitted_in_app", "default": "on" },
"email": { "template": "tpl_invoice_submitted_email", "default": "on",
"identity": "send_from_invoices" },
"push": { "template": "tpl_invoice_submitted_push", "default": "on" },
"chat": { "template": "tpl_invoice_submitted_chat", "default": "off",
"connection": "chat_sales_ops" }
},
"collapseKey": "invoice:{{record.id}}",
"collapseWindow": "5m",
"digestible": true,
"mandatory": false
}
}

raise uses exactly the vocabulary of record-triggered automation — on, when, changedToMeet, changed — because it is that vocabulary; a declarative notification type is a post-commit automation the platform writes for you. A type may also omit raise entirely and be raised explicitly from an automation body (notify("notify_invoice_submitted", { record })) when the condition is not expressible as a field-state predicate. Both paths land in the same pipeline.

A template:

{
"key": "tpl_invoice_submitted_email",
"label": "Invoice submitted — email",
"type": "message_template",
"body": {
"object": "invoice",
"channel": "email",
"requires": ["invoice.total_price"],
"onUnreadable": "minimal",
"locales": {
"default": {
"subject": "Invoice {{record.invoice_number}} needs your approval",
"body": "{{record.account.name}} — {{record.invoice_number}}\n\nTotal {{money(record.total_price)}}, margin {{percent(record.margin_pct)}}.\nSubmitted by {{record.submitted_by.name}}."
},
"es-MX": {
"subject": "La factura {{record.invoice_number}} requiere su aprobación",
"body": "…"
}
}
}
}

requires names invoice.total_price but not invoice.margin_pct — so a recipient who can read the total but not the margin gets the full message with the margin redacted, and a recipient who can read neither gets the minimal form. Deciding which fields are load-bearing is the author’s judgment; enforcing the consequence is not.

A sending identity:

{
"key": "send_from_invoices",
"label": "Invoices — outbound email identity",
"type": "sending_identity",
"body": {
"channel": "email",
"domain": "mail.example.com",
"from": "invoices@mail.example.com",
"displayName": "Example Invoices",
"class": "transactional",
"replyTo": { "mode": "routed", "attachTo": "subject_record" }
}
}

The domain is per-environment configuration data, not a hard-coded string in the component — a sandbox sends from a sandbox domain, and promoting the component does not point production mail at a test domain.

Chat is a connection plus a target:

{
"key": "chat_sales_ops",
"label": "Sales ops channel",
"type": "chat_connection",
"body": {
"provider": "slack",
"target": "#sales-ops",
"credential": "secret:slack_bot_token"
}
}

A notification is created in the Effects phase of the save order — the notification row and its resolved recipient rows are written inside the same transaction as the record change that caused them. Nothing is sent there. The dispatcher only ever picks up committed rows.

That ordering buys two properties that are difficult to retrofit:

  • A rolled-back save sends nothing. If validation fails three rules later, or the transaction aborts for any reason, the notification never existed. There is no compensating “actually, ignore that email.”
  • A committed save always eventually sends. The notification is durable before the transaction returns, so a dispatcher crash delays delivery, it does not lose it.

From there, delivery runs as background work: one unit of work per (notification, recipient, channel), scheduled, retried, and observable like any other job. Rendering happens in that unit — after commit, under the recipient’s access context, against the committed record.

The dispatcher resolves the recipient’s effective field access for the columns the template touches, then renders. Recipients whose access resolves identically over that specific field set share a render — the access signature is the set of readable fields among those the template references, and it is usually one of a handful of distinct values across thousands of recipients. Sending to an entire sales org typically produces two or three renders, not thousands.

Access signatures are computed from the same effective-access resolver that answers “may this user edit margin,” so there is no second access model to keep in sync, and a permission-set change invalidates signatures for the same reason it invalidates anything else.

queued ──▶ sending ──▶ delivered ──▶ opened
│ │ │
│ │ └──▶ bounced (email, hard or soft)
│ └──▶ failed (non-retriable)
├──▶ throttled (budget or rate limit; still queued)
└──▶ suppressed (preference, quiet hours, or onUnreadable)

Retry policy reads the error envelope rather than guessing: a failure marked retriable is retried with exponential backoff up to the channel’s attempt ceiling; a failure that is not is terminal and the reason is on the row. Every delivery carries the correlationId of the transaction that raised it, so a delivery joins to its execution trace, data history, and metadata audit with one id — “why did this person get this email” resolves to the save that caused it and the version of the type that shaped it.

suppressed is a state, not an absence. A notification that was not sent because the user muted it, because quiet hours held it past its relevance, or because a required field was unreadable, leaves a row saying so. Silence with no record is the failure mode that makes notification systems impossible to debug.

Two independent mechanisms, and they solve different problems.

Collapse handles bursts. Every notification carries a collapseKey, defaulting to type + subject record + recipient. Within the type’s collapseWindow, a second notification with the same key updates the existing undelivered one and increments its count rather than creating a second delivery — the tray shows “3 new comments,” and one email goes out instead of three. Once a delivery has been sent, the window closes and the next notification is genuinely new.

Digest handles volume. A digestible type may be set — by the user, per category or per type — to hourly or daily. Digestible deliveries accumulate in a per-(user, channel, window) bucket and one message goes out at the boundary, rendered from the same templates with the same per-recipient access rules applied to each item.

Neither applies to severity: "urgent", and neither applies to a notification whose type is mandatory. An approval request that a lifecycle is blocked on is not something a digest preference gets to delay by 24 hours.

Preferences resolve most-specific-wins, which is deliberately not the additive union used by the permission planes. A union is right for access, where the question is “did anyone grant this.” It is wrong for preferences, where the question is “what did this person most recently say.”

org channel policy → type default → user category preference → user type preference
(most specific wins)

A user sets preferences per category by default — approvals, mentions, assignments, system — and drills into an individual type only when they want to. A per-type × per-channel matrix presented raw is a matrix nobody edits.

Quiet hours are per user: a window and a time zone. During the window, email, push, and chat deliveries are held and released at the window’s end, collapsed if their keys match. in_app is never held — the tray is pull, not push, and holding it would mean the user opens the app to an empty tray and a stale world. urgent is never held.

Mandatory types cannot be opted out of, and the mechanism that keeps that list short is procedural and enforced:

  • A type with mandatory: true must carry a written mandatoryReason; the deploy validator rejects it otherwise.
  • Every mandatory type in the org is listed on one Setup screen with its reason and the component that declares it.
  • Adding one is a metadata audit entry flagged the same way a permission change is.

The base org ships exactly three: security and identity events about the user’s own account, an approval request where the user is the assigned approver, and a notice the org has declared legally required. The reason the list must stay near that size is not politeness. A channel carrying messages a user has decided are irrelevant and cannot silence is a channel the user stops reading, which destroys the delivery guarantee for the messages that do matter. Every mandatory type spends that budget.

The tray below is a package-delivered surface; the per-recipient render on the enforced read path, the delivery rows, and the read/unread state it draws from stay in the kernel.

Notifications [ Mark all read ]
┌────────────────────────────────────────────────────────────────────┐
│ ● Invoice 13315 needs your approval 2m Approvals │
│ Northwind Energy — $184,220 │
├────────────────────────────────────────────────────────────────────┤
│ ● Invoice 13288 — new comments 11m Collab │
│ collapsed ×3 │
├────────────────────────────────────────────────────────────────────┤
│ ○ Order 4471 shipped 1h Orders │
├────────────────────────────────────────────────────────────────────┤
│ ○ Invoice 13102 needs your approval 2d Approvals │
│ This record is no longer available to you. (link disabled) │
└────────────────────────────────────────────────────────────────────┘

Read/unread is per delivery, per user — it is a column on the delivery row, so “mark all read” is one update and the unread count is one indexed count, not a client-side reconciliation.

Deep links degrade, they do not error. Access to the target is resolved when the link is followed. If the record has been deleted, reassigned, or moved behind a floor the user no longer clears, the tray renders the notification in a tombstoned state — the row above — and the link is inert. It does not navigate to a permission error, because not-found beats forbidden: routing a user to “insufficient privileges” confirms that the record still exists, which for a record they have lost access to is precisely the fact that was withdrawn.

Deliveries are ordinary rows under an ordinary retention policy: 12 months hot, then archived, never hard-deleted, matching the archive-not-destroy stance of the log streams. There is no purge job that truncates a busy user’s tray, because a tray is not a fixed-size buffer.

How long something stays in the tray is a separate question from how long the row lives, and the two answers differ because they serve different readers. The row is evidence, kept for a year for whoever later asks why a person was told something. The tray is a working surface, and a working surface that shows a year of everything is a surface nobody scrolls.

  • Unread deliveries do not age out. An unread notification stays in the tray until it is read or dismissed, however old it is, up to the 12-month hot bound where the row archives. Hiding something a person has not seen — on the theory that it is probably stale — is the same failure as truncating the tray, arrived at more politely.
  • Read deliveries leave the default view after 30 days. They are not deleted and not archived early; they move behind the tray’s All notifications view, which queries the same rows across the full hot window and is searchable and filterable by category, type, and date. Nothing becomes unreachable; it becomes un-scrolled-past.
  • Dismissal is the user’s control, and it is a state. Dismissing removes a delivery from the tray immediately, read or not, and sets dismissed on the row with a timestamp. It is not a delete: the delivery still answers “was this person told, and when,” and it still appears in All notifications.

The 30-day window is the org’s, not the user’s: it is trayReadWindow on the org’s notification_settings component, defaulting to 30 days and settable between 7 days and the 12-month hot bound. Users get dismissal — an act on one item they have seen — and administrators get the window — a policy over everyone’s default view. Splitting it that way keeps the surface predictable for a support desk that has to describe it, and it keeps the one control that can hide something from a person in the hands of that person. No setting on either side can shorten the row’s life; retention is not a preference.

The sending domain is proven, not asserted. A sending_identity is verifiable only against a domain the org demonstrates control of, by publishing the platform’s DKIM CNAME records. Two key pairs are provisioned so rotation never requires a gap. An identity whose DNS regresses moves to degraded: sending continues on the last-known-good key through a grace window, an operator alert fires, and if the window expires the identity stops. There is no substitute-address fallback — the platform will not quietly rewrite From: to a platform-owned address so that a send technically succeeds, because a message that arrives from an address the recipient does not recognize is worse than a message that visibly did not send.

SPF, DKIM, and DMARC alignment are checked at verification and re-checked on a schedule. The check result is on the identity’s record, so “why is our mail going to spam” starts from a status rather than a support case.

Reply handling. An identity with replyTo.mode: "routed" stamps a signed routing token into the local part of the reply address. An inbound reply is verified against that signature, matched to the originating notification and its subject record, and lands as an activity on the record. An unsigned, expired, or unknown token is rejected at the boundary — an open inbound mail path that accepts anything is a spam relay.

Bounces attach to the delivery first. A bounce is a fact about this send to this address, so it lands on the delivery row with its SMTP status and diagnostic. It then updates a contact point — the address itself, wherever that address is stored, standard field or custom. A hard bounce marks the contact point undeliverable and suppresses further sends to it until it is cleared or corrected; a soft bounce increments a strike count and retries with backoff, promoting to undeliverable after the ceiling. Because suppression is keyed to the address and not to a particular field on a particular object, a company that stores a second email address in a custom field gets the same protection as one that uses the standard field.

Transactional and bulk are different capabilities. A notification is transactional by construction: an event caused it, and the recipient set was derived from target rules over a record. The pipeline refuses a recipient set that was not derived that way — there is no “send this template to this list of addresses” entry point on this surface. Bulk and marketing sending has different consent obligations, different unsubscribe requirements, and a reputation profile that must not be able to damage transactional deliverability; it belongs on its own identity, its own subdomain, and its own capability. class: "transactional" | "bulk" on the sending identity is what keeps the two reputations separate, and the platform will not use a bulk identity for a notification type.

Approval requests, recalls, approvals, and rejections are ordinary notification types shipped in the base org, raised by the approval engine. They are mandatory for the assigned approver, severity: "urgent", and never digested. The routing, locking, and decision semantics are owned by lifecycles, paths & approvals — this page only carries the messages.

Concern CAOS approach Salesforce Why their limit exists
Notification types per org No fixed cap — they are components, bounded by the deploy budget 500 custom notification types A fixed registry keyed to a runtime lookup
Recipients per notification No fixed cap; a resolved set over a threshold is materialized as a batched background job 10,000 users after expanding groups, queues, and teams; the invocable action itself accepts 500 recipient values Synchronous fan-out inside a transaction has to be bounded
Title and body Title 250 characters; body is per-channel and rich on in_app, email, and chat, plain on push because the transport is Title 250, body 750 on the action; desktop display truncates at 120 / 320; plain text only One payload shape serving every channel at once
Send rate Per-(type, recipient) ceiling of 60 per hour — more than one of the same type to the same person per minute is treated as runaway — plus a per-org, per-channel daily budget; both shed to throttled, never dropped. The org-wide guard sits at or above the incumbent’s 10,000/hour, but backpressures instead of losing sends 10,000 notification actions per org per hour; beyond it “no more notifications are sent in that hour and all unsent notifications are lost” No durable queue behind the action
Tray size and retention 12 months hot, then archived; no truncation. Unread never ages out of the tray; read leaves the default view after 30 days and stays in All notifications 30 days, 50 shown at a time; a purge job truncates a tray above 7,500 down to 5,000; above 10,000 the user receives nothing new; org-wide store trimmed to the most recent 1,000,000 The tray is a bounded store, not a queryable table
Outbound email volume Per-org, per-channel daily budget with backpressure and an operator alert before the ceiling 5,000 external addresses per day for single sends; email alerts 1,000 per standard licence per day, org cap 2,000,000; 250 external recipients per user per hour Shared multi-tenant sending infrastructure and reputation
Email per transaction None — notifications are enqueued transactionally and dispatched after commit 10 sendEmail calls per Apex transaction Sending happens inline, so it must be governed inline
Sending addresses Unlimited, each tied to a verified domain and a transactional / bulk class Unlimited org-wide email addresses No reputation partitioning to enforce

Runaway loop containment is three mechanisms, layered:

  1. Fan-out is declared. A notification raised from automation inherits the automation lifecycle’s bounded blast radius; a type cannot target a set it did not declare a rule for.
  2. Per-(type, recipient) rate limiting. Beyond 60 of one type to one person in an hour, the type auto-suspends for that recipient for the remainder of the window. One summary delivery replaces the flood, the suspension is written as an error-envelope event on the trace, and the org’s operators are alerted. The user is not silently muted and the automation is not silently allowed to continue.
  3. Org budgets shed with backpressure. Exceeding a daily channel budget moves deliveries to throttled — still queued, still visible, still recoverable when the window rolls or the budget is raised. Nothing is discarded. This is the single sharpest divergence from the incumbent’s documented behavior at its hourly ceiling, where unsent notifications are lost outright.

Notifications. A custom notification type is metadata (CustomNotificationType) declaring which channels — desktop, mobile — it may use. Sending is an invocable action, customNotificationAction, called from Flow, Apex, or the REST API, taking customNotifTypeId, recipientIds, title, body, and either targetId or targetPageRef. The action’s inputs cap at 500 recipient values, a 250-character title, and a 750-character body, and it requires the Send Custom Notifications permission except when the flow or process runs in system context (Actions Developer Guide).

The consequence of that shape is the one that matters here: the sender composes the text. title and body are strings the automation built, and the same bytes go to every id in recipientIds. There is no per-recipient render, so there is no place for field-level security to participate. Whether a recipient may read the number the flow interpolated is not a question the platform asks. Salesforce’s own field-level security is enforced by the UI, Lightning Data Service, and the standard data APIs, while Apex ran in system mode by default through API 66.0 and only became user-mode-by-default in API 67.0, for new code (stripInaccessible / user mode) — so the automation assembling a notification body is, by default in most existing orgs, reading without FLS.

Bodies are plain text only. Volume is capped at 10,000 notification actions per org per hour, and Salesforce documents the overflow behavior plainly: no more notifications are sent in that hour, and all unsent notifications are lost. The tray is a bounded store — 30 days, 50 shown at a time, a daily purge job that cuts a tray above 7,500 down to 5,000, and a hard stop at 10,000 above which the user receives nothing new (Considerations for Notifications). There is a standing IdeaExchange request to reach notifications past the retention window at all.

Templates. Four systems coexist. The EmailTemplate metadata type’s type field enumerates text, html (with a Classic Letterhead), custom (HTML without a letterhead), and visualforce, each gated behind a different permission; a separate uiType field splits the same object into Aloha (Classic), SFX (Lightning), and SFX_Sample; the subject limit differs by lineage (1,000 characters for Lightning, 230 for Classic); encodingKey applies to Classic only; and packaging support diverges — first-generation packaging only for Lightning templates, and packaging in general is supported for Classic templates only (EmailTemplate metadata type). Classic templates carry raw HTML or Visualforce markup and Lightning templates are component-structured, so the Lightning builder cannot parse a Classic body; there is no bulk converter, and migration is a manual rebuild per template (Salesforce Ben, migration guide). Merge syntax differs between the lineages as well.

Sending and deliverability. Email leaves through an email alert (from a workflow rule, approval process, or flow), a Send Email action, Apex, or the API. Org-wide email addresses let a send come from a shared address, and there is no limit on how many an org creates. Deliverability settings gate outbound mail at three levels — No access, System email only (the default for new and refreshed sandboxes), and All email — and carry the Activate Bounce Management switch (Deliverability guidelines). Sender authentication is DKIM keys published as CNAME records, with two key pairs to permit rotation (Set Up Secure DKIM Keys).

Bounce state lands on standard EmailBouncedReason, EmailBouncedDate, and IsEmailBounced fields on Lead, Contact, and Person Account — which means an address held in a custom email field gets no bounce flag even when the address matches, and bounce reasons may not surface on EmailMessage unless tied to a Lead or Contact (bounce management guide).

Volume limits are the well-known ones: 5,000 external addresses per day for single sends, enforced against email alerts, simple email actions, flow Send Email actions, and the REST API for orgs created in Spring ’19 or later; 250 external recipients per user per hour; an email-alert allocation of 1,000 per standard licence per day with a 2,000,000 org ceiling; and 10 sendEmail calls per Apex transaction (Email limit types, Daily allocations for email alerts, Apex governor limits). Hitting them produces a runtime exception or a failed alert rather than backpressure.

As of Spring ’26, Salesforce additionally requires every email-sending domain to be verified via an active DKIM key or an authorized email domain, phased in from patch 10 on 9 March 2026, with sandbox domains due 30 March 2026 and production domains 27 April 2026; unverified domains are restricted, and the documented escape hatch is a setting to substitute a different email address for unverified domains (Spring ’26 domain verification).

Preferences. Notification delivery settings let an admin enable or disable channels per notification type and decide whether users may opt out at all; user opt-out is ignored for system, approval, and most case notifications, and security-related system email cannot be disabled (Manage Notification Delivery Settings). Chatter carries its own separate preference surface with personal and group digest frequencies (Chatter email digests) — a second preference model for a second messaging system.

Where CAOS is genuinely better:

  • Per-recipient render on the enforced read path. The message is produced inside the recipient’s access context rather than composed once by the sender and fanned out, so field-level security applies to a merge field the same way it applies to a column. The incumbent’s action takes a finished string and a recipient list; there is no seam where per-recipient access could be consulted.
  • Backpressure instead of loss. Exceeding a rate ceiling moves deliveries to a throttled state on a durable row. The incumbent’s documented behavior at its hourly ceiling is that unsent notifications are lost.
  • One template family, one merge grammar. A single message_template component with a channel, a locale map, and expressions in the platform’s one typed language, versus four EmailTemplate type values crossed with three uiType values, two merge syntaxes, two subject limits, divergent packaging support, and no converter between the lineages.
  • Delivery is a queryable record with a state machine. Bounces, suppressions, retries, and opens are rows joined to the raising transaction by correlationId, instead of a bounce flag on three standard fields of three standard objects that custom email fields never reach.
  • Deep links resolve access at click time and tombstone. A notification about a record the user has since lost is rendered inert with an explanation, rather than routing to a permission error that confirms the record still exists.
  • The sending domain is a deploy gate. An unverified domain fails verification and the identity does not activate; there is no runtime substitution of the From address to make a send technically succeed.
  • Preference resolution is one function. Org policy, type default, category preference, and type preference resolve most-specific-wins in a single evaluable path, rather than notification delivery settings and Chatter email settings being two unrelated surfaces with different semantics.
  • Transactional and bulk are separated by the identity’s class. Reputation partitioning is a property of the model rather than an operational convention.

Parity: notification types as deployable metadata, a bell tray with read/unread and deep links, desktop and mobile-push delivery, message templates with merge fields and letterhead-style branding, org-wide sending addresses, DKIM-signed sending with two rotating key pairs, bounce management, per-user delivery preferences with a small mandatory carve-out, digest frequencies, and approval notifications raised by the approval engine. Salesforce has all of this somewhere, and most of it is the right shape. It is copied deliberately.

Costs and risks:

  • N renders, not one. Per-recipient rendering is the whole guarantee and the whole cost. Access-signature grouping usually collapses a large recipient set to a handful of renders, but computing signatures is itself work, and a template that touches many fields with heterogeneous FLS across the org degrades toward one render per recipient. This must be benchmarked at target fan-out, not assumed.
  • The platform owns deliverability. Verified domains, key rotation, IP and domain reputation, feedback loops, complaint handling, and suppression lists become operational responsibilities rather than a vendor’s. A shared sending reputation damaged by one tenant is a real multi-tenant hazard, and separating transactional from bulk identities mitigates it without eliminating it.
  • The delivery table is the highest-volume table on the platform. One row per recipient per channel, retained 12 months. Partitioning, index design, and archival tiering are mandatory, not later hardening — and “never truncate” is a promise that has to be paid for in storage.
  • Digest and collapse delay information by design. A user on a daily digest will miss something time-sensitive. The urgent and mandatory carve-outs are the safety valve, and they will be over-used, because every author believes their notification is the important one. The one-screen mandatory list with written reasons is the counterweight, and it is procedural.
  • Quiet hours are a distributed-systems problem in disguise. Windows, time zones, DST transitions, and users who travel all interact with a held-and-released queue. Held deliveries that release in a burst are exactly the flood the collapse window exists to prevent, so release must itself be collapsed and rate-limited.
  • Preference sprawl. Category × type × channel × user is a large space. Defaulting well is the only thing that makes it usable, and a bad default is felt by everyone at once.
  • External chat is somebody else’s uptime. Provider rate limits, token expiry, workspace reorganizations, and outages all surface as failed deliveries. The in_app channel being undisableable is what keeps a chat outage from meaning nobody was told.
  • suppress is a footgun. A template that suppresses on an unreadable required field means some recipients are told nothing at all, and the delivery row saying so is the only trace. It is correct for a narrow class of templates and wrong for most.
Component type Body
Notification type notification_type object, category, severity, raise, targets[], link, channels{}, collapseKey, collapseWindow, digestible, mandatory, mandatoryReason
Message template message_template object, channel, requires[], onUnreadable, locales{}
Sending identity sending_identity channel, domain, from, displayName, class, replyTo
Chat connection chat_connection provider, target, credential
Org notification settings notification_settings Per-channel daily budgets, default quiet-hour policy, category defaults, trayReadWindow

Preferences, notifications, and deliveries are record data and are never part of a component — the same split permission sets draw against their assignments and sharing rules draw against their shares. A deploy that changes a template body or adds a channel to a type is metadata-only and takes effect at the next raise under the ordinary generation flip; in-flight deliveries render against the generation they were pinned to when they were queued, so a mid-flight template change never produces a half-old, half-new batch.

Environment-varying values — the sending domain, chat targets, channel budgets — are configuration data referenced by the component, not literals inside it. Promoting a notification type from sandbox to production does not carry the sandbox’s mail domain with it.

Salesforce Metadata API analogs, for migration mapping: CustomNotificationType, EmailTemplate (with its type, style, uiType, and letterhead), Letterhead, WorkflowAlert (template plus recipients plus senderType / senderAddress), and the OrgWideEmailAddress records that back a shared From address.