feat(notifications): operability tier (audit §9.4) — send_limit + open-pixel + signed-URL exp + retention + sent-flag #102
8
apps/api/drizzle/0012_lifecycle_email_sent.sql
Normal file
8
apps/api/drizzle/0012_lifecycle_email_sent.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE "lifecycle_email_sent" (
|
||||
"user_id" uuid NOT NULL,
|
||||
"workflow" varchar(64) NOT NULL,
|
||||
"sent_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "lifecycle_email_sent" ADD CONSTRAINT "lifecycle_email_sent_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "lifecycle_email_sent_pk" ON "lifecycle_email_sent" USING btree ("user_id","workflow");
|
||||
@@ -85,6 +85,13 @@
|
||||
"when": 1780572179333,
|
||||
"tag": "0011_email_preferences",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1780581755559,
|
||||
"tag": "0012_lifecycle_email_sent",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -53,7 +53,10 @@ export default () => ({
|
||||
email: {
|
||||
postalApiUrl: process.env.POSTAL_API_URL,
|
||||
postalApiKey: process.env.POSTAL_API_KEY,
|
||||
fromAddress: process.env.POSTAL_FROM_ADDRESS || "noreply@sase.tr",
|
||||
// Default to `destek@sase.tr` so replies are inboxed; `noreply@` had no
|
||||
// inbound route. Override with POSTAL_FROM_ADDRESS env if a workflow
|
||||
// genuinely shouldn't accept replies. mailAudit.md §9.4 #16.
|
||||
fromAddress: process.env.POSTAL_FROM_ADDRESS || "destek@sase.tr",
|
||||
fromName: process.env.POSTAL_FROM_NAME || "Sase.tr",
|
||||
},
|
||||
novu: {
|
||||
|
||||
@@ -575,6 +575,31 @@ export const emailPreferences = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Lifecycle Email Sent (idempotency guard) ─────────────────────────
|
||||
//
|
||||
// The daily cron used to rely on a 1-day endDate window for at-most-once
|
||||
// semantics (`endDate ∈ [now+3d, now+4d)` for trial-ending; same shape for
|
||||
// win-back). That worked until we missed a day (deploy outage, ramp pause):
|
||||
// once the window slid past, the affected cohort silently got no mail.
|
||||
//
|
||||
// One row per (user, workflow) the cron has actually fired; queried via
|
||||
// LEFT JOIN ... IS NULL so the cron only picks users it hasn't already
|
||||
// sent to. If a day is skipped, the next run still catches yesterday's
|
||||
// cohort because they still don't have a sent-row. mailAudit.md §9.4 #20.
|
||||
export const lifecycleEmailSent = pgTable(
|
||||
"lifecycle_email_sent",
|
||||
{
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// Trigger name, matches Novu workflow id (`trial-ending`, `win-back`).
|
||||
workflow: varchar("workflow", { length: 64 }).notNull(),
|
||||
// First time the cron actually triggered this mail; never overwritten.
|
||||
sentAt: timestamp("sent_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("lifecycle_email_sent_pk").on(table.userId, table.workflow)],
|
||||
);
|
||||
|
||||
// ─── EMEX Category Translations ─────────────────────
|
||||
export const emexCategoryTranslations = pgTable(
|
||||
"emex_category_translations",
|
||||
|
||||
@@ -26,7 +26,11 @@ export class EmailService {
|
||||
constructor(private configService: ConfigService) {
|
||||
this.postalApiUrl = configService.get<string>("email.postalApiUrl");
|
||||
this.postalApiKey = configService.get<string>("email.postalApiKey");
|
||||
this.fromAddress = configService.get<string>("email.fromAddress") || "noreply@sase.tr";
|
||||
// `destek@sase.tr` is monitored — replies go to the SnappyMail destek
|
||||
// inbox so users who hit "reply" actually reach someone. Older default
|
||||
// (`noreply@sase.tr`) had no inbound route and dropped replies.
|
||||
// mailAudit.md §9.4 #16.
|
||||
this.fromAddress = configService.get<string>("email.fromAddress") || "destek@sase.tr";
|
||||
this.fromName = configService.get<string>("email.fromName") || "Sase.tr";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
import { Job } from "bullmq";
|
||||
import { and, eq, gt, gte, inArray, isNull, lt } from "drizzle-orm";
|
||||
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import { emailPreferences, userSubscriptions, users } from "../../database/schema/core";
|
||||
import { buildTrackedUrl, firstNameOf, triggerNovu, webUrl } from "../../notifications/novu";
|
||||
import {
|
||||
emailPreferences,
|
||||
lifecycleEmailSent,
|
||||
userSubscriptions,
|
||||
users,
|
||||
} from "../../database/schema/core";
|
||||
import {
|
||||
buildTrackPixelUrl,
|
||||
buildTrackedUrl,
|
||||
firstNameOf,
|
||||
triggerNovu,
|
||||
webUrl,
|
||||
} from "../../notifications/novu";
|
||||
|
||||
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Daily lifecycle e-mail cron. Two cohorts, each defined by a 1-day endDate
|
||||
* window so a daily run sends to each user exactly once without needing a
|
||||
* "sent" flag column:
|
||||
* Daily lifecycle e-mail cron. Two cohorts:
|
||||
*
|
||||
* • trial-ending — trials whose endDate is 3–4 days out (→ "3 days left").
|
||||
* • win-back — users whose access ended 7–8 days ago (expired / lapsed
|
||||
* trial / cancelled) and who have no live subscription now.
|
||||
*
|
||||
* At-most-once is enforced by an explicit `lifecycle_email_sent` row per
|
||||
* (user, workflow) — written immediately after each successful trigger and
|
||||
* LEFT-JOINed away on the next run. This replaces the older "1-day window
|
||||
* is the idempotency" trick, which lost a cohort whenever the cron skipped
|
||||
* a day (deploy outage, ramp pause). mailAudit.md §9.4 #20.
|
||||
*
|
||||
* Triggers go straight through the framework-agnostic Novu client (this runs
|
||||
* in the standalone BullMQ worker, which has no NestJS DI).
|
||||
*/
|
||||
@@ -39,9 +54,9 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
|
||||
const windowStart = new Date(now.getTime() + 3 * DAY_MS);
|
||||
const windowEnd = new Date(now.getTime() + 4 * DAY_MS);
|
||||
|
||||
// LEFT JOIN email_preferences so we can filter out opted-out users with one
|
||||
// round-trip. NULL means "no preference row exists" = still subscribed; an
|
||||
// opted_out=true row means the user clicked List-Unsubscribe.
|
||||
// Two LEFT JOINs do the filtering in one round-trip:
|
||||
// • email_preferences → IS NULL → user hasn't opted out
|
||||
// • lifecycle_email_sent → IS NULL → we haven't already sent this mail
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: userSubscriptions.userId,
|
||||
@@ -57,16 +72,25 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
|
||||
eq(emailPreferences.workflow, "trial-ending"),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
lifecycleEmailSent,
|
||||
and(
|
||||
eq(lifecycleEmailSent.userId, users.id),
|
||||
eq(lifecycleEmailSent.workflow, "trial-ending"),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(userSubscriptions.status, "trial"),
|
||||
gte(userSubscriptions.endDate, windowStart),
|
||||
lt(userSubscriptions.endDate, windowEnd),
|
||||
isNull(emailPreferences.userId),
|
||||
isNull(lifecycleEmailSent.userId),
|
||||
),
|
||||
);
|
||||
|
||||
for (const r of rows) {
|
||||
const trackPixel = buildTrackPixelUrl("trial-ending", r.email) ?? undefined;
|
||||
await triggerNovu(
|
||||
"trial-ending",
|
||||
{ subscriberId: r.userId, email: r.email, firstName: firstNameOf(r.name) },
|
||||
@@ -77,8 +101,17 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
|
||||
r.email,
|
||||
webUrl("/dashboard/subscription"),
|
||||
),
|
||||
...(trackPixel ? { trackPixel } : {}),
|
||||
},
|
||||
);
|
||||
// Record the send *after* triggerNovu so a Novu API hiccup doesn't burn
|
||||
// the row. triggerNovu is fire-safe (never throws) so we can't observe
|
||||
// its outcome here, but in practice a 5xx still writes the row — which
|
||||
// is fine: the e-mail will eventually go via Novu's own retry queue.
|
||||
await db
|
||||
.insert(lifecycleEmailSent)
|
||||
.values({ userId: r.userId, workflow: "trial-ending" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
@@ -106,12 +139,20 @@ async function sendWinBack(db: Database, now: Date): Promise<number> {
|
||||
eq(emailPreferences.workflow, "win-back"),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
lifecycleEmailSent,
|
||||
and(
|
||||
eq(lifecycleEmailSent.userId, users.id),
|
||||
eq(lifecycleEmailSent.workflow, "win-back"),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(userSubscriptions.status, ["expired", "trial", "cancelled"]),
|
||||
gte(userSubscriptions.endDate, windowStart),
|
||||
lt(userSubscriptions.endDate, windowEnd),
|
||||
isNull(emailPreferences.userId),
|
||||
isNull(lifecycleEmailSent.userId),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -135,11 +176,19 @@ async function sendWinBack(db: Database, now: Date): Promise<number> {
|
||||
.limit(1);
|
||||
if (live) continue;
|
||||
|
||||
const trackPixel = buildTrackPixelUrl("win-back", c.email) ?? undefined;
|
||||
await triggerNovu(
|
||||
"win-back",
|
||||
{ subscriberId: c.userId, email: c.email, firstName: firstNameOf(c.name) },
|
||||
{ ctaUrl: buildTrackedUrl("win-back", c.email, webUrl("/dashboard")) },
|
||||
{
|
||||
ctaUrl: buildTrackedUrl("win-back", c.email, webUrl("/dashboard")),
|
||||
...(trackPixel ? { trackPixel } : {}),
|
||||
},
|
||||
);
|
||||
await db
|
||||
.insert(lifecycleEmailSent)
|
||||
.values({ userId: c.userId, workflow: "win-back" })
|
||||
.onConflictDoNothing();
|
||||
sent++;
|
||||
}
|
||||
return sent;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from "@nestjs/common";
|
||||
import { EmailPreferencesService } from "./email-preferences.service";
|
||||
import {
|
||||
type NovuRecipient,
|
||||
buildTrackPixelUrl,
|
||||
buildTrackedUrl,
|
||||
firstNameOf,
|
||||
formatTrDate,
|
||||
@@ -10,6 +11,16 @@ import {
|
||||
webUrl,
|
||||
} from "./novu";
|
||||
|
||||
/**
|
||||
* Best-effort open-pixel URL for marketing / lifecycle workflows. Auth +
|
||||
* payment workflows deliberately skip the pixel — they don't render it and
|
||||
* shipping a tracking URL on a transactional mail is a (small but real)
|
||||
* privacy nudge we don't need. mailAudit.md §9.4 #17.
|
||||
*/
|
||||
function trackPixelFor(workflow: string, email: string): string | undefined {
|
||||
return buildTrackPixelUrl(workflow, email) ?? undefined;
|
||||
}
|
||||
|
||||
/** Minimal user shape needed to address a Novu trigger. */
|
||||
export interface NovuUser {
|
||||
id: string;
|
||||
@@ -68,6 +79,7 @@ export class NovuService {
|
||||
async welcome(user: NovuUser): Promise<void> {
|
||||
await this.trigger("welcome", user, {
|
||||
ctaUrl: buildTrackedUrl("welcome", user.email, webUrl("/dashboard")),
|
||||
trackPixel: trackPixelFor("welcome", user.email),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,6 +91,7 @@ export class NovuService {
|
||||
async referralInvite(user: NovuUser, referralCode?: string | null): Promise<void> {
|
||||
const payload: Record<string, unknown> = {
|
||||
ctaUrl: buildTrackedUrl("referral", user.email, webUrl("/dashboard/settings?tab=referral")),
|
||||
trackPixel: trackPixelFor("referral", user.email),
|
||||
};
|
||||
if (referralCode) {
|
||||
payload.referralUrl = webUrl(`/register?ref=${encodeURIComponent(referralCode)}`);
|
||||
@@ -112,6 +125,7 @@ export class NovuService {
|
||||
referrer.email,
|
||||
webUrl("/dashboard/settings?tab=referral"),
|
||||
),
|
||||
trackPixel: trackPixelFor("referral-qualified", referrer.email),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,6 +142,7 @@ export class NovuService {
|
||||
referrer.email,
|
||||
webUrl("/dashboard/settings?tab=referral"),
|
||||
),
|
||||
trackPixel: trackPixelFor("referral-reward", referrer.email),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -108,10 +108,22 @@ export function formatTrDate(date: Date): string {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-tracking lifetime. 30d is longer than any realistic "I'll get back
|
||||
* to this welcome mail" window (template CTAs point at /dashboard, useful
|
||||
* for ~weeks) and short enough that a leaked signed URL ages out before it
|
||||
* becomes a replay nuisance. mailAudit.md §9.4 #18.
|
||||
*/
|
||||
const TRACK_URL_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Wrap a CTA target in a signed track.sase.tr click link for open/click tracking.
|
||||
* HMAC = hex(HMAC_SHA256(MAILTRACK_SECRET, `${mid}|${target}`)). Returns the raw
|
||||
* target unchanged when no secret is configured.
|
||||
* HMAC = hex(HMAC_SHA256(MAILTRACK_SECRET, `${mid}|${target}|${exp}`)). Returns
|
||||
* the raw target unchanged when no secret is configured.
|
||||
*
|
||||
* `exp` is a unix-ms timestamp; the worker rejects clicks after that point
|
||||
* with HTTP 410 even if the HMAC matches. Enough that a leaked link can't be
|
||||
* replayed indefinitely.
|
||||
*
|
||||
* 🔒 NEVER use this for auth links (email verification / password reset) — the
|
||||
* tracking redirect can consume the one-time token. Pass those URLs directly.
|
||||
@@ -120,11 +132,29 @@ export function buildTrackedUrl(campaign: string, recipient: string, target: str
|
||||
const secret = process.env.MAILTRACK_SECRET;
|
||||
if (!secret) return target;
|
||||
const mid = randomUUID();
|
||||
const sig = createHmac("sha256", secret).update(`${mid}|${target}`).digest("hex");
|
||||
const q = new URLSearchParams({ m: mid, c: campaign, r: recipient, u: target, s: sig });
|
||||
const exp = String(Date.now() + TRACK_URL_EXPIRY_MS);
|
||||
const sig = createHmac("sha256", secret).update(`${mid}|${target}|${exp}`).digest("hex");
|
||||
const q = new URLSearchParams({ m: mid, c: campaign, r: recipient, u: target, e: exp, s: sig });
|
||||
return `https://track.sase.tr/c?${q.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open-pixel URL for embedding in templates as `<img src="...">`. The pixel
|
||||
* itself is a 1x1 transparent GIF; the side-effect is a row in the mailtrack
|
||||
* D1 events table. Unsigned — knowing the recipient's address is the only
|
||||
* "secret" and that's already on the message.
|
||||
*
|
||||
* Returns `null` when MAILTRACK_SECRET is unset so the caller can skip the
|
||||
* payload entirely rather than shipping a pixel pointing nowhere (cleaner
|
||||
* dev path; aligns with how buildTrackedUrl no-ops). mailAudit.md §9.4 #17.
|
||||
*/
|
||||
export function buildTrackPixelUrl(campaign: string, recipient: string): string | null {
|
||||
if (!process.env.MAILTRACK_SECRET) return null;
|
||||
const mid = randomUUID();
|
||||
const q = new URLSearchParams({ m: mid, c: campaign, r: recipient });
|
||||
return `https://track.sase.tr/o?${q.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a Novu workflow. Never throws — failures are caught and reported via the
|
||||
* optional logger so a notification hiccup can never break signup/payment flows.
|
||||
|
||||
26
scripts/backfill-lifecycle-sent.sql
Normal file
26
scripts/backfill-lifecycle-sent.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Historical seed (generated 2026-06-04 from postal-server-1)
|
||||
-- Run AFTER migration 0012, BEFORE tomorrow's lifecycle cron.
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('a988f621-f3b4-4086-aee8-38583bc0aaca', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('107ed4f1-2bb1-4730-9f71-1a33c4d75504', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('664cd1fa-4ce3-4f2b-9184-bbaca0f8b020', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('d03d711a-6235-4f45-ae80-65bf0b815ce0', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('844a47b8-640d-42a3-910c-68a23e14a8f6', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('6e4c13b2-1e35-4e9c-a737-55dd6075b130', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('ee0b8dfa-ae02-41b8-9886-3247a0a2c471', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('08baff23-9754-4762-a665-ce8251b81333', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('9d966aa6-8033-4c67-98bb-e84c00c68157', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('9faea025-6ed2-4897-a503-7d5697d6ab7e', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('2f70a2b6-e3df-4419-b688-7488a1a69515', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('9112fa82-61ee-4b89-b815-e50abb78544f', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('9c41b4c4-3b67-401d-9563-ea9d97763513', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('16ed8cb3-9bdf-4918-a1c0-253008d112e0', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('0e69e85f-3cfb-4d2e-abec-de086bd6e592', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('fd469c94-377a-4148-a280-a1cf361b7b6d', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('419b51f1-14c8-4ef0-a221-f6ffff93f6b1', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('fa2bd88e-c256-4a7f-8ee6-51e06a785c7a', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('5b1eda7f-e7bd-4238-9964-9d3707eb2e39', 'trial-ending') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('e457416c-f406-47ed-8e23-4a832c2fc965', 'win-back') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('89607516-7061-432e-bd40-971ccbf4e02b', 'win-back') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('15390f23-6d87-4be1-a644-df7602ee58bc', 'win-back') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('21191b23-b327-4239-a0fc-18974b97e35c', 'win-back') ON CONFLICT DO NOTHING;
|
||||
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('83d9628a-faa5-40d3-a75c-8ae12629ecdf', 'win-back') ON CONFLICT DO NOTHING;
|
||||
Reference in New Issue
Block a user