Skip to main content
Version: Latest

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

PurposeTransactional and business email (invoices posted, subscriptions renewed, password resets, etc.)
Implementing classEmailNotificationService (Shumoul.Notification.Core/Notifications/)
InterfaceIEmailNotificationService
Underlying transportIMailService (SMTP), wrapped with ITemplateRenderer
Provider failoverLayer-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 wrapperEmailChannelProvider (INotificationChannelProvider) wraps IEmailNotificationService — it does not bypass layer 1
ConfigurationMailSettings section in appsettings.json: DisplayName, EnableVerification, From, Mail, Host, Password (environment-sourced), Port, UserName

7.2 SMS

PurposeOTPs, short transactional alerts
Implementing classSmsNotificationService (Shumoul.Notification.Core/Notifications/)
InterfaceISmsNotificationService
Underlying transportISendSMSService, wrapped with ITemplateRenderer
Provider failoverLayer-1 abstraction, e.g. FourJawalySmsProvider
Dispatch-layer wrapperSmsChannelProvider
ConfigurationNo 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.

PurposeBusiness-approved WhatsApp template messages (order confirmations, OTPs, activation messages)
Implementing classMetaWhatsAppProvider (Shumoul.Infrastructure\Services\Notifications\, ERP-owned), registered as INotificationChannelProvider via WhatsAppChannelProviderAdapter
InterfaceIWhatsAppProvider (ERP-owned) / INotificationChannelProvider (Notification Framework contract)
Underlying transportIWhatsAppService (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):

  1. Returns PROVIDER_DISABLED if Metadata.Enabled is false (computed once at construction from whether AccessToken/PhoneNumberId are configured).
  2. Validates recipientPhone/templateName are non-empty.
  3. 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.
  4. Builds a single "body" template component from the already-rendered parameters and calls IWhatsAppService.SendTemplateAsync.

Phone normalization (Saudi E.164, no leading +, via WhatsAppPhoneHelper.TryNormalizePhone):

InputNormalized
0549000191966549000191
+966549000191 / 966549000191966549000191
549000191966549000191
Anything elsenormalization 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)

PurposeMobile/web push notifications via Firebase Cloud Messaging
Implementing classFirebasePushNotificationService (Shumoul.Notification.Core/Notifications/), registered as a singleton
InterfaceIPushNotificationService — deliberately does not extend ISingletonService/ITransientService to avoid double-registration with the platform's DynamicServiceRegistrationExtensions auto-scan; it is registered explicitly
Provider failoverLayer-1: INotificationProviderResolver.GetPushProviders() returns priority-ordered, enabled push providers (e.g. FirebasePushProvider); IProviderHealthMonitor skips any provider currently Unavailable
Dispatch-layer wrapperPushChannelProvider
MethodsSendToTokenAsync(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.
ConfigurationPushSettings section: ProjectId, CredentialJson (environment-sourced service-account JSON), CredentialFilePath, EnableSending (defaults false until explicitly turned on)
Recipient resolutionIDeviceTokenService.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

PurposeDurable, queryable notification record shown in the ERP's in-app notification center regardless of whether the user was online at send time
Implementing classInAppChannelProvider (dispatch layer) → creates an AppNotification row
EntityAppNotification (RecipientUserId, Title/FTitle, Body/FBody, Channel, IsRead, ReadOn, SentOn, EventKey, RefType, RefId)
Read viaAppNotificationController.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.