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
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:
12
apps/api/drizzle/0011_email_preferences.sql
Normal file
12
apps/api/drizzle/0011_email_preferences.sql
Normal 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");
|
||||
@@ -78,6 +78,13 @@
|
||||
"when": 1780281600000,
|
||||
"tag": "0010_dedupe_parts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1780572179333,
|
||||
"tag": "0011_email_preferences",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
103
apps/api/src/notifications/email-preferences.service.ts
Normal file
103
apps/api/src/notifications/email-preferences.service.ts
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
155
apps/api/src/notifications/unsubscribe.controller.ts
Normal file
155
apps/api/src/notifications/unsubscribe.controller.ts
Normal 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 > 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -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<string | null>(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 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bunu mu demek istedin?{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary underline underline-offset-2 hover:opacity-80"
|
||||
onClick={() => {
|
||||
setEmail(emailSuggestion);
|
||||
setEmailSuggestion(null);
|
||||
// PostHog signal so we can see how often the suggestion is
|
||||
// accepted vs ignored — informs whether to keep the
|
||||
// dictionary growing or just trust browser-native typo hints.
|
||||
capture("signup_email_typo_corrected", {
|
||||
from: email,
|
||||
to: emailSuggestion,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{emailSuggestion}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Şifre</Label>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -72,4 +72,6 @@ export {
|
||||
slugify,
|
||||
generateReferralCode,
|
||||
normalizeEmail,
|
||||
normalizeName,
|
||||
suggestEmailFix,
|
||||
} from "./utils/formatters.js";
|
||||
|
||||
@@ -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<string, string> = {
|
||||
// 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("");
|
||||
}
|
||||
|
||||
75
scripts/backfill-user-names.ts
Normal file
75
scripts/backfill-user-names.ts
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user