@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, native push (FCM: Android, iOS, web)
- 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
# SES only: configuration set every send names, so SES publishes its delivery/bounce/complaint events
SPFN_NOTIFICATION_SES_CONFIGURATION_SET=mail-events
# 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',
// SES only: configuration set every send names, so SES publishes its delivery/bounce/complaint events
configurationSetName: 'mail-events',
},
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',
});
Push (FCM)
Native push goes through Firebase Cloud Messaging's HTTP v1 API, which reaches Android, iOS (FCM relays to APNs — upload your APNs auth key in the Firebase console) and web.
Setup
pnpm add google-auth-library # optional peer dependency, needed only for push
| Variable | Meaning |
|---|---|
SPFN_NOTIFICATION_FCM_SERVICE_ACCOUNT |
Service-account JSON. Unset: Application Default Credentials (GKE Workload Identity, gcloud auth application-default login) |
SPFN_NOTIFICATION_FCM_PROJECT_ID |
Firebase project id; defaults to the credential's project |
SPFN_NOTIFICATION_PUSH_PROVIDER |
fcm (default), or the name of a provider passed to registerPushProvider |
Device tokens
The package stores tokens but ships no route: registering a token must be tied to the signed-in user, and the package does not depend on auth. Call it from your own authenticated route and pass your user id as a string.
import { registerPushDevice, unregisterPushDevice } from '@spfn/notification/server';
// after sign-in, and whenever the app gets a new FCM token
await registerPushDevice({
ownerId: String(auth.userId),
token: body.fcmToken,
platform: 'ios', // 'ios' | 'android' | 'web'
deviceId: body.installId, // optional, recommended
locale: body.locale, // optional: renders localised templates per device
});
// on sign-out
await unregisterPushDevice(body.fcmToken);
- A token registered by another owner moves to the new one, so a shared device that signs in as someone else stops receiving the previous user's pushes.
- With
deviceId, a rotated token replaces the device's old one (one active token per device, whoever owned it). TakedeviceIdfrom the device itself (an install id the app generates), never from user input: anyone who can name another user'sdeviceIdcan retire that device's token. - FCM answering
UNREGISTEREDorSENDER_ID_MISMATCHinvalidates the token. So doesINVALID_ARGUMENTwhose field violation namesmessage.token(a malformed token); anINVALID_ARGUMENTabout any other field is a payload error and leaves the token alone, since otherwise one bad payload would wipe every token it was sent to.
Sending
import { sendPush } from '@spfn/notification/server';
// every active device of the user
await sendPush({ to: { ownerId: userId }, title: 'Order shipped', body: '#1234', data: { orderId: '1234' } });
// narrowed: iOS only / named devices / the device used last
await sendPush({ to: { ownerId: userId, devices: { platforms: ['ios'] } }, title: '…' });
await sendPush({ to: { ownerId: userId, devices: { deviceIds: ['install-1'] } }, title: '…' });
await sendPush({ to: { ownerId: userId, devices: { latest: 1 } }, title: '…' });
// tokens you manage yourself
await sendPush({ to: { tokens: [a, b] }, title: '…' });
Which devices get a push is the caller's decision; the default is all of the owner's
active devices. For a rule the selector cannot express, read listPushDevices(ownerId)
and pass { tokens }.
The result is per device, with masked tokens:
{
success: false, // true only when every device succeeded
successCount: 1,
failureCount: 1,
results: [
{ token: 'dF3k9a…x81Q', deviceId: 'install-1', platform: 'ios', success: true, messageId: '…' },
{ token: 'c2Pq0Z…7tLm', platform: 'android', success: false, error: 'UNREGISTERED: …' },
],
}
An owner with no active device returns success: false, error: 'no_devices'.
datavalues must be strings (FCM's rule); a payload over 4096 bytes is refused before sending.options:priority(high/normal),ttlSeconds,collapseKey,badge,sound,contentAvailable(silent push: data only, wakes the app). They are mapped onto the Android, APNs and web blocks.fcmtakes raw FCMmessagefields, merged last.- Templates take
push: { title, body, data }, also per locale. A registered device'slocalewins over the send'slocale. idempotencyKeyapplies per device: a retry reaches only the devices that did not get it.sensitive: truekeeps title and body out of history.sendPushBulk(items)sends several pushes with bounded concurrency.
Scheduling
schedulePush(params, options) takes the same ScheduleOptions as email and SMS
(scheduledAt, referenceType/referenceId, guard, cancellation). Devices are
resolved when the job runs, so a device registered in between is included, and a
retry after a partial failure reaches only the devices that failed.
Delivery through FCM and APNs is best effort: an accepted message can still be dropped without the sender being told. Do not build anything that needs a push to arrive.
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';
const cancel = await cancelNotification(result.notificationId);
// { success: true, jobCancelled: true }
What a cancel guarantees. success: true means the notification will not be sent.
The history row moves from scheduled to cancelled in one status-guarded update, and
the scheduled job re-checks the row when it runs: it sends only a row it can move to
pending, and skips a cancelled or sent row without error. So a cancel holds even
when the queued job could not be removed (jobCancelled: false — no queue in this
process, or a queue error) and even when it races the send time. Whichever of the cancel
and the job updates the row first wins; the other sees the new status and stands down.
A cancel also works on a row whose attempt failed and is waiting for a job retry. It is
refused (success: false) for a send in progress or already sent. markNotificationCancelled(id) is the record-only
form: it updates the row and returns whether it did, without touching the queue.
Send guards — "send this later, unless it no longer applies"
A scheduled send can name a guard that the job calls when it runs, after checking the row was not cancelled. It answers the question the app could not answer at schedule time, so the app does not have to race a cancel against the send time.
import { registerSendGuard, scheduleEmail } from '@spfn/notification/server';
// Register in every process that runs notification jobs.
registerSendGuard('incident-still-open', async ({ referenceId }) =>
{
return (await incidents.find(referenceId))?.status === 'open';
});
await scheduleEmail(
{ to: oncall, template: 'outage', data: { incidentId } },
{
scheduledAt: new Date(Date.now() + 10 * 60 * 1000),
referenceType: 'incident',
referenceId: incidentId,
guard: 'incident-still-open',
},
);
The guard receives { notificationId, channel, referenceType, referenceId, data }.
| The guard | Row | Sent | Job |
|---|---|---|---|
returns true |
sent |
yes | done |
returns false |
skipped |
no | done, no retry |
| throws | failed |
no | retried; the guard runs again |
| is not registered when the job runs | failed (Send guard not registered: …) |
no | done, no retry |
An unknown guard name is refused when scheduling (success: false), before anything is
queued. A cancelled row never reaches its guard.
Retries without duplicates (idempotency keys)
A send can be retried safely by giving it an idempotencyKey. The first send with a key
records it on the history row; a later send with the same key, channel and recipient
returns the first result without calling the provider.
const result = await sendEmail({
to: user.email,
template: 'payment-failed',
data: { invoiceId },
idempotencyKey: `invoice-${invoiceId}:payment-failed`,
});
// retry → { success: true, messageId: '<first>', deduplicated: true }
The key must come out the same on every retry. Derive it from the business event,
or generate a UUID once and store it with the job that sends. A crypto.randomUUID()
called right before each send gives every attempt a new key and deduplicates nothing.
| First send's row | A repeat with the same key |
|---|---|
sent |
returns the first result, deduplicated: true |
failed |
takes over the same row and sends again |
pending / scheduled (in flight) |
success: false, error: 'send in progress', not sent |
cancelled / skipped |
success: false, error: 'already cancelled' (…), not sent |
- Available on
sendEmail,sendSMS,sendSlack, each bulk item, andscheduleEmail/scheduleSMS(a repeat schedule returns the firstnotificationIdand queues nothing). - The key is unique per channel and recipient: one key can cover an email and an SMS, and an SMS to several numbers is deduplicated number by number.
- 1–255 characters (
MAX_IDEMPOTENCY_KEY_LENGTH). RequiresenableHistory: true; a keyed send is refused when history is off, and when the key cannot be recorded, rather than sent without protection. - A keyed send that takes over a
failedrow also takes it away from the queued job that failed it: that job's retry finds the row no longer its own and sends nothing. - A process that dies after claiming a key and before the provider answers leaves the row
pending, and the key stays blocked. The provider may already have delivered, so it is not retried automatically: after checking,markNotificationFailed(id, reason)frees it.
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}}',
},
});
Localised Templates
A template can carry content per locale. A send picks one with locale:
registerTemplate({
name: 'order-shipped',
channels: ['email', 'sms'],
defaultLocale: 'en',
locales: {
ko: { email: { subject: '{{orderId}} 주문이 발송되었습니다', text: '…' }, sms: { message: '…' } },
en: { email: { subject: 'Order {{orderId}} has shipped', text: '…' } },
},
});
await sendEmail({ to, template: 'order-shipped', data: { orderId }, locale: user.locale });
- Resolution: the exact tag (
ko-KR), then its base language (ko), thendefaultLocale, then the template's unlocalisedemail/sms/slackcontent. Tags match case-insensitively. - It resolves per channel. A locale that has no content for the channel falls through to
the next candidate. In the example, an SMS with
locale: 'en'finds noenSMS, then no unlocalised SMS, and is refused; withlocale: 'ko'it is sent in Korean. - A template with
localesthat has nothing for the channel in any candidate is refused (success: false) instead of sending empty content. A template withoutlocalesbehaves as before. - The history row records the locale whose content was sent (
nullfor unlocalised content). localeworks on every send, bulk item and schedule; a scheduled send renders in it when the job runs.
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