@spfn/notification
The messages your product has to send before it has anything to say
A verification code. A password reset. An invitation. A receipt. None of these are your
product, and all of them are required before it ships. @spfn/auth already emits the
events — registration, invitation created, invitation accepted — and this package is what
turns those into a delivered email or SMS.
Multi-channel delivery for SPFN applications: one call site, a provider behind it, and a record of what was sent.
What you get
- Multi-channel support: Email, SMS, Slack (Push coming soon)
- Provider pattern: Pluggable providers (AWS SES, AWS SNS, etc.)
- Template system: Variable substitution with filters
- Scheduled delivery: Schedule notifications for later via pg-boss
- History tracking: Optional notification history with database storage
- Email tracking: Open pixel & click redirect tracking with engagement analytics
Installation
pnpm add @spfn/notification drizzle-orm@1.0.0-rc.4
Optional Dependencies
Install providers as needed:
# For AWS SES v2 (Email)
pnpm add @aws-sdk/client-sesv2
# For AWS SNS (SMS)
pnpm add @aws-sdk/client-sns
Quick Start
import { sendEmail, sendSMS, configureNotification } from '@spfn/notification/server';
// Configure (optional - uses environment variables by default)
configureNotification({
email: {
from: 'noreply@example.com',
},
defaults: {
appName: 'MyApp',
},
});
// Send email with template
await sendEmail({
to: 'user@example.com',
template: 'verification-code',
data: { code: '123456' },
});
// Send SMS
await sendSMS({
to: '+821012345678',
template: 'verification-code',
data: { code: '123456' },
});
Configuration
Environment Variables
# Email
SPFN_NOTIFICATION_EMAIL_PROVIDER=aws-ses
SPFN_NOTIFICATION_EMAIL_FROM=noreply@example.com
# SMS
SPFN_NOTIFICATION_SMS_PROVIDER=aws-sns
# Tracking
SPFN_NOTIFICATION_TRACKING_ENABLED=true
SPFN_NOTIFICATION_TRACKING_SECRET=your-hmac-secret-key
SPFN_NOTIFICATION_TRACKING_BASE_URL=https://api.example.com
# History (only needed when history.storeRecipient is 'hashed')
SPFN_NOTIFICATION_HISTORY_HASH_SECRET=your-history-hmac-secret
# AWS Credentials
AWS_REGION=ap-northeast-2
AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx
Code Configuration
import { configureNotification } from '@spfn/notification/server';
configureNotification({
email: {
provider: 'aws-ses',
from: 'noreply@example.com',
replyTo: 'support@example.com',
},
sms: {
provider: 'aws-sns',
defaultCountryCode: '+82',
},
defaults: {
appName: 'MyApp',
},
enableHistory: true, // Enable notification history tracking
history: {
storeContent: true, // Store rendered content + template data (default: true)
storeRecipient: 'raw', // 'raw' | 'hashed' — how the recipient column is stored
// hashSecret: '...', // Required for 'hashed' (or use the env var)
},
tracking: {
enabled: true, // Enable email tracking
secret: 'your-hmac-secret-key', // HMAC signing key
baseUrl: 'https://api.example.com', // Tracking endpoint URL
},
});
Sending Notifications
import { sendEmail, sendEmailBulk } from '@spfn/notification/server';
// With template
await sendEmail({
to: 'user@example.com',
template: 'welcome',
data: { name: 'John' },
});
// With direct content
await sendEmail({
to: 'user@example.com',
subject: 'Hello',
text: 'Plain text content',
html: '<h1>HTML content</h1>',
});
// Bulk send (in-process, parallel)
await sendEmailBulk([
{ to: 'user1@example.com', template: 'welcome', data: { name: 'John' } },
{ to: 'user2@example.com', template: 'welcome', data: { name: 'Jane' } },
], { concurrency: 20 });
// Bulk send (distributed across instances via pg-boss)
const result = await sendEmailBulk(items, { distributed: true });
// result.batchId — UUID for tracking this batch
// Actual sending happens in background via pg-boss workers
SMS
import { sendSMS, sendSMSBulk } from '@spfn/notification/server';
// With template
await sendSMS({
to: '+821012345678',
template: 'verification-code',
data: { code: '123456' },
});
// With direct message
await sendSMS({
to: '010-1234-5678', // Auto-normalized to E.164 format
message: 'Your code is 123456',
});
Scheduled Notifications
Schedule notifications for later delivery using pg-boss:
import { scheduleEmail, scheduleSMS } from '@spfn/notification/server';
// Schedule email for 1 hour later
const result = await scheduleEmail(
{
to: 'user@example.com',
template: 'reminder',
data: { eventName: 'Meeting' },
},
{
scheduledAt: new Date(Date.now() + 60 * 60 * 1000),
referenceType: 'event',
referenceId: 'event-123',
}
);
// Cancel scheduled notification
import { cancelNotification } from '@spfn/notification/server';
await cancelNotification(result.notificationId);
Bulk Sending
All channels support bulk sending with two modes:
In-Process Mode (default)
Sends all items in the current process with concurrency control. Suitable for single-instance deployments or smaller batches.
import { sendEmailBulk, sendSMSBulk, sendSlackBulk } from '@spfn/notification/server';
const result = await sendEmailBulk(items, { concurrency: 20 });
// result.results — SendResult[] in same order as input
// result.successCount / result.failureCount
// result.batchId — UUID for this batch
Distributed Mode
Enqueues items to pg-boss for processing across multiple instances. Each instance's worker fetches 50 items at a time and processes them in parallel. Failed items are individually retried by pg-boss.
const result = await sendEmailBulk(items, { distributed: true });
// Returns immediately with pending results
// result.results[i].messageId === 'pending:{batchId}'
Distributed mode flow:
- Validates and renders templates
- Batch inserts notification records (single query)
- Applies tracking (email only)
- Bulk inserts pg-boss jobs via
sendBatch() - Returns immediately — workers process in background
Requirements:
notificationJobRoutermust be registered (see Job Router Setup below)- pg-boss must be initialized
Job Router Setup
Register the notification job router with your server:
import { defineServerConfig } from '@spfn/core/server';
import { defineJobRouter } from '@spfn/core/job';
import { notificationJobRouter } from '@spfn/notification/server';
defineServerConfig()
.jobs(defineJobRouter({
notification: notificationJobRouter,
}))
.build();
Template System
Built-in Templates
| Name | Channels | Purpose |
|---|---|---|
verification-code |
email, sms | Verification codes |
welcome |
Welcome message |
Custom Templates
import { registerTemplate } from '@spfn/notification/server';
registerTemplate({
name: 'order-confirmation',
channels: ['email', 'sms'],
email: {
subject: '[{{appName}}] Order Confirmed',
html: `
<h1>Order #{{orderId}}</h1>
<p>Thank you, {{userName}}!</p>
<p>Total: {{amount | currency}}</p>
`,
text: 'Order #{{orderId}} confirmed. Total: {{amount}}',
},
sms: {
message: '[{{appName}}] Order #{{orderId}} confirmed. Total: {{amount | currency}}',
},
});
Template Filters
{{variable}} - Basic substitution
{{variable | uppercase}} - Convert to uppercase
{{variable | lowercase}} - Convert to lowercase
{{variable | currency}} - Format as currency (1,000)
{{variable | date}} - Format as date
{{variable | date:YYYY-MM-DD}} - Custom date format
{{variable | truncate:20}} - Truncate to length
{{variable | default:N/A}} - Default value if empty
Custom Filters
import { registerFilter } from '@spfn/notification/server';
registerFilter('phone', (value) => {
const str = String(value);
return `${str.slice(0, 3)}-${str.slice(3, 7)}-${str.slice(7)}`;
});
// Usage: {{phoneNumber | phone}} -> 010-1234-5678
Custom Providers
Email Provider
import { registerEmailProvider, EmailProvider } from '@spfn/notification/server';
const sendgridProvider: EmailProvider = {
name: 'sendgrid',
async send(params) {
// Your SendGrid implementation
return { success: true, messageId: '...' };
},
};
registerEmailProvider(sendgridProvider);
SMS Provider
import { registerSMSProvider, SMSProvider } from '@spfn/notification/server';
const twilioProvider: SMSProvider = {
name: 'twilio',
async send(params) {
// Your Twilio implementation
return { success: true, messageId: '...' };
},
};
registerSMSProvider(twilioProvider);
Logging & Error Handling
All channels log via @spfn/core/logger. Logs are tagged by channel.
Recipient fields never appear raw in logs. Every recipient field this
package writes — success, failure, and validation logs alike — carries a masked
value (jo***@example.com, +8210******78). A send resolved as sensitive
(see Sensitive sends) also keeps its subject out of the log,
because a verification subject line carries the code itself; the log identifies
the send by template name instead. The masking helpers are exported
(maskEmail, maskPhone, maskRecipient, maskRecipients) for application-side
logging of the same values.
Provider error text is scrubbed too. A provider failure is free text and
routinely quotes the recipient back — an SES sandbox MessageRejected reads
"The following identities failed the check in region US-EAST-1:
learner@example.com". Every provider error is scrubbed of email and phone
tokens before it reaches a log line or history.error_message, at the channel's
provider boundary — so a custom provider (see Custom Providers)
is covered without doing anything — and again in the service that writes the
column. Only address-shaped tokens are replaced: the error name (MessageRejected),
the region, the request id, timestamps and message ids are the diagnostic value
and survive untouched, and the built-in AWS providers log that name and the HTTP
status alongside the scrubbed message. scrubProviderError(text) is exported for
applications that log provider errors themselves.
Channel tags:
@spfn/notification:email— Email send success/failure, validation errors@spfn/notification:sms— SMS send success/failure, validation errors@spfn/notification:slack— Slack send success/failure, validation errors@spfn/notification:ses— AWS SES client lifecycle, provider errors@spfn/notification:sns— AWS SNS client lifecycle, provider errors
sendEmail, sendSMS, sendSlack are designed to not throw — they return a SendResult object. Always check the result:
const result = await sendEmail({
to: 'user@example.com',
template: 'welcome',
data: { name: 'John' },
});
if (!result.success)
{
// result.error contains the failure reason
console.error('Email failed:', result.error);
}
Notification History
Enable history tracking to store all notifications in the database:
configureNotification({
enableHistory: true,
});
What a row stores — and how to store less
A history row is the anchor for tracking (FK), scheduling (cancellation) and
bulk status, so the row itself is always created when history is enabled. What
lands in it is configurable, because two of its columns are privacy
surfaces: recipient is a person's address, and content / template_data
can carry a live credential (a magic link, an OTP code). error_message holds
the scrubbed provider message, so a row whose recipient is hashed cannot carry
the raw address in its failure text either.
configureNotification({
enableHistory: true,
history: {
// Keep rendered content and template data out of every row.
// Status, provider message id, error and timestamps still land.
storeContent: false,
// Store an HMAC per recipient instead of the raw address.
// findNotifications({ recipient }) keeps working — the filter is
// hashed the same way. Requires a secret (config or
// SPFN_NOTIFICATION_HISTORY_HASH_SECRET). A plain hash would be
// reversible by dictionary, which is why a keyed HMAC is used.
storeRecipient: 'hashed',
},
});
Neither option needs a migration — the payload columns are nullable and the
HMAC satisfies the recipient column as-is.
Sensitive sends
A send whose rendered output is a credential can opt out of payload storage
per send, regardless of the global storeContent setting:
await sendEmail({
to: 'user@example.com',
subject: 'Your access link',
html: accessLinkHtml,
sensitive: true, // no content, template data, or subject in the history row
});
A template can declare it for every send that uses it — the built-in
verification-code template does, since both its body and its subject line
contain the code. A per-send sensitive: true/false overrides the template's
declaration.
Scheduled and distributed sends caveat: a scheduled send's payload (raw
recipient, content, template data) necessarily travels through the pg-boss job
table — that is what gets sent later — and stays there until pg-boss archives
the job. The same applies to sendEmailBulk / sendSMSBulk with
distributed: true. The history options above do not change that. If your
contract forbids persisting a credential at all, send it immediately and
in-process rather than scheduling or distributing it.
Query History
import {
findNotifications,
getNotificationStats,
findScheduledNotifications,
} from '@spfn/notification/server';
// Find notifications
const notifications = await findNotifications({
channel: 'email',
status: 'sent',
recipient: 'user@example.com',
from: new Date('2024-01-01'),
limit: 100,
});
// Get statistics
const stats = await getNotificationStats({ channel: 'email' });
// { total: 1000, scheduled: 10, pending: 5, sent: 980, failed: 3, cancelled: 2 }
// Find scheduled notifications
const scheduled = await findScheduledNotifications({
channel: 'email',
from: new Date(),
to: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
Email Tracking
Track email opens and link clicks to measure engagement.
How It Works
- Open tracking: A 1x1 transparent GIF is inserted before
</body>. When the email client loads the image, an open event is recorded. - Click tracking:
<a href>links are wrapped with redirect URLs. When clicked, a click event is recorded and the user is redirected to the original URL. - Links with
mailto:,tel:,sms:,javascript:, and#protocols are automatically skipped. - URL fragments stay client-side. A fragment (
#token=...) is never sent to a server on a normal navigation — magic links carry their secret there for exactly that reason — so the rewriter keeps it out of the redirect's query string. Only the fragment-less URL is signed, sent, and recorded; the fragment rides on the tracking URL itself, and since the 302Locationcarries no fragment, the browser re-attaches it to the destination. The click is still counted. - Per-link opt-out: a link with a
data-no-trackattribute is left completely untouched — no rewrite, no record.
<!-- tracked; #token=... never reaches the tracking endpoint -->
<a href="https://app.example.com/access#token=abc">Open your access page</a>
<!-- not tracked at all -->
<a data-no-track href="https://app.example.com/access#token=abc">Open</a>
Setup
Tracking requires:
enableHistory: true(tracking events reference the history table via FK)tracking.secretconfigured (HMAC token signing)tracking.baseUrlconfigured (where tracking endpoints are accessible)trackingRouterregistered in your app router
import { configureNotification, trackingRouter } from '@spfn/notification/server';
import { defineRouter } from '@spfn/core/route';
configureNotification({
enableHistory: true,
tracking: {
enabled: true,
secret: 'your-hmac-secret-key',
baseUrl: 'https://api.example.com',
},
});
// Register tracking router (provides /_noti/t/o/:token and /_noti/t/c/:token)
const appRouter = defineRouter({ ... })
.packages([trackingRouter]);
Per-Email Control
Override the global tracking setting for individual emails:
// Force tracking on for this email (even if globally disabled)
await sendEmail({
to: 'user@example.com',
template: 'campaign',
data: { ... },
tracking: true,
});
// Force tracking off for this email (even if globally enabled)
await sendEmail({
to: 'admin@example.com',
subject: 'Internal Report',
html: '<p>...</p>',
tracking: false,
});
Priority
sendEmail({ tracking: true/false }) ← 1st: per-call override
configureNotification({ tracking: { enabled } }) ← 2nd: code config
SPFN_NOTIFICATION_TRACKING_ENABLED=true ← 3rd: environment variable
Tracking Endpoints
These endpoints skip authentication (accessed by email clients):
| Endpoint | Response | Action |
|---|---|---|
GET /_noti/t/o/:token |
200 + 1x1 GIF | Records open event |
GET /_noti/t/c/:token?url=... |
302 redirect | Records click event, redirects to original URL |
- An invalid open token still returns the pixel (UX protection); an invalid click token returns 404 — redirecting on an unverified token would be an open redirect
Cache-Control: no-storefor re-open tracking- DB writes are fire-and-forget (response speed first)
Analytics
import {
getTrackingStats,
getEngagementStats,
getClickDetails,
} from '@spfn/notification/server';
// Stats for a specific notification
const stats = await getTrackingStats(notificationId);
// { totalOpens: 15, uniqueOpens: 8, totalClicks: 5, uniqueClicks: 3 }
// Overall engagement stats
const engagement = await getEngagementStats({ channel: 'email' });
// { sent: 1000, opened: 450, clicked: 120, openRate: 45.00, clickRate: 12.00 }
// Click details per link
const clicks = await getClickDetails(notificationId);
// [{ linkUrl: 'https://...', linkIndex: 0, totalClicks: 5, uniqueClicks: 3 }]
Unique counts are based on distinct IP addresses.
Limitations
- Open tracking is approximate: Email clients like Apple Mail Privacy Protection may pre-fetch images, inflating open counts.
- Tracking auto-disables when
enableHistory: false(FK dependency) ortracking.secretis not set.
API Reference
Exports
// From '@spfn/notification'
export type {
NotificationChannel,
SendResult,
SendEmailParams,
SendSMSParams,
SendSlackParams,
TemplateDefinition,
TemplateData,
};
// From '@spfn/notification/server'
export {
// Configuration
configureNotification,
getNotificationConfig,
// Email
sendEmail,
sendEmailBulk,
registerEmailProvider,
type BulkEmailResult,
type BulkEmailOptions,
// SMS
sendSMS,
sendSMSBulk,
registerSMSProvider,
type BulkSMSResult,
type BulkSMSOptions,
// Slack
sendSlack,
sendSlackBulk,
registerSlackProvider,
type BulkSlackResult,
type BulkSlackOptions,
// Scheduling
scheduleEmail,
scheduleSMS,
cancelNotification,
// Templates
registerTemplate,
renderTemplate,
registerFilter,
// History
findNotifications,
getNotificationStats,
// Tracking
trackingRouter,
processTrackingHtml,
getTrackingStats,
getEngagementStats,
getClickDetails,
isTrackingEnabled,
// Jobs
notificationJobRouter,
sendBulkEmailItemJob,
sendBulkSmsItemJob,
sendBulkSlackItemJob,
};
License
MIT