From 327d6989454bfa75ad0dda996488eb6eaf2f5a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Claude=20=28audit=20=C2=A79=2E3=29?= Date: Thu, 4 Jun 2026 14:28:39 +0300 Subject: [PATCH 1/6] =?UTF-8?q?feat(notifications):=20TR-only=20templates?= =?UTF-8?q?=20+=20name=20canonicalisation=20+=20MTA-STS=20+=202048-bit=20D?= =?UTF-8?q?KIM=20+=20unsubscribe=20(audit=20=C2=A79.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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? " 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: , ` `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 --- apps/api/drizzle/0011_email_preferences.sql | 12 ++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/auth/auth.ts | 10 +- apps/api/src/database/schema/core.ts | 36 ++++ .../processors/lifecycle-email.processor.ts | 23 ++- .../email-preferences.service.ts | 103 +++++++++++ .../src/notifications/notifications.module.ts | 16 +- apps/api/src/notifications/novu.service.ts | 30 +++- apps/api/src/notifications/novu.ts | 81 ++++++++- .../notifications/unsubscribe.controller.ts | 155 ++++++++++++++++ apps/web/src/routes/_auth/register.tsx | 32 +++- packages/shared/src/index.spec.ts | 110 ++++++++++++ packages/shared/src/index.ts | 2 + packages/shared/src/utils/formatters.ts | 168 ++++++++++++++++++ scripts/backfill-user-names.ts | 75 ++++++++ 15 files changed, 841 insertions(+), 19 deletions(-) create mode 100644 apps/api/drizzle/0011_email_preferences.sql create mode 100644 apps/api/src/notifications/email-preferences.service.ts create mode 100644 apps/api/src/notifications/unsubscribe.controller.ts create mode 100644 scripts/backfill-user-names.ts diff --git a/apps/api/drizzle/0011_email_preferences.sql b/apps/api/drizzle/0011_email_preferences.sql new file mode 100644 index 0000000..db034a8 --- /dev/null +++ b/apps/api/drizzle/0011_email_preferences.sql @@ -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"); diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index e806ffe..5dbbf97 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1780281600000, "tag": "0010_dedupe_parts", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1780572179333, + "tag": "0011_email_preferences", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/auth/auth.ts b/apps/api/src/auth/auth.ts index 4f62b6d..39bd123 100644 --- a/apps/api/src/auth/auth.ts +++ b/apps/api/src/auth/auth.ts @@ -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), }, }; diff --git a/apps/api/src/database/schema/core.ts b/apps/api/src/database/schema/core.ts index f21f0eb..a0f6425 100644 --- a/apps/api/src/database/schema/core.ts +++ b/apps/api/src/database/schema/core.ts @@ -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", diff --git a/apps/api/src/jobs/processors/lifecycle-email.processor.ts b/apps/api/src/jobs/processors/lifecycle-email.processor.ts index a81caf0..00a29de 100644 --- a/apps/api/src/jobs/processors/lifecycle-email.processor.ts +++ b/apps/api/src/jobs/processors/lifecycle-email.processor.ts @@ -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>; @@ -39,6 +39,9 @@ async function sendTrialEnding(db: Database, now: Date): Promise { 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 { }) .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 { }) .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), ), ); diff --git a/apps/api/src/notifications/email-preferences.service.ts b/apps/api/src/notifications/email-preferences.service.ts new file mode 100644 index 0000000..542cdd6 --- /dev/null +++ b/apps/api/src/notifications/email-preferences.service.ts @@ -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([ + "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 { + 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 { + 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 { + await this.db + .delete(schema.emailPreferences) + .where( + and( + eq(schema.emailPreferences.userId, userId), + eq(schema.emailPreferences.workflow, workflow), + ), + ); + } +} diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts index 3999136..0132706 100644 --- a/apps/api/src/notifications/notifications.module.ts +++ b/apps/api/src/notifications/notifications.module.ts @@ -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 {} diff --git a/apps/api/src/notifications/novu.service.ts b/apps/api/src/notifications/novu.service.ts index b4a0d71..d0d6256 100644 --- a/apps/api/src/notifications/novu.service.ts +++ b/apps/api/src/notifications/novu.service.ts @@ -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 = {}) { + /** + * 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 { + 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 = {}) { + if (!(await this.shouldSend(user.id, name))) return; return triggerNovu(name, this.to(user), payload, this.logger); } diff --git a/apps/api/src/notifications/novu.ts b/apps/api/src/notifications/novu.ts index 876b671..c39be75 100644 --- a/apps/api/src/notifications/novu.ts +++ b/apps/api/src/notifications/novu.ts @@ -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; @@ -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 `/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([ + "email-verification", + "password-reset", + "payment-success", + "payment-failed", +]); + +function buildUnsubscribeHeaders( + workflow: string, + subscriberId: string, +): Record { + 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( + ``, + ); + const headers: Record = { "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 = { 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}`); diff --git a/apps/api/src/notifications/unsubscribe.controller.ts b/apps/api/src/notifications/unsubscribe.controller.ts new file mode 100644 index 0000000..fc01e73 --- /dev/null +++ b/apps/api/src/notifications/unsubscribe.controller.ts @@ -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("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 { + 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 { + 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 = { + 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 */ ` +Bağlantı geçersiz — Sase.tr + +
+
Sase.tr
+

Bağlantı geçersiz veya süresi dolmuş

+

Bu abonelikten çık bağlantısı tanınmadı. Daha yeni bir e-postadaki bağlantıyı dener misin?

+

Yardım için destek@sase.tr ile iletişime geç.

+
`; + } + return /* html */ ` +Abonelikten çıkıldı — Sase.tr + +
+
Sase.tr
+

Abonelikten çıkıldı

+

Artık ${escapeHtml(label)} almayacaksın. Hesabınla ilgili önemli bilgilendirme mailleri (e-posta doğrulama, ödeme bildirimleri) gelmeye devam eder.

+

Fikrini değiştirirsen ayarlar > bildirimler sayfasından geri açabilirsin.

+

Ayarları aç

+
`; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/apps/web/src/routes/_auth/register.tsx b/apps/web/src/routes/_auth/register.tsx index f9594ad..9018511 100644 --- a/apps/web/src/routes/_auth/register.tsx +++ b/apps/web/src/routes/_auth/register.tsx @@ -6,6 +6,7 @@ import { track as trackMeta } from "@/lib/meta-pixel"; import { capture, identifyUser } from "@/lib/posthog"; import { toast } from "@/lib/toast"; import { cleanModelName } from "@/lib/vehicle"; +import { suggestEmailFix } from "@sase/shared"; import { Button } from "@sase/ui"; import { Input } from "@sase/ui"; import { Label } from "@sase/ui"; @@ -65,6 +66,7 @@ function RegisterPage() { const [name, setName] = useState(""); const [email, setEmail] = useState(""); + const [emailSuggestion, setEmailSuggestion] = useState(null); const [password, setPassword] = useState(""); const [refCode, setRefCode] = useState(ref || ""); const [loading, setLoading] = useState(false); @@ -291,9 +293,37 @@ function RegisterPage() { type="email" placeholder="ornek@email.com" value={email} - onChange={(e) => setEmail(e.target.value)} + onChange={(e) => { + setEmail(e.target.value); + // Hide a previously shown suggestion as soon as the user keeps + // typing — recompute on blur so we don't nag mid-typing. + if (emailSuggestion) setEmailSuggestion(null); + }} + onBlur={() => setEmailSuggestion(suggestEmailFix(email))} required /> + {emailSuggestion && ( +

+ Bunu mu demek istedin?{" "} + +

+ )}
diff --git a/packages/shared/src/index.spec.ts b/packages/shared/src/index.spec.ts index 0d7be61..03c81d0 100644 --- a/packages/shared/src/index.spec.ts +++ b/packages/shared/src/index.spec.ts @@ -26,6 +26,8 @@ import { slugify, generateReferralCode, normalizeEmail, + normalizeName, + suggestEmailFix, referralRewardForCount, referralTotalRewardDays, // Constants @@ -631,6 +633,114 @@ describe("normalizeEmail", () => { }); }); +describe("normalizeName (Turkish-locale title-case)", () => { + it("title-cases lowercase input", () => { + expect(normalizeName("mehmet")).toBe("Mehmet"); + }); + + it("title-cases uppercase input", () => { + expect(normalizeName("MEHMET")).toBe("Mehmet"); + }); + + it("handles Turkish İ → i pair correctly (locale-aware)", () => { + // `İLKER` lower-cases to `ilker` (NOT `i̇lker`) under tr locale, + // and lowercase `i` upper-cases to `İ`, not `I`. + expect(normalizeName("İLKER")).toBe("İlker"); + expect(normalizeName("ilker")).toBe("İlker"); + }); + + it("handles Turkish ı (dotless) correctly", () => { + expect(normalizeName("ALİ YILMAZ")).toBe("Ali Yılmaz"); + expect(normalizeName("yılmaz")).toBe("Yılmaz"); + }); + + it("title-cases each whitespace-separated token", () => { + expect(normalizeName("ali yılmaz")).toBe("Ali Yılmaz"); + expect(normalizeName("ahmet veli mehmet")).toBe("Ahmet Veli Mehmet"); + }); + + it("collapses internal whitespace", () => { + expect(normalizeName("ali yılmaz")).toBe("Ali Yılmaz"); + expect(normalizeName(" ali\tyılmaz ")).toBe("Ali Yılmaz"); + }); + + it("preserves diacritics", () => { + expect(normalizeName("ÖMER")).toBe("Ömer"); + expect(normalizeName("ÇAĞRI")).toBe("Çağrı"); + expect(normalizeName("ŞENOL")).toBe("Şenol"); + expect(normalizeName("ÜLKÜ")).toBe("Ülkü"); + }); + + it("title-cases each segment of a hyphenated name", () => { + expect(normalizeName("mehmet-ali")).toBe("Mehmet-Ali"); + expect(normalizeName("ANNA-MARIA")).toBe("Anna-Maria"); + }); + + it("returns '' for null/undefined/whitespace-only", () => { + expect(normalizeName(null)).toBe(""); + expect(normalizeName(undefined)).toBe(""); + expect(normalizeName("")).toBe(""); + expect(normalizeName(" ")).toBe(""); + }); + + it("is idempotent (already-canonical input is unchanged)", () => { + expect(normalizeName("Mehmet")).toBe("Mehmet"); + expect(normalizeName("Ali Yılmaz")).toBe("Ali Yılmaz"); + expect(normalizeName(normalizeName("MEHMET"))).toBe("Mehmet"); + }); + + it("handles mixed-case noise (typo-tier signups)", () => { + expect(normalizeName("MEhmEt")).toBe("Mehmet"); + expect(normalizeName("aLİ")).toBe("Ali"); + expect(normalizeName("oto")).toBe("Oto"); + expect(normalizeName("OTO")).toBe("Oto"); + }); +}); + +describe("suggestEmailFix", () => { + it("returns null for already-popular domains", () => { + expect(suggestEmailFix("user@gmail.com")).toBe(null); + expect(suggestEmailFix("user@hotmail.com")).toBe(null); + expect(suggestEmailFix("user@outlook.com.tr")).toBe(null); + }); + + it("catches real-world prod typos via exact-match dictionary", () => { + // these all came from prod Postal suppressions: + expect(suggestEmailFix("muratsmz61@icould.com")).toBe("muratsmz61@icloud.com"); + expect(suggestEmailFix("user@gmial.com")).toBe("user@gmail.com"); + expect(suggestEmailFix("user@hotmial.com")).toBe("user@hotmail.com"); + expect(suggestEmailFix("user@gmail.co")).toBe("user@gmail.com"); + expect(suggestEmailFix("user@yaho.com")).toBe("user@yahoo.com"); + }); + + it("catches IDN-encoded typos (Turkish keyboard hiccups)", () => { + expect(suggestEmailFix("user@xn--gmail-bgd.com")).toBe("user@gmail.com"); + expect(suggestEmailFix("user@xn--iclud-p4a.com")).toBe("user@icloud.com"); + }); + + it("catches near-miss typos via Levenshtein ≤ 2", () => { + expect(suggestEmailFix("user@gnail.com")).toBe("user@gmail.com"); + expect(suggestEmailFix("user@htmail.com")).toBe("user@hotmail.com"); + }); + + it("returns null for plausibly legitimate non-popular domains", () => { + expect(suggestEmailFix("user@otoyedekparca.co")).toBe(null); + expect(suggestEmailFix("user@volanthastanesi.com.tr")).toBe(null); + expect(suggestEmailFix("user@karalarfiltre.com.tr")).toBe(null); + }); + + it("returns null for malformed input", () => { + expect(suggestEmailFix("")).toBe(null); + expect(suggestEmailFix("no-at-sign")).toBe(null); + expect(suggestEmailFix("@no-local-part.com")).toBe(null); + expect(suggestEmailFix("local-part-only@")).toBe(null); + }); + + it("preserves the local part", () => { + expect(suggestEmailFix("Foo.Bar+tag@gmial.com")).toBe("foo.bar+tag@gmail.com"); + }); +}); + describe("referralRewardForCount (per-milestone, every 3 → 7d, every 5 → 14d)", () => { it("grants nothing below the first milestone", () => { expect(referralRewardForCount(0)).toBe(0); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 004c76b..4a64500 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -72,4 +72,6 @@ export { slugify, generateReferralCode, normalizeEmail, + normalizeName, + suggestEmailFix, } from "./utils/formatters.js"; diff --git a/packages/shared/src/utils/formatters.ts b/packages/shared/src/utils/formatters.ts index 1da063b..1d6fd39 100644 --- a/packages/shared/src/utils/formatters.ts +++ b/packages/shared/src/utils/formatters.ts @@ -62,3 +62,171 @@ export function generateReferralCode(): string { } return code; } + +/** + * Normalises a personal name to "Sentence Case" with Turkish locale awareness. + * + * Why: Postal logs show signup names arrive in every casing — `mehmet`, + * `MEHMET`, `MEhmEt`, `İLKER`, `oto` — and we render them directly into mail + * subjects (`Sase.tr'ye hoş geldin, mehmet`). Title-casing in the auth hook + * means every downstream consumer (Novu subscriber profile, Stripe customer + * name, dashboard greeting) gets the same canonicalised string. + * + * Turkish rules that `toLowerCase()` / `toUpperCase()` get WRONG: + * - `I` ↔ `ı` (dotless), `İ` ↔ `i` (dotted) — invariant casing produces + * `i → I` which Turks read as a different letter. `toLocaleLowerCase("tr")` + * handles this correctly. + * - Other diacritics (Ç, Ğ, Ö, Ş, Ü) work fine under invariant casing but we + * use locale-aware for consistency. + * + * Behaviour: + * - Trims and collapses internal whitespace. + * - For each whitespace-separated token: first cp upper, rest lower. + * - Hyphenated names: each segment is title-cased (`mehmet-ali` → + * `Mehmet-Ali`). + * - Apostrophes and other punctuation are passed through unchanged. + * - Returns "" for null/undefined/whitespace-only input (so the caller's + * schema validation can reject it the same way as before). + * + * Examples: + * normalizeName("mehmet") → "Mehmet" + * normalizeName("MEHMET") → "Mehmet" + * normalizeName("İLKER") → "İlker" + * normalizeName("ali yılmaz")→ "Ali Yılmaz" + * normalizeName("ÖMER") → "Ömer" + * normalizeName("ahmet-ali") → "Ahmet-Ali" + */ +export function normalizeName(name: string | null | undefined): string { + if (name == null) return ""; + const trimmed = name.replace(/\s+/g, " ").trim(); + if (trimmed === "") return ""; + return trimmed + .split(" ") + .map(titleCaseToken) + .join(" "); +} + +/** + * Suggests a corrected e-mail when the domain looks like a typo of a popular + * provider. Returns `null` when the address looks fine. + * + * Why: prod Postal logs show a steady ~6% typo rate at signup — `icould.com`, + * `gmial.com`, `hotmial.com`, `gmail.co`, plus IDN-encoded variants like + * `xn--gmail-bgd.com` (Turkish keyboard "ı" → punycoded). These addresses + * hard-bounce, the user never gets the verification mail, and Postal + * suppresses the recipient. Catching it at signup avoids that whole loop. + * + * Strategy: + * 1. Lowercase + trim, then split local + domain. + * 2. Exact-match a typo dictionary first (cheapest, catches `gmial.com` → + * `gmail.com` and the punycoded IDNs we've actually seen in prod). + * 3. Fall back to Levenshtein distance ≤ 2 against a popular-provider list + * (catches longer-tail misses like `gnail.com` or `htmail.com`). + * 4. Refuse to suggest when the input domain is identical to a popular one + * (else `gmail.com` → suggest `gmail.com` self-suggest). + * + * Returns the FULL corrected address so the caller can swap it directly. + */ +const POPULAR_DOMAINS = [ + "gmail.com", + "hotmail.com", + "outlook.com", + "yahoo.com", + "icloud.com", + "msn.com", + "live.com", + "yandex.com", + "yandex.com.tr", + "outlook.com.tr", + "hotmail.com.tr", +]; + +const EXACT_TYPOS: Record = { + // Real punycoded typos we've seen in prod (Turkish keyboard quirks): + "xn--gmail-bgd.com": "gmail.com", + "xn--hotmail-cie.com": "hotmail.com", + "xn--iclud-p4a.com": "icloud.com", + // Common Latin-letter typos: + "gmial.com": "gmail.com", + "gnail.com": "gmail.com", + "gmail.co": "gmail.com", + "gmail.cm": "gmail.com", + "gmaill.com": "gmail.com", + "gmal.com": "gmail.com", + "gamil.com": "gmail.com", + "hotmial.com": "hotmail.com", + "hotmal.com": "hotmail.com", + "hotnail.com": "hotmail.com", + "hotmail.co": "hotmail.com", + "hotmail.cm": "hotmail.com", + "outlok.com": "outlook.com", + "outloook.com": "outlook.com", + "outloko.com": "outlook.com", + "yaho.com": "yahoo.com", + "yahooo.com": "yahoo.com", + "yahoo.co": "yahoo.com", + "icould.com": "icloud.com", + "iclod.com": "icloud.com", + "iclud.com": "icloud.com", +}; + +export function suggestEmailFix(email: string): string | null { + const trimmed = (email || "").trim().toLowerCase(); + const at = trimmed.lastIndexOf("@"); + if (at < 1 || at >= trimmed.length - 1) return null; + const local = trimmed.slice(0, at); + const domain = trimmed.slice(at + 1); + if (POPULAR_DOMAINS.includes(domain)) return null; + // Exact-match dictionary first (cheapest). + const exact = EXACT_TYPOS[domain]; + if (exact) return `${local}@${exact}`; + // Levenshtein distance ≤ 2 against popular list. + let best: { d: string; dist: number } | null = null; + for (const candidate of POPULAR_DOMAINS) { + // Quick length-prefilter: distance ≥ |len diff|. + if (Math.abs(candidate.length - domain.length) > 2) continue; + const dist = levenshtein(domain, candidate); + if (dist <= 2 && (best === null || dist < best.dist)) { + best = { d: candidate, dist }; + } + } + if (best) return `${local}@${best.d}`; + return null; +} + +/** Standard Levenshtein — small string, allocation-cheap. */ +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + const m = a.length; + const n = b.length; + let prev = new Array(n + 1).fill(0); + let curr = new Array(n + 1).fill(0); + for (let j = 0; j <= n; j++) prev[j] = j; + for (let i = 1; i <= m; i++) { + curr[0] = i; + for (let j = 1; j <= n; j++) { + const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1; + curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + } + [prev, curr] = [curr, prev]; + } + return prev[n]; +} + +/** Title-case a single whitespace-free token, hyphen-aware. */ +function titleCaseToken(token: string): string { + if (token === "") return token; + // Hyphen-separated names — each part gets its own title-case so + // `mehmet-ali` → `Mehmet-Ali`, not `Mehmet-ali`. + if (token.includes("-")) { + return token.split("-").map(titleCaseToken).join("-"); + } + // Use array-of-codepoints to avoid splitting surrogate pairs mid-character + // (Turkish letters are BMP, but defending against accidental emoji etc.). + const chars = [...token.toLocaleLowerCase("tr-TR")]; + if (chars.length === 0) return ""; + chars[0] = chars[0].toLocaleUpperCase("tr-TR"); + return chars.join(""); +} diff --git a/scripts/backfill-user-names.ts b/scripts/backfill-user-names.ts new file mode 100644 index 0000000..c573aca --- /dev/null +++ b/scripts/backfill-user-names.ts @@ -0,0 +1,75 @@ +/** + * One-shot backfill: title-case existing `users.name` rows. + * + * After this lands, every NEW signup gets canonicalised in the better-auth + * `user.create.before` hook (see apps/api/src/auth/auth.ts). Existing rows + * pre-date that hook and still carry whatever the user typed at signup: + * `mehmet`, `MEHMET`, `İLKER`, `OTO`, …. This script applies the same + * `normalizeName()` Turkish-locale-aware title-case to historical rows so + * mail subjects (`Sase.tr'ye hoş geldin, mehmet` → `, Mehmet`) and dashboard + * greetings render consistently. + * + * Safe to re-run: the UPDATE is gated on `name <> normalized`, so already- + * canonical rows aren't touched. + * + * Usage: + * pnpm tsx scripts/backfill-user-names.ts --dry-run # preview only + * pnpm tsx scripts/backfill-user-names.ts # write + * + * Run against BOTH prod (sase) and dev (sase_dev) DBs separately by pointing + * DATABASE_URL at each. mailAudit.md §9.3 #9. + */ + +import * as path from "node:path"; +import * as dotenv from "dotenv"; +import postgres from "postgres"; +import { normalizeName } from "@sase/shared"; + +dotenv.config({ path: path.join(__dirname, "../apps/api/.env") }); + +const argv = process.argv.slice(2); +const dryRun = argv.includes("--dry-run"); + +async function main() { + if (!process.env.DATABASE_URL) { + console.error("DATABASE_URL is not set"); + process.exit(1); + } + const sql = postgres(process.env.DATABASE_URL, { max: 1 }); + + const rows = await sql<{ id: string; name: string }[]>` + SELECT id, name FROM users WHERE name IS NOT NULL AND name <> '' + `; + console.log(`[backfill] scanned ${rows.length} users`); + + let changed = 0; + let unchanged = 0; + const samples: Array<{ before: string; after: string }> = []; + + for (const r of rows) { + const norm = normalizeName(r.name); + if (norm === r.name) { + unchanged++; + continue; + } + if (samples.length < 15) samples.push({ before: r.name, after: norm }); + if (!dryRun) { + await sql`UPDATE users SET name = ${norm}, updated_at = NOW() WHERE id = ${r.id}`; + } + changed++; + } + + console.log(`[backfill] ${changed} changed, ${unchanged} already canonical`); + if (samples.length) { + console.log("[backfill] sample diffs:"); + for (const s of samples) console.log(` '${s.before}' → '${s.after}'`); + } + if (dryRun) console.log("[backfill] DRY RUN — no writes"); + + await sql.end(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); From f5cd5be93333bc6292a3d54188ad2035215036c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Claude=20=28audit=20=C2=A79=2E3=29?= Date: Thu, 4 Jun 2026 14:51:10 +0300 Subject: [PATCH 2/6] =?UTF-8?q?feat(notifications):=20settings=20UI=20for?= =?UTF-8?q?=20per-workflow=20opt-out=20(audit=20=C2=A79.3=20#14=20follow-o?= =?UTF-8?q?n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the user-facing half of the unsubscribe preferences work. The one-click endpoint already shipped in this PR's main commit; this adds the proactive self-service path at /dashboard/settings?tab=notifications so users don't have to wait for a mail to land before tuning their preferences. Backend ------- New EmailPreferencesController at /api/email/preferences: GET → returns one row per OPTIONAL_WORKFLOWS entry, each with current optedOut boolean (false when no DB row exists). POST → body {workflow, optedOut} flips the row; source='settings_page' captured for the audit trail. Auth+payment workflows are deliberately not exposed — the server's OPTIONAL_WORKFLOWS set stays the single source of truth. Frontend -------- Adds a 'notifications' tab to /dashboard/settings (between 'preferences' and 'security'). One toggle row per optional workflow with TR copy that explains what each mail is for. Optimistic update — switch flips instantly and reverts on failure; PostHog event captures accept/reject. Static footer note clarifies that auth + payment mail keeps coming regardless of the switches above (so users don't think they've unsubscribed from password-reset). i18n ---- Added settings.tabs.notifications + settings.notifications.{title, description} to both tr.json and en.json. Body copy is hard-coded TR (matches audit §9.3 #11 TR-only decision). Co-Authored-By: Claude Opus 4.7 --- .../email-preferences.controller.ts | 67 +++++++ .../src/notifications/notifications.module.ts | 3 +- .../components/settings/settings-content.tsx | 166 ++++++++++++++++++ apps/web/src/messages/en.json | 64 +++++-- apps/web/src/messages/tr.json | 64 +++++-- apps/web/src/routes/dashboard/settings.tsx | 1 + 6 files changed, 338 insertions(+), 27 deletions(-) create mode 100644 apps/api/src/notifications/email-preferences.controller.ts diff --git a/apps/api/src/notifications/email-preferences.controller.ts b/apps/api/src/notifications/email-preferences.controller.ts new file mode 100644 index 0000000..da02418 --- /dev/null +++ b/apps/api/src/notifications/email-preferences.controller.ts @@ -0,0 +1,67 @@ +import { BadRequestException, Body, Controller, Get, Logger, Post } from "@nestjs/common"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { + EmailPreferencesService, + OPTIONAL_WORKFLOWS, +} from "./email-preferences.service"; + +/** + * Authenticated self-service preferences endpoint — paired with + * UnsubscribeController which handles the unauthenticated one-click flow. + * + * GET /api/email/preferences — current state (all optional + * workflows, with `optedOut: bool`). + * POST /api/email/preferences — body `{workflow, optedOut}`; + * true → insert opt-out row, + * false → delete it. + * + * Backs the `/dashboard/settings?tab=notifications` UI. Auth and payment + * workflows are deliberately not exposed: they're transactional and the + * service-level `OPTIONAL_WORKFLOWS` set is the single source of truth. + */ +@Controller("email/preferences") +export class EmailPreferencesController { + private readonly logger = new Logger(EmailPreferencesController.name); + + constructor(private readonly preferences: EmailPreferencesService) {} + + /** + * Returns the per-workflow opt-out state for the calling user. Always + * includes every optional workflow — caller renders one row per — so a + * missing DB row is just `{optedOut: false}`. + */ + @Get() + async list( + @CurrentUser() user: { id: string }, + ): Promise> { + const workflows = Array.from(OPTIONAL_WORKFLOWS); + const optedOutFlags = await Promise.all( + workflows.map((w) => this.preferences.isOptedOut(user.id, w)), + ); + return workflows.map((workflow, i) => ({ workflow, optedOut: optedOutFlags[i] })); + } + + /** Toggle a single workflow's opt-out state from the settings UI. */ + @Post() + async update( + @CurrentUser() user: { id: string }, + @Body() body: { workflow?: string; optedOut?: boolean }, + ): Promise<{ workflow: string; optedOut: boolean }> { + const { workflow, optedOut } = body; + if (!workflow || typeof workflow !== "string" || !OPTIONAL_WORKFLOWS.has(workflow)) { + throw new BadRequestException("invalid workflow"); + } + if (typeof optedOut !== "boolean") { + throw new BadRequestException("optedOut must be boolean"); + } + if (optedOut) { + await this.preferences.optOut(user.id, workflow, "settings_page"); + } else { + await this.preferences.optIn(user.id, workflow); + } + this.logger.log( + `[email-prefs] user=${user.id} workflow=${workflow} → ${optedOut ? "opt-out" : "opt-in"}`, + ); + return { workflow, optedOut }; + } +} diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts index 0132706..b65f03c 100644 --- a/apps/api/src/notifications/notifications.module.ts +++ b/apps/api/src/notifications/notifications.module.ts @@ -1,5 +1,6 @@ import { Global, Module } from "@nestjs/common"; import { DatabaseModule } from "../database/database.module"; +import { EmailPreferencesController } from "./email-preferences.controller"; import { EmailPreferencesService } from "./email-preferences.service"; import { NovuService } from "./novu.service"; import { UnsubscribeController } from "./unsubscribe.controller"; @@ -13,7 +14,7 @@ import { UnsubscribeController } from "./unsubscribe.controller"; @Global() @Module({ imports: [DatabaseModule], - controllers: [UnsubscribeController], + controllers: [UnsubscribeController, EmailPreferencesController], providers: [NovuService, EmailPreferencesService], exports: [NovuService, EmailPreferencesService], }) diff --git a/apps/web/src/components/settings/settings-content.tsx b/apps/web/src/components/settings/settings-content.tsx index b86329f..c990b33 100644 --- a/apps/web/src/components/settings/settings-content.tsx +++ b/apps/web/src/components/settings/settings-content.tsx @@ -26,6 +26,7 @@ import { Skeleton } from "@sase/ui"; import { useQuery } from "@tanstack/react-query"; import { AlertTriangle, + Bell, Copy, Eye, EyeOff, @@ -45,12 +46,61 @@ import { useEffect, useState } from "react"; const TAB_ITEMS = [ { value: "profile", icon: User, labelKey: "settings.tabs.profile" }, { value: "preferences", icon: SlidersHorizontal, labelKey: "settings.tabs.preferences" }, + { value: "notifications", icon: Bell, labelKey: "settings.tabs.notifications" }, { value: "security", icon: Shield, labelKey: "settings.tabs.security" }, { value: "connections", icon: Link2, labelKey: "settings.tabs.connections" }, { value: "referral", icon: Gift, labelKey: "settings.tabs.referral" }, { value: "account", icon: Trash2, labelKey: "settings.tabs.account" }, ] as const; +/** + * Per-workflow opt-out labels for the Notifications tab. Order matters — + * it's the order the user sees. Auth + payment workflows are deliberately + * NOT here (transactional → must always reach the user). Kept in sync with + * apps/api/src/notifications/email-preferences.service.ts OPTIONAL_WORKFLOWS. + */ +const NOTIFICATION_WORKFLOWS: ReadonlyArray<{ + workflow: string; + title: string; + description: string; +}> = [ + { + workflow: "welcome", + title: "Hoş geldin maili", + description: "Kayıt olduktan hemen sonra gelen kısa karşılama.", + }, + { + workflow: "trial-ending", + title: "Deneme bitiş hatırlatması", + description: "Deneme süresinin son birkaç gününde gönderilen yükseltme önerisi.", + }, + { + workflow: "referral", + title: "Davet hatırlatması", + description: "Kayıt olduktan 3 gün sonra arkadaşını davet etme hatırlatması.", + }, + { + workflow: "referral-qualified", + title: "Davet niteliği bildirimi", + description: "Davet ettiğin biri e-postasını doğrulayıp niteliklendiğinde haber alıyorsun.", + }, + { + workflow: "referral-reward", + title: "Ödül bildirimi", + description: "Davet ödülü kazandığında (7 / 14 gün) bilgilendirme.", + }, + { + workflow: "win-back", + title: "Geri kazanma maili", + description: "Uzun süre pasif kaldığında tek seferlik dönüş daveti.", + }, +]; + +interface NotificationPref { + workflow: string; + optedOut: boolean; +} + type ThemePref = "light" | "dark" | "system"; const THEME_OPTIONS: { value: ThemePref; icon: typeof Sun; labelKey: string }[] = [ @@ -326,6 +376,11 @@ export function SettingsContent({ {/* Preferences Tab */} + {/* Notifications Tab (audit §9.3 #14) */} + + + + @@ -620,3 +675,114 @@ export function SettingsContent({
); } + +/** + * Per-workflow opt-out toggles for the lifecycle / engagement e-mails. + * Pure presentation — heavy lifting (HMAC token, audit row) is on the API + * side. Auth + payment mail is unaffected (`OPTIONAL_WORKFLOWS` on the + * server is the canonical list). + */ +function NotificationsCard() { + const { t } = useTranslation(); + const [prefs, setPrefs] = useState(null); + const [pending, setPending] = useState>(new Set()); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + api + .get("/email/preferences") + .then((data) => { + if (!cancelled) setPrefs(data); + }) + .catch((err) => { + if (!cancelled) setError((err as Error).message || "Hata"); + }); + return () => { + cancelled = true; + }; + }, []); + + async function toggle(workflow: string, currentlyOptedOut: boolean) { + const next = !currentlyOptedOut; + // Optimistic update — flip the local row immediately so the switch feels + // instant; revert on failure. + setPrefs((cur) => + cur ? cur.map((p) => (p.workflow === workflow ? { ...p, optedOut: next } : p)) : cur, + ); + setPending((s) => new Set(s).add(workflow)); + try { + await api.post("/email/preferences", { workflow, optedOut: next }); + capture(next ? "email_workflow_opted_out" : "email_workflow_opted_in", { workflow }); + toast.success(next ? "Bildirim kapatıldı." : "Bildirim açıldı."); + } catch (err) { + // Revert + surface the error. + setPrefs((cur) => + cur + ? cur.map((p) => + p.workflow === workflow ? { ...p, optedOut: currentlyOptedOut } : p, + ) + : cur, + ); + toast.error((err as Error).message || "Güncellenemedi"); + } finally { + setPending((s) => { + const next = new Set(s); + next.delete(workflow); + return next; + }); + } + } + + return ( + + + {t("settings.notifications.title")} + {t("settings.notifications.description")} + + + {error ? ( +

{error}

+ ) : prefs === null ? ( +
+ {NOTIFICATION_WORKFLOWS.map((w) => ( + + ))} +
+ ) : ( + NOTIFICATION_WORKFLOWS.map((w) => { + const row = prefs.find((p) => p.workflow === w.workflow); + const optedOut = row?.optedOut ?? false; + const isPending = pending.has(w.workflow); + return ( +
+
+

{w.title}

+

{w.description}

+
+ +
+ ); + }) + )} +

+ Doğrulama, şifre sıfırlama ve ödeme bildirimleri buradan kapatılamaz — hesabını + yönetebilmen için gerekli. +

+
+
+ ); +} diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 106f526..66c0a99 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -497,7 +497,8 @@ "connections": "Connections", "referral": "Referral", "account": "Account", - "changelog": "Changelog" + "changelog": "Changelog", + "notifications": "Notifications" }, "preferences": { "title": "Preferences", @@ -577,6 +578,10 @@ "feature": "New Feature", "improvement": "Improvement" } + }, + "notifications": { + "title": "Notification preferences", + "description": "Choose which lifecycle e-mails you want to receive. Account security and payment notifications keep coming." } }, "errors": { @@ -612,10 +617,22 @@ }, "yearlyBadge": "2 months free", "stats": { - "brands": { "value": "27", "label": "brand catalogs" }, - "parts": { "value": "1M+", "label": "OEM & alternative parts" }, - "trial": { "value": "30 days", "label": "full access, free" }, - "refund": { "value": "7 days", "label": "no-questions refund" } + "brands": { + "value": "27", + "label": "brand catalogs" + }, + "parts": { + "value": "1M+", + "label": "OEM & alternative parts" + }, + "trial": { + "value": "30 days", + "label": "full access, free" + }, + "refund": { + "value": "7 days", + "label": "no-questions refund" + } }, "pageTitle": "Pricing — Sase.tr | Chassis Search Plans", "how": { @@ -926,10 +943,22 @@ "titleLine1": "Sase.tr", "titleLine2": "in Numbers", "items": { - "0": { "value": "1.2sn", "label": "Average lookup time" }, - "1": { "value": "27", "label": "Supported brands" }, - "2": { "value": "1M+", "label": "OEM part numbers" }, - "3": { "value": "%99.9", "label": "Platform uptime" } + "0": { + "value": "1.2sn", + "label": "Average lookup time" + }, + "1": { + "value": "27", + "label": "Supported brands" + }, + "2": { + "value": "1M+", + "label": "OEM part numbers" + }, + "3": { + "value": "%99.9", + "label": "Platform uptime" + } } }, "dashboard": { @@ -999,9 +1028,18 @@ "bullet4": "White-label — the widget matches your site's design", "cta": "Get in Touch", "stats": { - "0": { "value": "%42", "label": "Fewer Returns" }, - "1": { "value": "%35", "label": "Higher Conversion" }, - "2": { "value": "<30dk", "label": "Integration Time" } + "0": { + "value": "%42", + "label": "Fewer Returns" + }, + "1": { + "value": "%35", + "label": "Higher Conversion" + }, + "2": { + "value": "<30dk", + "label": "Integration Time" + } } }, "testimonials": { @@ -1130,4 +1168,4 @@ "decodeGeneric": "Something went wrong. Please try again." } } -} +} \ No newline at end of file diff --git a/apps/web/src/messages/tr.json b/apps/web/src/messages/tr.json index 7a5d37a..38b8087 100644 --- a/apps/web/src/messages/tr.json +++ b/apps/web/src/messages/tr.json @@ -497,7 +497,8 @@ "connections": "Bağlantılar", "referral": "Referans", "account": "Hesap", - "changelog": "Değişiklik Günlüğü" + "changelog": "Değişiklik Günlüğü", + "notifications": "Bildirimler" }, "preferences": { "title": "Tercihler", @@ -577,6 +578,10 @@ "feature": "Yeni Özellik", "improvement": "Geliştirme" } + }, + "notifications": { + "title": "Bildirim tercihleri", + "description": "Hangi lifecycle maillerini almak istediğini seç. Hesap güvenliği ve ödeme bildirimleri her zaman gelmeye devam eder." } }, "errors": { @@ -612,10 +617,22 @@ }, "yearlyBadge": "2 ay bedava", "stats": { - "brands": { "value": "27", "label": "marka kataloğu" }, - "parts": { "value": "1M+", "label": "OEM ve alternatif parça" }, - "trial": { "value": "30 gün", "label": "tüm özellikler ücretsiz" }, - "refund": { "value": "7 gün", "label": "koşulsuz iade" } + "brands": { + "value": "27", + "label": "marka kataloğu" + }, + "parts": { + "value": "1M+", + "label": "OEM ve alternatif parça" + }, + "trial": { + "value": "30 gün", + "label": "tüm özellikler ücretsiz" + }, + "refund": { + "value": "7 gün", + "label": "koşulsuz iade" + } }, "pageTitle": "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları", "how": { @@ -926,10 +943,22 @@ "titleLine1": "Rakamlarla", "titleLine2": "Sase.tr", "items": { - "0": { "value": "1.2sn", "label": "Ortalama sorgu süresi" }, - "1": { "value": "27", "label": "Desteklenen marka" }, - "2": { "value": "1M+", "label": "OEM parça numarası" }, - "3": { "value": "%99.9", "label": "Platform erişilebilirlik" } + "0": { + "value": "1.2sn", + "label": "Ortalama sorgu süresi" + }, + "1": { + "value": "27", + "label": "Desteklenen marka" + }, + "2": { + "value": "1M+", + "label": "OEM parça numarası" + }, + "3": { + "value": "%99.9", + "label": "Platform erişilebilirlik" + } } }, "dashboard": { @@ -999,9 +1028,18 @@ "bullet4": "White-Label — Widget sitenizin tasarımına uyum sağlar", "cta": "İletişime Geçin", "stats": { - "0": { "value": "%42", "label": "Daha Az İade" }, - "1": { "value": "%35", "label": "Daha Yüksek Dönüşüm" }, - "2": { "value": "<30dk", "label": "Entegrasyon Süresi" } + "0": { + "value": "%42", + "label": "Daha Az İade" + }, + "1": { + "value": "%35", + "label": "Daha Yüksek Dönüşüm" + }, + "2": { + "value": "<30dk", + "label": "Entegrasyon Süresi" + } } }, "testimonials": { @@ -1130,4 +1168,4 @@ "decodeGeneric": "Bir hata oluştu. Lütfen tekrar deneyin." } } -} +} \ No newline at end of file diff --git a/apps/web/src/routes/dashboard/settings.tsx b/apps/web/src/routes/dashboard/settings.tsx index 6a4f0fb..b3fda87 100644 --- a/apps/web/src/routes/dashboard/settings.tsx +++ b/apps/web/src/routes/dashboard/settings.tsx @@ -11,6 +11,7 @@ const SettingsContent = lazy(() => export const SETTINGS_TABS = [ "profile", "preferences", + "notifications", "security", "connections", "referral", From 4a115db8854d90654ddfab1456edaf753826af42 Mon Sep 17 00:00:00 2001 From: Semih Yesilyurt Date: Thu, 4 Jun 2026 16:58:03 +0300 Subject: [PATCH 3/6] fix(pl24): wire Fiat as P5-modern /p5fiat (was dead LEGACY_FIAT /fca) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fiat (fiatp_parts/fiatt_parts) was misconfigured as LEGACY_FIAT basePath /fca, which 404s on every request — PL24 Fiat decode was dead. Live discovery (de-708171) shows Fiat is a standard P5 Modern catalog at /p5fiat: directAccess + maingroups/subgroups/parts/images all match the existing P5 flow. Only the vinfoBasic record shape differs ({key,description} vs {values:{description,value}}). - types: fiatp_parts/fiatt_parts -> P5_MODERN, apiPath/basePath /p5fiat - parseVehicleResponse: parse the p5fiat vinfoBasic shape; friendly model from "Model bilgisi"; year from MY / production date Covers European (ZFA) Fiats + some commercial Tofas (fiatt). Turkish Tofas passenger VINs (NM4, incl. Egea) are not in this catalog. de account separation (resolveAccount Rule 1) unchanged; de auth handshake proxied, catalog data not. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/src/integrations/pl24/pl24.service.ts | 46 ++++++++++++++++--- apps/api/src/integrations/pl24/pl24.types.ts | 19 ++++---- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/apps/api/src/integrations/pl24/pl24.service.ts b/apps/api/src/integrations/pl24/pl24.service.ts index fe69449..ead0847 100644 --- a/apps/api/src/integrations/pl24/pl24.service.ts +++ b/apps/api/src/integrations/pl24/pl24.service.ts @@ -997,16 +997,36 @@ export class PL24Service { serviceName: string, ): Omit { const segments = - (data.segments as Record }> }>) || - {}; + (data.segments as Record< + string, + { + records?: Array<{ + values?: Record; + key?: string; + description?: string; + code?: string; + }>; + } + >) || {}; const vinfoRecords = segments.vinfoBasic?.records || []; const vehicleData: Record = {}; for (const record of vinfoRecords) { + // Two record shapes across P5 backends: + // p5vwag etc.: { values: { description: