09-communicationswave: W5filled7 citations

Email, SMS and Notifications — Patient Communications

Audiences: clinical-buyer, developer

Email, SMS and Notifications — Patient Communications

Dudoxx HMS delivers appointment confirmations, reminders, OTP codes, and password resets via SMTP email (Nodemailer + Strato) and Twilio SMS — with a dev-mode override, demo-domain redirect, and full communication log in a dedicated ddx_api_com database so every outbound message is traceable and GDPR-auditable.

Business Purpose

Clinics need to reach patients outside the platform: appointment confirmations, 24-hour reminders, cancellation notices, and OTP codes for patient login all require reliable outbound delivery. The communications layer handles three distinct outbound channels: transactional email (via SMTP), SMS (via Twilio), and in-app notifications (Prisma-backed, SSE-delivered). It also handles inbound email — configurable per-org IMAP polling that stores received messages in the ddx_api_com database for CRM workflows.

All outbound messages are tracked in ddx_api_com (CommunicationLog table) so clinics can answer GDPR questions: "Was this patient notified? When? Did it succeed or fail?"

Commercial value: replaces manual phone-call reminders (major staff time sink in small clinics), reduces no-show rates, and provides an audit trail that satisfies DIN EN ISO 9001 quality management requirements for patient communication.

Audiences

  • Investor: Automated patient communications reduce no-show rates by 20–35% (industry benchmark). Built-in delivery audit trail removes a compliance gap common in legacy clinic software.
  • Clinical buyer (doctor/nurse/receptionist): Appointment confirmations and reminders fire automatically when an appointment is booked or reaches its reminder window — no manual action needed. Receptionists can see delivery status in the communication log. SMS OTP powers patient self-service login.
  • Developer/partner: Three focused NestJS services (MailService, SmsService, NotificationsService) plus IncomingMailService for inbound. Email templates use a fluent builder pattern (createEmailBuilder(templateType).setLocale(locale).setVariables(vars).build()). All outbound tracking flows through CommunicationService in communication-core. Dev-mode override (OVERRIDE_COMMUNICATION_ENABLED=true) redirects all traffic to a test address.
  • Internal (ops/support): Demo-domain redirect prevents 550 SMTP errors for fake demo mailboxes (configured via SMTP_DEMO_DOMAINS, SMTP_DEMO_EMAILS, SMTP_DEMO_REDIRECT_DEST). Separate ddx_api_com Postgres database keeps communication logs isolated from clinical data.

Architecture

diagram
communication/
├── communication-core/          — CommunicationService (log + status tracking in ddx_api_com)
│   ├── communication.service.ts — logEmail(), logSms(), markAsSent(), markAsFailed()
│   └── prisma-com.service.ts    — PrismaComService (ddx_api_com client)
│
├── mail/                        — Outbound SMTP email (Nodemailer)
│   ├── mail.service.ts          — sendEmail(), sendTemplatedEmail(), fetchEmails() (IMAP)
│   ├── mail-queue.service.ts    — Redis-backed outbox queue (enqueue non-transactional bulk mail)
│   ├── mail-drain.job.ts        — MailDrainJob: @Cron */2 min batch drain, N per tick
│   └── templates/               — EmailTemplateType enum + EmailBuilder per locale (DE/EN)
│
├── sms/                         — Outbound SMS (Twilio)
│   ├── sms.service.ts           — sendSms(), sendTemplatedSms(), verifyPhone(), checkVerification()
│   └── templates/               — SmsTemplateType + buildSmsMessage() per locale
│
├── notifications/               — In-app notifications (Prisma-main, SSE-delivered)
│   └── notifications.service.ts — getNotifications(), markAsRead(), markAllAsRead(), cleanup cron
│
└── incoming-mail/               — Inbound IMAP polling
    ├── incoming-mail.service.ts — IncomingMailConfig CRUD, per-org IMAP config (encrypted creds)
    └── incoming-mail-poller.service.ts — Scheduled IMAP polling → stores in ddx_api_com

Schema split: outbound email/SMS delivery records live in ddx_api_com (prisma-com). In-app Notification rows live in ddx_api_main (prisma-main). Messenger conversations (separate module) are also in ddx_api_main. This separation was an explicit architectural decision — see ddx-api/CLAUDE.md operational note on prisma-com.

Tech Stack & Choices

ConcernChoiceRationale
SMTP transportNodemailer + Strato SMTPProduction-proven SMTP library; Strato is the current email provider; SMTP_HOST/SMTP_PORT/SMTP_SECURE env-controlled
IMAP fetchimap npm package + mailparserStandard IMAP client; used for both MailService.fetchEmails() and inbound polling
SMSTwilio SDKIndustry standard; supports VerifyService for OTP (phone number verification without self-managing codes)
Email templatesCustom EmailBuilder (fluent API, locale-aware)Avoids external template SaaS dependency; templates compiled in-process with DE/EN locale switching
Communication logCommunicationServiceddx_api_comDedicated database keeps delivery audit log isolated from clinical data; queried for GDPR reports
In-app notificationsNotification (Prisma-main) + SSEIn-app bell notifications delivered via existing SSE bus; no additional WebSocket needed
Inbound mail configPer-org IncomingMailConfig with encrypted IMAP credentialsOrgs can have their own IMAP mailbox; credentials encrypted at rest via IncomingMailCryptoService
Dev overrideOVERRIDE_COMMUNICATION_ENABLED=true + OVERRIDE_EMAIL_DEST / OVERRIDE_PHONE_DESTAll outbound redirected to test address — prevents accidental patient email in dev/staging

Data Flow

Outbound: appointment confirmation email (QUEUED — non-transactional bulk)

  1. AppointmentService creates appointment → calls MailService.sendTemplatedEmail(...) for APPOINTMENT_CONFIRMATION. Appointment mail is non-transactional bulk, so it takes the queued path.
  2. MailService calls createEmailBuilder(APPOINTMENT_CONFIRMATION).setLocale('de').setVariables(vars).build() → gets { subject, html, text }.
  3. Instead of sending inline, the built message is enqueued to the Redis outbox via MailQueueService.enqueue(dto); the caller gets a synthetic "accepted" result immediately.
  4. MailDrainJob (@Cron */2 min, SMTP_BATCH_CRON) drains the queue in batches (N per tick) — this is what stops SMTP 421 rate-limit storms under bulk load (project memory project_mail_outbox_queue).
  5. Per drained message: demo-domain redirect (SMTP_DEMO_DOMAINSSMTP_DEMO_REDIRECT_DEST) and dev-override (OVERRIDE_COMMUNICATION_ENABLEDOVERRIDE_EMAIL_DEST, subject prefixed [DEV→<original>]) are applied, then nodemailer.sendMail() sends with the inline TUCAN logo CID attachment.
  6. On success: CommunicationService.logEmail() creates a CommunicationLog row in ddx_api_commarkAsSent() with providerMessageId. On SMTP rejection: markAsFailed() records the reason.

Transactional vs bulk split: OTP, password-reset, and email-verification mail send synchronously (sendTemplatedEmail without the queue flag) — the user is waiting on them. Appointment/reminder/notification bulk mail is queued and batch-drained. See the queue flag on sendTemplatedEmail (mail.service.ts:1014, queued path at :1105).

Outbound: SMS OTP (patient login)

  1. Auth module calls SmsService.verifyPhone({ to: "+49...", channel: "sms" }).
  2. SmsService calls this.client.verify.v2.services(verifyServiceSid).verifications.create(...).
  3. Twilio generates and delivers the OTP code — Dudoxx never sees the code itself.
  4. Patient submits code → SmsService.checkVerification({ to, code }) calls Twilio VerificationCheck; returns { valid: true/false }.

Inbound: per-org email polling

  1. IncomingMailPollerService runs on a schedule; iterates all IncomingMailConfig rows.
  2. For each config: decrypts IMAP credentials via IncomingMailCryptoService; opens IMAP connection; fetches unseen messages.
  3. Stores message metadata + attachment references in ddx_api_com; attachments go to MinIO {slug}-attachments bucket via StorageService.

In-app notifications

  1. Any service calls NotificationsService.create(CreateNotificationDto) → writes Notification row to ddx_api_main.
  2. The calling service (or a separate publisher) emits an SSE event notification:new on the user:{userId} channel.
  3. Frontend SSE consumer updates bell badge without polling.
  4. Nightly cron (@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)) purges read notifications older than 30 days.

Implicated Code

  • ddx-api/src/communication/mail/mail.service.ts:47MailService; SMTP + IMAP config at :50–77; sendTemplatedEmail() at :688 with template builder + CID logo + CommunicationService tracking
  • ddx-api/src/communication/mail/mail.service.ts:781redirectDemoRecipients() — demo-domain redirect logic; protects demo mailboxes from 550 SMTP errors
  • ddx-api/src/communication/mail/mail.service.ts:388mailQueue.enqueue(dto) — batched-delivery path (drained every 2 min by MailDrainJob)
  • ddx-api/src/communication/mail/mail-queue.service.tsMailQueueService; Redis-backed outbound outbox (queueKey)
  • ddx-api/src/communication/mail/mail-drain.job.tsMailDrainJob; @Cron(SMTP_BATCH_CRON ?? '*/2 * * * *') batch drain (stops SMTP 421 storms under bulk load)
  • ddx-api/src/communication/mail/templates/EmailTemplateType enum; EmailBuilder fluent API; localized templates (DE/EN) for APPOINTMENT_CONFIRMATION, APPOINTMENT_REMINDER, APPOINTMENT_CANCELLED, APPOINTMENT_RESCHEDULED, PASSWORD_RESET, EMAIL_VERIFICATION, OTP_VERIFICATION, WELCOME, USER_INVITATION
  • ddx-api/src/communication/sms/sms.service.ts:55SmsService; Twilio client init at :57; sendTemplatedSms() with buildSmsMessage() locale-aware builder; verifyPhone() + checkVerification() for OTP at :80
  • ddx-api/src/communication/notifications/notifications.service.ts:13NotificationsService; getNotifications(), markAsRead(), markAllAsRead(), deleteNotification(); nightly cleanup cron
  • ddx-api/src/communication/incoming-mail/incoming-mail.service.ts:26IncomingMailService; per-org IMAP config CRUD; getPlatformDefaults() fallback at :37
  • ddx-api/src/communication/incoming-mail/incoming-mail-crypto.service.tsIncomingMailCryptoService; AES encryption for stored IMAP credentials
  • ddx-api/src/communication/communication-core/communication.service.tsCommunicationService; logEmail(), logSms(), markAsSent(), markAsFailed(); writes to ddx_api_com

Operational Notes

  • Dev override: Set OVERRIDE_COMMUNICATION_ENABLED=true + OVERRIDE_EMAIL_DEST=your@address.com in .env to redirect all outbound email to a test address. Without this, emails fire against real patient addresses in staging.
  • Demo redirect: SMTP_DEMO_DOMAINS and SMTP_DEMO_EMAILS (comma-separated) redirect demo mailbox recipients to SMTP_DEMO_REDIRECT_DEST. Essential for demo environments with fake patient data.
  • Twilio credentials: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_VERIFY_SERVICE_SID, TWILIO_FROM_NUMBER. If absent, SmsService.enabled = false and SMS attempts log a warning instead of failing.
  • prisma-com vs prisma-main: CommunicationLog (outbound delivery records) is in ddx_api_com; Notification (in-app bell) is in ddx_api_main. Never query notifications from PrismaComService or communication logs from PrismaService.
  • IMAP credential encryption: IMAP passwords in IncomingMailConfig are encrypted at rest. The encryption key is read from env — losing it makes stored configs unreadable. Back up the key alongside the ddx_api_com database.
  • Mail outbox queue — non-transactional bulk mail (appointment confirmations/reminders/notifications) is enqueued to a Redis outbox (MailQueueService) and drained in batches by MailDrainJob (@Cron */2 min, tunable via SMTP_BATCH_CRON, N per tick). This prevents SMTP 421 rate-limit storms when many appointments fire at once. Transactional/OTP mail bypasses the queue and sends synchronously. See project memory project_mail_outbox_queue.
  • Validation commands: pnpm tsc:check (ddx-api); verify Twilio connectivity with GET /communication/sms/health; verify SMTP with GET /communication/mail/health.
  • Messenger — in-platform clinical messaging (not external channels); uses prisma-main not prisma-com; SSE delivery
  • SSE Event Engine — in-app notification:new events are delivered via the SSE bus on user:{userId} channels
  • Audit Logging — communication actions are not separately audit-logged; CommunicationLog in ddx_api_com is the delivery record; auth events (password reset) flow through AuditInterceptor