Email, SMS and Notifications — Patient Communications
Audiences: clinical-buyer, developer
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_comdatabase 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) plusIncomingMailServicefor inbound. Email templates use a fluent builder pattern (createEmailBuilder(templateType).setLocale(locale).setVariables(vars).build()). All outbound tracking flows throughCommunicationServiceincommunication-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). Separateddx_api_comPostgres database keeps communication logs isolated from clinical data.
Architecture
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
| Concern | Choice | Rationale |
|---|---|---|
| SMTP transport | Nodemailer + Strato SMTP | Production-proven SMTP library; Strato is the current email provider; SMTP_HOST/SMTP_PORT/SMTP_SECURE env-controlled |
| IMAP fetch | imap npm package + mailparser | Standard IMAP client; used for both MailService.fetchEmails() and inbound polling |
| SMS | Twilio SDK | Industry standard; supports VerifyService for OTP (phone number verification without self-managing codes) |
| Email templates | Custom EmailBuilder (fluent API, locale-aware) | Avoids external template SaaS dependency; templates compiled in-process with DE/EN locale switching |
| Communication log | CommunicationService → ddx_api_com | Dedicated database keeps delivery audit log isolated from clinical data; queried for GDPR reports |
| In-app notifications | Notification (Prisma-main) + SSE | In-app bell notifications delivered via existing SSE bus; no additional WebSocket needed |
| Inbound mail config | Per-org IncomingMailConfig with encrypted IMAP credentials | Orgs can have their own IMAP mailbox; credentials encrypted at rest via IncomingMailCryptoService |
| Dev override | OVERRIDE_COMMUNICATION_ENABLED=true + OVERRIDE_EMAIL_DEST / OVERRIDE_PHONE_DEST | All outbound redirected to test address — prevents accidental patient email in dev/staging |
Data Flow
Outbound: appointment confirmation email (QUEUED — non-transactional bulk)
AppointmentServicecreates appointment → callsMailService.sendTemplatedEmail(...)forAPPOINTMENT_CONFIRMATION. Appointment mail is non-transactional bulk, so it takes the queued path.MailServicecallscreateEmailBuilder(APPOINTMENT_CONFIRMATION).setLocale('de').setVariables(vars).build()→ gets{ subject, html, text }.- 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. 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 memoryproject_mail_outbox_queue).- Per drained message: demo-domain redirect (
SMTP_DEMO_DOMAINS→SMTP_DEMO_REDIRECT_DEST) and dev-override (OVERRIDE_COMMUNICATION_ENABLED→OVERRIDE_EMAIL_DEST, subject prefixed[DEV→<original>]) are applied, thennodemailer.sendMail()sends with the inline TUCAN logo CID attachment. - On success:
CommunicationService.logEmail()creates aCommunicationLogrow inddx_api_com→markAsSent()withproviderMessageId. 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)
- Auth module calls
SmsService.verifyPhone({ to: "+49...", channel: "sms" }). SmsServicecallsthis.client.verify.v2.services(verifyServiceSid).verifications.create(...).- Twilio generates and delivers the OTP code — Dudoxx never sees the code itself.
- Patient submits code →
SmsService.checkVerification({ to, code })calls TwilioVerificationCheck; returns{ valid: true/false }.
Inbound: per-org email polling
IncomingMailPollerServiceruns on a schedule; iterates allIncomingMailConfigrows.- For each config: decrypts IMAP credentials via
IncomingMailCryptoService; opens IMAP connection; fetches unseen messages. - Stores message metadata + attachment references in
ddx_api_com; attachments go to MinIO{slug}-attachmentsbucket viaStorageService.
In-app notifications
- Any service calls
NotificationsService.create(CreateNotificationDto)→ writesNotificationrow toddx_api_main. - The calling service (or a separate publisher) emits an SSE event
notification:newon theuser:{userId}channel. - Frontend SSE consumer updates bell badge without polling.
- 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:47—MailService; SMTP + IMAP config at:50–77;sendTemplatedEmail()at:688with template builder + CID logo +CommunicationServicetrackingddx-api/src/communication/mail/mail.service.ts:781—redirectDemoRecipients()— demo-domain redirect logic; protects demo mailboxes from 550 SMTP errorsddx-api/src/communication/mail/mail.service.ts:388—mailQueue.enqueue(dto)— batched-delivery path (drained every 2 min byMailDrainJob)ddx-api/src/communication/mail/mail-queue.service.ts—MailQueueService; Redis-backed outbound outbox (queueKey)ddx-api/src/communication/mail/mail-drain.job.ts—MailDrainJob;@Cron(SMTP_BATCH_CRON ?? '*/2 * * * *')batch drain (stops SMTP 421 storms under bulk load)ddx-api/src/communication/mail/templates/—EmailTemplateTypeenum;EmailBuilderfluent API; localized templates (DE/EN) forAPPOINTMENT_CONFIRMATION,APPOINTMENT_REMINDER,APPOINTMENT_CANCELLED,APPOINTMENT_RESCHEDULED,PASSWORD_RESET,EMAIL_VERIFICATION,OTP_VERIFICATION,WELCOME,USER_INVITATIONddx-api/src/communication/sms/sms.service.ts:55—SmsService; Twilio client init at:57;sendTemplatedSms()withbuildSmsMessage()locale-aware builder;verifyPhone()+checkVerification()for OTP at:80ddx-api/src/communication/notifications/notifications.service.ts:13—NotificationsService;getNotifications(),markAsRead(),markAllAsRead(),deleteNotification(); nightly cleanup cronddx-api/src/communication/incoming-mail/incoming-mail.service.ts:26—IncomingMailService; per-org IMAP config CRUD;getPlatformDefaults()fallback at:37ddx-api/src/communication/incoming-mail/incoming-mail-crypto.service.ts—IncomingMailCryptoService; AES encryption for stored IMAP credentialsddx-api/src/communication/communication-core/communication.service.ts—CommunicationService;logEmail(),logSms(),markAsSent(),markAsFailed(); writes toddx_api_com
Operational Notes
- Dev override: Set
OVERRIDE_COMMUNICATION_ENABLED=true+OVERRIDE_EMAIL_DEST=your@address.comin.envto redirect all outbound email to a test address. Without this, emails fire against real patient addresses in staging. - Demo redirect:
SMTP_DEMO_DOMAINSandSMTP_DEMO_EMAILS(comma-separated) redirect demo mailbox recipients toSMTP_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 = falseand SMS attempts log a warning instead of failing. - prisma-com vs prisma-main:
CommunicationLog(outbound delivery records) is inddx_api_com;Notification(in-app bell) is inddx_api_main. Never query notifications fromPrismaComServiceor communication logs fromPrismaService. - IMAP credential encryption: IMAP passwords in
IncomingMailConfigare encrypted at rest. The encryption key is read from env — losing it makes stored configs unreadable. Back up the key alongside theddx_api_comdatabase. - Mail outbox queue — non-transactional bulk mail (appointment confirmations/reminders/notifications) is enqueued to a Redis outbox (
MailQueueService) and drained in batches byMailDrainJob(@Cron */2 min, tunable viaSMTP_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 memoryproject_mail_outbox_queue. - Validation commands:
pnpm tsc:check(ddx-api); verify Twilio connectivity withGET /communication/sms/health; verify SMTP withGET /communication/mail/health.
Related Topics
- Messenger — in-platform clinical messaging (not external channels); uses
prisma-mainnotprisma-com; SSE delivery - SSE Event Engine — in-app
notification:newevents are delivered via the SSE bus onuser:{userId}channels - Audit Logging — communication actions are not separately audit-logged;
CommunicationLoginddx_api_comis the delivery record; auth events (password reset) flow throughAuditInterceptor