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).