@spfn/notification
Multi-channel notification system for SPFN applications.
Features
- 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
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
# 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
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:
@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,
});
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.
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 |
- Invalid tokens still return pixel/redirect (UX protection)
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