Appearance
Notification Module
Overview
The Notification Module (src/modules/notify/) provides a template-based, multi-channel notification system. It supports:
- Email (SMTP, AWS SES, Sendmail, Infobip)
- In-app notifications
- Mattermost (webhook)
Notifications are defined as templates with per-provider, per-locale contents. A layout wraps email content in a shared HTML shell. Logs track every send attempt.
Each delivery target is a configured provider instance (odp_notify_providers): a channel (email / mattermost / inapp) plus, for email, a transport type (smtp / ses / sendmail / infobip) and its credentials. Providers can receive delivery reports through a public webhook, recorded as append-only delivery events.
Architecture
Data Model
odp_notify_settings (singleton)
| Column | Type | Description |
|---|---|---|
id | integer | Always 1 |
email_transport | varchar | smtp, ses, sendmail |
smtp_host | varchar | SMTP server hostname |
smtp_port | integer | SMTP port |
smtp_user | varchar | SMTP username |
smtp_pass | varchar | SMTP password |
smtp_secure | boolean | Use TLS |
ses_region | varchar | AWS SES region |
ses_access_key | varchar | AWS access key |
ses_secret_key | varchar | AWS secret key |
email_from | varchar | Sender email address |
admin_emails | json | Admin email list for cc_admin |
mattermost_url | varchar | Mattermost server URL |
mattermost_token | varchar | Mattermost bot token |
default_locale | varchar | Default language (e.g., en) |
default_channels | json | Default channels for all sends |
odp_notify_providers
A configured provider instance. Introduced by migration 069-notify-provider-tables.ts, which also seeds initial rows from the odp_notify_settings singleton.
| Column | Type | Description |
|---|---|---|
id | integer | Primary key |
name | varchar | Display name (e.g., Email (smtp)) |
channel | varchar | email, mattermost, inapp |
type | varchar | Email transport: smtp, ses, sendmail, infobip. null for mattermost/inapp |
config | json | Per-type credentials/settings (SMTP host, SES region, Infobip base URL/API key, Mattermost URL/token, …) |
is_primary | boolean | Primary provider for its channel (one per channel) |
enabled | boolean | Whether this provider is usable |
webhook_enabled | boolean | Accept delivery reports on the public webhook |
webhook_secret | varchar | Secret used to verify inbound webhook callbacks |
created_at | timestamp | Creation time |
updated_at | timestamp | Last update time |
One primary provider per channel is enforced by a partial unique index on PostgreSQL and in the service layer on MySQL/SQLite.
odp_notify_delivery_events
Append-only trace of provider delivery reports (created via the public webhook). Migration 069-notify-provider-tables.ts.
| Column | Type | Description |
|---|---|---|
id | integer | Primary key |
log_id | integer | FK to odp_notify_logs.id (nullable, SET NULL) |
provider_id | integer | FK to odp_notify_providers.id (nullable, SET NULL) |
provider_message_id | varchar | Provider-side message id for correlation |
event | varchar | delivered, bounced, rejected, deferred, opened, clicked, complaint, unsubscribed |
provider_status_raw | varchar | Raw provider status string |
reason | text | Failure/bounce reason |
occurred_at | timestamp | When the event happened (provider-reported) |
received_at | timestamp | When ODP ingested the event |
provider_event_id | varchar | Idempotency key (unique) |
raw_payload | json | Raw webhook payload |
odp_notify_layouts
| Column | Type | Description |
|---|---|---|
id | varchar | Layout ID (e.g., default, minimal) |
name | varchar | Display name |
description | varchar | Description |
content | text | HTML with {{{content}}} placeholder |
variables_schema | json | JSON schema for layout variables |
odp_notify_templates
| Column | Type | Description |
|---|---|---|
id | varchar | Template ID (e.g., welcome_email) |
name | varchar | Display name |
channels | json | Array of channel IDs |
cc_admin | boolean | CC admin emails on send |
variables_schema | json | JSON schema for template variables |
layout_id | varchar | FK to odp_notify_layouts.id |
status | varchar | published, draft, archived |
odp_notify_template_contents
| Column | Type | Description |
|---|---|---|
id | integer | Primary key |
template_id | varchar | FK to odp_notify_templates.id |
provider_id | integer | FK to odp_notify_providers.id — the delivery provider for this content |
provider | varchar | DEPRECATED — legacy channel value (e.g., email, inapp), kept until a later cleanup migration; use provider_id |
locale | varchar | Language code (e.g., en, vi) |
subject | varchar | Subject line (for email) |
content | text | Content body (Handlebars template) |
metadata | json | Provider-specific metadata |
active | boolean | Is this content active |
odp_notify_logs
| Column | Type | Description |
|---|---|---|
id | integer | Primary key |
status | varchar | pending, sending, sent, delivered, bounced, rejected, failed, cancelled |
template_id | varchar | FK to template (nullable for direct sends) |
channel | varchar | Provider channel used |
provider_id | integer | FK to odp_notify_providers.id (nullable, SET NULL) |
provider_message_id | varchar | Provider-side message id (for delivery-webhook correlation) |
recipients | json | Array of recipient addresses |
cc | json | CC recipients |
bcc | json | BCC recipients |
locale | varchar | Language used |
variables | json | Variables passed at trigger time |
subject_rendered | varchar | Final rendered subject |
content_rendered | text | Final rendered content |
scheduled_at | timestamp | When to send (null = immediate) |
sent_at | timestamp | Accepted/handed-off time |
delivered_at | timestamp | Confirmed-at-recipient time (set from a delivery webhook) |
error_message | text | Error description on failure |
provider_response | json | Raw provider API response |
metadata | json | Arbitrary metadata stored with the log |
Statuses beyond
sent(delivered,bounced,rejected) are advanced by inbound provider delivery events (seeodp_notify_delivery_events), correlated viaprovider_message_id.
Settings Endpoints
GET /notify/settings
Get notification settings (singleton).
Auth required: Yes
Response 200
json
{
"data": {
"id": 1,
"email_transport": "smtp",
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"email_from": "no-reply@example.com",
"default_locale": "en",
"default_channels": ["email"]
}
}PATCH /notify/settings
Update notification settings.
Auth required: Yes
Request Body
json
{
"email_transport": "smtp",
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"smtp_user": "user@example.com",
"smtp_pass": "password",
"smtp_secure": true,
"email_from": "no-reply@example.com"
}Channel Endpoints
GET /notify/channels
List the static channel handler catalog — the built-in transport capabilities (email / mattermost / inapp), not configured instances. This is the descriptor list that used to be returned by GET /notify/providers.
Auth required: Yes (admin)
Response 200
json
{
"data": [
{
"id": "email",
"name": "Email",
"description": "Send via SMTP/SES",
"icon": "email"
},
{
"id": "inapp",
"name": "In-App",
"description": "In-application notifications",
"icon": "notifications"
},
{
"id": "mattermost",
"name": "Mattermost",
"description": "Send to Mattermost channels",
"icon": "chat"
}
]
}Provider Endpoints
Providers are the configured instances stored in odp_notify_providers. All endpoints require admin.
GET /notify/providers
List configured provider rows. Supports the standard query system.
Auth required: Yes (admin)
Response 200
json
{
"data": [
{
"id": 1,
"name": "Email (smtp)",
"channel": "email",
"type": "smtp",
"config": { "host": "smtp.example.com", "port": 587, "from": "no-reply@example.com" },
"is_primary": true,
"enabled": true,
"webhook_enabled": false
}
]
}POST /notify/providers
Create a provider instance. If is_primary is true, it is promoted to primary for its channel.
Request Body
json
{
"name": "Infobip",
"channel": "email",
"type": "infobip",
"config": { "base_url": "https://xxxxx.api.infobip.com", "api_key": "…", "from": "no-reply@example.com" },
"is_primary": true,
"enabled": true
}Response 200 — Returns the new provider ID.
GET /notify/providers/:id
Read a single provider row.
PATCH /notify/providers/:id
Update a provider. Setting is_primary: true promotes it; is_primary: false clears the primary flag for its channel.
PATCH /notify/providers/:id/primary
Promote this provider to primary for its channel (demotes any previous primary).
Response 200 — Returns the provider ID.
DELETE /notify/providers/:id
Delete a provider. Fails with 400 if it is still referenced by template contents (FK RESTRICT) — disable it or remove those contents first.
Response 204
Layout Endpoints
GET /notify/layouts
List all layouts.
POST /notify/layouts
Create a layout.
Request Body
json
{
"id": "branded",
"name": "Branded Layout",
"description": "ODP branded email template",
"content": "<html>...<div style=\"max-width:600px\">{{{content}}}</div>...</html>"
}The content field must contain the {{{content}}} placeholder (triple braces = unescaped HTML).
GET /notify/layouts/:id
PATCH /notify/layouts/:id
DELETE /notify/layouts/:id
Template Endpoints
GET /notify/templates
List all templates.
POST /notify/templates
Create a template.
Request Body
json
{
"id": "welcome_email",
"name": "Welcome Email",
"channels": ["email"],
"cc_admin": false,
"layout_id": "default",
"status": "published",
"variables_schema": {
"type": "object",
"properties": {
"user_name": { "type": "string" },
"login_url": { "type": "string" }
}
}
}GET /notify/templates/:id
Read a template (includes contents array).
Response 200
json
{
"data": {
"id": "welcome_email",
"name": "Welcome Email",
"channels": ["email"],
"status": "published",
"contents": [
{
"id": 1,
"provider_id": 1,
"locale": "en",
"subject": "Welcome to ODP, {{user_name}}!",
"content": "<p>Hello {{user_name}},</p><p>Click <a href=\"{{login_url}}\">here</a> to log in.</p>",
"active": true
}
]
}
}PATCH /notify/templates/:id
DELETE /notify/templates/:id
System templates (created by the system) cannot be deleted — only their status can be changed to archived.
Template Content Endpoints
GET /notify/templates/:templateId/contents
List all content variants for a template.
POST /notify/templates/:templateId/contents
Add a new content variant. provider_id is required (FK to odp_notify_providers); locale defaults to default.
Request Body
json
{
"provider_id": 1,
"locale": "vi",
"subject": "Chào mừng {{user_name}} đến với ODP!",
"content": "<p>Xin chào {{user_name}},</p>",
"active": true
}PATCH /notify/contents/:id
Update a content variant.
DELETE /notify/contents/:id
Delete a content variant.
Log Endpoints
GET /notify/logs
List notification delivery logs.
Supports standard query system.
GET /notify/logs/:id
Read a single log entry.
PATCH /notify/logs/:id/cancel
Cancel a pending notification (status must be pending).
Response 200 — Returns the log ID.
DELETE /notify/logs/:id
Delete a log entry.
GET /notify/logs/stats
Get aggregate statistics.
Response 200
json
{
"data": {
"total": 500,
"sent": 480,
"failed": 10,
"pending": 5,
"cancelled": 5
}
}Trigger Endpoint
POST /notify/trigger
Trigger a notification by template ID.
Auth required: Yes
Request Body
json
{
"template_id": "welcome_email",
"recipients": ["user@example.com"],
"cc": ["manager@example.com"],
"variables": {
"user_name": "John Doe",
"login_url": "https://app.example.com/login"
},
"locale": "en",
"channels": ["email"],
"scheduled_at": "2026-03-26T12:00:00.000Z"
}| Field | Required | Description |
|---|---|---|
template_id | Yes | Template to use |
recipients | Yes | Array of recipient addresses (email/user IDs) |
cc | No | CC recipients |
bcc | No | BCC recipients |
variables | No | Template variable values |
locale | No | Language code (defaults to default_locale) |
channels | No | Override template's default channels |
scheduled_at | No | ISO 8601 timestamp for delayed send |
metadata | No | Arbitrary metadata stored in the log |
Response 200
json
{
"data": {
"log_ids": [42, 43]
}
}One log entry is created per channel per send.
Test Send Endpoint
POST /notify/test-send
Send a test notification directly via a configured provider (bypasses templates).
Auth required: Yes (admin)
Request Body
json
{
"provider_id": 1,
"recipients": ["test@example.com"],
"subject": "Test Notification",
"content": "<p>This is a test.</p>",
"metadata": {}
}| Field | Required | Description |
|---|---|---|
provider_id | Yes | ID of the configured provider (odp_notify_providers) to send through |
recipients | Yes | Array of recipient addresses |
subject | No | Subject line (defaults to empty) |
content | Yes | Body content |
metadata | No | Provider-specific metadata |
Response 200
json
{
"data": {
"success": true,
"message": "Email sent successfully"
}
}Template Validation Endpoint
POST /notify/validate-template
Validate a Handlebars template string for syntax errors.
Request Body
json
{
"template": "Hello {{user_name}}, your code is {{code}}."
}Response 200
json
{
"data": {
"valid": true,
"errors": []
}
}Delivery Webhook Endpoint
POST /notify/webhooks/:key
Public callback endpoint for provider delivery reports (bounces, opens, clicks, …). No user auth — each request is verified per-adapter against the webhook_secret of enabled providers whose type or channel matches :key. A dedicated rate limit applies.
:keyselects the webhook adapter (e.g.infobip). Unknown keys return404.- Verified events are parsed by the adapter and appended to
odp_notify_delivery_events, advancing the correlatedodp_notify_logsstatus. - Verification failure returns
401.
Response 200
json
{ "ok": true }Template Rendering
Templates use Handlebars syntax:
{{variable}}— HTML-escaped output{{{variable}}}— Raw unescaped HTML (use for URLs and HTML content){{#if condition}}...{{/if}}— Conditional blocks{{#each list}}...{{/each}}— Loop blocks
Layouts wrap template content using {{{content}}} (unescaped triple brace).
Worker & Async Processing
The NotifyWorker processes pending log entries asynchronously:
- Runs on the same server process (not a separate process)
- Registered via
registerNotifyWorker()inserver.ts - Scheduled sends are processed when
scheduled_at <= NOW - Failed sends are logged with
error_messageset - Workers respect
FILES_MAX_UPLOAD_CONCURRENCY(reuses the same concurrency limit)
Environment Variables
| Variable | Default | Description |
|---|---|---|
EMAIL_FROM | no-reply@example.com | Default sender address |
EMAIL_TRANSPORT | smtp | Transport: smtp, ses, sendmail |
EMAIL_SMTP_HOST | localhost | SMTP host |
EMAIL_SMTP_PORT | 587 | SMTP port |
EMAIL_SMTP_USER | `` | SMTP username |
EMAIL_SMTP_PASSWORD | `` | SMTP password |
EMAIL_SMTP_SECURE | false | Use TLS |