Skip to content

Email Tracking

Notification emails include tracking tokens for open detection, click tracking, and one-click unsubscribe. Tracking is implemented via the notification API routes and integrated with Resend webhooks.

Source: src/lib/notifications/email-tracking.ts, src/api/routes/notifications/index.ts

Architecture

Tracking Tokens

Tokens are cryptographically random 24-byte values encoded as base64url (no padding). Each email generates three tokens:

TypePurposeResponse
openDetect when email is opened1x1 transparent GIF
clickTrack link clicks with redirectHTTP 302 to destination URL
unsubscribeOne-click preference updateHTML confirmation page

Token Schema

typescript
interface TrackingToken {
	token: string; // 24-byte random, base64url
	delivery_id: string; // Associated delivery record
	token_type: 'open' | 'click' | 'unsubscribe';
	destination_url?: string; // For click tokens: original link
	metadata?: {
		notification_type?: string; // For unsubscribe: which preference to disable
	};
	consumed_at?: string; // Set when token is used
	expires_at: string; // 30-day expiry
	created_at: string;
}

Token Lifecycle

  1. Created during email rendering (3 tokens per email)
  2. Stored in notification_email_tracking_tokens table
  3. Consumed on first use (consumed_at set, prevents replay)
  4. Expired after 30 days (cleaned up by scheduler worker)

Tracking URLs

URLs are constructed using the app's base URL:

Open:        /api/notifications/email/open/{token}
Click:       /api/notifications/email/click/{token}
Unsubscribe: /api/notifications/email/unsubscribe/{token}

URL Helpers

typescript
import {
	buildNotificationEmailTrackingPath,
	buildNotificationEmailTrackingUrl
} from '$lib/notifications/email-tracking';

// Relative path
buildNotificationEmailTrackingPath('open', token);
// → '/api/notifications/email/open/abc123...'

// Full URL
buildNotificationEmailTrackingUrl('click', token, 'https://app.podcasterplus.com');
// → 'https://app.podcasterplus.com/api/notifications/email/click/abc123...'

Open Tracking

When an email client loads images, it requests the tracking pixel:

Endpoint: GET /api/notifications/email/open/:token

  1. Validate token exists, is open type, not expired
  2. Mark token as consumed (consumed_at = now)
  3. Update delivery status to opened (with opened_at timestamp)
  4. Return 1x1 transparent GIF
typescript
// Pixel response helper
createNotificationTrackingPixelResponse();
// → Response with transparent GIF, correct headers, no-cache

Tracking Limitations

Email open tracking relies on image loading, which is blocked by some email clients. Use Resend webhooks as a complementary signal.

Click Tracking

Email links are wrapped to pass through the click tracker:

Endpoint: GET /api/notifications/email/click/:token

  1. Validate token exists, is click type, has destination_url, not expired
  2. Mark token as consumed
  3. Update delivery status to clicked (with clicked_at timestamp)
  4. HTTP 302 redirect to destination_url
  5. Falls back to app homepage if destination URL is missing

Unsubscribe

One-click unsubscribe disables the specific notification type:

Endpoint: GET /api/notifications/email/unsubscribe/:token

  1. Validate token exists, is unsubscribe type, not expired
  2. Extract notification_type from token metadata
  3. Map notification type to preference key (e.g., booking.requestedemail_new_booking)
  4. Update user_profiles.notification_preferences to set that key to false
  5. Mark token as consumed
  6. Return HTML confirmation page

The unsubscribe page is a self-contained HTML page rendered by renderNotificationUnsubscribeHtml().

Resend Webhooks

Resend sends webhook events for email lifecycle, providing server-side tracking that complements token-based tracking:

Endpoint: POST /api/notifications/webhooks/resend

Webhook Events

Resend EventDelivery StatusTimestamp Field
email.sentsentsent_at
email.delivereddelivereddelivered_at
email.openedopenedopened_at
email.clickedclickedclicked_at
email.bouncedbouncedfailed_at
email.complainedfailedfailed_at
email.failedfailedfailed_at

Status Hierarchy

Delivery status never downgrades. The chooseDeliveryStatus() function enforces this order:

pending < queued < processing < sent < delivered < opened < clicked

If the current status is opened and a delivered webhook arrives (late), the status stays opened.

Webhook Verification

Webhooks are verified using Resend's Svix-based signature:

svix-id: msg_xxx
svix-timestamp: 1234567890
svix-signature: v1,base64signature

The RESEND_WEBHOOK_SECRET environment variable is required for signature verification.

Rate Limiting

The webhook endpoint is rate-limited to 300 requests per minute per IP to prevent abuse.

Email Renderer Integration

When the email channel executor renders an email, it:

  1. Creates three tracking tokens (open, click, unsubscribe) via the store
  2. Builds tracking URLs using the app's base URL
  3. Passes URLs to the email renderer:
    • Open pixel: Injected as an <img> tag at the bottom of the email
    • Click wrapper: CTA button links are wrapped through the click tracker
    • Unsubscribe link: Added to the email footer
  4. Resend also receives tags (delivery_id, notification_id, notification_type) for webhook correlation

Token Cleanup

Expired tokens (older than 30 days) are automatically cleaned up by the notification-scheduler worker during each cron run, in batches of 100.

Internal documentation - Not for public distribution