Skip to main content
Version: 1.1

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

WhatsApp is the one channel not routed through INotificationChannelProviderResolver — it is invoked directly and inline by NotificationDispatchService, because it needs Meta-specific business template handling that the generic channel-provider contract does not model (template name, template language, positional body parameters).

PurposeBusiness-approved WhatsApp template messages (order confirmations, OTPs, activation messages)
Implementing classWhatsAppNotificationSender (Shumoul.Infrastructure\Services\Notifications\, ERP-owned)
InterfaceIWhatsAppNotificationSender
Underlying transportIWhatsAppService (Shumoul.Saas.WhatsAppIntegration) → Meta Graph API

Guard chain (in order — any failure logs and returns silently, never throws):

  1. SaaSNotificationSettings.EnableWhatsApp && EnableWhatsAppNotifications must both be true.
  2. EnableWhatsAppTemplates must be true.
  3. AppNotificationTemplate.WhatsAppTemplateName must be non-empty.
  4. WhatsAppCloudApiOptions.AccessToken and PhoneNumberId must both be configured.
  5. The recipient phone must normalize successfully (see below).

Language resolution: request.LanguageCode (per-dispatch override) → template.WhatsAppLanguageCode"ar" default.

Body parameters: AppNotificationTemplate.WhatsAppBodyParams is a JSON array of Scriban expression strings (e.g. ["{{ customerName }}", "{{ orderNumber }}"]). Each expression is rendered independently against request.Data and mapped 1:1 to WhatsApp template {{1}}, {{2}}, … positional parameters inside a single "body" component. A malformed JSON array logs a warning and sends zero parameters rather than failing the whole dispatch.

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

InputNormalized
0549000191966549000191
+966549000191 / 966549000191966549000191
549000191966549000191
Anything elsenormalization fails → dispatch skipped, warning logged

Audit logging: every WhatsApp send creates a TenantNotificationLog row via ITenantNotificationLogService.CreatePendingAsync(...) (tenant ID resolved from the "TenantId" JWT claim), transitioned to MarkSentAsync/MarkFailedAsync after the Graph API call returns. The logged message body is never the real content — it is the literal string "Template:{templateName}". Access tokens, app secrets, and rendered parameter values are never logged.

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.