Skip to content

Internal design of the quadkit-notification package.


quadkit-notification provides multi-channel notification delivery (SMS, push, email, in-app inbox) for Quadkit applications. It is an extension package depending only on quadkit and quadkit-contracts.

flowchart BT
    subgraph Apps[Application Layer]
        Controller[Web Controller]
        Service[Domain Service]
        Worker[Background Worker]
    end
    subgraph Notification[quadkit-notification]
        Inbox[InboxService]
        Mailer[MailerProtocol]
        SMS[SMSChannelProtocol]
        Push[PushChannelProtocol]
    end
    subgraph Providers[External Providers]
        Twilio
        FCM
        APNs
        SMTP
        SendGrid
    end

    Controller --> Inbox
    Service --> Mailer
    Service --> SMS
    Worker --> Push
    Service --> Inbox
    Mailer --> SMTP
    Mailer --> SendGrid
    SMS --> Twilio
    Push --> FCM
    Push --> APNs
    Push --> WebPush[Web Push]

Import constraint: quadkit-notification imports only from quadkit and quadkit-contracts. No cross-extension imports.


The package delivers notifications through four channel families, each backed by one or more providers:

flowchart LR
    subgraph Email[Email]
        SMTP[SMTPMailer]
        SendGrid[SendGridMailer]
    end
    subgraph SMSChannel[SMS]
        TW[Twilio]
        WA[WhatsApp]
    end
    subgraph PushChannel[Push]
        FCM
        APNs
        WP[Web Push]
    end
    subgraph InApp[In-App]
        IB[InboxService]
    end

    Email -->|MailerProtocol| SMTP
    Email -->|MailerProtocol| SendGrid
    SMSChannel -->|SMSChannelProtocol| TW
    SMSChannel -->|SMSChannelProtocol| WA
    PushChannel -->|PushChannelProtocol| FCM
    PushChannel -->|PushChannelProtocol| APNs
    PushChannel -->|PushChannelProtocol| WP
    InApp -->|InboxStoreProtocol| IB

Channel routing for backend selection is driven by Named DI multi-backend:

# Primary backend (unnamed binding — auto-injected)
sms = await container.resolve(SMSChannelProtocol)
# Named backend — explicit routing by name
alerts_sms = await container.resolve(
Annotated[SMSChannelProtocol, Named("alerts")]
)

Each channel family (SMS, push, email) supports multiple named backends. The primary backend gets the unnamed binding; named backends are addressed via Annotated[Protocol, Named("name")].


sequenceDiagram
    participant App as Application
    participant Inbox as InboxService
    participant Mailer as MailerProtocol
    participant SMS as SMSChannelProtocol
    participant Push as PushChannelProtocol
    participant Retry as RetryingMailer
    participant Store as DeliveryStore
    participant Events as Event Bus

    App->>Inbox: send(user_id, title, body)
    Inbox->>Inbox: Create InboxMessage
    Inbox->>Store: save(message)
    Inbox->>Events: emit InboxMessageCreatedEvent

    App->>Mailer: send(EmailMessage)
    alt Infrastructure failure
        Mailer->>Retry: exponential backoff (max 3)
        Retry->>Store: record_attempt / schedule_retry
        Store-->>Retry: retry state
        Retry->>Mailer: resend
        Mailer-->>Retry: Ok | Err
        Retry->>Events: emit EmailSentEvent / EmailBouncedEvent
    else Expected failure (bounce)
        Mailer->>Events: emit EmailBouncedEvent
    else Success
        Mailer-->>App: Ok(MessageDeliveryReceipt)
        Mailer->>Events: emit EmailSentEvent
    end

    App->>SMS: send(SMSMessage)
    SMS-->>App: Ok | Err(NotificationError)
    App->>Push: send(PushMessage)
    Push-->>App: Ok | Err(NotificationError)
    Push->>Events: emit PushNotificationSentEvent

Delivery status tracking follows this lifecycle:

PENDING → RETRYING → (DELIVERED | FAILED)

The channel-neutral DeliveryStoreProtocol (defined in quadkit-contracts) persists payloads, leases, attempt outcomes, retry time and terminal state for any registered delivery channel. RetryingMailer wraps a MailerProtocol for synchronous sends; MailerProvider applies its inline exponential-backoff policy to the primary binding when configured. DeferredRetryMailer performs one initial email attempt against a raw backend and schedules a failure. The generic flush_retries(store, dispatcher, ...) worker then claims due records, dispatches email/Slack/other registered channels, applies capped exponential backoff and dead-letters exhausted deliveries. Their defaults align at five total attempts and a 60-second base delay; when overriding the optional helper, pass deferred.max_retries and deferred.base_delay as the worker’s max_attempts and base_delay. Gatewai queued mail bypasses both wrappers and uses the raw provider backend with the generic worker.


Email templates use the Mailable pattern — a lightweight strategy class that encapsulates template rendering for a single email type:

class WelcomeMail(Mailable):
def __init__(self, user_name: str, user_email: str) -> None:
self.user_name = user_name
self.user_email = user_email
def to_message(self) -> EmailMessage:
return EmailMessage(
to=[self.user_email],
subject=f"Welcome, {self.user_name}!",
body=f"Hi {self.user_name}, welcome aboard.",
html_body=f"<p>Hi <b>{self.user_name}</b>, welcome aboard.</p>",
)
flowchart LR
    M[Mailable subclass] -->|to_message| EM[EmailMessage]
    EM -->|MailerProtocol.send| Backend[SMTP / SendGrid]
    M -->|render| HTML[HTML body]
    M -->|render| Text[Plain text body]

Channels without templates (SMS, push) construct SMSMessage / PushMessage value objects directly — no templating layer beyond simple string formatting. The Mailable base class ships in quadkit.notification.mailer.mailable.


Two DI providers register notification backends:

class NotificationProvider(Provider):
name = "notification"
priority = ProviderPriority.INFRASTRUCTURE
class MailerProvider(Provider):
name = "mailer"
priority = ProviderPriority.INFRASTRUCTURE

Discovered automatically via the quadkit.providers entry point (mailer = quadkit.notification.di.mailer_provider:MailerProvider).

sequenceDiagram
    participant App as Application
    participant Container as DI Container
    participant NP as NotificationProvider
    participant MP as MailerProvider
    participant SMS as TwilioSMS
    participant Push as FCMPush
    participant Mail as SMTPMailer

    App->>Container: ModuleCompiler.compile()
    Container->>NP: register(registrar)
    NP->>NP: Iterate sms_backends
    NP->>SMS: _create_sms(entry)
    NP->>Container: singleton(SMSChannelProtocol, name=..., factory)
    NP->>NP: Iterate push_backends
    NP->>Push: _create_push(entry)
    NP->>Container: singleton(PushChannelProtocol, name=..., factory)
    Container->>MP: register(registrar)
    MP->>Mail: _create_mailer(entry)
    MP->>Container: singleton(MailerProtocol, name=..., factory)
    Container->>Container: freeze()
    Container->>NP: boot(resolver)
    NP->>SMS: health_check()
    NP->>Push: health_check()
    Container->>MP: boot(resolver)
    Note over MP: no-op (stateless)
    App->>Container: resolve(SMSChannelProtocol) → TwilioSMS
    App->>Container: resolve(PushChannelProtocol) → FCMPush
    App->>Container: resolve(MailerProtocol) → SMTPMailer

ProtocolLocation (quadkit-contracts)Implemented By
SMSChannelProtocolquadkit.contracts.notification.protocolsTwilioSMS
PushChannelProtocolquadkit.contracts.notification.protocolsFCMPush, APNsPush, WebPushChannel
MailerProtocolquadkit.contracts.mailer.protocolsSMTPMailer, SendGridMailer, ConsoleMailer, RetryingMailer
InboxStoreProtocolquadkit.contracts.notification.inboxInMemoryInboxStore, DatabaseInboxStore
DeliveryStoreProtocolquadkit.contracts.notification.deliveryApplication-provided (persistent tracking)

Value types shared across packages:

TypeLocationUsed By
SMSMessagequadkit.contracts.notification.typesSMS backends
PushMessagequadkit.contracts.notification.typesPush backends
EmailMessagequadkit.contracts.mailer.typesMailer backends
MessageDeliveryReceiptquadkit.contracts.mailer.typesAll channels
InboxMessagequadkit.contracts.notification.inboxInbox service and stores

Exception hierarchy:

NotificationError (quadkit-contracts)
├── TwilioNotificationError
├── FCMNotificationError
├── APNsNotificationError
├── WebPushNotificationError
├── PermanentDeliveryFailure
├── InboxError
│ ├── InboxMessageNotFoundError
│ └── InboxPermissionError
MailerError (quadkit-contracts)
├── SMTPMailerError
└── SendGridMailerError

PointMechanism
Custom SMS driverImplement SMSChannelProtocol, add a NamedSMSConfig subclass
Custom push driverImplement PushChannelProtocol, add a NamedPushConfig subclass
Custom mailerImplement MailerProtocol, add a NamedMailerConfig subclass
Custom inbox storeImplement InboxStoreProtocol
Custom delivery storeImplement DeliveryStoreProtocol
Retry wrapperRetryingMailer (inline, over a MailerProtocol) or DeferredRetryMailer (one initial email attempt); the generic flush_retries(store, dispatcher, ...) worker owns scheduled retries for all registered channels
Named multi-backendDeclare multiple entries in backends / sms_backends / push_backends in config
Hook pointsRegister callbacks via HookRegistryProtocol for NotificationSentHook, NotificationFailedHook, EmailDispatchedHook, EmailRenderedHook, InboxMessageCreatedHook, InboxMessageReadHook
Domain eventsSubscribe to NotificationSentEvent, NotificationFailedEvent, EmailSentEvent, EmailBouncedEvent, InboxMessageCreatedEvent, InboxMessageReadEvent