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:
- exact TenantId+UserId+Code row;
- TenantId+UserId+empty-Code global row;
- 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.
PUT /api/notifications/preferences HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-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
| Severity | Default channels |
|---|---|
| Info | Inbox |
| Success | Inbox + Email |
| Warning | Inbox + SMS |
| Error | Inbox + 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.
[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:
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.
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.
| Channel | Required value | Connector selection |
|---|---|---|
| SMS | Phone; optional template/sign values | Aliyun/Tencent/Huawei/Twilio/Vonage/Yunpian |
| Alibaba Enterprise Mail / Microsoft 365 | ||
| WeCom/DingTalk/Feishu | metadata UserId | multiple 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.
| Channel | Configuration section |
|---|---|
EnterpriseMail (refined per provider to EnterpriseMail:Alibaba or EnterpriseMail:Microsoft365) | |
| Sms | Sms (refined per provider to Sms:Aliyun/Sms:Tencent/Sms:Huawei/Sms:Twilio/Sms:Vonage/Sms:Yunpian) |
| WeCom | TeamWork:WeCom |
| DingTalk | TeamWork:DingTalk |
| Feishu | TeamWork: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
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:
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.