Skip to content
bitzorcas
中EN

Guide

Notifications preferences, CAP, and multi-channel delivery

Understand preference precedence, severity defaults, CAP consumption, contact metadata, provider connectors, failure semantics, and a reliable-delivery target model.

Last updated

External delivery follows NotificationService → notification.created → NotificationCreatedConsumer → MultiChannelDeliveryAdapter. The current path makes best-effort provider calls; it does not prove at-least-once business delivery or per-channel recovery.

1. Preference precedence

RepositoryNotificationPreferenceStore resolves in this order:

  1. exact TenantId+UserId+Code row;
  2. TenantId+UserId+empty-Code global row;
  3. default InboxEnabled=true and ExternalDeliveryEnabled=true.

The preference table also stores a comma-separated EnabledChannels, but the resolved type omits it and the orchestrator never reads it. Users can only disable the entire inbox or all external channels, not select Email/SMS/WeCom individually.

The NotificationPreference object now carries a set of presentation fields: InAppPopupEnabled (default true), SystemNotificationEnabled (default false), SoundEnabled (default false), MutedUntil (nullable), CenterView (default Recent, values Recent/Grouped), and PreferredType (nullable, values System/Business/Todo/Approval/Alert). PUT /api/notifications/preferences accepts these, validating values via IsSupportedCenterView/IsSupportedPreferredType; an invalid value returns Notification.Preference.PresentationInvalid.

A separate mute endpoint PUT /api/notifications/preferences/mute takes a single DurationMinutes field (one of the preset steps 0/10/15/30/60/120/240/480/720/1440 minutes; 0 means unmute), and the server computes MutedUntil from it. An invalid duration returns Notification.Preference.MuteDurationInvalid. Muting suppresses only the popup, system notification, and sound; it does not affect inbox fact persistence or external delivery.

Disable external delivery for one notification code
PUT /api/notifications/preferences HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Content-Type: application/json
{
"code": "marketing.campaign.started",
"inboxEnabled": true,
"externalDeliveryEnabled": false,
"enabledChannels": "Email"
}

EnabledChannels is persisted but has no effect. ExternalDeliveryEnabled=false drives the actual decision. No security/transactional classification enforces a mandatory route; a user can disable Inbox and External simultaneously.

2. Severity defaults

SeverityDefault channels
InfoInbox
SuccessInbox + Email
WarningInbox + SMS
ErrorInbox + SMS + WeCom + DingTalk + Feishu

ExternalDeliveryEnabled=false returns Inbox only. InboxEnabled=false removes Inbox from the default set. Preferences only subtract; they never add a channel outside the severity default.

Current channel-resolution contract
[Theory]
[InlineData(false, true, 0)] // Info has no channel after Inbox is removed.
[InlineData(true, false, 1)] // Disabling external channels leaves Inbox only.
public async Task Info_Preference_Should_Resolve_Expected_Channel_Count(
bool inboxEnabled,
bool externalEnabled,
int expected)
{
// The exact-code preference wins over the user's global preference.
preferenceStore.Returns(new NotificationPreference(
"tenant-a", "user-1", "info.code", inboxEnabled, externalEnabled));
// Info defaults to Inbox only; external=true does not add Email.
var channels = await orchestrator.ResolveChannelsAsync(
new NotificationContext(
"tenant-a", "user-1", "info.code", NotificationSeverity.Info,
"General", null),
CancellationToken.None);
channels.Count.ShouldBe(expected);
}

3. Creation and the CAP event

CreateAsync currently executes:

CAP publisherNotificationRepositoryPreferenceStoreNotificationServiceCAP publisherNotificationRepositoryPreferenceStoreNotificationServiceno row is savedalt[Inbox enabled][Inbox disabled]add skipExternalDelivery=true when external is disabledresolve tenant + user + codesave Notificationnotification.created + notificationId + tenantId + userId + code

If the Command pipeline, CAP outbox, and repository share one transaction, save and publish can be atomic. NotificationService is also callable outside a Command, so every caller must verify its actual transaction boundary.

There is no business DeduplicationKey or event id. Replaying CreateAsync generates another Id and event; transport-level CAP deduplication cannot merge two separate business creations.

4. Broken Inbox-disabled path

When InboxEnabled=false, the service publishes an id without saving the Notification. The consumer’s first action is repository.FindByIdAsync(notificationId); it logs a warning and returns when absent. The promised “no inbox but preserve external delivery” mode therefore cannot work.

Possible repairs:

  • always persist the logical Notification and model InboxVisible separately;
  • publish an immutable validated delivery snapshot that does not reload Inbox; or
  • persist a NotificationIntent/DeliveryJob fact and build Inbox as one projection.

The first option fits the current model and supports audit, retry, and deduplication.

5. Consumer tenant and failure semantics

The consumer reads only notificationId. It ignores tenantId and skipExternalDelivery and never explicitly pushes an EffectiveTenant. FindById has no tenant argument, so background visibility depends on an ambient-tenant mechanism that the consumer itself does not establish.

Target consumer boundary
public async Task HandleAsync(NotificationCreatedV1 message, CancellationToken ct)
{
// Establish an explicit one-message tenant scope; never inherit thread state.
await using var tenantScope = currentTenant.Push(message.TenantId);
// Inbox visibility does not remove the logical delivery fact.
var notification = await repository.GetRequiredAsync(
message.TenantId, message.NotificationId, ct);
// NotificationId+Channel is the uniqueness key for each delivery task.
foreach (var channel in await policy.ResolveAsync(notification, ct))
await attempts.EnqueueOnceAsync(notification.Id, channel, ct);
}

NotificationCreatedConsumer catches every non-OperationCanceledException, logs, and does not rethrow. CAP therefore treats the message as consumed instead of following normal retry/dead-letter behavior. MultiChannelDeliveryAdapter catches per-channel exceptions too; a port Failure is only a warning. Every delivery failure becomes a log-only fact.

6. Contact metadata and provider ports

MultiChannel deserializes UserId, Phone, Email, SmsTemplateCode, SmsTemplateParams, and SmsSignName from Notification.MetadataJson. Invalid JSON becomes an empty contact and each affected channel logs and skips.

ChannelRequired valueConnector selection
SMSPhone; optional template/sign valuesAliyun/Tencent/Huawei/Twilio/Vonage/Yunpian
EmailEmailAlibaba Enterprise Mail / Microsoft 365
WeCom/DingTalk/Feishumetadata UserIdmultiple TeamWork providers routed by providerKey

SMS and Email take FirstOrDefault from their DI collections, so configuration selects one provider. The IM adapter collects multiple TeamWork providers and routes by key.

Putting phone/email in notification metadata retains them in hot/archive tables, backups, and API responses. Prefer a recipient reference resolved through a controlled directory at send time, or an encrypted short-retention delivery snapshot separate from Inbox.

7. External delivery channel readiness

GET /api/notifications/delivery-readiness (resource notifications/delivery, Action View) returns the readiness of five delivery channels so operators can confirm the assembly boundary before sending. Report fields: Channel, Provider, Status (Configured/NotConfigured/Unavailable), AdapterRegistered, ConfigurationSection, ChangesRequireRestart, DependentCapabilities.

ChannelConfiguration section
EmailEnterpriseMail (refined per provider to EnterpriseMail:Alibaba or EnterpriseMail:Microsoft365)
SmsSms (refined per provider to Sms:Aliyun/Sms:Tencent/Sms:Huawei/Sms:Twilio/Sms:Vonage/Sms:Yunpian)
WeComTeamWork:WeCom
DingTalkTeamWork:DingTalk
FeishuTeamWork:Feishu

This report is the same kind of read-only projection as the Operations external-connector readiness: it answers whether a configuration section exists and an adapter is registered, and it performs no real delivery probe. ChangesRequireRestart is always true. When an adapter is not registered, the report is fail-closed to Unavailable rather than pretending readiness. The default UnavailableNotificationDeliveryReadinessReader returns Unavailable when no real reader is registered, preventing silent optimism.

8. Reliable-delivery target model

retry/dead

NotificationIntent
business idempotency key

InboxProjection

DeliveryAttempt
NotificationId+Channel unique

bounded retry worker

Provider

ProviderMessageId / receipt

DeliveryAttempt should include Channel, Status, AttemptCount, NextAttemptAt, LastErrorCode, ProviderMessageId, PayloadHash, CreatedAt, and DeliveredAt. It must be queryable and replayable, with a unique constraint that prevents duplicate enqueue.

A provider timeout has an unknown outcome. Do not retry blindly: use a provider idempotency key or status query first. Classify permanent errors such as invalid recipients separately from transient 429/5xx/network failures.

9. Versioned event contract

The current Dictionary<string, string> offers no compile-time shape or schema evolution. A target event can be explicit:

Versioned notification-created event
public sealed record NotificationCreatedV1(
string EventId,
string NotificationId,
string TenantId,
string UserId,
string Code,
DateTimeOffset OccurredAt,
string TraceId);
// Body and contact data remain out of the event; consumer reads minimum data in-tenant.
// EventId and NotificationId+Channel guard consumer and task idempotency separately.

If reload is retained, the consumer must establish tenant context and distinguish temporarily invisible, permanently absent, and preference-disabled states.

10. Observability and alerts

Record notificationId, tenant_hash, low-cardinality code, channel, attempt, result, provider, provider_code, latency, eventId, and traceId. Never log Body, MetadataJson, Email, Phone, or raw UserId.

Alert on event-without-row, absent tenant context, unregistered ports, missing contact data, provider Failure/exception, exhausted retries, queue age, duplicate delivery, and preference query failures.

11. Required tests

  • exact-Code/global/default preference precedence and effective EnabledChannels;
  • mandatory security notices cannot disable every route;
  • Inbox=false + External=true still delivers externally;
  • consumer reloads in the correct tenant and fails closed in another;
  • skipExternalDelivery or an equivalent consistent decision;
  • provider success/failure/throw/timeout/429 and cancellation propagation;
  • CAP replay, concurrent consumers, and one attempt per channel;
  • corrupt/missing metadata and PII log scanning;
  • multi-IM routing and single-provider SMS/Email configuration;
  • reconciliation, manual replay, and recovery drills.

Back to Notifications

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%