Skip to content

Operations Alerts — Email Delivery ​

This document covers the Notifications module side of the operations email delivery pipeline. For the full end-to-end flow (scheduling, listener, hydration, rule lifecycle), see Email delivery on schedule arrival.

INotificationSender Port ​

INotificationSender is the domain-agnostic port that operations (and any future caller) uses to send emails without knowing the underlying SMTP implementation.

Path: apps/api/src/modules/notifications/domain/ports/notification-sender.port.tsDI token: NotificationSenderKey = Symbol('INotificationSender')

ts
export interface ISendEmailInput {
  templateKey: string;
  recipients: Array<{ email: string; name: string }>;
  variables: Record<string, unknown>;
}

export interface INotificationSender {
  sendEmail(input: ISendEmailInput): Promise<void>;
}

Contract semantics ​

  • Throws on failure — any SMTP error or partial delivery failure causes sendEmail to throw. The caller (operations listener) lets the exception propagate so BullMQ retries the job.
  • Partial delivery semantics — the adapter iterates recipients in order. Each recipient's email-send attempt is independent: a failed attempt saves a NotificationLog row with success = false and increments a failure counter. After the loop, if failedCount > 0, the method throws with a summary message. This ensures all NotificationLog rows are written before the exception propagates.
  • Template-not-found — if the templateKey is not registered, throws immediately before any SMTP call. Callers should treat this as a programming error (wrong key), not a transient failure.

This is a deliberate departure from SendEmailNotificationCommand.execute, which returns Result<T, Error> and never throws. The port throws loud because the listener needs error propagation for BullMQ retry semantics.

Template Registry Entries ​

The four operations email templates are registered in TemplateRegistryAdapter under the following keys (defined in the EmailTemplates const):

KeyConst nameTemplate component
operations.event.startEmailTemplates.OperationsEventStartOperationsEventStartTemplate
operations.event.endEmailTemplates.OperationsEventEndOperationsEventEndTemplate
operations.task.startEmailTemplates.OperationsTaskStartOperationsTaskStartTemplate
operations.task.endEmailTemplates.OperationsTaskEndOperationsTaskEndTemplate

Template prop shape ​

All four templates share the same props interface:

ts
interface OperationsEmailTemplateProps {
  recipientName: string;   // resolved by adapter from recipient.name
  subjectTitle: string;    // event.title OR task description (truncated to 80 chars)
  whenLabel: string;       // pre-formatted Spanish date/time string, e.g. "21 de abril a las 14:30"
}

whenLabel is a pre-formatted string produced by OperationsSubjectHydrator using Intl.DateTimeFormat('es', { dateStyle: 'long', timeStyle: 'short' }). Templates never receive raw Date objects — formatting is the hydrator's responsibility.

recipientName is merged into variables by the adapter at send time: { ...variables, recipientName: recipient.name }.

Subject lines (Spanish) ​

Template keySubject
operations.event.startTu evento "{subjectTitle}" está por comenzar
operations.event.endTu evento "{subjectTitle}" está por finalizar
operations.task.startTu tarea "{subjectTitle}" está por iniciar
operations.task.endTu tarea "{subjectTitle}" está por cerrarse

Open question: Spanish copy is pending stakeholder review. The subjects and body text above reflect the initial implementation and may change before GA.

SMTP Dependency and NodemailerNotificationSenderAdapter ​

Path: apps/api/src/modules/notifications/infrastructure/adapters/nodemailer-notification-sender.adapter.ts

The adapter implements INotificationSender and is registered under NotificationSenderKey in NotificationsInfrastructureAdapters. NotificationsModule exports NotificationSenderKey so that OperationsModule (which imports NotificationsModule) can inject it.

Dependencies injected into the adapter:

PortTokenPurpose
ITemplateRegistryPortTemplateRegistryPortKeyTemplate lookup by key
IEmailPortEmailAdapterKeyNodemailer SMTP send
INotificationLogRepositoryNotificationLogRepositoryKeyPersist NotificationLog per recipient
IAppLoggerServiceAppLoggerServiceKeyStructured logging

Adapter behavior per recipient ​

  1. Look up template — throw templateNotFound if missing.
  2. Merge variables with { recipientName: recipient.name }.
  3. Call template.subject(mergedVars) to get the email subject.
  4. Call template.render(mergedVars) to render HTML.
  5. Call emailPort.send({ to, subject, html }).
  6. Save NotificationLog with success = true / false.
  7. Log notifications.sender.sent (success) or notifications.sender.failed (SMTP error).
  8. If SMTP threw: increment failedCount.
  9. After the loop: if failedCount > 0 — throw with a summary message.

Domain Boundary Note ​

The Notifications module stays domain-agnostic. It knows nothing about:

  • NotificationRule entities or their lifecycle.
  • OperationsSubjectHydrator or how recipients are resolved.
  • The schedule.arrived event or BullMQ retry semantics.

All operations-specific logic (rule validation, idempotency guard, hydration, recipient deduplication) lives in the Operations module's listener and hydrator. The Notifications module only provides:

  • The INotificationSender port (throw-on-failure contract).
  • The SMTP adapter implementation.
  • The template registry with operations template entries.

This keeps the Notifications module reusable for other future callers (e.g., a different module triggering alerts).