Dev Notification Outbox
Overview
A development-only tool for previewing outbound notifications locally, at
http://localhost:4000/dev/notifications.
Testing notification flows used to be awkward and channel-specific: SMS routed a Slack copy but still hit the real provider, email went out via real SES, and WhatsApp was simply disabled. The outbox is one place to see what a flow would have sent — across SMS, email, and WhatsApp — without sending anything real.
Use it whenever you're building or debugging a flow that sends a notification (signup/OTP, appointment reminders, referrals, invites, password reset, etc.).
Captured: SMS, email, WhatsApp. Not captured: push and in-app notifications — they are zero-cost and run exactly as in production.
How to use
- Restart the Rails server after pulling (this feature is config-level — routes,
an initializer, and the mailer
delivery_method— none of which hot-reload). - Trigger a flow that sends a notification.
- Open
http://localhost:4000/dev/notifications— hit it directly on the backend port (:4000), not through the frontend/apiproxy.- Filter by channel (SMS / Email / WhatsApp).
- Clear all empties the outbox.
http://localhost:4000/dev/notifications.jsonreturns the raw captures.
Notes:
- Email also opens in
letter_opener(a browser tab) in addition to appearing in the outbox. - WhatsApp only appears when a flow actually calls
WhatsappService— Wati has no dev sandbox, so those sends are captured-and-skipped.
Behavior by environment
| Channel | Development | Staging / Production |
|---|---|---|
| SMS | captured; real send suppressed | delivered normally (not captured) |
captured; delivered via letter_opener (no SES) | delivered via SES (not captured) | |
| captured; send skipped (Wati has no dev sandbox) | delivered (not captured) | |
| Push / in-app | run as production | run as production |
The outbox is development only — see Security below.
Technical Architecture
Capture happens at the low-level channel services, not the newer
Dispatcher, because ~200 legacy sms/* / whatsapp/* templates call the
services directly and most notifications fire from Sidekiq jobs. The services
are the only seam that catches 100% of a channel's traffic.
Shared::Notifications::DevOutbox(domains/shared/services/notifications/dev_outbox.rb) — the store.record/all/clear, all gated onenabled?(Rails.env.development?). Backed by a capped Redis list ($redis, keydev:notification_outbox, cap 500) so the web process and Sidekiq workers share one outbox. Best-effort: a Redis error is logged and swallowed, never breaking a send.SmsService.call— records to the outbox, then in development returns success before the real provider (MSG91/Twilio).DevOutbox.recordno-ops outside development, so staging/production fall through and deliver.WhatsappService.call— records into the outbox inside the existing development short-circuit.- Email — an anonymous
Mailinterceptor registered inconfig/initializers/setup_email.rb(development only) records every outgoing message. Delivery still happens vialetter_opener(seedevelopment.rb). Shared::Notifications::DevOutboxHtml— renders the viewer page; every message-derived value is HTML-escaped viaERB::Util.html_escape.Shared::DevNotificationsController(shared/controllers) + the/dev/notificationsroutes — the viewer, mounted only in development.
Security
The viewer is unauthenticated and renders message bodies (which contain OTPs and PHI), so it is locked to local development by four independent gates:
DevOutbox.enabled?isRails.env.development?—record/all/clearall no-op otherwise.- The
/dev/notificationsroutes are mounted only insideif Rails.env.development?. - The
Mailinterceptor is registered only in development. - The controller's
before_actionreturns404unlessRails.env.development? && request.local?— i.e. loopback only.
Caveat: the loopback gate (request.local?) means that if you run the backend
behind a Docker port-forward or reverse proxy where the client IP is not
127.0.0.1/::1 (e.g. a Docker bridge gateway), the viewer returns 404. Native
localhost access (the default) works. If your setup needs it, relax
require_local_development! to an explicit IP allowlist.
Configuration
No configuration required — it's on automatically in development. Constants live in
DevOutbox: REDIS_KEY (dev:notification_outbox) and MAX_ENTRIES (500,
newest-first).
Email preview requires config.action_mailer.delivery_method = :letter_opener in
config/environments/development.rb (the letter_opener gem is in the Gemfile's
:development group).
Side effects (development only)
Because the SMS path short-circuits before delivery, in development SMS
NotificationLog rows are not written and dedup is skipped. This is dev-only and
has no effect on staging/production.
Testing
domains/shared/test/services/notifications/dev_outbox_test.rb covers the
DevOutbox store (record/all/filter/no-op/clear), the SMS and WhatsApp capture
seams (stubbing Rails.env.development?), and the viewer's HTML-escaping guarantee
(a <script>-laden body renders escaped). enabled? is stubbed true in tests
since it is false in the test environment by design.