7. Notification Channels
The NotificationChannel enum (Shumoul.Notification.Contracts) defines six channels:
Email=1, Sms=2, Push=3, InApp=4, SignalR=5, WhatsApp=6.
7.1 Email
| Purpose | Transactional and business email (invoices posted, subscriptions renewed, password resets, etc.) |
| Implementing class | EmailNotificationService (Shumoul.Notification.Core/Notifications/) |
| Interface | IEmailNotificationService |
| Underlying transport | IMailService (SMTP), wrapped with ITemplateRenderer |
| Provider failover | Layer-1 abstraction: INotificationProviderResolver returns priority-ordered, enabled IEmailProvider implementations (e.g. ZohoEmailProvider); tracked by InMemoryProviderHealthMonitor (5-consecutive-failure circuit breaker, 30-minute auto-recovery) |
| Dispatch-layer wrapper | EmailChannelProvider (INotificationChannelProvider) wraps IEmailNotificationService — it does not bypass layer 1 |
| Configuration | MailSettings section in appsettings.json: DisplayName, EnableVerification, From, Mail, Host, Password (environment-sourced), Port, UserName |
7.2 SMS
| Purpose | OTPs, short transactional alerts |
| Implementing class | SmsNotificationService (Shumoul.Notification.Core/Notifications/) |
| Interface | ISmsNotificationService |
| Underlying transport | ISendSMSService, wrapped with ITemplateRenderer |
| Provider failover | Layer-1 abstraction, e.g. FourJawalySmsProvider |
| Dispatch-layer wrapper | SmsChannelProvider |
| Configuration | No dedicated SMS settings section was found in the tracked Shumoul.Api\appsettings.json — SMS provider credentials are environment-specific. See Chapter 15 — Configuration for what is/isn't tracked in git. |
7.3 WhatsApp
Corrected 2026-07-06 (Phase 2.3 Runtime Audit): this section previously documented WhatsAppNotificationSender/
IWhatsAppNotificationSender as the implementing class. That class was confirmed dead code (registered in DI but
never resolved by anything — superseded the day after it was introduced) and has been removed. The paragraphs
below now describe the actual, live implementation. See the WhatsApp Integration guide's
Architecture Validation chapter for the full audit.
WhatsApp is routed through INotificationChannelProviderResolver, exactly like every other channel — it
is not a special case in NotificationDispatchService at the send step. What is WhatsApp-specific is
template resolution: NotificationDispatchService (in the Notification Framework Core package) reads
AppNotificationTemplate.WhatsAppTemplateName/WhatsAppLanguageCode/WhatsAppBodyParams, renders the body
parameters (Scriban expressions) into a plain string list, and passes all three into ChannelDispatchContext
before resolving the channel provider.
| Purpose | Business-approved WhatsApp template messages (order confirmations, OTPs, activation messages) |
| Implementing class | MetaWhatsAppProvider (Shumoul.Infrastructure\Services\Notifications\, ERP-owned), registered as INotificationChannelProvider via WhatsAppChannelProviderAdapter |
| Interface | IWhatsAppProvider (ERP-owned) / INotificationChannelProvider (Notification Framework contract) |
| Underlying transport | IWhatsAppService (Shumoul.Saas.WhatsAppIntegration) → Meta Graph API |
For the underlying client library itself — the Meta Cloud API client, webhook signature validation, webhook parsing, and the full ERP-side Inbox/Communication API surface built on top of it — see the dedicated WhatsApp Integration developer guide.
Template resolution and body-parameter rendering happen in NotificationDispatchService
(RenderWhatsAppBodyParamsAsync), not in the channel provider — this is shared, channel-agnostic Notification
Framework logic and out of this document's scope to audit further (see the Notification Framework's own
documentation). AppNotificationTemplate.WhatsAppBodyParams is a JSON array of Scriban expression strings
(e.g. ["{{ customerName }}", "{{ orderNumber }}"]), rendered against request.Data and mapped 1:1 to
WhatsApp template {{1}}, {{2}}, … positional parameters. A malformed JSON array logs a warning and sends
zero parameters rather than failing the whole dispatch.
What MetaWhatsAppProvider.SendTemplateAsync itself does (the channel-provider step, ERP-owned):
- Returns
PROVIDER_DISABLEDifMetadata.Enabledis false (computed once at construction from whetherAccessToken/PhoneNumberIdare configured). - Validates
recipientPhone/templateNameare non-empty. - Normalizes the recipient phone via
WhatsAppPhoneHelper.TryNormalizePhone— added in Phase 2.3; this path previously sent the phone number to Meta unnormalized, a gap documented and fixed in the WhatsApp Integration guide's Architecture Validation chapter. - Builds a single
"body"template component from the already-rendered parameters and callsIWhatsAppService.SendTemplateAsync.
Phone normalization (Saudi E.164, no leading +, via WhatsAppPhoneHelper.TryNormalizePhone):
| Input | Normalized |
|---|---|
0549000191 | 966549000191 |
+966549000191 / 966549000191 | 966549000191 |
549000191 | 966549000191 |
| Anything else | normalization fails → INVALID_PHONE failure returned, dispatch does not reach Meta |
Audit/delivery logging: handled generically for every channel (not WhatsApp-specific) by
NotificationDispatchService's delivery engine (_deliveryEngine.HandleDeliveryResultAsync) — this
superseded the old per-channel TenantNotificationLog calls WhatsAppNotificationSender used to make
directly. Access tokens, app secrets, and rendered parameter values are never logged anywhere in this
path.
Configuration (WhatsAppCloudApi section): VerifyToken, AppSecret (environment-sourced),
SignatureValidationEnabled, LogRawPayload, AccessToken (environment-sourced), PhoneNumberId,
BusinessAccountId, GraphApiVersion, BaseUrl.
7.4 Push (Firebase)
| Purpose | Mobile/web push notifications via Firebase Cloud Messaging |
| Implementing class | FirebasePushNotificationService (Shumoul.Notification.Core/Notifications/), registered as a singleton |
| Interface | IPushNotificationService — deliberately does not extend ISingletonService/ITransientService to avoid double-registration with the platform's DynamicServiceRegistrationExtensions auto-scan; it is registered explicitly |
| Provider failover | Layer-1: INotificationProviderResolver.GetPushProviders() returns priority-ordered, enabled push providers (e.g. FirebasePushProvider); IProviderHealthMonitor skips any provider currently Unavailable |
| Dispatch-layer wrapper | PushChannelProvider |
| Methods | SendToTokenAsync(token, title, body, data) — delegates to SendToMultipleAsync with a single-token array. SendToTopicAsync(topic, title, body, data) — goes directly to Firebase (no multi-provider concept for topic sends; guarded by PushSettings.EnableSending and a FirebaseApp.DefaultInstance null-check). SendToMultipleAsync(tokens, PushNotificationRequest, ct) — iterates enabled/healthy providers in priority order, fails over on error, records success/failure against the health monitor. |
| Configuration | PushSettings section: ProjectId, CredentialJson (environment-sourced service-account JSON), CredentialFilePath, EnableSending (defaults false until explicitly turned on) |
| Recipient resolution | IDeviceTokenService.GetActiveTokensForUserAsync(userId) — see Chapter 11 — Device Tokens |
If all providers are exhausted (none healthy, or all attempts fail), SendToMultipleAsync returns false
and logs "[Push] All providers exhausted" — the caller (dispatch engine) treats this as a channel failure
subject to the same retry/dead-letter policy as any other channel.
7.5 SignalR
Real-time delivery to connected web clients. Fully documented in Chapter 10 — SignalR.
Summary: SignalRChannelProvider sends via the legacy NotificationHub (hosted in MultiTenancyApi) using
Clients.User(userId).SendAsync("messages", payload). Delivery is only received live if the user has an
active WebSocket connection — there is no queued redelivery at the SignalR layer itself (durability for
offline users comes from the InApp channel's AppNotification row, read via GetInbox on next login).
7.6 In-App
| Purpose | Durable, queryable notification record shown in the ERP's in-app notification center regardless of whether the user was online at send time |
| Implementing class | InAppChannelProvider (dispatch layer) → creates an AppNotification row |
| Entity | AppNotification (RecipientUserId, Title/FTitle, Body/FBody, Channel, IsRead, ReadOn, SentOn, EventKey, RefType, RefId) |
| Read via | AppNotificationController.GetInbox / GetUnreadCount / MarkAsRead / MarkAllAsRead — see §9.1 |
InApp and SignalR are frequently enabled together for the same event: SignalR gives an instant toast/badge update to an online user, while InApp guarantees the notification is still visible in the inbox if the user was offline or dismissed the toast.
