feat(notifications): TR-only templates + name canonicalisation + MTA-STS + 2048-bit DKIM + unsubscribe (audit §9.3)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Lands the §9.3 "compliance + brand" tier of mailAudit.md as one PR. Six
changes share enough surface (notifications, shared utils, infrastructure)
that splitting them would require multiple stacked PRs.

#9 — Turkish-locale title-case for names at signup
   • New `normalizeName()` in @sase/shared, locale-aware (İ/ı pairs handled
     via toLocaleLowerCase('tr-TR') + matching toLocaleUpperCase). Hyphen-
     aware, collapses whitespace, idempotent.
   • Wired into better-auth's `user.create.before` hook so every new signup
     gets canonicalised before the row lands in Postgres.
   • 28 unit tests in packages/shared/src/index.spec.ts.
   • Backfill script at `scripts/backfill-user-names.ts` (already run
     against prod + dev — 210/402 prod users and 72/153 dev users
     canonicalised, plus 71 Novu subscribers).

#10 — Email typo correction at signup
   • New `suggestEmailFix()` in @sase/shared: exact-match typo dictionary
     for the addresses we've actually suppressed (icould.com, gmial.com,
     xn--gmail-bgd.com, …) plus Levenshtein ≤ 2 fallback against popular
     providers.
   • Inline UI hint on the register form — "Bunu mu demek istedin? <link>"
     that swaps the email on click; PostHog event tracks acceptance.

#11 — Strip EN branches (decision: TR-only)
   • 0/205 prod subscribers have locale='en' and there's no marketing in
     English — the {{#equals subscriber.locale "en"}}…{{else}}…{{/equals}}
     framework was dead code in all 10 templates.
   • Templates updated in-place (avg ~30 % smaller). Renamed
     `novu-welcome-tr.html` → `novu-welcome.html` for consistency with the
     other 9 files.
   • Novu workflow definitions in both Dev + Prod envs updated via Mongo:
     subjects collapsed to TR-only, content replaced with new HTML
     (mongodump/restore-safe).
   • App code: `NovuRecipient.locale` and `NovuUser.locale` removed; the
     `...(user.locale === "en" ? { locale: "en" } : {})` spread in NovuService
     is gone.

#12 — DKIM rotated to 2048-bit RSA
   • Postal default was 1024-bit (selector `postal-YeIm3w`). Generated new
     2048-bit key, added DNS TXT `postal-2k260604._domainkey.sase.tr`,
     atomically swapped `domains.dkim_identifier_string` +
     `dkim_private_key` in Postal MariaDB, restarted Postal SMTP.
   • Verified: outgoing welcome mail now signs with `s=postal-2k260604`
     and a 256-byte signature body (vs the previous 128-byte 1024-bit
     signature). Pubkey on DNS matches the private key.
   • OLD TXT record (`postal-YeIm3w._domainkey`) stays in DNS for ~7 days
     as a grace window for in-flight mail.

#13 — MTA-STS + TLS-RPT
   • Extended the existing mailtrack Cloudflare Worker to also serve
     `mta-sts.sase.tr/.well-known/mta-sts.txt` (`mode: enforce, mx:
     mx.postal.sase.tr, max_age: 604800`). Workers Domain bound to the
     mailtrack service via Cloudflare API.
   • DNS:
       `_mta-sts.sase.tr`        TXT  "v=STSv1; id=20260604111347"
       `_smtp._tls.sase.tr`      TXT  "v=TLSRPTv1; rua=mailto:dmarc@sase.tr"
   • Verified policy fetch returns 200 with the expected body; cert valid
     (sase.tr SAN issued by GTS).

#14 — Unsubscribe preferences + RFC 8058 one-click endpoint
   • New `email_preferences` table (migration 0011) keyed
     (user_id, workflow), captures source for audit
     (one_click / manual_link / settings_page).
   • New `UnsubscribeController` at `/api/email/unsubscribe`:
       - POST: Gmail/Yahoo one-click bot path (200 fast)
       - GET:  human-visit, renders a Turkish confirmation page
     Both validate an HMAC-SHA256(`userId|workflow`) token under
     `UNSUBSCRIBE_SECRET` — stateless, no DB lookup to validate, secret
     rotation invalidates all outstanding tokens.
   • `triggerNovu()` now mints the per-call `overrides.email.headers`:
       `List-Unsubscribe: <https://…?u=&w=&t=>, <mailto:unsubscribe@…>`
       `List-Unsubscribe-Post: List-Unsubscribe=One-Click`
     Auth + payment workflows opt out via NO_UNSUBSCRIBE_WORKFLOWS so the
     unsubscribe URL never appears on transactional mail.
   • `NovuService.trigger()` pre-flight-checks `isOptedOut()` and skips the
     trigger entirely if the user opted out. Fail-open on DB error so a
     transient blip can't swallow auth mail.
   • `lifecycle-email.processor.ts` (standalone BullMQ worker — no NestJS
     DI) does the same check inline via a LEFT JOIN on
     `email_preferences WHERE opted_out IS NULL`.
   • Coolify env wired in both Prod and Dev apps:
       `UNSUBSCRIBE_SECRET` (32-byte hex, distinct per env)
       `UNSUBSCRIBE_URL_BASE` = `https://(dev.)sase.tr/api/email/unsubscribe`

## Companion sibling changes (already applied, NOT in this PR)

- Cloudflare worker `mailtrack` redeployed with mta-sts.sase.tr custom domain.
- Postal MariaDB `domains.dkim_identifier_string` + `dkim_private_key`
  updated to the new 2k260604 selector (live since 2026-06-04 11:18).
- `postal-2k260604._domainkey.sase.tr` TXT record live at Cloudflare.
- `_mta-sts.sase.tr` + `_smtp._tls.sase.tr` TXT records live at Cloudflare.
- Novu Mongo notification + message templates updated to TR-only.
- 282 user names canonicalised across prod + dev + Novu subscribers.

## Verification snapshot

- Postal raw_headers (ID 157, post-rotation): `s=postal-2k260604` + 256-byte b=
- `dig +short TXT _mta-sts.sase.tr @1.1.1.1` ⇒ live id=20260604111347
- `curl https://mta-sts.sase.tr/.well-known/mta-sts.txt` ⇒ 200 with policy
- 28 unit tests (normalizeName + suggestEmailFix) all green via Node sanity.

## Deploy notes

- Re-run `pnpm db:generate` to regenerate the drizzle snapshot for 0011
  (added the journal entry manually because no drizzle-kit on this box).
- Run `pnpm tsx scripts/backfill-user-names.ts --apply` against any DB not
  yet canonicalised (already done for prod + dev today).
- The host-side Novu nodemailer-headers patch at
  `postal/novu-patches/apply-headers-patch.sh` must be re-run after every
  Novu container redeploy or the List-Unsubscribe header is silently dropped
  before reaching Postal (see audit §9.1 #3 for the upstream cause).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude (audit §9.3)
2026-06-04 14:28:39 +03:00
parent 294e365bbd
commit 327d698945
15 changed files with 841 additions and 19 deletions

View File

@@ -0,0 +1,12 @@
CREATE TABLE "email_preferences" (
"user_id" uuid NOT NULL,
"workflow" varchar(64) NOT NULL,
"opted_out" boolean DEFAULT true NOT NULL,
"source" varchar(32) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "email_preferences" ADD CONSTRAINT "email_preferences_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 "email_preferences_pk" ON "email_preferences" USING btree ("user_id","workflow");--> statement-breakpoint
CREATE INDEX "email_preferences_workflow_idx" ON "email_preferences" USING btree ("workflow");

View File

@@ -78,6 +78,13 @@
"when": 1780281600000,
"tag": "0010_dedupe_parts",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1780572179333,
"tag": "0011_email_preferences",
"breakpoints": true
}
]
}

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { generateReferralCode } from "@sase/shared";
import { generateReferralCode, normalizeName } from "@sase/shared";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { captcha } from "better-auth/plugins";
@@ -139,9 +139,17 @@ export function createAuth(
user: {
create: {
before: async (userData) => {
// Postal logs show signup names arrive in every casing (`mehmet`,
// `MEHMET`, `İLKER`, `OTO`) and we render them straight into mail
// subjects — `Sase.tr'ye hoş geldin, mehmet` looks unprofessional.
// Canonicalise here so every downstream consumer (Novu subscriber,
// Stripe customer, dashboard greeting) sees one consistent form.
// Turkish-locale-aware (İ/ı handled).
const cleanedName = normalizeName(userData.name);
return {
data: {
...userData,
...(cleanedName ? { name: cleanedName } : {}),
referralCode: await generateUniqueReferralCode(db),
},
};

View File

@@ -539,6 +539,42 @@ export const blogPosts = pgTable(
],
);
// ─── Email Preferences (per-workflow unsubscribe state) ────────────────
//
// One row per (user, workflow) the user has explicitly opted out of. Absent
// rows mean "still subscribed" — we don't pre-seed because the default is
// always opt-in (with a List-Unsubscribe header in every mail) and creating
// per-user rows at signup would 10x the table size for no behaviour change.
//
// The `workflow` column maps to Novu trigger names (`welcome`, `win-back`,
// `referral`, `trial-ending`, `referral-qualified`, `referral-reward`).
// Auth flows (`email-verification`, `password-reset`, `payment-success`,
// `payment-failed`) are explicitly NOT respectful of this table — they're
// transactional and must reach the user.
export const emailPreferences = pgTable(
"email_preferences",
{
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
workflow: varchar("workflow", { length: 64 }).notNull(),
// Always `true` while a row exists — column kept for future tri-state
// (subscribed / unsubscribed / digest-only). Row presence is the
// canonical signal today.
optedOut: boolean("opted_out").default(true).notNull(),
// Audit trail: which surface flipped the flag (one_click email,
// settings_page, admin_panel, …). Helps with abuse / wrong-user
// unsub investigations.
source: varchar("source", { length: 32 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("email_preferences_pk").on(table.userId, table.workflow),
index("email_preferences_workflow_idx").on(table.workflow),
],
);
// ─── EMEX Category Translations ─────────────────────
export const emexCategoryTranslations = pgTable(
"emex_category_translations",

View File

@@ -1,7 +1,7 @@
import { Job } from "bullmq";
import { and, eq, gt, gte, inArray, lt } from "drizzle-orm";
import { and, eq, gt, gte, inArray, isNull, lt } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { userSubscriptions, users } from "../../database/schema/core";
import { emailPreferences, userSubscriptions, users } from "../../database/schema/core";
import { buildTrackedUrl, firstNameOf, triggerNovu, webUrl } from "../../notifications/novu";
type Database = PostgresJsDatabase<Record<string, unknown>>;
@@ -39,6 +39,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.
const rows = await db
.select({
userId: userSubscriptions.userId,
@@ -47,11 +50,19 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
})
.from(userSubscriptions)
.innerJoin(users, eq(userSubscriptions.userId, users.id))
.leftJoin(
emailPreferences,
and(
eq(emailPreferences.userId, users.id),
eq(emailPreferences.workflow, "trial-ending"),
),
)
.where(
and(
eq(userSubscriptions.status, "trial"),
gte(userSubscriptions.endDate, windowStart),
lt(userSubscriptions.endDate, windowEnd),
isNull(emailPreferences.userId),
),
);
@@ -88,11 +99,19 @@ async function sendWinBack(db: Database, now: Date): Promise<number> {
})
.from(userSubscriptions)
.innerJoin(users, eq(userSubscriptions.userId, users.id))
.leftJoin(
emailPreferences,
and(
eq(emailPreferences.userId, users.id),
eq(emailPreferences.workflow, "win-back"),
),
)
.where(
and(
inArray(userSubscriptions.status, ["expired", "trial", "cancelled"]),
gte(userSubscriptions.endDate, windowStart),
lt(userSubscriptions.endDate, windowEnd),
isNull(emailPreferences.userId),
),
);

View File

@@ -0,0 +1,103 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { Inject, Injectable, Logger } from "@nestjs/common";
import { and, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import * as schema from "../database/schema/core";
/**
* Workflows the user can opt out of. Auth + payment flows are deliberately
* NOT in this set — they're transactional and must reach the user (the
* compliance argument is the same as Stripe's "we still send receipts even
* if you unsubscribed from marketing").
*/
export const OPTIONAL_WORKFLOWS = new Set<string>([
"welcome",
"trial-ending",
"win-back",
"referral",
"referral-qualified",
"referral-reward",
]);
/**
* Stateless HMAC token in the List-Unsubscribe URL — no DB lookup needed to
* validate. Anyone holding the token can opt out, but only the server can
* mint one (the secret never leaves the API). Rotating UNSUBSCRIBE_SECRET
* invalidates every outstanding token, which is a useful nuke-button if a
* mail leak ever surfaces.
*/
export function signUnsubscribeToken(secret: string, userId: string, workflow: string): string {
return createHmac("sha256", secret).update(`${userId}|${workflow}`).digest("hex");
}
export function verifyUnsubscribeToken(
secret: string,
userId: string,
workflow: string,
token: string,
): boolean {
if (!secret) return false;
if (!/^[0-9a-f]+$/i.test(token) || token.length % 2 !== 0) return false;
const expected = signUnsubscribeToken(secret, userId, workflow);
if (expected.length !== token.length) return false;
try {
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(token, "hex"));
} catch {
return false;
}
}
@Injectable()
export class EmailPreferencesService {
private readonly logger = new Logger(EmailPreferencesService.name);
constructor(@Inject(DATABASE) private readonly db: Database) {}
/** True if the user has explicitly opted out of `workflow`. */
async isOptedOut(userId: string, workflow: string): Promise<boolean> {
if (!OPTIONAL_WORKFLOWS.has(workflow)) return false;
const [row] = await this.db
.select({ optedOut: schema.emailPreferences.optedOut })
.from(schema.emailPreferences)
.where(
and(
eq(schema.emailPreferences.userId, userId),
eq(schema.emailPreferences.workflow, workflow),
),
)
.limit(1);
return row?.optedOut === true;
}
/**
* Mark a (user, workflow) pair as opted-out. Idempotent — re-clicking the
* unsubscribe link doesn't error, just no-ops the row's updated_at.
* `source` is captured for audit (`one_click`, `settings_page`,
* `admin_panel`, …).
*/
async optOut(userId: string, workflow: string, source: string): Promise<void> {
if (!OPTIONAL_WORKFLOWS.has(workflow)) {
this.logger.warn(`refusing optOut on non-optional workflow ${workflow}`);
return;
}
await this.db
.insert(schema.emailPreferences)
.values({ userId, workflow, optedOut: true, source })
.onConflictDoUpdate({
target: [schema.emailPreferences.userId, schema.emailPreferences.workflow],
set: { optedOut: true, source, updatedAt: new Date() },
});
}
/** Re-subscribe — used by the dashboard settings UI when a user toggles back on. */
async optIn(userId: string, workflow: string): Promise<void> {
await this.db
.delete(schema.emailPreferences)
.where(
and(
eq(schema.emailPreferences.userId, userId),
eq(schema.emailPreferences.workflow, workflow),
),
);
}
}

View File

@@ -1,14 +1,20 @@
import { Global, Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module";
import { EmailPreferencesService } from "./email-preferences.service";
import { NovuService } from "./novu.service";
import { UnsubscribeController } from "./unsubscribe.controller";
/**
* Global so any module can inject NovuService without re-importing — mirrors
* EmailModule. The standalone BullMQ worker does not use this module; it calls
* the framework-agnostic helpers in ./novu directly.
* Global so any module can inject NovuService / EmailPreferencesService
* without re-importing — mirrors EmailModule. The standalone BullMQ worker
* does not use this module; it calls the framework-agnostic helpers in
* ./novu directly.
*/
@Global()
@Module({
providers: [NovuService],
exports: [NovuService],
imports: [DatabaseModule],
controllers: [UnsubscribeController],
providers: [NovuService, EmailPreferencesService],
exports: [NovuService, EmailPreferencesService],
})
export class NotificationsModule {}

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from "@nestjs/common";
import { EmailPreferencesService } from "./email-preferences.service";
import {
type NovuRecipient,
buildTrackedUrl,
@@ -14,8 +15,6 @@ export interface NovuUser {
id: string;
email: string;
name?: string | null;
/** "en" → English template; anything else / undefined → Turkish (default). */
locale?: string | null;
}
/**
@@ -30,17 +29,38 @@ export interface NovuUser {
export class NovuService {
private readonly logger = new Logger(NovuService.name);
constructor(private readonly preferences: EmailPreferencesService) {}
private to(user: NovuUser): NovuRecipient {
return {
subscriberId: user.id,
email: user.email,
firstName: firstNameOf(user.name),
// No locale column yet → Turkish default. Set "en" here once stored.
...(user.locale === "en" ? { locale: "en" } : {}),
};
}
private trigger(name: string, user: NovuUser, payload: Record<string, unknown> = {}) {
/**
* Skip the trigger if the user has opted out of this workflow.
* Pre-flight check is best-effort — a DB hiccup must not block the trigger
* (auth+payment workflows must still fire), so on lookup failure we log
* and send anyway.
*/
private async shouldSend(userId: string, name: string): Promise<boolean> {
try {
const optedOut = await this.preferences.isOptedOut(userId, name);
if (optedOut) {
this.logger.log(`[novu] skipped "${name}" → user=${userId} (opted out)`);
return false;
}
return true;
} catch (err) {
this.logger.warn(`[novu] preference check failed for "${name}": ${String(err)}`);
return true; // fail-open so a DB blip doesn't silently swallow mail
}
}
private async trigger(name: string, user: NovuUser, payload: Record<string, unknown> = {}) {
if (!(await this.shouldSend(user.id, name))) return;
return triggerNovu(name, this.to(user), payload, this.logger);
}

View File

@@ -15,8 +15,6 @@ export interface NovuRecipient {
email: string;
/** First name for greeting (`Merhaba {firstName}`). */
firstName?: string;
/** "en" → English template; anything else / undefined → Turkish (default). */
locale?: string;
}
export type NovuPayload = Record<string, unknown>;
@@ -28,6 +26,68 @@ const NOVU_API_URL = (process.env.NOVU_API_URL || "https://api.bildirim.semih.ai
const APP_PUBLIC_URL = (process.env.APP_PUBLIC_URL || "https://sase.tr").replace(/\/+$/, "");
const TRIGGER_TIMEOUT_MS = 10_000;
/**
* Mailbox we expose as the List-Unsubscribe mailto: target. Receives any
* "please unsubscribe me" replies — Postal has a route on `unsubscribe@sase.tr`
* (audit §9.1) forwarding to destek's SnappyMail so the team sees them.
*/
const UNSUBSCRIBE_EMAIL = process.env.UNSUBSCRIBE_EMAIL || "unsubscribe@sase.tr";
/**
* HTTPS one-click endpoint base. Defaults to `<APP_PUBLIC_URL>/api/email/unsubscribe`
* which is where UnsubscribeController lives. Empty string disables the HTTPS
* variant (mailto-only header), which is what we want until UNSUBSCRIBE_SECRET
* is configured.
*/
const UNSUBSCRIBE_URL_BASE =
process.env.UNSUBSCRIBE_URL_BASE || `${APP_PUBLIC_URL}/api/email/unsubscribe`;
/**
* HMAC secret for stateless unsubscribe tokens. Must be set in prod for the
* HTTPS variant to mint valid tokens — when unset, we ship the mailto: header
* only (still RFC-2369-compliant, satisfies Yahoo, partial credit on Gmail).
*/
const UNSUBSCRIBE_SECRET = process.env.UNSUBSCRIBE_SECRET || "";
/**
* Auth + payment flows where we MUST NOT advertise an unsubscribe link —
* privacy-proxy bots sometimes pre-fetch List-Unsubscribe URLs and we don't
* want token consumption for the verify/reset case, and we don't want to
* suppress receipt/dunning mail at all.
*/
const NO_UNSUBSCRIBE_WORKFLOWS = new Set<string>([
"email-verification",
"password-reset",
"payment-success",
"payment-failed",
]);
function buildUnsubscribeHeaders(
workflow: string,
subscriberId: string,
): Record<string, string> {
if (NO_UNSUBSCRIBE_WORKFLOWS.has(workflow)) return {};
const targets: string[] = [];
if (UNSUBSCRIBE_URL_BASE && UNSUBSCRIBE_SECRET) {
const token = createHmac("sha256", UNSUBSCRIBE_SECRET)
.update(`${subscriberId}|${workflow}`)
.digest("hex");
const q = new URLSearchParams({ u: subscriberId, w: workflow, t: token });
targets.push(`<${UNSUBSCRIBE_URL_BASE}?${q.toString()}>`);
}
targets.push(
`<mailto:${UNSUBSCRIBE_EMAIL}?subject=unsubscribe%3A${encodeURIComponent(workflow)}>`,
);
const headers: Record<string, string> = { "List-Unsubscribe": targets.join(", ") };
// RFC 8058 one-click — only assert when an HTTPS endpoint is wired; Gmail
// will probe the HTTPS target with POST when this header is present, so
// gate it behind both env vars being set.
if (UNSUBSCRIBE_URL_BASE && UNSUBSCRIBE_SECRET) {
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
}
return headers;
}
/** Build an absolute URL on the public marketing site (e.g. webUrl("/dashboard")). */
export function webUrl(path: string): string {
if (/^https?:\/\//i.test(path)) return path;
@@ -84,16 +144,27 @@ export async function triggerNovu(
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRIGGER_TIMEOUT_MS);
// Bulk-sender compliance (Gmail/Yahoo Feb-2024) — see buildUnsubscribeHeaders.
// Auth + payment workflows opt out via NO_UNSUBSCRIBE_WORKFLOWS. Header reaches
// Postal only AFTER the host-side Novu NodemailerProvider patch is applied —
// see postal/novu-patches/apply-headers-patch.sh.
const unsubHeaders = buildUnsubscribeHeaders(name, to.subscriberId);
const body: Record<string, unknown> = { name, to, payload };
if (Object.keys(unsubHeaders).length > 0) {
body.overrides = { email: { headers: unsubHeaders } };
}
try {
const res = await fetch(`${NOVU_API_URL}/v1/events/trigger`, {
method: "POST",
headers: { Authorization: `ApiKey ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ name, to, payload }),
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
logger.error(`[novu] trigger "${name}" failed: HTTP ${res.status} ${body.slice(0, 300)}`);
const errBody = await res.text().catch(() => "");
logger.error(
`[novu] trigger "${name}" failed: HTTP ${res.status} ${errBody.slice(0, 300)}`,
);
return;
}
logger.log(`[novu] triggered "${name}" → ${to.email}`);

View File

@@ -0,0 +1,155 @@
import { Body, Controller, Get, Logger, Post, Query, Res } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Throttle } from "@nestjs/throttler";
import type { Response } from "express";
import { Public } from "../common/decorators/public.decorator";
import {
EmailPreferencesService,
OPTIONAL_WORKFLOWS,
verifyUnsubscribeToken,
} from "./email-preferences.service";
/**
* RFC 8058 one-click + manual unsubscribe endpoint.
*
* POST /api/email/unsubscribe?u=USERID&w=WORKFLOW&t=HMAC
* body: `List-Unsubscribe=One-Click` (Gmail/Yahoo bot path; must return 200
* fast). The `List-Unsubscribe-Post` header in outgoing mail tells the bot
* to send this exact POST.
*
* GET /api/email/unsubscribe?u=USERID&w=WORKFLOW&t=HMAC
* Human visit (mail client surfaced the URL as a clickable link). We mark
* the row opted-out AND render a tiny HTML confirmation page so the user
* doesn't see an empty 200.
*
* The token is an HMAC of `userId|workflow` under UNSUBSCRIBE_SECRET — see
* email-preferences.service.ts. Stateless; no DB lookup needed to validate.
* mailAudit.md §9.3 #14.
*/
@Controller("email/unsubscribe")
export class UnsubscribeController {
private readonly logger = new Logger(UnsubscribeController.name);
private readonly secret: string;
constructor(
private readonly preferences: EmailPreferencesService,
config: ConfigService,
) {
this.secret =
config.get<string>("UNSUBSCRIBE_SECRET") || process.env.UNSUBSCRIBE_SECRET || "";
if (!this.secret) {
this.logger.warn(
"UNSUBSCRIBE_SECRET is unset — all one-click requests will be rejected",
);
}
}
/** RFC 8058 one-click. Must respond 200 fast — Gmail/Yahoo timeout aggressively. */
@Post()
@Public()
// Slightly higher than user-facing throttles because mail clients sometimes
// probe the URL multiple times during inbox scan.
@Throttle({ default: { limit: 20, ttl: 600_000 } })
async oneClick(
@Query("u") userId: string,
@Query("w") workflow: string,
@Query("t") token: string,
@Body() _body: unknown,
@Res({ passthrough: true }) res: Response,
): Promise<{ ok: boolean }> {
const ok = await this.applyOptOut(userId, workflow, token, "one_click");
res.status(ok ? 200 : 400);
return { ok };
}
/**
* Human-visit path. Same validation as POST; on success returns a minimal
* HTML confirmation page (or a "link expired" page on invalid token).
*/
@Get()
@Public()
@Throttle({ default: { limit: 10, ttl: 600_000 } })
async render(
@Query("u") userId: string,
@Query("w") workflow: string,
@Query("t") token: string,
@Res() res: Response,
): Promise<void> {
const ok = await this.applyOptOut(userId, workflow, token, "manual_link");
res.status(ok ? 200 : 400).type("html").send(renderPage(ok, workflow));
}
/** Shared validation + DB update. Returns false on bad token / bad workflow. */
private async applyOptOut(
userId: string,
workflow: string,
token: string,
source: "one_click" | "manual_link",
): Promise<boolean> {
if (!userId || !workflow || !token) return false;
if (!OPTIONAL_WORKFLOWS.has(workflow)) {
this.logger.warn(`unsubscribe rejected — non-optional workflow ${workflow}`);
return false;
}
if (!verifyUnsubscribeToken(this.secret, userId, workflow, token)) {
this.logger.warn(`unsubscribe rejected — invalid token (workflow=${workflow})`);
return false;
}
try {
await this.preferences.optOut(userId, workflow, source);
this.logger.log(`opt-out: user=${userId} workflow=${workflow} source=${source}`);
return true;
} catch (err) {
this.logger.error(`unsubscribe DB error: ${String(err)}`);
return false;
}
}
}
const WORKFLOW_LABELS: Record<string, string> = {
welcome: "Hoş geldin maili",
"trial-ending": "Deneme bitiş hatırlatması",
"win-back": "Geri kazanma maili",
referral: "Davet hatırlatması",
"referral-qualified": "Davet bildirimleri",
"referral-reward": "Ödül bildirimleri",
};
/**
* Plain-HTML response — kept dependency-free (no template engine) so it works
* even when the SPA isn't reachable. Same wordmark/colours as the email
* footers so the user knows it's us.
*/
function renderPage(ok: boolean, workflow: string): string {
const label = WORKFLOW_LABELS[workflow] || workflow;
if (!ok) {
return /* html */ `<!doctype html><meta charset="utf-8">
<title>Bağlantı geçersiz — Sase.tr</title>
<body style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;background:#f4f5f7;color:#1a1a1a;margin:0;padding:48px 16px;">
<main style="max-width:540px;margin:0 auto;background:#fff;border-radius:14px;padding:36px 32px;">
<div style="font-size:22px;font-weight:700;letter-spacing:-0.4px;">Sase.tr</div>
<h1 style="font-size:20px;margin:22px 0 14px;">Bağlantı geçersiz veya süresi dolmuş</h1>
<p style="color:#4a4a4a;line-height:1.6;">Bu abonelikten çık bağlantısı tanınmadı. Daha yeni bir e-postadaki bağlantıyı dener misin?</p>
<p style="color:#777;font-size:14px;margin-top:24px;">Yardım için <a href="mailto:destek@sase.tr" style="color:#2563eb;">destek@sase.tr</a> ile iletişime geç.</p>
</main></body>`;
}
return /* html */ `<!doctype html><meta charset="utf-8">
<title>Abonelikten çıkıldı — Sase.tr</title>
<body style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;background:#f4f5f7;color:#1a1a1a;margin:0;padding:48px 16px;">
<main style="max-width:540px;margin:0 auto;background:#fff;border-radius:14px;padding:36px 32px;">
<div style="font-size:22px;font-weight:700;letter-spacing:-0.4px;">Sase.tr</div>
<h1 style="font-size:20px;margin:22px 0 14px;">Abonelikten çıkıldı</h1>
<p style="color:#4a4a4a;line-height:1.6;">Artık <strong>${escapeHtml(label)}</strong> almayacaksın. Hesabınla ilgili önemli bilgilendirme mailleri (e-posta doğrulama, ödeme bildirimleri) gelmeye devam eder.</p>
<p style="color:#777;font-size:14px;margin-top:24px;">Fikrini değiştirirsen ayarlar &gt; bildirimler sayfasından geri açabilirsin.</p>
<p style="margin-top:22px;"><a href="https://sase.tr/dashboard/settings/notifications" style="display:inline-block;background:#111;color:#fff;text-decoration:none;padding:12px 26px;border-radius:8px;font-weight:600;">Ayarları aç</a></p>
</main></body>`;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}