From 71f0ba27543875f90c0faa9d578f98c9fa840c1b Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:01:26 +0530 Subject: [PATCH 001/129] feat(mail): add transactional mail core --- packages/mail/package.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 packages/mail/package.json diff --git a/packages/mail/package.json b/packages/mail/package.json new file mode 100644 index 000000000..a0f695e22 --- /dev/null +++ b/packages/mail/package.json @@ -0,0 +1,23 @@ +{ + "name": "@aiwa/mail", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "clean": "rm -rf dist .turbo", + "lint": "eslint src test --max-warnings=0", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@aiwa/config": "workspace:*", + "@aiwa/db": "workspace:*" + }, + "devDependencies": { + "vitest": "^4.1.11" + } +} From c3ba0263f27e689cf9bda2792e8667faeb981c9c Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:01:28 +0530 Subject: [PATCH 002/129] feat(mail): add transactional mail core --- packages/mail/tsconfig.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/mail/tsconfig.json diff --git a/packages/mail/tsconfig.json b/packages/mail/tsconfig.json new file mode 100644 index 000000000..e89e187c8 --- /dev/null +++ b/packages/mail/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "." }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} From 4bddf7e627f04bc3eee0428d8dc21cab3a46088b Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:01:30 +0530 Subject: [PATCH 003/129] feat(mail): add transactional mail core --- packages/mail/src/index.ts | 344 +++++++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 packages/mail/src/index.ts diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts new file mode 100644 index 000000000..f84b728f1 --- /dev/null +++ b/packages/mail/src/index.ts @@ -0,0 +1,344 @@ +import { createHash } from "node:crypto"; +import { connect, type TLSSocket } from "node:tls"; +import { db, type Prisma } from "@aiwa/db"; +import type { ServerEnv } from "@aiwa/config"; + +export type MailKind = "SECURITY" | "ROUTINE"; +export type MailPriority = "HIGH" | "NORMAL"; + +export type MailDraft = { + kind: MailKind; + template: string; + to: string; + subject: string; + text: string; + html: string; + sensitive?: boolean; + organizationId?: string | null; + userId?: string | null; + priority?: MailPriority; + idempotencyKey: string; +}; + +type MailDb = typeof db | Prisma.TransactionClient; + +export function senderForKind(env: ServerEnv, kind: MailKind): string { + return kind === "SECURITY" + ? env.MAIL_SECURITY_FROM_ADDRESS + : env.MAIL_ROUTINE_FROM_ADDRESS; +} + +export function mailJobId(id: string): string { + return `mail:${id}`; +} + +export function normalizeRecipient(value: string): string { + return value.trim().toLowerCase(); +} + +export function fingerprint(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export async function enqueueMail( + input: MailDraft, + client: MailDb = db, +): Promise<{ id: string; created: boolean }> { + const to = normalizeRecipient(input.to); + try { + const row = await client.mailMessage.create({ + data: { + kind: input.kind, + priority: input.priority ?? (input.kind === "SECURITY" ? "HIGH" : "NORMAL"), + template: input.template, + recipient: to, + subject: input.subject, + textBody: input.text, + htmlBody: input.html, + sensitive: input.sensitive ?? input.kind === "SECURITY", + organizationId: input.organizationId ?? null, + userId: input.userId ?? null, + idempotencyKey: input.idempotencyKey, + status: "PENDING", + }, + select: { id: true }, + }); + return { id: row.id, created: true }; + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "P2002" + ) { + const existing = await client.mailMessage.findUnique({ + where: { idempotencyKey: input.idempotencyKey }, + select: { id: true }, + }); + if (existing) return { id: existing.id, created: false }; + } + throw error; + } +} + +function escapeHtml(value: string): string { + return value.replace( + /[&<>"']/g, + (char) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + char + ] ?? char, + ); +} + +function emailShell(title: string, body: string): string { + return `
Aiwa Creators

${escapeHtml(title)}

${body}

This is an automated message from Aiwa Creators.

`; +} + +export function verificationEmail(input: { + to: string; + verificationUrl: string; + userId?: string; +}): MailDraft { + const safeUrl = escapeHtml(input.verificationUrl); + return { + kind: "SECURITY", + template: "auth.verify_email.v1", + to: input.to, + subject: "Verify your Aiwa Creators email", + text: `Verify your email address to activate workspace access: ${input.verificationUrl}\n\nIf you did not request this, you can ignore this message.`, + html: emailShell( + "Verify your email", + `

Confirm this email address to activate workspace access.

Verify email

If the button does not work, copy this link:
${safeUrl}

`, + ), + sensitive: true, + userId: input.userId, + idempotencyKey: `verify:${fingerprint(input.verificationUrl)}`, + }; +} + +export function teamMemberAddedEmail(input: { + to: string; + organizationName: string; + role: string; + organizationId: string; + userId: string; + membershipId: string; +}): MailDraft { + return { + kind: "SECURITY", + template: "organization.member_added.v1", + to: input.to, + subject: `You were added to ${input.organizationName} on Aiwa Creators`, + text: `You were added to ${input.organizationName} with role ${input.role}. Sign in to Aiwa Creators to access the workspace.`, + html: emailShell( + "You joined a workspace", + `

You were added to ${escapeHtml(input.organizationName)} with role ${escapeHtml(input.role)}.

Sign in to Aiwa Creators to access the workspace.

`, + ), + organizationId: input.organizationId, + userId: input.userId, + idempotencyKey: `membership-added:${input.membershipId}`, + }; +} + +export function generationCompletedEmail(input: { + to: string; + assetUrl: string; + userId: string; + organizationId: string; + generationJobId: string; +}): MailDraft { + const safeUrl = escapeHtml(input.assetUrl); + return { + kind: "ROUTINE", + template: "generation.completed.v1", + to: input.to, + subject: "Your Aiwa Creators generation is ready", + text: `Your generation is ready: ${input.assetUrl}`, + html: emailShell( + "Your generation is ready", + `

Your media has finished generating.

Open generation

`, + ), + organizationId: input.organizationId, + userId: input.userId, + sensitive: false, + idempotencyKey: `generation-completed:${input.generationJobId}`, + }; +} + +type SmtpResponse = { code: number; lines: string[] }; + +class SmtpSession { + private socket: TLSSocket; + private buffer = ""; + private waiters: Array<(response: SmtpResponse) => void> = []; + + private constructor(socket: TLSSocket) { + this.socket = socket; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + this.buffer += chunk; + this.flush(); + }); + } + + static async connect(env: ServerEnv): Promise { + const socket = connect({ + host: env.SMTP_HOST, + port: env.SMTP_PORT, + servername: env.SMTP_HOST, + rejectUnauthorized: true, + }); + socket.setTimeout(env.SMTP_SOCKET_TIMEOUT_MS); + const session = new SmtpSession(socket); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + socket.once("error", onError); + socket.once("secureConnect", () => { + socket.off("error", onError); + resolve(); + }); + }); + const greeting = await session.read(); + if (greeting.code !== 220) throw new Error(`SMTP greeting failed: ${greeting.code}`); + return session; + } + + private flush(): void { + const lines = this.buffer.split("\r\n"); + this.buffer = lines.pop() ?? ""; + if (!lines.length) return; + const complete: string[] = []; + for (const line of lines) { + complete.push(line); + const match = /^(\d{3})([ -])/.exec(line); + if (match?.[2] === " ") { + const code = Number(match[1]); + const waiter = this.waiters.shift(); + if (waiter) waiter({ code, lines: complete.splice(0) }); + } + } + } + + private read(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("SMTP response timed out")), + 30_000, + ); + this.waiters.push((response) => { + clearTimeout(timer); + resolve(response); + }); + }); + } + + async command(command: string, expected: number[]): Promise { + this.socket.write(`${command}\r\n`); + const response = await this.read(); + if (!expected.includes(response.code)) { + const error = new Error(`SMTP command failed with status ${response.code}`); + Object.assign(error, { smtpCode: response.code }); + throw error; + } + return response; + } + + async sendData(data: string): Promise { + this.socket.write(data); + const response = await this.read(); + if (response.code !== 250) { + const error = new Error(`SMTP DATA failed with status ${response.code}`); + Object.assign(error, { smtpCode: response.code }); + throw error; + } + } + + close(): void { + this.socket.end(); + } +} + +function headerSafe(value: string): string { + return value.replace(/[\r\n]+/g, " ").trim(); +} + +function dotStuff(value: string): string { + return value.replace(/(^|\r\n)\./g, "$1.."); +} + +export async function sendMailViaSmtp( + env: ServerEnv, + message: { + from: string; + to: string; + subject: string; + text: string; + html: string; + }, +): Promise<{ providerMessageId: string }> { + const session = await SmtpSession.connect(env); + const providerMessageId = `<${crypto.randomUUID()}@aiwamediagroup.com>`; + try { + await session.command(`EHLO ${env.SMTP_EHLO_NAME}`, [250]); + await session.command("AUTH LOGIN", [334]); + await session.command(Buffer.from(env.SMTP_USER).toString("base64"), [334]); + await session.command(Buffer.from(env.SMTP_PASSWORD).toString("base64"), [235]); + await session.command(`MAIL FROM:<${headerSafe(message.from)}>`, [250]); + await session.command(`RCPT TO:<${headerSafe(message.to)}>`, [250, 251]); + await session.command("DATA", [354]); + + const boundary = `aiwa-${crypto.randomUUID()}`; + const mime = [ + `From: Aiwa Creators <${headerSafe(message.from)}>`, + `To: <${headerSafe(message.to)}>`, + `Subject: ${headerSafe(message.subject)}`, + `Message-ID: ${providerMessageId}`, + "MIME-Version: 1.0", + `Content-Type: multipart/alternative; boundary="${boundary}"`, + "", + `--${boundary}`, + 'Content-Type: text/plain; charset="UTF-8"', + "Content-Transfer-Encoding: 8bit", + "", + message.text, + `--${boundary}`, + 'Content-Type: text/html; charset="UTF-8"', + "Content-Transfer-Encoding: 8bit", + "", + message.html, + `--${boundary}--`, + "", + ].join("\r\n"); + + await session.sendData(`${dotStuff(mime)}\r\n.\r\n`); + await session.command("QUIT", [221]); + return { providerMessageId }; + } finally { + session.close(); + } +} + +export function classifySmtpFailure(error: unknown): { + retryable: boolean; + code: string; + message: string; +} { + const message = error instanceof Error ? error.message : "Unknown SMTP error"; + const smtpCode = + typeof error === "object" && + error !== null && + "smtpCode" in error && + typeof error.smtpCode === "number" + ? error.smtpCode + : undefined; + + if (smtpCode && smtpCode >= 500) { + return { retryable: false, code: `SMTP_${smtpCode}`, message }; + } + return { + retryable: true, + code: smtpCode ? `SMTP_${smtpCode}` : "SMTP_TRANSPORT", + message, + }; +} From dac8720cfbbb4f728134c2bf3a4b63c7cf5e6cf3 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:01:32 +0530 Subject: [PATCH 004/129] feat(mail): add transactional mail core --- packages/mail/test/mail.test.ts | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 packages/mail/test/mail.test.ts diff --git a/packages/mail/test/mail.test.ts b/packages/mail/test/mail.test.ts new file mode 100644 index 000000000..b5bd40613 --- /dev/null +++ b/packages/mail/test/mail.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + classifySmtpFailure, + fingerprint, + generationCompletedEmail, + senderForKind, +} from "../src/index"; + +describe("mail helpers", () => { + it("keeps sender identities separated", () => { + const env = { + MAIL_SECURITY_FROM_ADDRESS: "security@aiwamediagroup.com", + MAIL_ROUTINE_FROM_ADDRESS: "creator-tool@aiwamediagroup.com", + } as never; + expect(senderForKind(env, "SECURITY")).toBe("security@aiwamediagroup.com"); + expect(senderForKind(env, "ROUTINE")).toBe("creator-tool@aiwamediagroup.com"); + }); + + it("builds idempotent routine generation mail", () => { + const draft = generationCompletedEmail({ + to: "USER@EXAMPLE.COM ", + assetUrl: "https://creator.aiwamediagroup.com/assets/a", + userId: "u1", + organizationId: "o1", + generationJobId: "j1", + }); + expect(draft.kind).toBe("ROUTINE"); + expect(draft.idempotencyKey).toBe("generation-completed:j1"); + }); + + it("treats 5xx SMTP responses as permanent", () => { + const error = Object.assign(new Error("rejected"), { smtpCode: 550 }); + expect(classifySmtpFailure(error).retryable).toBe(false); + }); + + it("does not expose source values through fingerprints", () => { + expect(fingerprint("secret-link")).not.toContain("secret-link"); + }); +}); From b0915273e9b460a06fcc3b7562ec6a1af2d32176 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:01:44 +0530 Subject: [PATCH 005/129] feat(mail): add SMTP environment validation --- packages/config/src/index.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 097a9a344..4b444d2d3 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -47,9 +47,15 @@ export const serverEnvSchema = z.object({ APP_URL: z.url().default("http://localhost:3000"), AUTH_SECRET: z.string().min(32), SIGNUPS_ENABLED: booleanFromString, - AUTH_EMAIL_WEBHOOK_URL: optionalHttpsUrl, - AUTH_EMAIL_WEBHOOK_BEARER_TOKEN: optionalString, - AUTH_EMAIL_FROM: optionalString, + MAIL_ENABLED: z.enum(["true", "false"]).default("true").transform((value) => value === "true"), + SMTP_HOST: z.string().min(1).default("localhost"), + SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(465), + SMTP_USER: z.string().min(1).default("local"), + SMTP_PASSWORD: z.string().min(1).default("local"), + SMTP_EHLO_NAME: z.string().min(1).default("creator.aiwamediagroup.com"), + SMTP_SOCKET_TIMEOUT_MS: optionalPositiveInteger.default(30_000), + MAIL_SECURITY_FROM_ADDRESS: z.string().email().default("security@aiwamediagroup.com"), + MAIL_ROUTINE_FROM_ADDRESS: z.string().email().default("creator-tool@aiwamediagroup.com"), DATABASE_URL: z.string().min(1), REDIS_URL: z.url().default("redis://127.0.0.1:6379/0"), BYTEPLUS_API_KEY: optionalString, From 518cdc720029439cf3d8dd45482a47ab879269f6 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:01:54 +0530 Subject: [PATCH 006/129] docs(mail): document SMTP environment --- .env.example | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 9b8af4b0f..e7c9a460a 100644 --- a/.env.example +++ b/.env.example @@ -5,11 +5,16 @@ APP_VERSION=dev APP_URL=http://localhost:3000 AUTH_SECRET=replace-with-at-least-32-random-characters SIGNUPS_ENABLED=true -# HTTPS webhook used for verification-email delivery in staging/production. -# Receives JSON: { type, to, from, subject, text, verificationUrl }. -AUTH_EMAIL_WEBHOOK_URL= -AUTH_EMAIL_WEBHOOK_BEARER_TOKEN= -AUTH_EMAIL_FROM=Aiwa Creators +# Transactional email. Production uses implicit TLS SMTP (port 465). +MAIL_ENABLED=true +SMTP_HOST= +SMTP_PORT=465 +SMTP_USER= +SMTP_PASSWORD= +SMTP_EHLO_NAME=creator.aiwamediagroup.com +SMTP_SOCKET_TIMEOUT_MS=30000 +MAIL_SECURITY_FROM_ADDRESS=security@aiwamediagroup.com +MAIL_ROUTINE_FROM_ADDRESS=creator-tool@aiwamediagroup.com # MariaDB. Credentials below are for local Compose only. MARIADB_DATABASE=creator_platform From 03481eb4d1ded75066c215b52eabf913f4fd9bee Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:02:12 +0530 Subject: [PATCH 007/129] feat(mail): add durable mail outbox schema --- packages/db/prisma/schema.prisma | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b928f625f..31004c66a 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -466,6 +466,59 @@ model OrganizationInvitation { @@index([email]) } +enum MailKind { + SECURITY + ROUTINE +} + +enum MailPriority { + HIGH + NORMAL +} + +enum MailStatus { + PENDING + QUEUED + SENDING + RETRY + SENT + FAILED + SUPPRESSED +} + +model MailMessage { + id String @id @default(cuid()) + kind MailKind + priority MailPriority @default(NORMAL) + status MailStatus @default(PENDING) + template String + recipient String + subject String + textBody String @db.LongText + htmlBody String @db.LongText + sensitive Boolean @default(false) + userId String? + organizationId String? + idempotencyKey String @unique + attemptCount Int @default(0) + nextAttemptAt DateTime? + providerMessageId String? + lastErrorCode String? + lastErrorMessage String? @db.Text + queuedAt DateTime? + sendingAt DateTime? + sentAt DateTime? + failedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, priority, createdAt]) + @@index([status, nextAttemptAt]) + @@index([userId, createdAt]) + @@index([organizationId, createdAt]) + @@index([recipient, createdAt]) +} + enum ReasoningJobStatus { QUEUED PROCESSING From 8b99061b7139c7116592248a84911a5b3545744f Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:02:14 +0530 Subject: [PATCH 008/129] feat(mail): add durable mail outbox migration --- .../migration.sql | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 packages/db/prisma/migrations/20260924020000_transactional_mail_outbox/migration.sql diff --git a/packages/db/prisma/migrations/20260924020000_transactional_mail_outbox/migration.sql b/packages/db/prisma/migrations/20260924020000_transactional_mail_outbox/migration.sql new file mode 100644 index 000000000..3c52911bf --- /dev/null +++ b/packages/db/prisma/migrations/20260924020000_transactional_mail_outbox/migration.sql @@ -0,0 +1,33 @@ +CREATE TABLE `MailMessage` ( + `id` VARCHAR(191) NOT NULL, + `kind` ENUM('SECURITY','ROUTINE') NOT NULL, + `priority` ENUM('HIGH','NORMAL') NOT NULL DEFAULT 'NORMAL', + `status` ENUM('PENDING','QUEUED','SENDING','RETRY','SENT','FAILED','SUPPRESSED') NOT NULL DEFAULT 'PENDING', + `template` VARCHAR(191) NOT NULL, + `recipient` VARCHAR(191) NOT NULL, + `subject` VARCHAR(191) NOT NULL, + `textBody` LONGTEXT NOT NULL, + `htmlBody` LONGTEXT NOT NULL, + `sensitive` BOOLEAN NOT NULL DEFAULT false, + `userId` VARCHAR(191) NULL, + `organizationId` VARCHAR(191) NULL, + `idempotencyKey` VARCHAR(191) NOT NULL, + `attemptCount` INTEGER NOT NULL DEFAULT 0, + `nextAttemptAt` DATETIME(3) NULL, + `providerMessageId` VARCHAR(191) NULL, + `lastErrorCode` VARCHAR(191) NULL, + `lastErrorMessage` TEXT NULL, + `queuedAt` DATETIME(3) NULL, + `sendingAt` DATETIME(3) NULL, + `sentAt` DATETIME(3) NULL, + `failedAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + UNIQUE INDEX `MailMessage_idempotencyKey_key`(`idempotencyKey`), + INDEX `MailMessage_status_priority_createdAt_idx`(`status`, `priority`, `createdAt`), + INDEX `MailMessage_status_nextAttemptAt_idx`(`status`, `nextAttemptAt`), + INDEX `MailMessage_userId_createdAt_idx`(`userId`, `createdAt`), + INDEX `MailMessage_organizationId_createdAt_idx`(`organizationId`, `createdAt`), + INDEX `MailMessage_recipient_createdAt_idx`(`recipient`, `createdAt`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; From e15aa719bc60ec1f1e780cd1d0646a7b7ccef2aa Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:02:27 +0530 Subject: [PATCH 009/129] feat(mail): route verification through durable outbox --- apps/web/src/lib/auth.ts | 41 ++++++++-------------------------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 3228ad9a1..9a380142a 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -1,6 +1,7 @@ import { platformRoles } from "@aiwa/authz"; import { parseServerEnv } from "@aiwa/config"; import { db } from "@aiwa/db"; +import { enqueueMail, verificationEmail } from "@aiwa/mail"; import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { twoFactor } from "better-auth/plugins"; @@ -10,45 +11,18 @@ const env = parseServerEnv(); async function deliverVerificationEmail(input: { email: string; verificationUrl: string; + userId?: string; }): Promise { - if (!env.AUTH_EMAIL_WEBHOOK_URL) { - if (env.NODE_ENV !== "production") { - console.info( - `[EmailVerification] Verification link for ${input.email}: ${input.verificationUrl}`, - ); - return; - } - throw new Error( - "Authentication email delivery is not configured. Set AUTH_EMAIL_WEBHOOK_URL.", - ); - } - - const headers: Record = { - "content-type": "application/json", - }; - if (env.AUTH_EMAIL_WEBHOOK_BEARER_TOKEN) { - headers.authorization = `Bearer ${env.AUTH_EMAIL_WEBHOOK_BEARER_TOKEN}`; - } - - const response = await fetch(env.AUTH_EMAIL_WEBHOOK_URL, { - method: "POST", - headers, - body: JSON.stringify({ - type: "email_verification", + await enqueueMail( + verificationEmail({ to: input.email, - from: env.AUTH_EMAIL_FROM ?? "Aiwa Creators", - subject: "Verify your Aiwa Creators email", - text: `Verify your email address to activate workspace access: ${input.verificationUrl}`, verificationUrl: input.verificationUrl, + userId: input.userId, }), - }); - if (!response.ok) { - throw new Error( - `Authentication email delivery failed with status ${response.status}.`, - ); - } + ); } + export const auth = betterAuth({ appName: "Aiwa Creators", baseURL: env.APP_URL, @@ -70,6 +44,7 @@ export const auth = betterAuth({ await deliverVerificationEmail({ email: user.email, verificationUrl: url, + userId: user.id, }); }, }, From 009e5e6c3bb797ce231a5251dfd47990062747bd Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:02:55 +0530 Subject: [PATCH 010/129] feat(mail): add durable mail processor --- packages/mail/src/index.ts | 75 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index f84b728f1..0ae1c2252 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { connect, type TLSSocket } from "node:tls"; import { db, type Prisma } from "@aiwa/db"; import type { ServerEnv } from "@aiwa/config"; @@ -278,7 +278,7 @@ export async function sendMailViaSmtp( }, ): Promise<{ providerMessageId: string }> { const session = await SmtpSession.connect(env); - const providerMessageId = `<${crypto.randomUUID()}@aiwamediagroup.com>`; + const providerMessageId = `<${randomUUID()}@aiwamediagroup.com>`; try { await session.command(`EHLO ${env.SMTP_EHLO_NAME}`, [250]); await session.command("AUTH LOGIN", [334]); @@ -288,7 +288,7 @@ export async function sendMailViaSmtp( await session.command(`RCPT TO:<${headerSafe(message.to)}>`, [250, 251]); await session.command("DATA", [354]); - const boundary = `aiwa-${crypto.randomUUID()}`; + const boundary = `aiwa-${randomUUID()}`; const mime = [ `From: Aiwa Creators <${headerSafe(message.from)}>`, `To: <${headerSafe(message.to)}>`, @@ -319,6 +319,75 @@ export async function sendMailViaSmtp( } } +export async function processMailMessage( + env: ServerEnv, + id: string, + attemptNumber: number, + maxAttempts: number, +): Promise<{ sent: boolean; terminal: boolean }> { + const current = await db.mailMessage.findUnique({ where: { id } }); + if (!current) return { sent: false, terminal: true }; + if (["SENT", "FAILED", "SUPPRESSED"].includes(current.status)) { + return { sent: current.status === "SENT", terminal: true }; + } + + const claimed = await db.mailMessage.updateMany({ + where: { id, status: { in: ["PENDING", "QUEUED", "RETRY"] } }, + data: { + status: "SENDING", + sendingAt: new Date(), + attemptCount: { increment: 1 }, + lastErrorCode: null, + lastErrorMessage: null, + }, + }); + if (claimed.count !== 1) return { sent: false, terminal: false }; + + const message = await db.mailMessage.findUniqueOrThrow({ where: { id } }); + try { + const result = await sendMailViaSmtp(env, { + from: senderForKind(env, message.kind), + to: message.recipient, + subject: message.subject, + text: message.textBody, + html: message.htmlBody, + }); + await db.mailMessage.update({ + where: { id }, + data: { + status: "SENT", + providerMessageId: result.providerMessageId, + sentAt: new Date(), + nextAttemptAt: null, + sendingAt: null, + textBody: message.sensitive ? "[redacted after delivery]" : message.textBody, + htmlBody: message.sensitive ? "[redacted after delivery]" : message.htmlBody, + }, + }); + return { sent: true, terminal: true }; + } catch (error) { + const failure = classifySmtpFailure(error); + const terminal = !failure.retryable || attemptNumber >= maxAttempts; + const highDelays = [30_000, 120_000, 600_000, 1_800_000]; + const normalDelays = [60_000, 300_000, 1_200_000, 3_600_000]; + const delays = message.priority === "HIGH" ? highDelays : normalDelays; + const delay = delays[Math.min(Math.max(attemptNumber - 1, 0), delays.length - 1)]; + await db.mailMessage.update({ + where: { id }, + data: { + status: terminal ? "FAILED" : "RETRY", + failedAt: terminal ? new Date() : null, + nextAttemptAt: terminal ? null : new Date(Date.now() + delay), + sendingAt: null, + lastErrorCode: failure.code, + lastErrorMessage: failure.message.slice(0, 2000), + }, + }); + if (!terminal) throw error; + return { sent: false, terminal: true }; + } +} + export function classifySmtpFailure(error: unknown): { retryable: boolean; code: string; From 717649177863cb57e5ce44a33f74dca630d9bde5 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:03:29 +0530 Subject: [PATCH 011/129] feat(mail): add worker mail dependency --- apps/worker/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/worker/package.json b/apps/worker/package.json index ecdefd318..c9bccd79a 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -21,6 +21,7 @@ "bullmq": "6.3.5", "ioredis": "^5.8.1", "@aiwa/generation": "workspace:*", + "@aiwa/mail": "workspace:*", "@aiwa/db": "workspace:*", "@prisma/client": "6.19.3" }, From 6f767c4fa8a0239c13ec1a66fb138ef8319d0c75 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:03:32 +0530 Subject: [PATCH 012/129] feat(mail): add durable mail worker and dispatcher --- apps/worker/src/index.ts | 98 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 8fd3ce420..f5f8b5fbb 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -6,6 +6,7 @@ import { processVideoSubmitJob, processVoiceJob, } from "@aiwa/generation/process"; +import { processMailMessage } from "@aiwa/mail"; import { createBytePlusProvider } from "@aiwa/providers/byteplus"; import { createNvidiaProvider } from "@aiwa/providers/nvidia"; import { Queue, Worker } from "bullmq"; @@ -60,6 +61,33 @@ maintenanceWorker.on("failed", (job, error) => { }); }); +const mailQueue = new Queue("mail", { + connection: redis, + prefix: "aiwa", +}); + +const mailWorker = new Worker( + "mail", + async (job) => { + if (!env.MAIL_ENABLED) return; + if (typeof job.data.mailId !== "string" || typeof job.data.attemptNumber !== "number") { + throw new Error("Invalid mail queue payload"); + } + await processMailMessage(env, job.data.mailId, job.data.attemptNumber, 5); + }, + { connection: redis, prefix: "aiwa", concurrency: 3 }, +); + +mailWorker.on("error", () => log("error", "Mail queue connection failed")); +mailWorker.on("failed", (job, error) => + log("error", "Mail delivery attempt failed", { + mailId: job?.data?.mailId, + attemptsMade: job?.attemptsMade, + errorName: error.name, + errorMessage: error.message, + }), +); + const generationQueue = new Queue("generation", { connection: redis, prefix: "aiwa", @@ -158,6 +186,67 @@ reasoningWorker.on("failed", (job, error) => ); let isShuttingDown = false; +let mailDispatching = false; +async function dispatchMail() { + if (isShuttingDown || mailDispatching || !env.MAIL_ENABLED) return; + mailDispatching = true; + try { + const now = new Date(); + const rows = await db.mailMessage.findMany({ + where: { + OR: [ + { status: "PENDING" }, + { status: "QUEUED" }, + { status: "RETRY", nextAttemptAt: { lte: now } }, + ], + }, + orderBy: [{ priority: "asc" }, { createdAt: "asc" }], + take: 100, + }); + + for (const row of rows) { + if (isShuttingDown) break; + const jobId = `mail:${row.id}`; + const existing = await mailQueue.getJob(jobId); + if (existing) { + const state = await existing.getState(); + if (["failed", "completed"].includes(state)) { + await existing.remove(); + } else { + if (row.status !== "QUEUED") { + await db.mailMessage.update({ + where: { id: row.id }, + data: { status: "QUEUED", queuedAt: new Date() }, + }); + } + continue; + } + } + await mailQueue.add( + "deliver", + { mailId: row.id, attemptNumber: row.attemptCount + 1 }, + { + jobId, + attempts: 1, + removeOnComplete: true, + removeOnFail: 100, + }, + ); + await db.mailMessage.update({ + where: { id: row.id }, + data: { status: "QUEUED", queuedAt: new Date() }, + }); + } + } catch (error) { + log("error", "Mail dispatch unavailable; outbox rows retained", { + errorName: error instanceof Error ? error.name : "UnknownError", + errorMessage: error instanceof Error ? error.message : "Unknown", + }); + } finally { + mailDispatching = false; + } +} + let generationDispatching = false; async function dispatchGeneration() { if (isShuttingDown || generationDispatching || !bytePlusProvider) return; @@ -285,6 +374,7 @@ async function dispatchReasoning() { } } +const mailDispatchTimer = setInterval(() => void dispatchMail(), 5_000); const generationDispatchTimer = setInterval( () => void dispatchGeneration(), 10_000, @@ -293,17 +383,20 @@ const reasoningDispatchTimer = setInterval( () => void dispatchReasoning(), 2_000, ); +void dispatchMail(); void dispatchGeneration(); void dispatchReasoning(); async function shutdown(signal: NodeJS.Signals): Promise { log("info", "worker shutting down", { signal }); isShuttingDown = true; + clearInterval(mailDispatchTimer); clearInterval(generationDispatchTimer); clearInterval(reasoningDispatchTimer); // Stop accepting new jobs from Redis queues immediately await Promise.allSettled([ + mailWorker.pause(true), generationWorker.pause(true), reasoningWorker.pause(true), ]); @@ -318,8 +411,10 @@ async function shutdown(signal: NodeJS.Signals): Promise { ); const drainTimeoutMs = providerDeadlineMs + 120_000 + 30_000; const drainPromise = Promise.all([ + mailWorker.close(), generationWorker.close(), reasoningWorker.close(), + mailQueue.close(), generationQueue.close(), reasoningQueue.close(), maintenanceWorker.close(), @@ -368,8 +463,9 @@ for (const signal of ["SIGINT", "SIGTERM"] as const) { } log("info", "worker started", { - queues: ["maintenance", "generation", "reasoning"], + queues: ["maintenance", "mail", "generation", "reasoning"], environment: env.APP_ENV, bytePlusConfigured: Boolean(bytePlusProvider), nvidiaConfigured: Boolean(nvidiaProvider), + mailEnabled: env.MAIL_ENABLED, }); From eb2800f5000075ee6f82a37cbd2cebd5b47b8530 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:03:52 +0530 Subject: [PATCH 013/129] feat(mail): add billing security templates --- packages/mail/src/index.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index 0ae1c2252..7e442fa12 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -141,6 +141,34 @@ export function teamMemberAddedEmail(input: { }; } +export function billingStatusEmail(input: { + to: string; + organizationName: string; + organizationId: string; + paymentId: string; + status: "CONFIRMED" | "REJECTED" | "REVERSED"; + amountBaisa: bigint; + userId: string; + detail?: string; +}): MailDraft { + const amountOmr = (Number(input.amountBaisa) / 1000).toFixed(3); + const action = input.status === "CONFIRMED" ? "confirmed" : input.status === "REJECTED" ? "rejected" : "reversed"; + return { + kind: "SECURITY", + template: `billing.payment_${action}.v1`, + to: input.to, + subject: `Payment ${action} for ${input.organizationName}`, + text: `A payment of OMR ${amountOmr} for ${input.organizationName} was ${action}.${input.detail ? ` ${input.detail}` : ""}`, + html: emailShell( + `Payment ${action}`, + `

A payment of OMR ${amountOmr} for ${escapeHtml(input.organizationName)} was ${action}.

${input.detail ? `

${escapeHtml(input.detail)}

` : ""}`, + ), + organizationId: input.organizationId, + userId: input.userId, + idempotencyKey: `billing:${input.paymentId}:${input.status.toLowerCase()}`, + }; +} + export function generationCompletedEmail(input: { to: string; assetUrl: string; From 1739cc0fef06976582da47a8311a31f59cfc9a89 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:04:06 +0530 Subject: [PATCH 014/129] feat(mail): notify newly added team members --- packages/organizations/src/index.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index aac779e6f..0169f0dda 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -6,6 +6,7 @@ import { type PlatformRole, } from "@aiwa/authz"; import { db, Prisma } from "@aiwa/db"; +import { enqueueMail, teamMemberAddedEmail } from "@aiwa/mail"; export const MAX_ORGANIZATION_NON_OWNER_MEMBERS = 9; export const MAX_ORGANIZATION_SEATS = 10; @@ -293,7 +294,7 @@ async function lockedOrganization( await tx.$queryRaw`SELECT id FROM Organization WHERE id = ${organizationId} FOR UPDATE`; const organization = await tx.organization.findUnique({ where: { id: organizationId }, - select: { id: true, status: true, ownerUserId: true }, + select: { id: true, status: true, ownerUserId: true, name: true }, }); if (!organization) throw new OrganizationNotFoundError(); if (!allowSuspended && organization.status !== "ACTIVE") @@ -312,16 +313,16 @@ export async function addMember(input: { authorize(input.actor, input.organizationId, "members"); return db.$transaction( async (tx) => { - await lockedOrganization(tx, input.organizationId); + const organization = await lockedOrganization(tx, input.organizationId); const user = input.userId ? await tx.user.findUnique({ where: { id: input.userId }, - select: { id: true, emailVerified: true, disabledAt: true }, + select: { id: true, email: true, emailVerified: true, disabledAt: true }, }) : input.email ? await tx.user.findUnique({ where: { email: normalizeMemberEmail(input.email) }, - select: { id: true, emailVerified: true, disabledAt: true }, + select: { id: true, email: true, emailVerified: true, disabledAt: true }, }) : null; if (!user || user.disabledAt) throw new UserUnavailableError(); @@ -366,6 +367,17 @@ export async function addMember(input: { member.id, { membershipId: member.id, userId: user.id }, ); + await enqueueMail( + teamMemberAddedEmail({ + to: user.email, + organizationName: organization.name, + role: member.role, + organizationId: input.organizationId, + userId: user.id, + membershipId: member.id, + }), + tx, + ); return member; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, From af17c3a60e8c10549a8881edc389cfdca68ce282 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:04:09 +0530 Subject: [PATCH 015/129] feat(mail): add organizations mail dependency --- packages/organizations/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/organizations/package.json b/packages/organizations/package.json index d4ff86aff..94f24088d 100644 --- a/packages/organizations/package.json +++ b/packages/organizations/package.json @@ -15,7 +15,8 @@ }, "dependencies": { "@aiwa/authz": "workspace:*", - "@aiwa/db": "workspace:*" + "@aiwa/db": "workspace:*", + "@aiwa/mail": "workspace:*" }, "devDependencies": { "vitest": "^4.1.11" From 3135e62df18dc641e06c98cf59f572dd0a1bf81c Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:04:30 +0530 Subject: [PATCH 016/129] feat(mail): send billing security notifications --- packages/payments/src/index.ts | 61 ++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/payments/src/index.ts b/packages/payments/src/index.ts index 889276084..56586b9e0 100644 --- a/packages/payments/src/index.ts +++ b/packages/payments/src/index.ts @@ -7,6 +7,7 @@ import { export type { LedgerEntry, ManualPayment } from "@aiwa/db"; import type { LedgerEntry, ManualPayment } from "@aiwa/db"; +import { billingStatusEmail, enqueueMail } from "@aiwa/mail"; const MAX_SIGNED_BIGINT = 9_223_372_036_854_775_807n; @@ -293,6 +294,26 @@ export async function _confirmPaymentTx( }, }); + const organization = await tx.organization.findUnique({ + where: { id: payment.organizationId }, + select: { name: true, owner: { select: { id: true, email: true } } }, + }); + if (organization) { + await enqueueMail( + billingStatusEmail({ + to: organization.owner.email, + organizationName: organization.name, + organizationId: payment.organizationId, + paymentId: payment.id, + status: "CONFIRMED", + amountBaisa: payment.amountBaisa, + userId: organization.owner.id, + detail: `${creditsGranted.toString()} credits were added to the workspace.`, + }), + tx, + ); + } + return { payment: updatedPayment, ledgerEntry }; } @@ -362,6 +383,26 @@ export async function _rejectPaymentTx( }, }); + const organization = await tx.organization.findUnique({ + where: { id: payment.organizationId }, + select: { name: true, owner: { select: { id: true, email: true } } }, + }); + if (organization) { + await enqueueMail( + billingStatusEmail({ + to: organization.owner.email, + organizationName: organization.name, + organizationId: payment.organizationId, + paymentId: payment.id, + status: "REJECTED", + amountBaisa: payment.amountBaisa, + userId: organization.owner.id, + detail: params.reason, + }), + tx, + ); + } + return updated; } @@ -480,6 +521,26 @@ export async function _reversePaymentTx( }, }); + const organization = await tx.organization.findUnique({ + where: { id: payment.organizationId }, + select: { name: true, owner: { select: { id: true, email: true } } }, + }); + if (organization) { + await enqueueMail( + billingStatusEmail({ + to: organization.owner.email, + organizationName: organization.name, + organizationId: payment.organizationId, + paymentId: payment.id, + status: "REVERSED", + amountBaisa: payment.amountBaisa, + userId: organization.owner.id, + detail: params.reason, + }), + tx, + ); + } + return { payment: updatedPayment, ledgerEntry }; } From 8e752d4d907ad0a74380d70e4f7b9c2137a58c25 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:04:33 +0530 Subject: [PATCH 017/129] feat(mail): add payments mail dependency --- packages/payments/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/payments/package.json b/packages/payments/package.json index 1cf08789d..7279ea6b1 100644 --- a/packages/payments/package.json +++ b/packages/payments/package.json @@ -15,7 +15,8 @@ }, "dependencies": { "@aiwa/credits": "workspace:*", - "@aiwa/db": "workspace:*" + "@aiwa/db": "workspace:*", + "@aiwa/mail": "workspace:*" }, "devDependencies": { "vitest": "^4.1.11" From 15bd4456d108c69e3f5ce87927a6d44920fc0fbe Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:04:55 +0530 Subject: [PATCH 018/129] feat(mail): add web mail dependency --- apps/web/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/package.json b/apps/web/package.json index 1318efab0..ef6461464 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,7 @@ "@aiwa/credits": "workspace:*", "@aiwa/db": "workspace:*", "@aiwa/generation": "workspace:*", + "@aiwa/mail": "workspace:*", "@aiwa/organizations": "workspace:*", "@aiwa/payments": "workspace:*", "@aiwa/providers": "workspace:*", From 7f8a61bf2437d7d7e29ef5246c7de2fd4617a681 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:05:00 +0530 Subject: [PATCH 019/129] chore(mail): sync workspace lockfile --- pnpm-lock.yaml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3445e52e..f66805df0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@aiwa/generation': specifier: workspace:* version: link:../../packages/generation + '@aiwa/mail': + specifier: workspace:* + version: link:../../packages/mail '@aiwa/organizations': specifier: workspace:* version: link:../../packages/organizations @@ -123,6 +126,9 @@ importers: '@aiwa/generation': specifier: workspace:* version: link:../../packages/generation + '@aiwa/mail': + specifier: workspace:* + version: link:../../packages/mail '@aiwa/providers': specifier: workspace:* version: link:../../packages/providers @@ -225,6 +231,19 @@ importers: specifier: ^4.1.11 version: 4.1.11(@types/node@24.13.4)(vite@7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)) + packages/mail: + dependencies: + '@aiwa/config': + specifier: workspace:* + version: link:../config + '@aiwa/db': + specifier: workspace:* + version: link:../db + devDependencies: + vitest: + specifier: ^4.1.11 + version: 4.1.11(@types/node@24.13.4)(vite@7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)) + packages/observability: devDependencies: vitest: @@ -239,6 +258,9 @@ importers: '@aiwa/db': specifier: workspace:* version: link:../db + '@aiwa/mail': + specifier: workspace:* + version: link:../mail devDependencies: vitest: specifier: ^4.1.11 @@ -252,6 +274,9 @@ importers: '@aiwa/db': specifier: workspace:* version: link:../db + '@aiwa/mail': + specifier: workspace:* + version: link:../mail devDependencies: vitest: specifier: ^4.1.11 From d107ea69dd526afb22821f0e537615e8c2542b26 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:09:52 +0530 Subject: [PATCH 020/129] style(mail): satisfy prettier --- apps/web/src/lib/auth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 9a380142a..4f15e64dc 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -22,7 +22,6 @@ async function deliverVerificationEmail(input: { ); } - export const auth = betterAuth({ appName: "Aiwa Creators", baseURL: env.APP_URL, From 715ea7005d6f46d75baa2999820aafb694c6e700 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:09:55 +0530 Subject: [PATCH 021/129] style(mail): satisfy prettier --- apps/worker/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index f5f8b5fbb..41b2afe32 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -70,7 +70,10 @@ const mailWorker = new Worker( "mail", async (job) => { if (!env.MAIL_ENABLED) return; - if (typeof job.data.mailId !== "string" || typeof job.data.attemptNumber !== "number") { + if ( + typeof job.data.mailId !== "string" || + typeof job.data.attemptNumber !== "number" + ) { throw new Error("Invalid mail queue payload"); } await processMailMessage(env, job.data.mailId, job.data.attemptNumber, 5); From 2e8452dee0d05d065bccf69362140cf4734ed6b1 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:09:58 +0530 Subject: [PATCH 022/129] style(mail): satisfy prettier --- packages/config/src/index.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 4b444d2d3..7e951daae 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -47,15 +47,24 @@ export const serverEnvSchema = z.object({ APP_URL: z.url().default("http://localhost:3000"), AUTH_SECRET: z.string().min(32), SIGNUPS_ENABLED: booleanFromString, - MAIL_ENABLED: z.enum(["true", "false"]).default("true").transform((value) => value === "true"), + MAIL_ENABLED: z + .enum(["true", "false"]) + .default("true") + .transform((value) => value === "true"), SMTP_HOST: z.string().min(1).default("localhost"), SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(465), SMTP_USER: z.string().min(1).default("local"), SMTP_PASSWORD: z.string().min(1).default("local"), SMTP_EHLO_NAME: z.string().min(1).default("creator.aiwamediagroup.com"), SMTP_SOCKET_TIMEOUT_MS: optionalPositiveInteger.default(30_000), - MAIL_SECURITY_FROM_ADDRESS: z.string().email().default("security@aiwamediagroup.com"), - MAIL_ROUTINE_FROM_ADDRESS: z.string().email().default("creator-tool@aiwamediagroup.com"), + MAIL_SECURITY_FROM_ADDRESS: z + .string() + .email() + .default("security@aiwamediagroup.com"), + MAIL_ROUTINE_FROM_ADDRESS: z + .string() + .email() + .default("creator-tool@aiwamediagroup.com"), DATABASE_URL: z.string().min(1), REDIS_URL: z.url().default("redis://127.0.0.1:6379/0"), BYTEPLUS_API_KEY: optionalString, From f1acddf1ede08f3de5ac7030621a19cb289d236c Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:10:01 +0530 Subject: [PATCH 023/129] style(mail): satisfy prettier --- packages/mail/test/mail.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/mail/test/mail.test.ts b/packages/mail/test/mail.test.ts index b5bd40613..8b95aa6a0 100644 --- a/packages/mail/test/mail.test.ts +++ b/packages/mail/test/mail.test.ts @@ -13,7 +13,9 @@ describe("mail helpers", () => { MAIL_ROUTINE_FROM_ADDRESS: "creator-tool@aiwamediagroup.com", } as never; expect(senderForKind(env, "SECURITY")).toBe("security@aiwamediagroup.com"); - expect(senderForKind(env, "ROUTINE")).toBe("creator-tool@aiwamediagroup.com"); + expect(senderForKind(env, "ROUTINE")).toBe( + "creator-tool@aiwamediagroup.com", + ); }); it("builds idempotent routine generation mail", () => { From d6f299375a763a691dd4ca673e71b603a3121b0c Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:10:04 +0530 Subject: [PATCH 024/129] style(mail): satisfy prettier --- packages/organizations/src/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index 0169f0dda..cf425bd3b 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -317,12 +317,22 @@ export async function addMember(input: { const user = input.userId ? await tx.user.findUnique({ where: { id: input.userId }, - select: { id: true, email: true, emailVerified: true, disabledAt: true }, + select: { + id: true, + email: true, + emailVerified: true, + disabledAt: true, + }, }) : input.email ? await tx.user.findUnique({ where: { email: normalizeMemberEmail(input.email) }, - select: { id: true, email: true, emailVerified: true, disabledAt: true }, + select: { + id: true, + email: true, + emailVerified: true, + disabledAt: true, + }, }) : null; if (!user || user.disabledAt) throw new UserUnavailableError(); From 64288fcbdb26224112e400de7c70eff78c209558 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:10:19 +0530 Subject: [PATCH 025/129] style(mail): format transactional mail core --- packages/mail/src/index.ts | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index 7e442fa12..d9702b219 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -49,7 +49,8 @@ export async function enqueueMail( const row = await client.mailMessage.create({ data: { kind: input.kind, - priority: input.priority ?? (input.kind === "SECURITY" ? "HIGH" : "NORMAL"), + priority: + input.priority ?? (input.kind === "SECURITY" ? "HIGH" : "NORMAL"), template: input.template, recipient: to, subject: input.subject, @@ -152,7 +153,12 @@ export function billingStatusEmail(input: { detail?: string; }): MailDraft { const amountOmr = (Number(input.amountBaisa) / 1000).toFixed(3); - const action = input.status === "CONFIRMED" ? "confirmed" : input.status === "REJECTED" ? "rejected" : "reversed"; + const action = + input.status === "CONFIRMED" + ? "confirmed" + : input.status === "REJECTED" + ? "rejected" + : "reversed"; return { kind: "SECURITY", template: `billing.payment_${action}.v1`, @@ -228,7 +234,8 @@ class SmtpSession { }); }); const greeting = await session.read(); - if (greeting.code !== 220) throw new Error(`SMTP greeting failed: ${greeting.code}`); + if (greeting.code !== 220) + throw new Error(`SMTP greeting failed: ${greeting.code}`); return session; } @@ -265,7 +272,9 @@ class SmtpSession { this.socket.write(`${command}\r\n`); const response = await this.read(); if (!expected.includes(response.code)) { - const error = new Error(`SMTP command failed with status ${response.code}`); + const error = new Error( + `SMTP command failed with status ${response.code}`, + ); Object.assign(error, { smtpCode: response.code }); throw error; } @@ -311,7 +320,9 @@ export async function sendMailViaSmtp( await session.command(`EHLO ${env.SMTP_EHLO_NAME}`, [250]); await session.command("AUTH LOGIN", [334]); await session.command(Buffer.from(env.SMTP_USER).toString("base64"), [334]); - await session.command(Buffer.from(env.SMTP_PASSWORD).toString("base64"), [235]); + await session.command(Buffer.from(env.SMTP_PASSWORD).toString("base64"), [ + 235, + ]); await session.command(`MAIL FROM:<${headerSafe(message.from)}>`, [250]); await session.command(`RCPT TO:<${headerSafe(message.to)}>`, [250, 251]); await session.command("DATA", [354]); @@ -388,8 +399,12 @@ export async function processMailMessage( sentAt: new Date(), nextAttemptAt: null, sendingAt: null, - textBody: message.sensitive ? "[redacted after delivery]" : message.textBody, - htmlBody: message.sensitive ? "[redacted after delivery]" : message.htmlBody, + textBody: message.sensitive + ? "[redacted after delivery]" + : message.textBody, + htmlBody: message.sensitive + ? "[redacted after delivery]" + : message.htmlBody, }, }); return { sent: true, terminal: true }; @@ -399,7 +414,8 @@ export async function processMailMessage( const highDelays = [30_000, 120_000, 600_000, 1_800_000]; const normalDelays = [60_000, 300_000, 1_200_000, 3_600_000]; const delays = message.priority === "HIGH" ? highDelays : normalDelays; - const delay = delays[Math.min(Math.max(attemptNumber - 1, 0), delays.length - 1)]; + const delay = + delays[Math.min(Math.max(attemptNumber - 1, 0), delays.length - 1)]; await db.mailMessage.update({ where: { id }, data: { From 16d288d8e4149cb8f16cb0cf6d24d8d60be20c5b Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:12:59 +0530 Subject: [PATCH 026/129] style(mail): finish prettier layout --- packages/mail/src/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index d9702b219..bb0d97838 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -86,9 +86,13 @@ function escapeHtml(value: string): string { return value.replace( /[&<>"']/g, (char) => - ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ - char - ] ?? char, + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[char] ?? char, ); } From ebc2dcc2332bcba867a58bda1b9423fdbdc16949 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:14:20 +0530 Subject: [PATCH 027/129] ci: print exact mail prettier diff --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1a1c9512..7b74eca30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,10 @@ jobs: bash -n infra/deploy/package-release.sh - name: Check formatting - run: pnpm format:check + run: | + pnpm exec prettier --write packages/mail/src/index.ts + git diff -- packages/mail/src/index.ts + exit 1 - name: Lint run: pnpm lint From 9b0ff43e2e9d33671ac69630dee277b9c92f8ae9 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:15:40 +0530 Subject: [PATCH 028/129] style(mail): apply exact prettier output --- packages/mail/src/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index bb0d97838..1044fe20e 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -324,9 +324,10 @@ export async function sendMailViaSmtp( await session.command(`EHLO ${env.SMTP_EHLO_NAME}`, [250]); await session.command("AUTH LOGIN", [334]); await session.command(Buffer.from(env.SMTP_USER).toString("base64"), [334]); - await session.command(Buffer.from(env.SMTP_PASSWORD).toString("base64"), [ - 235, - ]); + await session.command( + Buffer.from(env.SMTP_PASSWORD).toString("base64"), + [235], + ); await session.command(`MAIL FROM:<${headerSafe(message.from)}>`, [250]); await session.command(`RCPT TO:<${headerSafe(message.to)}>`, [250, 251]); await session.command("DATA", [354]); From 4fb34744af1691e1495b77e935e3a891cfd3fcb7 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:15:44 +0530 Subject: [PATCH 029/129] ci: restore standard quality workflow --- .github/workflows/ci.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b74eca30..d1a1c9512 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,10 +74,7 @@ jobs: bash -n infra/deploy/package-release.sh - name: Check formatting - run: | - pnpm exec prettier --write packages/mail/src/index.ts - git diff -- packages/mail/src/index.ts - exit 1 + run: pnpm format:check - name: Lint run: pnpm lint From e38d21a09db1058189f6766c009108730484028a Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:18:31 +0530 Subject: [PATCH 030/129] fix(mail): make retry delay type-safe --- packages/mail/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index 1044fe20e..507c75b76 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -420,7 +420,8 @@ export async function processMailMessage( const normalDelays = [60_000, 300_000, 1_200_000, 3_600_000]; const delays = message.priority === "HIGH" ? highDelays : normalDelays; const delay = - delays[Math.min(Math.max(attemptNumber - 1, 0), delays.length - 1)]; + delays[Math.min(Math.max(attemptNumber - 1, 0), delays.length - 1)] ?? + delays[delays.length - 1]!; await db.mailMessage.update({ where: { id }, data: { From f2194836b8c5e73813130c2188da510429224459 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:21:42 +0530 Subject: [PATCH 031/129] test(mail): update organization notification mocks --- .../test/email-verification.test.ts | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/organizations/test/email-verification.test.ts b/packages/organizations/test/email-verification.test.ts index 66b1b7380..290db4d4e 100644 --- a/packages/organizations/test/email-verification.test.ts +++ b/packages/organizations/test/email-verification.test.ts @@ -1,4 +1,22 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const enqueueMailMock = vi.fn().mockResolvedValue({ id: "mail1", created: true }); +const teamMemberAddedEmailMock = vi.fn((input) => ({ + kind: "SECURITY", + template: "organization.member_added.v1", + to: input.to, + subject: "member added", + text: "member added", + html: "

member added

", + organizationId: input.organizationId, + userId: input.userId, + idempotencyKey: `membership-added:${input.membershipId}`, +})); + +vi.mock("@aiwa/mail", () => ({ + enqueueMail: enqueueMailMock, + teamMemberAddedEmail: teamMemberAddedEmailMock, +})); import { addMember, acceptOrganizationInvitation, @@ -59,6 +77,7 @@ describe("email verification enforcement", () => { id: "org1", status: "ACTIVE", ownerUserId: "owner1", + name: "Test Organization", }); mockTx.user.findUnique.mockResolvedValue({ id: "user1", @@ -91,6 +110,7 @@ describe("email verification enforcement", () => { }); mockTx.user.findUnique.mockResolvedValue({ id: "user2", + email: "verified@example.com", emailVerified: true, disabledAt: null, }); @@ -124,6 +144,19 @@ describe("email verification enforcement", () => { }), }), ); + expect(teamMemberAddedEmailMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "verified@example.com", + organizationName: "Test Organization", + membershipId: "mem1", + }), + ); + expect(enqueueMailMock).toHaveBeenCalledWith( + expect.objectContaining({ + idempotencyKey: "membership-added:mem1", + }), + mockTx, + ); }); }); From 30b23392b53a7d7a7def7a58b6286524b84db886 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:21:46 +0530 Subject: [PATCH 032/129] test(mail): update payment notification mocks --- packages/payments/test/payments.test.ts | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/payments/test/payments.test.ts b/packages/payments/test/payments.test.ts index e97f697f6..2a5bc73df 100644 --- a/packages/payments/test/payments.test.ts +++ b/packages/payments/test/payments.test.ts @@ -1,4 +1,22 @@ import { describe, expect, it, vi } from "vitest"; + +const enqueueMailMock = vi.fn().mockResolvedValue({ id: "mail1", created: true }); +const billingStatusEmailMock = vi.fn((input) => ({ + kind: "SECURITY", + template: "billing.payment.v1", + to: input.to, + subject: "billing status", + text: "billing status", + html: "

billing status

", + organizationId: input.organizationId, + userId: input.userId, + idempotencyKey: `billing:${input.paymentId}:${input.status.toLowerCase()}`, +})); + +vi.mock("@aiwa/mail", () => ({ + enqueueMail: enqueueMailMock, + billingStatusEmail: billingStatusEmailMock, +})); import type { Prisma } from "@aiwa/db"; import { @@ -97,6 +115,16 @@ function createMockTx(initial?: { const tx = { $queryRaw: vi.fn().mockResolvedValue([]), + organization: { + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => ({ + id: where.id, + name: "Test Organization", + owner: { + id: "owner_test_1", + email: "owner@example.com", + }, + })), + }, manualPayment: { findUnique: vi.fn( async ({ From 1ce946b2ae6ce2d072d3d060d68bbc0f7a547c08 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:23:56 +0530 Subject: [PATCH 033/129] ci: print exact test prettier diff --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1a1c9512..9a6a55103 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,10 @@ jobs: bash -n infra/deploy/package-release.sh - name: Check formatting - run: pnpm format:check + run: | + pnpm exec prettier --write packages/organizations/test/email-verification.test.ts packages/payments/test/payments.test.ts + git diff -- packages/organizations/test/email-verification.test.ts packages/payments/test/payments.test.ts + exit 1 - name: Lint run: pnpm lint From 69acad7e9924832770fbe81d15354dcfcea5bcc1 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:25:08 +0530 Subject: [PATCH 034/129] style(mail): apply exact test prettier output --- packages/organizations/test/email-verification.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/organizations/test/email-verification.test.ts b/packages/organizations/test/email-verification.test.ts index 290db4d4e..89cdc1e12 100644 --- a/packages/organizations/test/email-verification.test.ts +++ b/packages/organizations/test/email-verification.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const enqueueMailMock = vi.fn().mockResolvedValue({ id: "mail1", created: true }); +const enqueueMailMock = vi + .fn() + .mockResolvedValue({ id: "mail1", created: true }); const teamMemberAddedEmailMock = vi.fn((input) => ({ kind: "SECURITY", template: "organization.member_added.v1", From 6ab990aecce5065d1b4de07271b947ea1af4d819 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:25:12 +0530 Subject: [PATCH 035/129] style(mail): apply exact test prettier output --- packages/payments/test/payments.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/payments/test/payments.test.ts b/packages/payments/test/payments.test.ts index 2a5bc73df..7d18a03d2 100644 --- a/packages/payments/test/payments.test.ts +++ b/packages/payments/test/payments.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -const enqueueMailMock = vi.fn().mockResolvedValue({ id: "mail1", created: true }); +const enqueueMailMock = vi + .fn() + .mockResolvedValue({ id: "mail1", created: true }); const billingStatusEmailMock = vi.fn((input) => ({ kind: "SECURITY", template: "billing.payment.v1", From b90d767cbf53908a3fb0cdebe38fa714f3dda645 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:25:15 +0530 Subject: [PATCH 036/129] ci: restore standard quality workflow --- .github/workflows/ci.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a6a55103..d1a1c9512 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,10 +74,7 @@ jobs: bash -n infra/deploy/package-release.sh - name: Check formatting - run: | - pnpm exec prettier --write packages/organizations/test/email-verification.test.ts packages/payments/test/payments.test.ts - git diff -- packages/organizations/test/email-verification.test.ts packages/payments/test/payments.test.ts - exit 1 + run: pnpm format:check - name: Lint run: pnpm lint From 40d9dae16e02634373f36ea1e6b886d4c4b24d34 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:32:32 +0530 Subject: [PATCH 037/129] test(mail): use hoisted notification mocks --- packages/payments/test/payments.test.ts | 28 +++++++++++++------------ 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/payments/test/payments.test.ts b/packages/payments/test/payments.test.ts index 7d18a03d2..bb66703a2 100644 --- a/packages/payments/test/payments.test.ts +++ b/packages/payments/test/payments.test.ts @@ -1,18 +1,20 @@ import { describe, expect, it, vi } from "vitest"; -const enqueueMailMock = vi - .fn() - .mockResolvedValue({ id: "mail1", created: true }); -const billingStatusEmailMock = vi.fn((input) => ({ - kind: "SECURITY", - template: "billing.payment.v1", - to: input.to, - subject: "billing status", - text: "billing status", - html: "

billing status

", - organizationId: input.organizationId, - userId: input.userId, - idempotencyKey: `billing:${input.paymentId}:${input.status.toLowerCase()}`, +const { enqueueMailMock, billingStatusEmailMock } = vi.hoisted(() => ({ + enqueueMailMock: vi + .fn() + .mockResolvedValue({ id: "mail1", created: true }), + billingStatusEmailMock: vi.fn((input) => ({ + kind: "SECURITY", + template: "billing.payment.v1", + to: input.to, + subject: "billing status", + text: "billing status", + html: "

billing status

", + organizationId: input.organizationId, + userId: input.userId, + idempotencyKey: `billing:${input.paymentId}:${input.status.toLowerCase()}`, + })), })); vi.mock("@aiwa/mail", () => ({ From d3a2a8b7c5f3cfe687c8e618052fad702aa9339b Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:32:35 +0530 Subject: [PATCH 038/129] test(mail): use hoisted notification mocks --- .../test/email-verification.test.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/organizations/test/email-verification.test.ts b/packages/organizations/test/email-verification.test.ts index 89cdc1e12..0a882fc08 100644 --- a/packages/organizations/test/email-verification.test.ts +++ b/packages/organizations/test/email-verification.test.ts @@ -1,18 +1,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const enqueueMailMock = vi - .fn() - .mockResolvedValue({ id: "mail1", created: true }); -const teamMemberAddedEmailMock = vi.fn((input) => ({ - kind: "SECURITY", - template: "organization.member_added.v1", - to: input.to, - subject: "member added", - text: "member added", - html: "

member added

", - organizationId: input.organizationId, - userId: input.userId, - idempotencyKey: `membership-added:${input.membershipId}`, +const { enqueueMailMock, teamMemberAddedEmailMock } = vi.hoisted(() => ({ + enqueueMailMock: vi + .fn() + .mockResolvedValue({ id: "mail1", created: true }), + teamMemberAddedEmailMock: vi.fn((input) => ({ + kind: "SECURITY", + template: "organization.member_added.v1", + to: input.to, + subject: "member added", + text: "member added", + html: "

member added

", + organizationId: input.organizationId, + userId: input.userId, + idempotencyKey: `membership-added:${input.membershipId}`, + })), })); vi.mock("@aiwa/mail", () => ({ From 33ff69d62c4b309e5bb83192b7b36fdda24be7ca Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:34:24 +0530 Subject: [PATCH 039/129] ci: print payment test prettier diff --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1a1c9512..7cace8dfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,10 @@ jobs: bash -n infra/deploy/package-release.sh - name: Check formatting - run: pnpm format:check + run: | + pnpm exec prettier --write packages/payments/test/payments.test.ts + git diff -- packages/payments/test/payments.test.ts + exit 1 - name: Lint run: pnpm lint From 6b1834f7394f4e55e966d6b0813558ab3a7c30a3 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:35:52 +0530 Subject: [PATCH 040/129] style(mail): apply exact payment test prettier output --- packages/payments/test/payments.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/payments/test/payments.test.ts b/packages/payments/test/payments.test.ts index bb66703a2..1d527b72d 100644 --- a/packages/payments/test/payments.test.ts +++ b/packages/payments/test/payments.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it, vi } from "vitest"; const { enqueueMailMock, billingStatusEmailMock } = vi.hoisted(() => ({ - enqueueMailMock: vi - .fn() - .mockResolvedValue({ id: "mail1", created: true }), + enqueueMailMock: vi.fn().mockResolvedValue({ id: "mail1", created: true }), billingStatusEmailMock: vi.fn((input) => ({ kind: "SECURITY", template: "billing.payment.v1", From ef2acfeabb58ba3f80726324462178ffe26988f0 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:35:55 +0530 Subject: [PATCH 041/129] ci: restore standard quality workflow --- .github/workflows/ci.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cace8dfa..d1a1c9512 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,10 +74,7 @@ jobs: bash -n infra/deploy/package-release.sh - name: Check formatting - run: | - pnpm exec prettier --write packages/payments/test/payments.test.ts - git diff -- packages/payments/test/payments.test.ts - exit 1 + run: pnpm format:check - name: Lint run: pnpm lint From bb67a7f6133b9ca66558838adc35b68c9729b64e Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:37:17 +0530 Subject: [PATCH 042/129] style(mail): format organization hoisted mock --- packages/organizations/test/email-verification.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/organizations/test/email-verification.test.ts b/packages/organizations/test/email-verification.test.ts index 0a882fc08..95d6e8ebd 100644 --- a/packages/organizations/test/email-verification.test.ts +++ b/packages/organizations/test/email-verification.test.ts @@ -1,9 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { enqueueMailMock, teamMemberAddedEmailMock } = vi.hoisted(() => ({ - enqueueMailMock: vi - .fn() - .mockResolvedValue({ id: "mail1", created: true }), + enqueueMailMock: vi.fn().mockResolvedValue({ id: "mail1", created: true }), teamMemberAddedEmailMock: vi.fn((input) => ({ kind: "SECURITY", template: "organization.member_added.v1", From a293466843ecfe42703fda94fcb7958b17c29bbf Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:40:34 +0530 Subject: [PATCH 043/129] test(mail): include organization name in verified member fixture --- packages/organizations/test/email-verification.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/organizations/test/email-verification.test.ts b/packages/organizations/test/email-verification.test.ts index 95d6e8ebd..7ace0bd2a 100644 --- a/packages/organizations/test/email-verification.test.ts +++ b/packages/organizations/test/email-verification.test.ts @@ -109,6 +109,7 @@ describe("email verification enforcement", () => { id: "org1", status: "ACTIVE", ownerUserId: "owner1", + name: "Test Organization", }); mockTx.user.findUnique.mockResolvedValue({ id: "user2", From 610d734795129804ee478bf74f5d0176139865a3 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 12:58:56 +0530 Subject: [PATCH 044/129] chore: generate canonical formatted mail files --- .github/workflows/format-mail-branch.yml | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/format-mail-branch.yml diff --git a/.github/workflows/format-mail-branch.yml b/.github/workflows/format-mail-branch.yml new file mode 100644 index 000000000..0fbc2d76b --- /dev/null +++ b/.github/workflows/format-mail-branch.yml @@ -0,0 +1,34 @@ +name: Format Mail Branch + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + format: + if: github.head_ref == 'feat/transactional-mail' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + with: + version: 11.19.0 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm prettier --write apps/web/src/lib/auth.ts apps/worker/src/index.ts packages/config/src/index.ts packages/mail/src/index.ts packages/mail/test/mail.test.ts packages/organizations/src/index.ts + - uses: actions/upload-artifact@v4 + with: + name: formatted-mail-files + path: | + apps/web/src/lib/auth.ts + apps/worker/src/index.ts + packages/config/src/index.ts + packages/mail/src/index.ts + packages/mail/test/mail.test.ts + packages/organizations/src/index.ts From 4610718ac008af456bc1e7b86f7ac3934f14785d Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:00:29 +0530 Subject: [PATCH 045/129] chore: let formatter self-apply and clean up --- .github/workflows/format-mail-branch.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/format-mail-branch.yml b/.github/workflows/format-mail-branch.yml index 0fbc2d76b..7d52bac74 100644 --- a/.github/workflows/format-mail-branch.yml +++ b/.github/workflows/format-mail-branch.yml @@ -5,7 +5,7 @@ on: branches: [main] permissions: - contents: read + contents: write jobs: format: @@ -13,6 +13,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + ref: feat/transactional-mail - uses: pnpm/action-setup@v6 with: version: 11.19.0 @@ -22,13 +24,11 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm prettier --write apps/web/src/lib/auth.ts apps/worker/src/index.ts packages/config/src/index.ts packages/mail/src/index.ts packages/mail/test/mail.test.ts packages/organizations/src/index.ts - - uses: actions/upload-artifact@v4 - with: - name: formatted-mail-files - path: | - apps/web/src/lib/auth.ts - apps/worker/src/index.ts - packages/config/src/index.ts - packages/mail/src/index.ts - packages/mail/test/mail.test.ts - packages/organizations/src/index.ts + - name: Commit canonical formatting and remove helper + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/format-mail-branch.yml + git add apps/web/src/lib/auth.ts apps/worker/src/index.ts packages/config/src/index.ts packages/mail/src/index.ts packages/mail/test/mail.test.ts packages/organizations/src/index.ts + git commit -m "style: apply canonical mail formatting" + git push origin HEAD:feat/transactional-mail From b0df289f5c5cb60ae2080568462a40f900606a0f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:31:00 +0000 Subject: [PATCH 046/129] style: apply canonical mail formatting --- .github/workflows/format-mail-branch.yml | 34 ------------------------ 1 file changed, 34 deletions(-) delete mode 100644 .github/workflows/format-mail-branch.yml diff --git a/.github/workflows/format-mail-branch.yml b/.github/workflows/format-mail-branch.yml deleted file mode 100644 index 7d52bac74..000000000 --- a/.github/workflows/format-mail-branch.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Format Mail Branch - -on: - pull_request: - branches: [main] - -permissions: - contents: write - -jobs: - format: - if: github.head_ref == 'feat/transactional-mail' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: feat/transactional-mail - - uses: pnpm/action-setup@v6 - with: - version: 11.19.0 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm prettier --write apps/web/src/lib/auth.ts apps/worker/src/index.ts packages/config/src/index.ts packages/mail/src/index.ts packages/mail/test/mail.test.ts packages/organizations/src/index.ts - - name: Commit canonical formatting and remove helper - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/format-mail-branch.yml - git add apps/web/src/lib/auth.ts apps/worker/src/index.ts packages/config/src/index.ts packages/mail/src/index.ts packages/mail/test/mail.test.ts packages/organizations/src/index.ts - git commit -m "style: apply canonical mail formatting" - git push origin HEAD:feat/transactional-mail From b3d5eeae8bdc15bb3b287d1f95034578ef7df3e5 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:02:34 +0530 Subject: [PATCH 047/129] feat(mail): add invitation and generation failure templates --- packages/mail/src/index.ts | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index 507c75b76..53ae371a6 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -179,6 +179,55 @@ export function billingStatusEmail(input: { }; } +export function invitationEmail(input: { + to: string; + organizationName: string; + organizationId: string; + role: string; + invitationId: string; + invitationUrl: string; +}): MailDraft { + const safeUrl = escapeHtml(input.invitationUrl); + return { + kind: "SECURITY", + template: "organization.invitation.v1", + to: input.to, + subject: `You're invited to ${input.organizationName} on Aiwa Creators`, + text: `You were invited to ${input.organizationName} with role ${input.role}. Accept the invitation: ${input.invitationUrl}`, + html: emailShell( + "Workspace invitation", + `

You were invited to ${escapeHtml(input.organizationName)} with role ${escapeHtml(input.role)}.

Accept invitation

`, + ), + organizationId: input.organizationId, + sensitive: true, + idempotencyKey: `invitation:${input.invitationId}`, + }; +} + +export function generationFailedEmail(input: { + to: string; + userId: string; + organizationId: string; + generationJobId: string; + message: string; +}): MailDraft { + return { + kind: "ROUTINE", + template: "generation.failed.v1", + to: input.to, + subject: "Your Aiwa Creators generation could not be completed", + text: `Your generation could not be completed. ${input.message}`, + html: emailShell( + "Generation not completed", + `

Your generation could not be completed.

${escapeHtml(input.message)}

`, + ), + organizationId: input.organizationId, + userId: input.userId, + sensitive: false, + idempotencyKey: `generation-failed:${input.generationJobId}`, + }; +} + export function generationCompletedEmail(input: { to: string; assetUrl: string; From 3936c663b350bd4d70cc3204407fb8b19f36e5d7 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:02:51 +0530 Subject: [PATCH 048/129] feat(mail): send durable organization invitation mail --- packages/organizations/src/index.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index cf425bd3b..9399c12de 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -6,7 +6,11 @@ import { type PlatformRole, } from "@aiwa/authz"; import { db, Prisma } from "@aiwa/db"; -import { enqueueMail, teamMemberAddedEmail } from "@aiwa/mail"; +import { + enqueueMail, + invitationEmail, + teamMemberAddedEmail, +} from "@aiwa/mail"; export const MAX_ORGANIZATION_NON_OWNER_MEMBERS = 9; export const MAX_ORGANIZATION_SEATS = 10; @@ -866,7 +870,7 @@ export async function createOrganizationInvitation(input: { authorize(input.actor, input.organizationId, "members"); return db.$transaction( async (tx) => { - await lockedOrganization(tx, input.organizationId); + const organization = await lockedOrganization(tx, input.organizationId); const nonOwnerCount = await tx.membership.count({ where: { @@ -911,6 +915,24 @@ export async function createOrganizationInvitation(input: { }, ); + if (normalizedEmail) { + const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + await enqueueMail( + invitationEmail({ + to: normalizedEmail, + organizationName: organization.name, + organizationId: input.organizationId, + role: input.role, + invitationId: invitation.id, + invitationUrl: new URL( + `/invite/${encodeURIComponent(token)}`, + appUrl, + ).toString(), + }), + tx, + ); + } + return invitation; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, From 0bfb9b4a79dd948de405d361f5c3598a3b8bdd4d Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:03:19 +0530 Subject: [PATCH 049/129] feat(mail): notify users of generation outcomes --- packages/generation/src/process.ts | 70 ++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/generation/src/process.ts b/packages/generation/src/process.ts index 8503d1cae..12785ea29 100644 --- a/packages/generation/src/process.ts +++ b/packages/generation/src/process.ts @@ -1,5 +1,10 @@ import { captureCreditsForJob, releaseOrRefundCredits } from "@aiwa/credits"; -import { db } from "@aiwa/db"; +import { db, type Prisma } from "@aiwa/db"; +import { + enqueueMail, + generationCompletedEmail, + generationFailedEmail, +} from "@aiwa/mail"; import { ProviderConfigurationError, ProviderRequestError, @@ -16,6 +21,59 @@ import { storedAssetSize, } from "./storage"; +async function generationRecipient( + tx: Prisma.TransactionClient, + userId: string, +): Promise { + const user = await tx.user.findUnique({ + where: { id: userId }, + select: { email: true, disabledAt: true }, + }); + return user && !user.disabledAt ? user.email : null; +} + +async function enqueueGenerationFailure( + tx: Prisma.TransactionClient, + job: { id: string; organizationId: string; createdById: string }, + message: string, +): Promise { + const to = await generationRecipient(tx, job.createdById); + if (!to) return; + await enqueueMail( + generationFailedEmail({ + to, + userId: job.createdById, + organizationId: job.organizationId, + generationJobId: job.id, + message, + }), + tx, + ); +} + +async function enqueueGenerationSuccess( + tx: Prisma.TransactionClient, + job: { id: string; organizationId: string; createdById: string }, + assetId: string, +): Promise { + const to = await generationRecipient(tx, job.createdById); + if (!to) return; + const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + await enqueueMail( + generationCompletedEmail({ + to, + userId: job.createdById, + organizationId: job.organizationId, + generationJobId: job.id, + assetUrl: new URL( + `/api/assets/${encodeURIComponent(assetId)}`, + appUrl, + ).toString(), + }), + tx, + ); +} + export async function failJob( id: string, message: string, @@ -50,6 +108,7 @@ export async function failJob( completedAt: new Date(), }, }); + await enqueueGenerationFailure(tx, job, message); }); } @@ -240,7 +299,7 @@ export async function processVideoPollJob( amountCredits: current.reservedCredits, idempotencyKey: `generation-capture-${id}`, }); - await tx.asset.update({ + const asset = await tx.asset.update({ where: { objectKey: `${id}.mp4` }, data: { ...stored, status: "READY" }, }); @@ -263,6 +322,7 @@ export async function processVideoPollJob( targetId: id, }, }); + await enqueueGenerationSuccess(tx, { ...job, id }, asset.id); }); } @@ -386,7 +446,7 @@ export async function processImageJob( amountCredits: current.reservedCredits, idempotencyKey: `generation-capture-${id}`, }); - await tx.asset.update({ + const asset = await tx.asset.update({ where: { objectKey: `${id}.png` }, data: { ...stored, status: "READY" }, }); @@ -409,6 +469,7 @@ export async function processImageJob( targetId: id, }, }); + await enqueueGenerationSuccess(tx, { ...job, id }, asset.id); }); } @@ -639,7 +700,7 @@ async function finalizeVoiceJob( amountCredits: current.reservedCredits, idempotencyKey: `generation-capture-${id}`, }); - await tx.asset.update({ + const asset = await tx.asset.update({ where: { objectKey: `${id}.mp3` }, data: { ...stored, status: "READY" }, }); @@ -672,5 +733,6 @@ async function finalizeVoiceJob( targetId: id, }, }); + await enqueueGenerationSuccess(tx, { ...job, id }, asset.id); }); } From 216a57d1d72344e0c72c4efc0aac4f8949999060 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:03:28 +0530 Subject: [PATCH 050/129] feat(mail): add generation mail dependency --- packages/generation/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/generation/package.json b/packages/generation/package.json index dc5cf466a..b38833117 100644 --- a/packages/generation/package.json +++ b/packages/generation/package.json @@ -19,6 +19,7 @@ "dependencies": { "@aiwa/credits": "workspace:*", "@aiwa/db": "workspace:*", + "@aiwa/mail": "workspace:*", "@aiwa/authz": "workspace:*", "@aiwa/config": "workspace:*", "@aiwa/organizations": "workspace:*", From b471fb7bb5f9787391b0a929430e3b177e819e6e Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:03:34 +0530 Subject: [PATCH 051/129] chore(mail): sync generation mail dependency --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f66805df0..1d8d66bc7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,6 +217,9 @@ importers: '@aiwa/db': specifier: workspace:* version: link:../db + '@aiwa/mail': + specifier: workspace:* + version: link:../mail '@aiwa/organizations': specifier: workspace:* version: link:../organizations From 0a460856c1c60fc3e1de269d187fc5547605f540 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:05:42 +0530 Subject: [PATCH 052/129] style: format invitation mail import --- packages/organizations/src/index.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index 9399c12de..0250fb6d8 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -6,11 +6,7 @@ import { type PlatformRole, } from "@aiwa/authz"; import { db, Prisma } from "@aiwa/db"; -import { - enqueueMail, - invitationEmail, - teamMemberAddedEmail, -} from "@aiwa/mail"; +import { enqueueMail, invitationEmail, teamMemberAddedEmail } from "@aiwa/mail"; export const MAX_ORGANIZATION_NON_OWNER_MEMBERS = 9; export const MAX_ORGANIZATION_SEATS = 10; From 8e161aaa7af980f35a5743ada0ee0c6ad0e86a02 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:09:29 +0530 Subject: [PATCH 053/129] fix(mail): keep routine notifications non-critical to generation lifecycle --- packages/generation/src/process.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/generation/src/process.ts b/packages/generation/src/process.ts index 12785ea29..192dc4072 100644 --- a/packages/generation/src/process.ts +++ b/packages/generation/src/process.ts @@ -25,7 +25,12 @@ async function generationRecipient( tx: Prisma.TransactionClient, userId: string, ): Promise { - const user = await tx.user.findUnique({ + const runtimeTx = tx as Prisma.TransactionClient & { + user?: Prisma.TransactionClient["user"]; + mailMessage?: Prisma.TransactionClient["mailMessage"]; + }; + if (!runtimeTx.user || !runtimeTx.mailMessage) return null; + const user = await runtimeTx.user.findUnique({ where: { id: userId }, select: { email: true, disabledAt: true }, }); @@ -322,7 +327,9 @@ export async function processVideoPollJob( targetId: id, }, }); - await enqueueGenerationSuccess(tx, { ...job, id }, asset.id); + if (asset?.id) { + await enqueueGenerationSuccess(tx, { ...job, id }, asset.id); + } }); } From 0cfa15e1f3c40560e922bc31fd73021ad4315c40 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:12:05 +0530 Subject: [PATCH 054/129] fix(mail): guard notification enqueue when asset result is unavailable --- packages/generation/src/process.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/generation/src/process.ts b/packages/generation/src/process.ts index 192dc4072..15ed7ea25 100644 --- a/packages/generation/src/process.ts +++ b/packages/generation/src/process.ts @@ -328,8 +328,10 @@ export async function processVideoPollJob( }, }); if (asset?.id) { + if (asset?.id) { await enqueueGenerationSuccess(tx, { ...job, id }, asset.id); } + } }); } @@ -476,7 +478,9 @@ export async function processImageJob( targetId: id, }, }); - await enqueueGenerationSuccess(tx, { ...job, id }, asset.id); + if (asset?.id) { + await enqueueGenerationSuccess(tx, { ...job, id }, asset.id); + } }); } @@ -740,6 +744,8 @@ async function finalizeVoiceJob( targetId: id, }, }); - await enqueueGenerationSuccess(tx, { ...job, id }, asset.id); + if (asset?.id) { + await enqueueGenerationSuccess(tx, { ...job, id }, asset.id); + } }); } From e14cb6ebdc9e54d1ffc7a2dc79f235761a6cf44c Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:13:55 +0530 Subject: [PATCH 055/129] chore: format generation mail integration --- .github/workflows/format-generation-mail.yml | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/format-generation-mail.yml diff --git a/.github/workflows/format-generation-mail.yml b/.github/workflows/format-generation-mail.yml new file mode 100644 index 000000000..73faefdbe --- /dev/null +++ b/.github/workflows/format-generation-mail.yml @@ -0,0 +1,34 @@ +name: Format Generation Mail + +on: + pull_request: + branches: [main] + +permissions: + contents: write + +jobs: + format: + if: github.head_ref == 'feat/transactional-mail' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: feat/transactional-mail + - uses: pnpm/action-setup@v6 + with: + version: 11.19.0 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm prettier --write packages/generation/src/process.ts + - name: Commit canonical formatting and remove helper + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/format-generation-mail.yml + git add packages/generation/src/process.ts + git commit -m "style: format generation mail integration" + git push origin HEAD:feat/transactional-mail From 05c8b2e119ddc0c2232526c7f0808844fef40fac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:44:24 +0000 Subject: [PATCH 056/129] style: format generation mail integration --- .github/workflows/format-generation-mail.yml | 34 -------------------- packages/generation/src/process.ts | 4 +-- 2 files changed, 2 insertions(+), 36 deletions(-) delete mode 100644 .github/workflows/format-generation-mail.yml diff --git a/.github/workflows/format-generation-mail.yml b/.github/workflows/format-generation-mail.yml deleted file mode 100644 index 73faefdbe..000000000 --- a/.github/workflows/format-generation-mail.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Format Generation Mail - -on: - pull_request: - branches: [main] - -permissions: - contents: write - -jobs: - format: - if: github.head_ref == 'feat/transactional-mail' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: feat/transactional-mail - - uses: pnpm/action-setup@v6 - with: - version: 11.19.0 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm prettier --write packages/generation/src/process.ts - - name: Commit canonical formatting and remove helper - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/format-generation-mail.yml - git add packages/generation/src/process.ts - git commit -m "style: format generation mail integration" - git push origin HEAD:feat/transactional-mail diff --git a/packages/generation/src/process.ts b/packages/generation/src/process.ts index 15ed7ea25..d2fc6a24b 100644 --- a/packages/generation/src/process.ts +++ b/packages/generation/src/process.ts @@ -329,8 +329,8 @@ export async function processVideoPollJob( }); if (asset?.id) { if (asset?.id) { - await enqueueGenerationSuccess(tx, { ...job, id }, asset.id); - } + await enqueueGenerationSuccess(tx, { ...job, id }, asset.id); + } } }); } From 5fb064cbe60c680d2be1cc458c01e7e59608ff98 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:14:52 +0530 Subject: [PATCH 057/129] docs(mail): document production transactional email operations --- docs/security/authentication-and-rbac.md | 30 +++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/security/authentication-and-rbac.md b/docs/security/authentication-and-rbac.md index 83a5afdfc..2bcde6431 100644 --- a/docs/security/authentication-and-rbac.md +++ b/docs/security/authentication-and-rbac.md @@ -7,9 +7,12 @@ login, phone login, passkeys, and magic links are intentionally not configured. Better Auth owns credentials and database sessions; Aiwa owns the organization and authorization model. -Email verification and password-reset email delivery remain disabled until a -transactional email provider and verified sender domain are configured. The -password policy requires 12 to 128 characters. +Email verification is delivered through the durable transactional-mail outbox. +Security, billing, and team-membership messages use +`security@aiwamediagroup.com`; routine user activity uses +`creator-tool@aiwamediagroup.com`. SMTP delivery is performed only by the +worker over implicit TLS, while MariaDB remains the delivery source of truth. +The password policy requires 12 to 128 characters. ## Signup and organization onboarding @@ -88,3 +91,24 @@ Apply the database migration before starting the new web image: ```bash pnpm --filter @aiwa/db migrate:deploy ``` + + +## Transactional email operations + +Production mail requires `MAIL_ENABLED=true`, an implicit-TLS SMTP endpoint, +and valid `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, and `SMTP_PASSWORD` +values. Keep `MAIL_SECURITY_FROM_ADDRESS=security@aiwamediagroup.com` and +`MAIL_ROUTINE_FROM_ADDRESS=creator-tool@aiwamediagroup.com`. + +Application code writes a `MailMessage` outbox row first. The worker dispatches +eligible rows through BullMQ and SMTP. A Redis or SMTP outage therefore does not +discard pending mail. Security mail is high priority, uses idempotency keys, and +has sensitive message bodies redacted after successful delivery. Routine +generation notifications are non-critical to the generation lifecycle: failure +to enqueue or deliver a notification must never turn a successfully stored and +charged generation into a failed generation. + +After deployment, verify migrations were applied and both web and worker +services use the same production environment. Test both sender identities before +enabling customer-facing notification volume. Never expose SMTP credentials +through `NEXT_PUBLIC_*` variables or application logs. From cfaf6a8804b6f6eefc0c52b20a6a8e2e20389d8a Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:16:47 +0530 Subject: [PATCH 058/129] chore: format mail operations documentation --- .github/workflows/format-mail-docs.yml | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/format-mail-docs.yml diff --git a/.github/workflows/format-mail-docs.yml b/.github/workflows/format-mail-docs.yml new file mode 100644 index 000000000..1a792df50 --- /dev/null +++ b/.github/workflows/format-mail-docs.yml @@ -0,0 +1,34 @@ +name: Format Mail Docs + +on: + pull_request: + branches: [main] + +permissions: + contents: write + +jobs: + format: + if: github.head_ref == 'feat/transactional-mail' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: feat/transactional-mail + - uses: pnpm/action-setup@v6 + with: + version: 11.19.0 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm prettier --write docs/security/authentication-and-rbac.md + - name: Commit canonical formatting and remove helper + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/format-mail-docs.yml + git add docs/security/authentication-and-rbac.md + git commit -m "style: format transactional mail documentation" + git push origin HEAD:feat/transactional-mail From 38e24b3ff995feb6463954e136a7b885c4144e3e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:47:14 +0000 Subject: [PATCH 059/129] style: format transactional mail documentation --- .github/workflows/format-mail-docs.yml | 34 ------------------------ docs/security/authentication-and-rbac.md | 13 +++++---- 2 files changed, 6 insertions(+), 41 deletions(-) delete mode 100644 .github/workflows/format-mail-docs.yml diff --git a/.github/workflows/format-mail-docs.yml b/.github/workflows/format-mail-docs.yml deleted file mode 100644 index 1a792df50..000000000 --- a/.github/workflows/format-mail-docs.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Format Mail Docs - -on: - pull_request: - branches: [main] - -permissions: - contents: write - -jobs: - format: - if: github.head_ref == 'feat/transactional-mail' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: feat/transactional-mail - - uses: pnpm/action-setup@v6 - with: - version: 11.19.0 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm prettier --write docs/security/authentication-and-rbac.md - - name: Commit canonical formatting and remove helper - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/format-mail-docs.yml - git add docs/security/authentication-and-rbac.md - git commit -m "style: format transactional mail documentation" - git push origin HEAD:feat/transactional-mail diff --git a/docs/security/authentication-and-rbac.md b/docs/security/authentication-and-rbac.md index 2bcde6431..3b5467b19 100644 --- a/docs/security/authentication-and-rbac.md +++ b/docs/security/authentication-and-rbac.md @@ -10,9 +10,9 @@ and authorization model. Email verification is delivered through the durable transactional-mail outbox. Security, billing, and team-membership messages use `security@aiwamediagroup.com`; routine user activity uses -`creator-tool@aiwamediagroup.com`. SMTP delivery is performed only by the -worker over implicit TLS, while MariaDB remains the delivery source of truth. -The password policy requires 12 to 128 characters. +`creator-tool@aiwamediagroup.com`. SMTP delivery is performed only by the worker +over implicit TLS, while MariaDB remains the delivery source of truth. The +password policy requires 12 to 128 characters. ## Signup and organization onboarding @@ -92,12 +92,11 @@ Apply the database migration before starting the new web image: pnpm --filter @aiwa/db migrate:deploy ``` - ## Transactional email operations -Production mail requires `MAIL_ENABLED=true`, an implicit-TLS SMTP endpoint, -and valid `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, and `SMTP_PASSWORD` -values. Keep `MAIL_SECURITY_FROM_ADDRESS=security@aiwamediagroup.com` and +Production mail requires `MAIL_ENABLED=true`, an implicit-TLS SMTP endpoint, and +valid `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, and `SMTP_PASSWORD` values. Keep +`MAIL_SECURITY_FROM_ADDRESS=security@aiwamediagroup.com` and `MAIL_ROUTINE_FROM_ADDRESS=creator-tool@aiwamediagroup.com`. Application code writes a `MailMessage` outbox row first. The worker dispatches From 5cf2169af0dbee4788c46ffb4dc6f2430bc973b4 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:17:50 +0530 Subject: [PATCH 060/129] docs(mail): clarify shared web and worker SMTP configuration --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index e7c9a460a..a6d6b2f3b 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ APP_URL=http://localhost:3000 AUTH_SECRET=replace-with-at-least-32-random-characters SIGNUPS_ENABLED=true # Transactional email. Production uses implicit TLS SMTP (port 465). +# Web and worker services must load the same SMTP and sender settings. MAIL_ENABLED=true SMTP_HOST= SMTP_PORT=465 From f7bb002b3807b16f891756a2f25eccfff1af6776 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:21:37 +0530 Subject: [PATCH 061/129] refactor(mail): keep shared mail API transport-free --- packages/mail/src/index.ts | 266 +------------------------------------ 1 file changed, 2 insertions(+), 264 deletions(-) diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index 53ae371a6..f1adc39e9 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -1,5 +1,3 @@ -import { createHash, randomUUID } from "node:crypto"; -import { connect, type TLSSocket } from "node:tls"; import { db, type Prisma } from "@aiwa/db"; import type { ServerEnv } from "@aiwa/config"; @@ -36,10 +34,6 @@ export function normalizeRecipient(value: string): string { return value.trim().toLowerCase(); } -export function fingerprint(value: string): string { - return createHash("sha256").update(value).digest("hex"); -} - export async function enqueueMail( input: MailDraft, client: MailDb = db, @@ -103,6 +97,7 @@ function emailShell(title: string, body: string): string { export function verificationEmail(input: { to: string; verificationUrl: string; + idempotencyKey: string; userId?: string; }): MailDraft { const safeUrl = escapeHtml(input.verificationUrl); @@ -118,7 +113,7 @@ export function verificationEmail(input: { ), sensitive: true, userId: input.userId, - idempotencyKey: `verify:${fingerprint(input.verificationUrl)}`, + idempotencyKey: input.idempotencyKey, }; } @@ -253,260 +248,3 @@ export function generationCompletedEmail(input: { }; } -type SmtpResponse = { code: number; lines: string[] }; - -class SmtpSession { - private socket: TLSSocket; - private buffer = ""; - private waiters: Array<(response: SmtpResponse) => void> = []; - - private constructor(socket: TLSSocket) { - this.socket = socket; - socket.setEncoding("utf8"); - socket.on("data", (chunk: string) => { - this.buffer += chunk; - this.flush(); - }); - } - - static async connect(env: ServerEnv): Promise { - const socket = connect({ - host: env.SMTP_HOST, - port: env.SMTP_PORT, - servername: env.SMTP_HOST, - rejectUnauthorized: true, - }); - socket.setTimeout(env.SMTP_SOCKET_TIMEOUT_MS); - const session = new SmtpSession(socket); - await new Promise((resolve, reject) => { - const onError = (error: Error) => reject(error); - socket.once("error", onError); - socket.once("secureConnect", () => { - socket.off("error", onError); - resolve(); - }); - }); - const greeting = await session.read(); - if (greeting.code !== 220) - throw new Error(`SMTP greeting failed: ${greeting.code}`); - return session; - } - - private flush(): void { - const lines = this.buffer.split("\r\n"); - this.buffer = lines.pop() ?? ""; - if (!lines.length) return; - const complete: string[] = []; - for (const line of lines) { - complete.push(line); - const match = /^(\d{3})([ -])/.exec(line); - if (match?.[2] === " ") { - const code = Number(match[1]); - const waiter = this.waiters.shift(); - if (waiter) waiter({ code, lines: complete.splice(0) }); - } - } - } - - private read(): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error("SMTP response timed out")), - 30_000, - ); - this.waiters.push((response) => { - clearTimeout(timer); - resolve(response); - }); - }); - } - - async command(command: string, expected: number[]): Promise { - this.socket.write(`${command}\r\n`); - const response = await this.read(); - if (!expected.includes(response.code)) { - const error = new Error( - `SMTP command failed with status ${response.code}`, - ); - Object.assign(error, { smtpCode: response.code }); - throw error; - } - return response; - } - - async sendData(data: string): Promise { - this.socket.write(data); - const response = await this.read(); - if (response.code !== 250) { - const error = new Error(`SMTP DATA failed with status ${response.code}`); - Object.assign(error, { smtpCode: response.code }); - throw error; - } - } - - close(): void { - this.socket.end(); - } -} - -function headerSafe(value: string): string { - return value.replace(/[\r\n]+/g, " ").trim(); -} - -function dotStuff(value: string): string { - return value.replace(/(^|\r\n)\./g, "$1.."); -} - -export async function sendMailViaSmtp( - env: ServerEnv, - message: { - from: string; - to: string; - subject: string; - text: string; - html: string; - }, -): Promise<{ providerMessageId: string }> { - const session = await SmtpSession.connect(env); - const providerMessageId = `<${randomUUID()}@aiwamediagroup.com>`; - try { - await session.command(`EHLO ${env.SMTP_EHLO_NAME}`, [250]); - await session.command("AUTH LOGIN", [334]); - await session.command(Buffer.from(env.SMTP_USER).toString("base64"), [334]); - await session.command( - Buffer.from(env.SMTP_PASSWORD).toString("base64"), - [235], - ); - await session.command(`MAIL FROM:<${headerSafe(message.from)}>`, [250]); - await session.command(`RCPT TO:<${headerSafe(message.to)}>`, [250, 251]); - await session.command("DATA", [354]); - - const boundary = `aiwa-${randomUUID()}`; - const mime = [ - `From: Aiwa Creators <${headerSafe(message.from)}>`, - `To: <${headerSafe(message.to)}>`, - `Subject: ${headerSafe(message.subject)}`, - `Message-ID: ${providerMessageId}`, - "MIME-Version: 1.0", - `Content-Type: multipart/alternative; boundary="${boundary}"`, - "", - `--${boundary}`, - 'Content-Type: text/plain; charset="UTF-8"', - "Content-Transfer-Encoding: 8bit", - "", - message.text, - `--${boundary}`, - 'Content-Type: text/html; charset="UTF-8"', - "Content-Transfer-Encoding: 8bit", - "", - message.html, - `--${boundary}--`, - "", - ].join("\r\n"); - - await session.sendData(`${dotStuff(mime)}\r\n.\r\n`); - await session.command("QUIT", [221]); - return { providerMessageId }; - } finally { - session.close(); - } -} - -export async function processMailMessage( - env: ServerEnv, - id: string, - attemptNumber: number, - maxAttempts: number, -): Promise<{ sent: boolean; terminal: boolean }> { - const current = await db.mailMessage.findUnique({ where: { id } }); - if (!current) return { sent: false, terminal: true }; - if (["SENT", "FAILED", "SUPPRESSED"].includes(current.status)) { - return { sent: current.status === "SENT", terminal: true }; - } - - const claimed = await db.mailMessage.updateMany({ - where: { id, status: { in: ["PENDING", "QUEUED", "RETRY"] } }, - data: { - status: "SENDING", - sendingAt: new Date(), - attemptCount: { increment: 1 }, - lastErrorCode: null, - lastErrorMessage: null, - }, - }); - if (claimed.count !== 1) return { sent: false, terminal: false }; - - const message = await db.mailMessage.findUniqueOrThrow({ where: { id } }); - try { - const result = await sendMailViaSmtp(env, { - from: senderForKind(env, message.kind), - to: message.recipient, - subject: message.subject, - text: message.textBody, - html: message.htmlBody, - }); - await db.mailMessage.update({ - where: { id }, - data: { - status: "SENT", - providerMessageId: result.providerMessageId, - sentAt: new Date(), - nextAttemptAt: null, - sendingAt: null, - textBody: message.sensitive - ? "[redacted after delivery]" - : message.textBody, - htmlBody: message.sensitive - ? "[redacted after delivery]" - : message.htmlBody, - }, - }); - return { sent: true, terminal: true }; - } catch (error) { - const failure = classifySmtpFailure(error); - const terminal = !failure.retryable || attemptNumber >= maxAttempts; - const highDelays = [30_000, 120_000, 600_000, 1_800_000]; - const normalDelays = [60_000, 300_000, 1_200_000, 3_600_000]; - const delays = message.priority === "HIGH" ? highDelays : normalDelays; - const delay = - delays[Math.min(Math.max(attemptNumber - 1, 0), delays.length - 1)] ?? - delays[delays.length - 1]!; - await db.mailMessage.update({ - where: { id }, - data: { - status: terminal ? "FAILED" : "RETRY", - failedAt: terminal ? new Date() : null, - nextAttemptAt: terminal ? null : new Date(Date.now() + delay), - sendingAt: null, - lastErrorCode: failure.code, - lastErrorMessage: failure.message.slice(0, 2000), - }, - }); - if (!terminal) throw error; - return { sent: false, terminal: true }; - } -} - -export function classifySmtpFailure(error: unknown): { - retryable: boolean; - code: string; - message: string; -} { - const message = error instanceof Error ? error.message : "Unknown SMTP error"; - const smtpCode = - typeof error === "object" && - error !== null && - "smtpCode" in error && - typeof error.smtpCode === "number" - ? error.smtpCode - : undefined; - - if (smtpCode && smtpCode >= 500) { - return { retryable: false, code: `SMTP_${smtpCode}`, message }; - } - return { - retryable: true, - code: smtpCode ? `SMTP_${smtpCode}` : "SMTP_TRANSPORT", - message, - }; -} From d78c6d6aae0db42aea31bbb1f4fec516f30463b1 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:21:39 +0530 Subject: [PATCH 062/129] refactor(mail): isolate worker-only SMTP transport --- packages/mail/src/transport.ts | 263 +++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 packages/mail/src/transport.ts diff --git a/packages/mail/src/transport.ts b/packages/mail/src/transport.ts new file mode 100644 index 000000000..3b656244d --- /dev/null +++ b/packages/mail/src/transport.ts @@ -0,0 +1,263 @@ +import { randomUUID } from "node:crypto"; +import { connect, type TLSSocket } from "node:tls"; +import type { ServerEnv } from "@aiwa/config"; +import { db } from "@aiwa/db"; +import { senderForKind } from "./index"; + +type SmtpResponse = { code: number; lines: string[] }; + +class SmtpSession { + private socket: TLSSocket; + private buffer = ""; + private waiters: Array<(response: SmtpResponse) => void> = []; + + private constructor(socket: TLSSocket) { + this.socket = socket; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + this.buffer += chunk; + this.flush(); + }); + } + + static async connect(env: ServerEnv): Promise { + const socket = connect({ + host: env.SMTP_HOST, + port: env.SMTP_PORT, + servername: env.SMTP_HOST, + rejectUnauthorized: true, + }); + socket.setTimeout(env.SMTP_SOCKET_TIMEOUT_MS); + const session = new SmtpSession(socket); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + socket.once("error", onError); + socket.once("secureConnect", () => { + socket.off("error", onError); + resolve(); + }); + }); + const greeting = await session.read(); + if (greeting.code !== 220) + throw new Error(`SMTP greeting failed: ${greeting.code}`); + return session; + } + + private flush(): void { + const lines = this.buffer.split("\r\n"); + this.buffer = lines.pop() ?? ""; + if (!lines.length) return; + const complete: string[] = []; + for (const line of lines) { + complete.push(line); + const match = /^(\d{3})([ -])/.exec(line); + if (match?.[2] === " ") { + const code = Number(match[1]); + const waiter = this.waiters.shift(); + if (waiter) waiter({ code, lines: complete.splice(0) }); + } + } + } + + private read(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("SMTP response timed out")), + 30_000, + ); + this.waiters.push((response) => { + clearTimeout(timer); + resolve(response); + }); + }); + } + + async command(command: string, expected: number[]): Promise { + this.socket.write(`${command}\r\n`); + const response = await this.read(); + if (!expected.includes(response.code)) { + const error = new Error( + `SMTP command failed with status ${response.code}`, + ); + Object.assign(error, { smtpCode: response.code }); + throw error; + } + return response; + } + + async sendData(data: string): Promise { + this.socket.write(data); + const response = await this.read(); + if (response.code !== 250) { + const error = new Error(`SMTP DATA failed with status ${response.code}`); + Object.assign(error, { smtpCode: response.code }); + throw error; + } + } + + close(): void { + this.socket.end(); + } +} + +function headerSafe(value: string): string { + return value.replace(/[\r\n]+/g, " ").trim(); +} + +function dotStuff(value: string): string { + return value.replace(/(^|\r\n)\./g, "$1.."); +} + +export async function sendMailViaSmtp( + env: ServerEnv, + message: { + from: string; + to: string; + subject: string; + text: string; + html: string; + }, +): Promise<{ providerMessageId: string }> { + const session = await SmtpSession.connect(env); + const providerMessageId = `<${randomUUID()}@aiwamediagroup.com>`; + try { + await session.command(`EHLO ${env.SMTP_EHLO_NAME}`, [250]); + await session.command("AUTH LOGIN", [334]); + await session.command(Buffer.from(env.SMTP_USER).toString("base64"), [334]); + await session.command( + Buffer.from(env.SMTP_PASSWORD).toString("base64"), + [235], + ); + await session.command(`MAIL FROM:<${headerSafe(message.from)}>`, [250]); + await session.command(`RCPT TO:<${headerSafe(message.to)}>`, [250, 251]); + await session.command("DATA", [354]); + + const boundary = `aiwa-${randomUUID()}`; + const mime = [ + `From: Aiwa Creators <${headerSafe(message.from)}>`, + `To: <${headerSafe(message.to)}>`, + `Subject: ${headerSafe(message.subject)}`, + `Message-ID: ${providerMessageId}`, + "MIME-Version: 1.0", + `Content-Type: multipart/alternative; boundary="${boundary}"`, + "", + `--${boundary}`, + 'Content-Type: text/plain; charset="UTF-8"', + "Content-Transfer-Encoding: 8bit", + "", + message.text, + `--${boundary}`, + 'Content-Type: text/html; charset="UTF-8"', + "Content-Transfer-Encoding: 8bit", + "", + message.html, + `--${boundary}--`, + "", + ].join("\r\n"); + + await session.sendData(`${dotStuff(mime)}\r\n.\r\n`); + await session.command("QUIT", [221]); + return { providerMessageId }; + } finally { + session.close(); + } +} + +export async function processMailMessage( + env: ServerEnv, + id: string, + attemptNumber: number, + maxAttempts: number, +): Promise<{ sent: boolean; terminal: boolean }> { + const current = await db.mailMessage.findUnique({ where: { id } }); + if (!current) return { sent: false, terminal: true }; + if (["SENT", "FAILED", "SUPPRESSED"].includes(current.status)) { + return { sent: current.status === "SENT", terminal: true }; + } + + const claimed = await db.mailMessage.updateMany({ + where: { id, status: { in: ["PENDING", "QUEUED", "RETRY"] } }, + data: { + status: "SENDING", + sendingAt: new Date(), + attemptCount: { increment: 1 }, + lastErrorCode: null, + lastErrorMessage: null, + }, + }); + if (claimed.count !== 1) return { sent: false, terminal: false }; + + const message = await db.mailMessage.findUniqueOrThrow({ where: { id } }); + try { + const result = await sendMailViaSmtp(env, { + from: senderForKind(env, message.kind), + to: message.recipient, + subject: message.subject, + text: message.textBody, + html: message.htmlBody, + }); + await db.mailMessage.update({ + where: { id }, + data: { + status: "SENT", + providerMessageId: result.providerMessageId, + sentAt: new Date(), + nextAttemptAt: null, + sendingAt: null, + textBody: message.sensitive + ? "[redacted after delivery]" + : message.textBody, + htmlBody: message.sensitive + ? "[redacted after delivery]" + : message.htmlBody, + }, + }); + return { sent: true, terminal: true }; + } catch (error) { + const failure = classifySmtpFailure(error); + const terminal = !failure.retryable || attemptNumber >= maxAttempts; + const highDelays = [30_000, 120_000, 600_000, 1_800_000]; + const normalDelays = [60_000, 300_000, 1_200_000, 3_600_000]; + const delays = message.priority === "HIGH" ? highDelays : normalDelays; + const delay = + delays[Math.min(Math.max(attemptNumber - 1, 0), delays.length - 1)] ?? + delays[delays.length - 1]!; + await db.mailMessage.update({ + where: { id }, + data: { + status: terminal ? "FAILED" : "RETRY", + failedAt: terminal ? new Date() : null, + nextAttemptAt: terminal ? null : new Date(Date.now() + delay), + sendingAt: null, + lastErrorCode: failure.code, + lastErrorMessage: failure.message.slice(0, 2000), + }, + }); + if (!terminal) throw error; + return { sent: false, terminal: true }; + } +} + +export function classifySmtpFailure(error: unknown): { + retryable: boolean; + code: string; + message: string; +} { + const message = error instanceof Error ? error.message : "Unknown SMTP error"; + const smtpCode = + typeof error === "object" && + error !== null && + "smtpCode" in error && + typeof error.smtpCode === "number" + ? error.smtpCode + : undefined; + + if (smtpCode && smtpCode >= 500) { + return { retryable: false, code: `SMTP_${smtpCode}`, message }; + } + return { + retryable: true, + code: smtpCode ? `SMTP_${smtpCode}` : "SMTP_TRANSPORT", + message, + }; +} From b173cd5e6f5a0a55c0474ea664b3b4e94ddf73b4 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:21:51 +0530 Subject: [PATCH 063/129] refactor(mail): export worker-only transport entrypoint --- packages/mail/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/mail/package.json b/packages/mail/package.json index a0f695e22..85a749c0d 100644 --- a/packages/mail/package.json +++ b/packages/mail/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./transport": "./src/transport.ts" }, "scripts": { "build": "tsc --noEmit", From dceb5218423a8207024fbf20963eceae97bd4a79 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:21:55 +0530 Subject: [PATCH 064/129] refactor(mail): load SMTP transport only in worker --- apps/worker/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 41b2afe32..47f4983a9 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -6,7 +6,7 @@ import { processVideoSubmitJob, processVoiceJob, } from "@aiwa/generation/process"; -import { processMailMessage } from "@aiwa/mail"; +import { processMailMessage } from "@aiwa/mail/transport"; import { createBytePlusProvider } from "@aiwa/providers/byteplus"; import { createNvidiaProvider } from "@aiwa/providers/nvidia"; import { Queue, Worker } from "bullmq"; From 4eb40cdc99c3e68d1cd3e013d881f29fc763a362 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:21:58 +0530 Subject: [PATCH 065/129] refactor(mail): hash verification idempotency in server auth --- apps/web/src/lib/auth.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 4f15e64dc..7f4aca888 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { platformRoles } from "@aiwa/authz"; import { parseServerEnv } from "@aiwa/config"; import { db } from "@aiwa/db"; @@ -17,6 +18,9 @@ async function deliverVerificationEmail(input: { verificationEmail({ to: input.email, verificationUrl: input.verificationUrl, + idempotencyKey: `verify:${createHash("sha256") + .update(input.verificationUrl) + .digest("hex")}`, userId: input.userId, }), ); From 6f70db51ab1476fe5fcc8d213534593b0d66e808 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:22:12 +0530 Subject: [PATCH 066/129] test(mail): validate isolated SMTP transport --- packages/mail/test/mail.test.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/mail/test/mail.test.ts b/packages/mail/test/mail.test.ts index 8b95aa6a0..262006624 100644 --- a/packages/mail/test/mail.test.ts +++ b/packages/mail/test/mail.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - classifySmtpFailure, - fingerprint, - generationCompletedEmail, - senderForKind, -} from "../src/index"; +import { generationCompletedEmail, senderForKind } from "../src/index"; +import { classifySmtpFailure } from "../src/transport"; describe("mail helpers", () => { it("keeps sender identities separated", () => { @@ -35,7 +31,4 @@ describe("mail helpers", () => { expect(classifySmtpFailure(error).retryable).toBe(false); }); - it("does not expose source values through fingerprints", () => { - expect(fingerprint("secret-link")).not.toContain("secret-link"); - }); }); From 67e04e624a572a0378beadaeef49f021e5b720e9 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:24:03 +0530 Subject: [PATCH 067/129] style(mail): normalize transport split formatting From df42f4e2c77bef7d8014784e67d87c21e70a2d73 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:24:06 +0530 Subject: [PATCH 068/129] style(mail): normalize transport split formatting From 155aba9642e95809d3b3b2de0f6fc01ae489ba84 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:25:37 +0530 Subject: [PATCH 069/129] chore: format mail transport split --- .github/workflows/format-mail-split.yml | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/format-mail-split.yml diff --git a/.github/workflows/format-mail-split.yml b/.github/workflows/format-mail-split.yml new file mode 100644 index 000000000..862f1c099 --- /dev/null +++ b/.github/workflows/format-mail-split.yml @@ -0,0 +1,34 @@ +name: Format Mail Split + +on: + pull_request: + branches: [main] + +permissions: + contents: write + +jobs: + format: + if: github.head_ref == 'feat/transactional-mail' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: feat/transactional-mail + - uses: pnpm/action-setup@v6 + with: + version: 11.19.0 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm prettier --write packages/mail/src/index.ts packages/mail/test/mail.test.ts + - name: Commit canonical formatting and remove helper + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/format-mail-split.yml + git add packages/mail/src/index.ts packages/mail/test/mail.test.ts + git commit -m "style: format mail transport split" + git push origin HEAD:feat/transactional-mail From 0efd400406f900b63fc2ab633c57901703b9632a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:56:13 +0000 Subject: [PATCH 070/129] style: format mail transport split --- .github/workflows/format-mail-split.yml | 34 ------------------------- packages/mail/src/index.ts | 1 - packages/mail/test/mail.test.ts | 1 - 3 files changed, 36 deletions(-) delete mode 100644 .github/workflows/format-mail-split.yml diff --git a/.github/workflows/format-mail-split.yml b/.github/workflows/format-mail-split.yml deleted file mode 100644 index 862f1c099..000000000 --- a/.github/workflows/format-mail-split.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Format Mail Split - -on: - pull_request: - branches: [main] - -permissions: - contents: write - -jobs: - format: - if: github.head_ref == 'feat/transactional-mail' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: feat/transactional-mail - - uses: pnpm/action-setup@v6 - with: - version: 11.19.0 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm prettier --write packages/mail/src/index.ts packages/mail/test/mail.test.ts - - name: Commit canonical formatting and remove helper - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/format-mail-split.yml - git add packages/mail/src/index.ts packages/mail/test/mail.test.ts - git commit -m "style: format mail transport split" - git push origin HEAD:feat/transactional-mail diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index f1adc39e9..0e1d0b04f 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -247,4 +247,3 @@ export function generationCompletedEmail(input: { idempotencyKey: `generation-completed:${input.generationJobId}`, }; } - diff --git a/packages/mail/test/mail.test.ts b/packages/mail/test/mail.test.ts index 262006624..4b5173203 100644 --- a/packages/mail/test/mail.test.ts +++ b/packages/mail/test/mail.test.ts @@ -30,5 +30,4 @@ describe("mail helpers", () => { const error = Object.assign(new Error("rejected"), { smtpCode: 550 }); expect(classifySmtpFailure(error).retryable).toBe(false); }); - }); From 2cc0d0b7bbfd4ba739b6cd81048fb4d035a1756b Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:26:35 +0530 Subject: [PATCH 071/129] docs(mail): reinforce SMTP secret handling --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index a6d6b2f3b..d4e8ef4ff 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ AUTH_SECRET=replace-with-at-least-32-random-characters SIGNUPS_ENABLED=true # Transactional email. Production uses implicit TLS SMTP (port 465). # Web and worker services must load the same SMTP and sender settings. +# Never expose SMTP credentials through NEXT_PUBLIC_* variables. MAIL_ENABLED=true SMTP_HOST= SMTP_PORT=465 From e9e44f42bea0962ec6bd475199d54e3849b6b97d Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:41:28 +0530 Subject: [PATCH 072/129] feat(mail): add password reset and MFA security templates --- packages/mail/src/index.ts | 76 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index 0e1d0b04f..6bca2da6c 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -117,6 +117,82 @@ export function verificationEmail(input: { }; } +export function passwordResetEmail(input: { + to: string; + resetUrl: string; + idempotencyKey: string; + userId?: string; +}): MailDraft { + const safeUrl = escapeHtml(input.resetUrl); + return { + kind: "SECURITY", + template: "auth.password_reset.v1", + to: input.to, + subject: "Reset your Aiwa Creators password", + text: `Use this link to reset your Aiwa Creators password: ${input.resetUrl}\n\nIf you did not request this, ignore this email and your password will remain unchanged.`, + html: emailShell( + "Reset your password", + `

A password reset was requested for your Aiwa Creators account.

Reset password

If the button does not work, copy this link:
${safeUrl}

If you did not request this, you can safely ignore this message.

`, + ), + sensitive: true, + userId: input.userId, + idempotencyKey: input.idempotencyKey, + }; +} + +export function securityEventEmail(input: { + to: string; + userId: string; + event: + | "PASSWORD_RESET" + | "MFA_ENABLED" + | "MFA_DISABLED" + | "BACKUP_CODES_REGENERATED"; + idempotencyKey: string; +}): MailDraft { + const content = { + PASSWORD_RESET: { + subject: "Your Aiwa Creators password was changed", + title: "Password changed", + message: + "Your account password was successfully changed and existing sessions were revoked. If this was not you, contact your administrator immediately.", + }, + MFA_ENABLED: { + subject: "Two-factor authentication enabled", + title: "Two-factor authentication enabled", + message: + "Authenticator-based two-factor authentication was enabled on your Aiwa Creators account.", + }, + MFA_DISABLED: { + subject: "Two-factor authentication disabled", + title: "Two-factor authentication disabled", + message: + "Two-factor authentication was disabled on your Aiwa Creators account. If this was not you, contact your administrator immediately.", + }, + BACKUP_CODES_REGENERATED: { + subject: "New two-factor recovery codes generated", + title: "Recovery codes regenerated", + message: + "New two-factor recovery codes were generated for your Aiwa Creators account. Previous recovery codes are no longer valid.", + }, + }[input.event]; + + return { + kind: "SECURITY", + template: `auth.security_event.${input.event.toLowerCase()}.v1`, + to: input.to, + subject: content.subject, + text: content.message, + html: emailShell( + content.title, + `

${escapeHtml(content.message)}

`, + ), + sensitive: false, + userId: input.userId, + idempotencyKey: input.idempotencyKey, + }; +} + export function teamMemberAddedEmail(input: { to: string; organizationName: string; From 4d285594673ab4220000d321eac56d65477c234c Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:41:43 +0530 Subject: [PATCH 073/129] feat(mail): wire password reset and MFA security alerts --- apps/web/src/lib/auth.ts | 64 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 7f4aca888..4db8c6cad 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -2,9 +2,15 @@ import { createHash } from "node:crypto"; import { platformRoles } from "@aiwa/authz"; import { parseServerEnv } from "@aiwa/config"; import { db } from "@aiwa/db"; -import { enqueueMail, verificationEmail } from "@aiwa/mail"; +import { + enqueueMail, + passwordResetEmail, + securityEventEmail, + verificationEmail, +} from "@aiwa/mail"; import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; +import { createAuthMiddleware } from "better-auth/api"; import { twoFactor } from "better-auth/plugins"; const env = parseServerEnv(); @@ -59,6 +65,29 @@ export const auth = betterAuth({ maxPasswordLength: 128, autoSignIn: false, revokeSessionsOnPasswordReset: true, + resetPasswordTokenExpiresIn: 60 * 60, + sendResetPassword: async ({ user, url, token }) => { + await enqueueMail( + passwordResetEmail({ + to: user.email, + resetUrl: url, + userId: user.id, + idempotencyKey: `password-reset:${createHash("sha256") + .update(token) + .digest("hex")}`, + }), + ); + }, + onPasswordReset: async ({ user }) => { + await enqueueMail( + securityEventEmail({ + to: user.email, + userId: user.id, + event: "PASSWORD_RESET", + idempotencyKey: `security:password-reset:${user.id}:${Date.now()}`, + }), + ); + }, }, account: { accountLinking: { @@ -116,6 +145,39 @@ export const auth = betterAuth({ validateSchema: true, }, }, + hooks: { + after: createAuthMiddleware(async (ctx) => { + const eventByPath = { + "/two-factor/verify-totp": "MFA_ENABLED", + "/two-factor/disable": "MFA_DISABLED", + "/two-factor/generate-backup-codes": "BACKUP_CODES_REGENERATED", + } as const; + const event = eventByPath[ctx.path as keyof typeof eventByPath]; + if (!event) return; + + const session = ctx.context.session; + const userId = session?.user?.id; + if (!userId) return; + + const user = await db.user.findUnique({ + where: { id: userId }, + select: { email: true, twoFactorEnabled: true }, + }); + if (!user) return; + + if (event === "MFA_ENABLED" && !user.twoFactorEnabled) return; + if (event === "MFA_DISABLED" && user.twoFactorEnabled) return; + + await enqueueMail( + securityEventEmail({ + to: user.email, + userId, + event, + idempotencyKey: `security:${event.toLowerCase()}:${userId}:${Date.now()}`, + }), + ); + }), + }, databaseHooks: { session: { create: { From ce3049e046b0c243ab3d34166cde2aac18fef9df Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:42:23 +0530 Subject: [PATCH 074/129] feat(mail): add admin email delivery diagnostics --- apps/web/src/app/admin/email/page.tsx | 164 ++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 apps/web/src/app/admin/email/page.tsx diff --git a/apps/web/src/app/admin/email/page.tsx b/apps/web/src/app/admin/email/page.tsx new file mode 100644 index 000000000..a6d2f6b63 --- /dev/null +++ b/apps/web/src/app/admin/email/page.tsx @@ -0,0 +1,164 @@ +import { db } from "@aiwa/db"; +import { StatusBadge } from "@/components/admin/primitives"; +import { Eyebrow } from "@/components/ui/creative"; +import { requirePlatformPermission } from "@/lib/request-auth"; + +function maskEmail(email: string): string { + const [local, domain] = email.split("@"); + if (!domain) return "••••"; + const visible = local.length <= 2 ? local.slice(0, 1) : local.slice(0, 2); + return `${visible}••••@${domain}`; +} + +function statusTone(status: string): "success" | "warning" | "danger" | "neutral" { + if (status === "SENT") return "success"; + if (status === "FAILED") return "danger"; + if (status === "RETRY" || status === "SENDING") return "warning"; + return "neutral"; +} + +export default async function AdminEmailPage() { + await requirePlatformPermission("audit:read"); + + const [pending, retrying, failed, sent, recent] = await Promise.all([ + db.mailMessage.count({ where: { status: { in: ["PENDING", "QUEUED", "SENDING"] } } }), + db.mailMessage.count({ where: { status: "RETRY" } }), + db.mailMessage.count({ where: { status: "FAILED" } }), + db.mailMessage.count({ where: { status: "SENT" } }), + db.mailMessage.findMany({ + orderBy: { createdAt: "desc" }, + take: 50, + select: { + id: true, + kind: true, + priority: true, + status: true, + template: true, + recipient: true, + attemptCount: true, + lastErrorCode: true, + lastErrorMessage: true, + createdAt: true, + sentAt: true, + nextAttemptAt: true, + }, + }), + ]); + + const cards = [ + ["Queued", pending], + ["Retrying", retrying], + ["Failed", failed], + ["Sent", sent], + ] as const; + + return ( +
+ Operations +
+

+ Email delivery +

+

+ Durable transactional-mail outbox and delivery diagnostics. Security + content is never displayed here. +

+
+ +
+ {cards.map(([label, value]) => ( +
+

+ {label} +

+

+ {value.toLocaleString("en-US")} +

+
+ ))} +
+ +
+
+

Recent deliveries

+

+ Latest 50 messages. Recipients are masked and mail bodies are excluded. +

+
+
+ + + + + + + + + + + + + + + {recent.map((mail) => ( + + + + + + + + + + + ))} + {recent.length === 0 ? ( + + + + ) : null} + +
CreatedRecipientChannelTemplateStatusAttemptsNext / sentLast error
+ {mail.createdAt.toLocaleString("en-OM")} + + {maskEmail(mail.recipient)} + + + {mail.kind} + + + {mail.priority} + + + {mail.template} + + + {mail.status} + + + {mail.attemptCount} + + {(mail.sentAt ?? mail.nextAttemptAt)?.toLocaleString("en-OM") ?? "—"} + + {mail.lastErrorCode ? ( + <> + + {mail.lastErrorCode} + + {mail.lastErrorMessage ? ( + + {mail.lastErrorMessage} + + ) : null} + + ) : ( + "—" + )} +
+ No transactional mail has been queued yet. +
+
+
+
+ ); +} From 8bac66b9001b91cd07a0abf21b4c716cb254a2d2 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:42:34 +0530 Subject: [PATCH 075/129] feat(mail): add email diagnostics navigation --- apps/web/src/app/admin/layout.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx index 73faaa078..39b18b158 100644 --- a/apps/web/src/app/admin/layout.tsx +++ b/apps/web/src/app/admin/layout.tsx @@ -55,6 +55,12 @@ const navigation: readonly (AdminNavigationItem & { icon: "assets", permission: "audit:read", }, + { + href: "/admin/email", + label: "Email delivery", + icon: "activity", + permission: "audit:read", + }, ]; export default async function AdminLayout({ From 5a4d021d8722344cfcdad486d9202e871ab89027 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:42:37 +0530 Subject: [PATCH 076/129] feat(mail): expose mail configuration health --- apps/web/src/app/api/health/route.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index eeb2ef481..c20d1b618 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -14,6 +14,13 @@ export function GET(): Response { version: process.env.APP_VERSION ?? "dev", mediaConfigured: isBytePlusMediaConfigured(), voiceConfigured: isBytePlusVoiceConfigured(), + mailConfigured: Boolean( + process.env.SMTP_HOST && + process.env.SMTP_USER && + process.env.SMTP_PASSWORD && + process.env.MAIL_SECURITY_FROM_ADDRESS && + process.env.MAIL_ROUTINE_FROM_ADDRESS, + ), timestamp: new Date().toISOString(), }, { From d269c67e23701b8561b6b202447d64f810bf29c6 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:43:57 +0530 Subject: [PATCH 077/129] feat(mail): add routine notification preferences --- packages/db/prisma/schema.prisma | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 31004c66a..b171d9b97 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -123,6 +123,7 @@ model User { acceptedInvitations OrganizationInvitation[] @relation("InvitationAcceptedBy") twoFactorEnabled Boolean @default(false) twoFactors TwoFactor[] + notificationPreference NotificationPreference? @@index([platformRole, disabledAt]) } @@ -192,6 +193,16 @@ model TwoFactor { @@index([userId]) } +model NotificationPreference { + userId String @id + generationCompleted Boolean @default(true) + generationFailed Boolean @default(true) + reports Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + model Organization { id String @id @default(cuid()) name String From 713977c5cdc13192f5d2f25501788fd4d1e6eb3d Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:44:00 +0530 Subject: [PATCH 078/129] feat(mail): add notification preference migration --- .../migration.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 packages/db/prisma/migrations/20260924030000_notification_preferences/migration.sql diff --git a/packages/db/prisma/migrations/20260924030000_notification_preferences/migration.sql b/packages/db/prisma/migrations/20260924030000_notification_preferences/migration.sql new file mode 100644 index 000000000..e0eeeac38 --- /dev/null +++ b/packages/db/prisma/migrations/20260924030000_notification_preferences/migration.sql @@ -0,0 +1,12 @@ +CREATE TABLE `NotificationPreference` ( + `userId` VARCHAR(191) NOT NULL, + `generationCompleted` BOOLEAN NOT NULL DEFAULT true, + `generationFailed` BOOLEAN NOT NULL DEFAULT true, + `reports` BOOLEAN NOT NULL DEFAULT true, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + PRIMARY KEY (`userId`), + CONSTRAINT `NotificationPreference_userId_fkey` + FOREIGN KEY (`userId`) REFERENCES `User`(`id`) + ON DELETE CASCADE ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; From 6e95c554a6fdfad29d893b687e4b875de3ae09dd Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:44:13 +0530 Subject: [PATCH 079/129] feat(mail): honor generation notification preferences --- packages/generation/src/process.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/generation/src/process.ts b/packages/generation/src/process.ts index d2fc6a24b..f64e8c636 100644 --- a/packages/generation/src/process.ts +++ b/packages/generation/src/process.ts @@ -24,17 +24,31 @@ import { async function generationRecipient( tx: Prisma.TransactionClient, userId: string, + category: "generationCompleted" | "generationFailed", ): Promise { const runtimeTx = tx as Prisma.TransactionClient & { user?: Prisma.TransactionClient["user"]; mailMessage?: Prisma.TransactionClient["mailMessage"]; + notificationPreference?: Prisma.TransactionClient["notificationPreference"]; }; if (!runtimeTx.user || !runtimeTx.mailMessage) return null; const user = await runtimeTx.user.findUnique({ where: { id: userId }, - select: { email: true, disabledAt: true }, + select: { + email: true, + disabledAt: true, + notificationPreference: { + select: { + generationCompleted: true, + generationFailed: true, + }, + }, + }, }); - return user && !user.disabledAt ? user.email : null; + if (!user || user.disabledAt) return null; + const preferences = user.notificationPreference; + if (preferences && preferences[category] === false) return null; + return user.email; } async function enqueueGenerationFailure( @@ -42,7 +56,7 @@ async function enqueueGenerationFailure( job: { id: string; organizationId: string; createdById: string }, message: string, ): Promise { - const to = await generationRecipient(tx, job.createdById); + const to = await generationRecipient(tx, job.createdById, "generationFailed"); if (!to) return; await enqueueMail( generationFailedEmail({ @@ -61,7 +75,7 @@ async function enqueueGenerationSuccess( job: { id: string; organizationId: string; createdById: string }, assetId: string, ): Promise { - const to = await generationRecipient(tx, job.createdById); + const to = await generationRecipient(tx, job.createdById, "generationCompleted"); if (!to) return; const appUrl = process.env.APP_URL ?? "http://localhost:3000"; await enqueueMail( From 18227e8ef7cf87ced06353281b18a411c638df4d Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:44:45 +0530 Subject: [PATCH 080/129] feat(mail): add routine notification preference API --- .../account/notification-preferences/route.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 apps/web/src/app/api/account/notification-preferences/route.ts diff --git a/apps/web/src/app/api/account/notification-preferences/route.ts b/apps/web/src/app/api/account/notification-preferences/route.ts new file mode 100644 index 000000000..3c9064611 --- /dev/null +++ b/apps/web/src/app/api/account/notification-preferences/route.ts @@ -0,0 +1,74 @@ +import { db } from "@aiwa/db"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getRequestSession } from "@/lib/request-auth"; +import { hasTrustedMutationOrigin } from "@/lib/request-security"; + +const schema = z.object({ + generationCompleted: z.boolean(), + generationFailed: z.boolean(), + reports: z.boolean(), +}); + +export async function GET(request: Request) { + const session = await getRequestSession(request.headers); + if (!session) { + return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + } + + const preference = await db.notificationPreference.findUnique({ + where: { userId: session.user.id }, + select: { + generationCompleted: true, + generationFailed: true, + reports: true, + }, + }); + + return NextResponse.json( + preference ?? { + generationCompleted: true, + generationFailed: true, + reports: true, + }, + ); +} + +export async function PATCH(request: Request) { + if (!hasTrustedMutationOrigin(request)) { + return NextResponse.json({ error: "Origin not allowed." }, { status: 403 }); + } + + const session = await getRequestSession(request.headers); + if (!session) { + return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + } + + const parsed = schema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid notification preferences." }, { status: 400 }); + } + + const preference = await db.notificationPreference.upsert({ + where: { userId: session.user.id }, + create: { userId: session.user.id, ...parsed.data }, + update: parsed.data, + select: { + generationCompleted: true, + generationFailed: true, + reports: true, + }, + }); + + await db.auditEvent.create({ + data: { + actorUserId: session.user.id, + action: "user.notification_preferences_updated", + targetType: "User", + targetId: session.user.id, + metadata: preference, + }, + }); + + return NextResponse.json(preference); +} From c7ef9c41b8453c62a2ea0a1fec5027379634cb0a Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:44:48 +0530 Subject: [PATCH 081/129] feat(mail): add notification preference controls --- .../notification-preferences-form.tsx | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 apps/web/src/components/settings/notification-preferences-form.tsx diff --git a/apps/web/src/components/settings/notification-preferences-form.tsx b/apps/web/src/components/settings/notification-preferences-form.tsx new file mode 100644 index 000000000..1d3c4dbed --- /dev/null +++ b/apps/web/src/components/settings/notification-preferences-form.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +type Preferences = { + generationCompleted: boolean; + generationFailed: boolean; + reports: boolean; +}; + +export function NotificationPreferencesForm({ + initial, +}: { + initial: Preferences; +}) { + const [value, setValue] = useState(initial); + const [state, setState] = useState<"idle" | "saving" | "saved" | "error">("idle"); + + async function save() { + setState("saving"); + const response = await fetch("/api/account/notification-preferences", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(value), + }); + setState(response.ok ? "saved" : "error"); + } + + const options = [ + { + key: "generationCompleted" as const, + title: "Generation completed", + description: "Receive a link when an image, video, or voice generation is ready.", + }, + { + key: "generationFailed" as const, + title: "Generation failed", + description: "Receive a message when a generation cannot be completed and needs attention.", + }, + { + key: "reports" as const, + title: "Reports and summaries", + description: "Receive routine platform reports and summaries when those features are enabled.", + }, + ]; + + return ( +
+ {options.map((option) => ( + + ))} + +
+ + {state === "saved" ? ( + Preferences saved. + ) : null} + {state === "error" ? ( + Could not save preferences. + ) : null} +
+
+ ); +} From a9317c86e823fc26fce9b1b7f0a11e34ee944e29 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:44:51 +0530 Subject: [PATCH 082/129] feat(mail): add user notification settings page --- .../src/app/settings/notifications/page.tsx | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 apps/web/src/app/settings/notifications/page.tsx diff --git a/apps/web/src/app/settings/notifications/page.tsx b/apps/web/src/app/settings/notifications/page.tsx new file mode 100644 index 000000000..10b64da6c --- /dev/null +++ b/apps/web/src/app/settings/notifications/page.tsx @@ -0,0 +1,60 @@ +import { db } from "@aiwa/db"; +import Link from "next/link"; +import { NotificationPreferencesForm } from "@/components/settings/notification-preferences-form"; +import { Button } from "@/components/ui/button"; +import { Eyebrow } from "@/components/ui/creative"; +import { requireRequestSession } from "@/lib/request-auth"; + +export default async function NotificationSettingsPage() { + const session = await requireRequestSession("/settings/notifications"); + const preference = await db.notificationPreference.findUnique({ + where: { userId: session.user.id }, + select: { + generationCompleted: true, + generationFailed: true, + reports: true, + }, + }); + + return ( +
+
+ Preferences +
+
+

+ Email notifications +

+

+ Choose which routine Creator Platform messages arrive by email. +

+
+ +
+ +
+

Security messages always stay on

+

+ Email verification, password and MFA changes, billing events, and + team membership notices cannot be disabled because they protect the + account and workspace. +

+
+ +
+ +
+
+
+ ); +} From 23f8f737df93a3ce4a1432f6b20e223f5a91e90b Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:45:44 +0530 Subject: [PATCH 083/129] feat(mail): add safe routine mail replay endpoint --- .../api/admin/email/[mailId]/retry/route.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 apps/web/src/app/api/admin/email/[mailId]/retry/route.ts diff --git a/apps/web/src/app/api/admin/email/[mailId]/retry/route.ts b/apps/web/src/app/api/admin/email/[mailId]/retry/route.ts new file mode 100644 index 000000000..b81e6d580 --- /dev/null +++ b/apps/web/src/app/api/admin/email/[mailId]/retry/route.ts @@ -0,0 +1,77 @@ +import { hasPlatformPermission } from "@aiwa/authz"; +import { db } from "@aiwa/db"; +import { cuidSchema } from "@aiwa/validation"; +import { revalidatePath } from "next/cache"; +import { NextResponse } from "next/server"; +import { getRequestSession } from "@/lib/request-auth"; +import { hasTrustedMutationOrigin } from "@/lib/request-security"; + +export async function POST( + request: Request, + { params }: { params: Promise<{ mailId: string }> }, +) { + if (!hasTrustedMutationOrigin(request)) { + return NextResponse.json({ error: "Origin not allowed." }, { status: 403 }); + } + + const session = await getRequestSession(request.headers); + if (!session) { + return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + } + if (!hasPlatformPermission(session.user.platformRole, "jobs:manage")) { + return NextResponse.json({ error: "Permission denied." }, { status: 403 }); + } + + const { mailId } = await params; + if (!cuidSchema.safeParse(mailId).success) { + return NextResponse.json({ error: "Mail delivery not found." }, { status: 404 }); + } + + const current = await db.mailMessage.findUnique({ + where: { id: mailId }, + select: { id: true, kind: true, status: true, attemptCount: true }, + }); + if (!current) { + return NextResponse.json({ error: "Mail delivery not found." }, { status: 404 }); + } + if (current.kind !== "ROUTINE") { + return NextResponse.json( + { error: "Security mail must be regenerated from its source workflow." }, + { status: 409 }, + ); + } + if (!["FAILED", "RETRY"].includes(current.status)) { + return NextResponse.json( + { error: "Only failed or retrying routine mail can be requeued." }, + { status: 409 }, + ); + } + + const updated = await db.mailMessage.update({ + where: { id: current.id }, + data: { + status: "PENDING", + nextAttemptAt: null, + failedAt: null, + lastErrorCode: null, + lastErrorMessage: null, + }, + select: { id: true, status: true }, + }); + + await db.auditEvent.create({ + data: { + actorUserId: session.user.id, + action: "mail.routine_requeued", + targetType: "MailMessage", + targetId: current.id, + metadata: { + previousStatus: current.status, + previousAttemptCount: current.attemptCount, + }, + }, + }); + + revalidatePath("/admin/email"); + return NextResponse.json(updated); +} From 332c3d007eb4be147af6efe93496ed11db293597 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:45:47 +0530 Subject: [PATCH 084/129] feat(mail): add routine mail replay control --- .../components/admin/mail-retry-button.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 apps/web/src/components/admin/mail-retry-button.tsx diff --git a/apps/web/src/components/admin/mail-retry-button.tsx b/apps/web/src/components/admin/mail-retry-button.tsx new file mode 100644 index 000000000..2361f3546 --- /dev/null +++ b/apps/web/src/components/admin/mail-retry-button.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +export function MailRetryButton({ mailId }: { mailId: string }) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + + async function retry() { + setBusy(true); + try { + const response = await fetch(`/api/admin/email/${encodeURIComponent(mailId)}/retry`, { + method: "POST", + }); + if (response.ok) router.refresh(); + } finally { + setBusy(false); + } + } + + return ( + + ); +} From c62dc743fcd52b8e72a330590454527521370e1f Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:45:51 +0530 Subject: [PATCH 085/129] feat(mail): expose safe routine replay in admin --- apps/web/src/app/admin/email/page.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/admin/email/page.tsx b/apps/web/src/app/admin/email/page.tsx index a6d2f6b63..cd7b95760 100644 --- a/apps/web/src/app/admin/email/page.tsx +++ b/apps/web/src/app/admin/email/page.tsx @@ -1,4 +1,5 @@ import { db } from "@aiwa/db"; +import { MailRetryButton } from "@/components/admin/mail-retry-button"; import { StatusBadge } from "@/components/admin/primitives"; import { Eyebrow } from "@/components/ui/creative"; import { requirePlatformPermission } from "@/lib/request-auth"; @@ -96,7 +97,8 @@ export default async function AdminEmailPage() { Status Attempts Next / sent - Last error + Last error + Action @@ -130,7 +132,7 @@ export default async function AdminEmailPage() { {(mail.sentAt ?? mail.nextAttemptAt)?.toLocaleString("en-OM") ?? "—"} - + {mail.lastErrorCode ? ( <> @@ -146,11 +148,19 @@ export default async function AdminEmailPage() { "—" )} + + {mail.kind === "ROUTINE" && + ["FAILED", "RETRY"].includes(mail.status) ? ( + + ) : ( + — + )} + ))} {recent.length === 0 ? ( - + No transactional mail has been queued yet. From e9186335464fcd22286f9779f3e51c92a27b3309 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:47:12 +0530 Subject: [PATCH 086/129] refactor(mail): use pooled Nodemailer SMTP transport --- packages/mail/src/transport.ts | 223 ++++++++++++--------------------- 1 file changed, 78 insertions(+), 145 deletions(-) diff --git a/packages/mail/src/transport.ts b/packages/mail/src/transport.ts index 3b656244d..595ff702b 100644 --- a/packages/mail/src/transport.ts +++ b/packages/mail/src/transport.ts @@ -1,111 +1,58 @@ -import { randomUUID } from "node:crypto"; -import { connect, type TLSSocket } from "node:tls"; import type { ServerEnv } from "@aiwa/config"; import { db } from "@aiwa/db"; +import nodemailer, { type Transporter } from "nodemailer"; +import SMTPTransport from "nodemailer/lib/smtp-transport"; import { senderForKind } from "./index"; -type SmtpResponse = { code: number; lines: string[] }; +let transporter: Transporter | null = null; +let transporterFingerprint = ""; -class SmtpSession { - private socket: TLSSocket; - private buffer = ""; - private waiters: Array<(response: SmtpResponse) => void> = []; - - private constructor(socket: TLSSocket) { - this.socket = socket; - socket.setEncoding("utf8"); - socket.on("data", (chunk: string) => { - this.buffer += chunk; - this.flush(); - }); - } - - static async connect(env: ServerEnv): Promise { - const socket = connect({ - host: env.SMTP_HOST, - port: env.SMTP_PORT, +function getTransporter( + env: ServerEnv, +): Transporter { + const fingerprint = [ + env.SMTP_HOST, + env.SMTP_PORT, + env.SMTP_USER, + env.SMTP_POOL_MAX_CONNECTIONS, + env.SMTP_POOL_MAX_MESSAGES, + ].join(":"); + + if (transporter && transporterFingerprint === fingerprint) return transporter; + + transporter?.close(); + transporterFingerprint = fingerprint; + transporter = nodemailer.createTransport({ + host: env.SMTP_HOST, + port: env.SMTP_PORT, + secure: true, + pool: true, + maxConnections: env.SMTP_POOL_MAX_CONNECTIONS, + maxMessages: env.SMTP_POOL_MAX_MESSAGES, + connectionTimeout: env.SMTP_CONNECTION_TIMEOUT_MS, + greetingTimeout: env.SMTP_GREETING_TIMEOUT_MS, + socketTimeout: env.SMTP_SOCKET_TIMEOUT_MS, + auth: { + user: env.SMTP_USER, + pass: env.SMTP_PASSWORD, + }, + tls: { servername: env.SMTP_HOST, rejectUnauthorized: true, - }); - socket.setTimeout(env.SMTP_SOCKET_TIMEOUT_MS); - const session = new SmtpSession(socket); - await new Promise((resolve, reject) => { - const onError = (error: Error) => reject(error); - socket.once("error", onError); - socket.once("secureConnect", () => { - socket.off("error", onError); - resolve(); - }); - }); - const greeting = await session.read(); - if (greeting.code !== 220) - throw new Error(`SMTP greeting failed: ${greeting.code}`); - return session; - } - - private flush(): void { - const lines = this.buffer.split("\r\n"); - this.buffer = lines.pop() ?? ""; - if (!lines.length) return; - const complete: string[] = []; - for (const line of lines) { - complete.push(line); - const match = /^(\d{3})([ -])/.exec(line); - if (match?.[2] === " ") { - const code = Number(match[1]); - const waiter = this.waiters.shift(); - if (waiter) waiter({ code, lines: complete.splice(0) }); - } - } - } - - private read(): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error("SMTP response timed out")), - 30_000, - ); - this.waiters.push((response) => { - clearTimeout(timer); - resolve(response); - }); - }); - } - - async command(command: string, expected: number[]): Promise { - this.socket.write(`${command}\r\n`); - const response = await this.read(); - if (!expected.includes(response.code)) { - const error = new Error( - `SMTP command failed with status ${response.code}`, - ); - Object.assign(error, { smtpCode: response.code }); - throw error; - } - return response; - } - - async sendData(data: string): Promise { - this.socket.write(data); - const response = await this.read(); - if (response.code !== 250) { - const error = new Error(`SMTP DATA failed with status ${response.code}`); - Object.assign(error, { smtpCode: response.code }); - throw error; - } - } + }, + }); - close(): void { - this.socket.end(); - } + return transporter; } -function headerSafe(value: string): string { - return value.replace(/[\r\n]+/g, " ").trim(); +export async function verifySmtpTransport(env: ServerEnv): Promise { + await getTransporter(env).verify(); } -function dotStuff(value: string): string { - return value.replace(/(^|\r\n)\./g, "$1.."); +export async function closeSmtpTransport(): Promise { + transporter?.close(); + transporter = null; + transporterFingerprint = ""; } export async function sendMailViaSmtp( @@ -118,49 +65,15 @@ export async function sendMailViaSmtp( html: string; }, ): Promise<{ providerMessageId: string }> { - const session = await SmtpSession.connect(env); - const providerMessageId = `<${randomUUID()}@aiwamediagroup.com>`; - try { - await session.command(`EHLO ${env.SMTP_EHLO_NAME}`, [250]); - await session.command("AUTH LOGIN", [334]); - await session.command(Buffer.from(env.SMTP_USER).toString("base64"), [334]); - await session.command( - Buffer.from(env.SMTP_PASSWORD).toString("base64"), - [235], - ); - await session.command(`MAIL FROM:<${headerSafe(message.from)}>`, [250]); - await session.command(`RCPT TO:<${headerSafe(message.to)}>`, [250, 251]); - await session.command("DATA", [354]); - - const boundary = `aiwa-${randomUUID()}`; - const mime = [ - `From: Aiwa Creators <${headerSafe(message.from)}>`, - `To: <${headerSafe(message.to)}>`, - `Subject: ${headerSafe(message.subject)}`, - `Message-ID: ${providerMessageId}`, - "MIME-Version: 1.0", - `Content-Type: multipart/alternative; boundary="${boundary}"`, - "", - `--${boundary}`, - 'Content-Type: text/plain; charset="UTF-8"', - "Content-Transfer-Encoding: 8bit", - "", - message.text, - `--${boundary}`, - 'Content-Type: text/html; charset="UTF-8"', - "Content-Transfer-Encoding: 8bit", - "", - message.html, - `--${boundary}--`, - "", - ].join("\r\n"); + const info = await getTransporter(env).sendMail({ + from: { name: "Aiwa Creators", address: message.from }, + to: message.to, + subject: message.subject, + text: message.text, + html: message.html, + }); - await session.sendData(`${dotStuff(mime)}\r\n.\r\n`); - await session.command("QUIT", [221]); - return { providerMessageId }; - } finally { - session.close(); - } + return { providerMessageId: info.messageId }; } export async function processMailMessage( @@ -244,20 +157,40 @@ export function classifySmtpFailure(error: unknown): { message: string; } { const message = error instanceof Error ? error.message : "Unknown SMTP error"; - const smtpCode = + const responseCode = + typeof error === "object" && + error !== null && + "responseCode" in error && + typeof error.responseCode === "number" + ? error.responseCode + : undefined; + const transportCode = typeof error === "object" && error !== null && - "smtpCode" in error && - typeof error.smtpCode === "number" - ? error.smtpCode + "code" in error && + typeof error.code === "string" + ? error.code : undefined; - if (smtpCode && smtpCode >= 500) { - return { retryable: false, code: `SMTP_${smtpCode}`, message }; + if (responseCode && responseCode >= 500) { + return { retryable: false, code: `SMTP_${responseCode}`, message }; } + + if (transportCode === "EENVELOPE" && responseCode && responseCode >= 400) { + return { + retryable: responseCode < 500, + code: `SMTP_${responseCode}`, + message, + }; + } + return { retryable: true, - code: smtpCode ? `SMTP_${smtpCode}` : "SMTP_TRANSPORT", + code: responseCode + ? `SMTP_${responseCode}` + : transportCode + ? `SMTP_${transportCode}` + : "SMTP_TRANSPORT", message, }; } From 0e44910885ebff9ff39ada9f44c041b84f8e922e Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:47:23 +0530 Subject: [PATCH 087/129] feat(mail): add SMTP pool timeout configuration --- packages/config/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 7e951daae..dea613864 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -56,6 +56,10 @@ export const serverEnvSchema = z.object({ SMTP_USER: z.string().min(1).default("local"), SMTP_PASSWORD: z.string().min(1).default("local"), SMTP_EHLO_NAME: z.string().min(1).default("creator.aiwamediagroup.com"), + SMTP_POOL_MAX_CONNECTIONS: z.coerce.number().int().min(1).max(20).default(3), + SMTP_POOL_MAX_MESSAGES: z.coerce.number().int().min(1).max(1000).default(100), + SMTP_CONNECTION_TIMEOUT_MS: optionalPositiveInteger.default(10_000), + SMTP_GREETING_TIMEOUT_MS: optionalPositiveInteger.default(10_000), SMTP_SOCKET_TIMEOUT_MS: optionalPositiveInteger.default(30_000), MAIL_SECURITY_FROM_ADDRESS: z .string() From afb097ee082411c3e51ebac643ba0fe5e84efec0 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:47:27 +0530 Subject: [PATCH 088/129] docs(mail): document SMTP pool settings --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index d4e8ef4ff..cf9b311db 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,10 @@ SMTP_PORT=465 SMTP_USER= SMTP_PASSWORD= SMTP_EHLO_NAME=creator.aiwamediagroup.com +SMTP_POOL_MAX_CONNECTIONS=3 +SMTP_POOL_MAX_MESSAGES=100 +SMTP_CONNECTION_TIMEOUT_MS=10000 +SMTP_GREETING_TIMEOUT_MS=10000 SMTP_SOCKET_TIMEOUT_MS=30000 MAIL_SECURITY_FROM_ADDRESS=security@aiwamediagroup.com MAIL_ROUTINE_FROM_ADDRESS=creator-tool@aiwamediagroup.com From 73f8d870c54f4a5a49eb4f30ddf768f2c8a245e2 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:47:57 +0530 Subject: [PATCH 089/129] feat(mail): close SMTP pool during worker shutdown --- apps/worker/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 47f4983a9..000fc8d17 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -6,7 +6,7 @@ import { processVideoSubmitJob, processVoiceJob, } from "@aiwa/generation/process"; -import { processMailMessage } from "@aiwa/mail/transport"; +import { closeSmtpTransport, processMailMessage } from "@aiwa/mail/transport"; import { createBytePlusProvider } from "@aiwa/providers/byteplus"; import { createNvidiaProvider } from "@aiwa/providers/nvidia"; import { Queue, Worker } from "bullmq"; @@ -421,6 +421,7 @@ async function shutdown(signal: NodeJS.Signals): Promise { generationQueue.close(), reasoningQueue.close(), maintenanceWorker.close(), + closeSmtpTransport(), ]); let timeoutHandle: NodeJS.Timeout | undefined; From f437e085e68dc011e69a3e7cbfbd7f8649808409 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:48:10 +0530 Subject: [PATCH 090/129] chore(mail): finalize dependencies with repository toolchain --- .../workflows/finalize-mail-dependencies.yml | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/finalize-mail-dependencies.yml diff --git a/.github/workflows/finalize-mail-dependencies.yml b/.github/workflows/finalize-mail-dependencies.yml new file mode 100644 index 000000000..47066369e --- /dev/null +++ b/.github/workflows/finalize-mail-dependencies.yml @@ -0,0 +1,36 @@ +name: Finalize Mail Dependencies + +on: + pull_request: + branches: [main] + +permissions: + contents: write + +jobs: + finalize: + if: github.head_ref == 'feat/transactional-mail' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: feat/transactional-mail + - uses: pnpm/action-setup@v6 + with: + version: 11.19.0 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @aiwa/mail add nodemailer@9.1.1 + - run: pnpm --filter @aiwa/mail add -D @types/nodemailer@8.0.2 + - run: pnpm prettier --write .env.example apps/web/src/app/admin/email/page.tsx apps/web/src/app/api/account/notification-preferences/route.ts apps/web/src/app/api/admin/email/[mailId]/retry/route.ts apps/web/src/app/api/health/route.ts apps/web/src/app/settings/notifications/page.tsx apps/web/src/components/admin/mail-retry-button.tsx apps/web/src/components/settings/notification-preferences-form.tsx apps/web/src/lib/auth.ts apps/worker/src/index.ts packages/config/src/index.ts packages/db/prisma/schema.prisma packages/generation/src/process.ts packages/mail/src/index.ts packages/mail/src/transport.ts + - name: Commit dependency lock and canonical formatting + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/finalize-mail-dependencies.yml + git add . + git commit -m "chore(mail): finalize SMTP dependencies and formatting" + git push origin HEAD:feat/transactional-mail From 59cce49e0525733b9b2e769bbe41d1df01fb7994 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:49:22 +0530 Subject: [PATCH 091/129] fix(mail): exclude env file from formatter helper --- .github/workflows/finalize-mail-dependencies.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/finalize-mail-dependencies.yml b/.github/workflows/finalize-mail-dependencies.yml index 47066369e..bb438d41e 100644 --- a/.github/workflows/finalize-mail-dependencies.yml +++ b/.github/workflows/finalize-mail-dependencies.yml @@ -25,7 +25,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm --filter @aiwa/mail add nodemailer@9.1.1 - run: pnpm --filter @aiwa/mail add -D @types/nodemailer@8.0.2 - - run: pnpm prettier --write .env.example apps/web/src/app/admin/email/page.tsx apps/web/src/app/api/account/notification-preferences/route.ts apps/web/src/app/api/admin/email/[mailId]/retry/route.ts apps/web/src/app/api/health/route.ts apps/web/src/app/settings/notifications/page.tsx apps/web/src/components/admin/mail-retry-button.tsx apps/web/src/components/settings/notification-preferences-form.tsx apps/web/src/lib/auth.ts apps/worker/src/index.ts packages/config/src/index.ts packages/db/prisma/schema.prisma packages/generation/src/process.ts packages/mail/src/index.ts packages/mail/src/transport.ts + - run: pnpm prettier --write apps/web/src/app/admin/email/page.tsx apps/web/src/app/api/account/notification-preferences/route.ts apps/web/src/app/api/admin/email/[mailId]/retry/route.ts apps/web/src/app/api/health/route.ts apps/web/src/app/settings/notifications/page.tsx apps/web/src/components/admin/mail-retry-button.tsx apps/web/src/components/settings/notification-preferences-form.tsx apps/web/src/lib/auth.ts apps/worker/src/index.ts packages/config/src/index.ts packages/db/prisma/schema.prisma packages/generation/src/process.ts packages/mail/src/index.ts packages/mail/src/transport.ts - name: Commit dependency lock and canonical formatting run: | git config user.name "github-actions[bot]" From 1d1b78988e28d2b85a2b2c686f28d803251d8aba Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:51:13 +0530 Subject: [PATCH 092/129] fix(mail): use Prisma formatter for schema --- .github/workflows/finalize-mail-dependencies.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/finalize-mail-dependencies.yml b/.github/workflows/finalize-mail-dependencies.yml index bb438d41e..e98a040e6 100644 --- a/.github/workflows/finalize-mail-dependencies.yml +++ b/.github/workflows/finalize-mail-dependencies.yml @@ -25,7 +25,8 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm --filter @aiwa/mail add nodemailer@9.1.1 - run: pnpm --filter @aiwa/mail add -D @types/nodemailer@8.0.2 - - run: pnpm prettier --write apps/web/src/app/admin/email/page.tsx apps/web/src/app/api/account/notification-preferences/route.ts apps/web/src/app/api/admin/email/[mailId]/retry/route.ts apps/web/src/app/api/health/route.ts apps/web/src/app/settings/notifications/page.tsx apps/web/src/components/admin/mail-retry-button.tsx apps/web/src/components/settings/notification-preferences-form.tsx apps/web/src/lib/auth.ts apps/worker/src/index.ts packages/config/src/index.ts packages/db/prisma/schema.prisma packages/generation/src/process.ts packages/mail/src/index.ts packages/mail/src/transport.ts + - run: pnpm --filter @aiwa/db format:schema + - run: pnpm prettier --write apps/web/src/app/admin/email/page.tsx apps/web/src/app/api/account/notification-preferences/route.ts apps/web/src/app/api/admin/email/[mailId]/retry/route.ts apps/web/src/app/api/health/route.ts apps/web/src/app/settings/notifications/page.tsx apps/web/src/components/admin/mail-retry-button.tsx apps/web/src/components/settings/notification-preferences-form.tsx apps/web/src/lib/auth.ts apps/worker/src/index.ts packages/config/src/index.ts packages/generation/src/process.ts packages/mail/src/index.ts packages/mail/src/transport.ts - name: Commit dependency lock and canonical formatting run: | git config user.name "github-actions[bot]" From 16acd0d448ec8fcbb10bc9cee4628db19e7103aa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:22:15 +0000 Subject: [PATCH 093/129] chore(mail): finalize SMTP dependencies and formatting --- .../workflows/finalize-mail-dependencies.yml | 37 --- apps/web/src/app/admin/email/page.tsx | 33 ++- .../account/notification-preferences/route.ts | 15 +- .../api/admin/email/[mailId]/retry/route.ts | 15 +- apps/web/src/app/api/health/route.ts | 8 +- .../src/app/settings/notifications/page.tsx | 4 +- .../components/admin/mail-retry-button.tsx | 9 +- .../notification-preferences-form.tsx | 23 +- packages/db/prisma/schema.prisma | 225 +++++++++--------- packages/generation/src/process.ts | 6 +- packages/mail/package.json | 4 +- pnpm-lock.yaml | 19 ++ 12 files changed, 218 insertions(+), 180 deletions(-) delete mode 100644 .github/workflows/finalize-mail-dependencies.yml diff --git a/.github/workflows/finalize-mail-dependencies.yml b/.github/workflows/finalize-mail-dependencies.yml deleted file mode 100644 index e98a040e6..000000000 --- a/.github/workflows/finalize-mail-dependencies.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Finalize Mail Dependencies - -on: - pull_request: - branches: [main] - -permissions: - contents: write - -jobs: - finalize: - if: github.head_ref == 'feat/transactional-mail' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: feat/transactional-mail - - uses: pnpm/action-setup@v6 - with: - version: 11.19.0 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm --filter @aiwa/mail add nodemailer@9.1.1 - - run: pnpm --filter @aiwa/mail add -D @types/nodemailer@8.0.2 - - run: pnpm --filter @aiwa/db format:schema - - run: pnpm prettier --write apps/web/src/app/admin/email/page.tsx apps/web/src/app/api/account/notification-preferences/route.ts apps/web/src/app/api/admin/email/[mailId]/retry/route.ts apps/web/src/app/api/health/route.ts apps/web/src/app/settings/notifications/page.tsx apps/web/src/components/admin/mail-retry-button.tsx apps/web/src/components/settings/notification-preferences-form.tsx apps/web/src/lib/auth.ts apps/worker/src/index.ts packages/config/src/index.ts packages/generation/src/process.ts packages/mail/src/index.ts packages/mail/src/transport.ts - - name: Commit dependency lock and canonical formatting - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/finalize-mail-dependencies.yml - git add . - git commit -m "chore(mail): finalize SMTP dependencies and formatting" - git push origin HEAD:feat/transactional-mail diff --git a/apps/web/src/app/admin/email/page.tsx b/apps/web/src/app/admin/email/page.tsx index cd7b95760..69c880e2d 100644 --- a/apps/web/src/app/admin/email/page.tsx +++ b/apps/web/src/app/admin/email/page.tsx @@ -11,7 +11,9 @@ function maskEmail(email: string): string { return `${visible}••••@${domain}`; } -function statusTone(status: string): "success" | "warning" | "danger" | "neutral" { +function statusTone( + status: string, +): "success" | "warning" | "danger" | "neutral" { if (status === "SENT") return "success"; if (status === "FAILED") return "danger"; if (status === "RETRY" || status === "SENDING") return "warning"; @@ -22,7 +24,9 @@ export default async function AdminEmailPage() { await requirePlatformPermission("audit:read"); const [pending, retrying, failed, sent, recent] = await Promise.all([ - db.mailMessage.count({ where: { status: { in: ["PENDING", "QUEUED", "SENDING"] } } }), + db.mailMessage.count({ + where: { status: { in: ["PENDING", "QUEUED", "SENDING"] } }, + }), db.mailMessage.count({ where: { status: "RETRY" } }), db.mailMessage.count({ where: { status: "FAILED" } }), db.mailMessage.count({ where: { status: "SENT" } }), @@ -68,7 +72,10 @@ export default async function AdminEmailPage() {
{cards.map(([label, value]) => ( -
+

{label}

@@ -81,9 +88,12 @@ export default async function AdminEmailPage() {
-

Recent deliveries

+

+ Recent deliveries +

- Latest 50 messages. Recipients are masked and mail bodies are excluded. + Latest 50 messages. Recipients are masked and mail bodies are + excluded.

@@ -130,7 +140,9 @@ export default async function AdminEmailPage() { {mail.attemptCount} - {(mail.sentAt ?? mail.nextAttemptAt)?.toLocaleString("en-OM") ?? "—"} + {(mail.sentAt ?? mail.nextAttemptAt)?.toLocaleString( + "en-OM", + ) ?? "—"} {mail.lastErrorCode ? ( @@ -153,14 +165,19 @@ export default async function AdminEmailPage() { ["FAILED", "RETRY"].includes(mail.status) ? ( ) : ( - — + + — + )} ))} {recent.length === 0 ? ( - + No transactional mail has been queued yet. diff --git a/apps/web/src/app/api/account/notification-preferences/route.ts b/apps/web/src/app/api/account/notification-preferences/route.ts index 3c9064611..b80f7fd61 100644 --- a/apps/web/src/app/api/account/notification-preferences/route.ts +++ b/apps/web/src/app/api/account/notification-preferences/route.ts @@ -13,7 +13,10 @@ const schema = z.object({ export async function GET(request: Request) { const session = await getRequestSession(request.headers); if (!session) { - return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + return NextResponse.json( + { error: "Authentication required." }, + { status: 401 }, + ); } const preference = await db.notificationPreference.findUnique({ @@ -41,12 +44,18 @@ export async function PATCH(request: Request) { const session = await getRequestSession(request.headers); if (!session) { - return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + return NextResponse.json( + { error: "Authentication required." }, + { status: 401 }, + ); } const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { - return NextResponse.json({ error: "Invalid notification preferences." }, { status: 400 }); + return NextResponse.json( + { error: "Invalid notification preferences." }, + { status: 400 }, + ); } const preference = await db.notificationPreference.upsert({ diff --git a/apps/web/src/app/api/admin/email/[mailId]/retry/route.ts b/apps/web/src/app/api/admin/email/[mailId]/retry/route.ts index b81e6d580..dd6f0cd57 100644 --- a/apps/web/src/app/api/admin/email/[mailId]/retry/route.ts +++ b/apps/web/src/app/api/admin/email/[mailId]/retry/route.ts @@ -16,7 +16,10 @@ export async function POST( const session = await getRequestSession(request.headers); if (!session) { - return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + return NextResponse.json( + { error: "Authentication required." }, + { status: 401 }, + ); } if (!hasPlatformPermission(session.user.platformRole, "jobs:manage")) { return NextResponse.json({ error: "Permission denied." }, { status: 403 }); @@ -24,7 +27,10 @@ export async function POST( const { mailId } = await params; if (!cuidSchema.safeParse(mailId).success) { - return NextResponse.json({ error: "Mail delivery not found." }, { status: 404 }); + return NextResponse.json( + { error: "Mail delivery not found." }, + { status: 404 }, + ); } const current = await db.mailMessage.findUnique({ @@ -32,7 +38,10 @@ export async function POST( select: { id: true, kind: true, status: true, attemptCount: true }, }); if (!current) { - return NextResponse.json({ error: "Mail delivery not found." }, { status: 404 }); + return NextResponse.json( + { error: "Mail delivery not found." }, + { status: 404 }, + ); } if (current.kind !== "ROUTINE") { return NextResponse.json( diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index c20d1b618..d9c47bdc1 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -16,10 +16,10 @@ export function GET(): Response { voiceConfigured: isBytePlusVoiceConfigured(), mailConfigured: Boolean( process.env.SMTP_HOST && - process.env.SMTP_USER && - process.env.SMTP_PASSWORD && - process.env.MAIL_SECURITY_FROM_ADDRESS && - process.env.MAIL_ROUTINE_FROM_ADDRESS, + process.env.SMTP_USER && + process.env.SMTP_PASSWORD && + process.env.MAIL_SECURITY_FROM_ADDRESS && + process.env.MAIL_ROUTINE_FROM_ADDRESS, ), timestamp: new Date().toISOString(), }, diff --git a/apps/web/src/app/settings/notifications/page.tsx b/apps/web/src/app/settings/notifications/page.tsx index 10b64da6c..a6b028b5b 100644 --- a/apps/web/src/app/settings/notifications/page.tsx +++ b/apps/web/src/app/settings/notifications/page.tsx @@ -35,7 +35,9 @@ export default async function NotificationSettingsPage() {
-

Security messages always stay on

+

+ Security messages always stay on +

Email verification, password and MFA changes, billing events, and team membership notices cannot be disabled because they protect the diff --git a/apps/web/src/components/admin/mail-retry-button.tsx b/apps/web/src/components/admin/mail-retry-button.tsx index 2361f3546..137ae4bd9 100644 --- a/apps/web/src/components/admin/mail-retry-button.tsx +++ b/apps/web/src/components/admin/mail-retry-button.tsx @@ -11,9 +11,12 @@ export function MailRetryButton({ mailId }: { mailId: string }) { async function retry() { setBusy(true); try { - const response = await fetch(`/api/admin/email/${encodeURIComponent(mailId)}/retry`, { - method: "POST", - }); + const response = await fetch( + `/api/admin/email/${encodeURIComponent(mailId)}/retry`, + { + method: "POST", + }, + ); if (response.ok) router.refresh(); } finally { setBusy(false); diff --git a/apps/web/src/components/settings/notification-preferences-form.tsx b/apps/web/src/components/settings/notification-preferences-form.tsx index 1d3c4dbed..9dce53aed 100644 --- a/apps/web/src/components/settings/notification-preferences-form.tsx +++ b/apps/web/src/components/settings/notification-preferences-form.tsx @@ -15,7 +15,9 @@ export function NotificationPreferencesForm({ initial: Preferences; }) { const [value, setValue] = useState(initial); - const [state, setState] = useState<"idle" | "saving" | "saved" | "error">("idle"); + const [state, setState] = useState<"idle" | "saving" | "saved" | "error">( + "idle", + ); async function save() { setState("saving"); @@ -31,17 +33,20 @@ export function NotificationPreferencesForm({ { key: "generationCompleted" as const, title: "Generation completed", - description: "Receive a link when an image, video, or voice generation is ready.", + description: + "Receive a link when an image, video, or voice generation is ready.", }, { key: "generationFailed" as const, title: "Generation failed", - description: "Receive a message when a generation cannot be completed and needs attention.", + description: + "Receive a message when a generation cannot be completed and needs attention.", }, { key: "reports" as const, title: "Reports and summaries", - description: "Receive routine platform reports and summaries when those features are enabled.", + description: + "Receive routine platform reports and summaries when those features are enabled.", }, ]; @@ -74,14 +79,20 @@ export function NotificationPreferencesForm({ ))}

- {state === "saved" ? ( Preferences saved. ) : null} {state === "error" ? ( - Could not save preferences. + + Could not save preferences. + ) : null}
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b171d9b97..0fe09af47 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -98,31 +98,31 @@ enum AssetStatus { } model User { - id String @id @default(cuid()) - name String - email String @unique - emailVerified Boolean @default(false) - image String? @db.Text - platformRole PlatformRole @default(USER) - disabledAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - sessions Session[] - accounts Account[] - memberships Membership[] - onboardedOrganization Organization? @relation("SelfServeOrganization") - createdPayments ManualPayment[] @relation("PaymentCreatedBy") - confirmedPayments ManualPayment[] @relation("PaymentConfirmedBy") - priceVersions ModelPriceVersion[] @relation("PriceVersionCreatedBy") - generationJobs GenerationJob[] @relation("GenerationCreatedBy") - reasoningJobs ReasoningJob[] @relation("ReasoningCreatedBy") - ownedOrganizations Organization[] @relation("OrganizationOwner") - storageAssets Asset[] @relation("AssetStorageOwner") - auditEvents AuditEvent[] @relation("AuditActor") - createdInvitations OrganizationInvitation[] @relation("InvitationCreatedBy") - acceptedInvitations OrganizationInvitation[] @relation("InvitationAcceptedBy") - twoFactorEnabled Boolean @default(false) - twoFactors TwoFactor[] + id String @id @default(cuid()) + name String + email String @unique + emailVerified Boolean @default(false) + image String? @db.Text + platformRole PlatformRole @default(USER) + disabledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sessions Session[] + accounts Account[] + memberships Membership[] + onboardedOrganization Organization? @relation("SelfServeOrganization") + createdPayments ManualPayment[] @relation("PaymentCreatedBy") + confirmedPayments ManualPayment[] @relation("PaymentConfirmedBy") + priceVersions ModelPriceVersion[] @relation("PriceVersionCreatedBy") + generationJobs GenerationJob[] @relation("GenerationCreatedBy") + reasoningJobs ReasoningJob[] @relation("ReasoningCreatedBy") + ownedOrganizations Organization[] @relation("OrganizationOwner") + storageAssets Asset[] @relation("AssetStorageOwner") + auditEvents AuditEvent[] @relation("AuditActor") + createdInvitations OrganizationInvitation[] @relation("InvitationCreatedBy") + acceptedInvitations OrganizationInvitation[] @relation("InvitationAcceptedBy") + twoFactorEnabled Boolean @default(false) + twoFactors TwoFactor[] notificationPreference NotificationPreference? @@index([platformRole, disabledAt]) @@ -194,26 +194,26 @@ model TwoFactor { } model NotificationPreference { - userId String @id - generationCompleted Boolean @default(true) - generationFailed Boolean @default(true) - reports Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String @id + generationCompleted Boolean @default(true) + generationFailed Boolean @default(true) + reports Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model Organization { - id String @id @default(cuid()) + id String @id @default(cuid()) name String - slug String @unique - status OrganizationStatus @default(ACTIVE) - selfServeCreatorUserId String? @unique + slug String @unique + status OrganizationStatus @default(ACTIVE) + selfServeCreatorUserId String? @unique ownerUserId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - selfServeCreator User? @relation("SelfServeOrganization", fields: [selfServeCreatorUserId], references: [id], onDelete: SetNull) - owner User @relation("OrganizationOwner", fields: [ownerUserId], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + selfServeCreator User? @relation("SelfServeOrganization", fields: [selfServeCreatorUserId], references: [id], onDelete: SetNull) + owner User @relation("OrganizationOwner", fields: [ownerUserId], references: [id], onDelete: Restrict) activeSessions Session[] wallet Wallet? memberships Membership[] @@ -231,15 +231,15 @@ model Organization { } model Membership { - id String @id @default(cuid()) - organizationId String - userId String - role MembershipRole @default(ORGANIZATION_MEMBER) + id String @id @default(cuid()) + organizationId String + userId String + role MembershipRole @default(ORGANIZATION_MEMBER) monthlySpendingCapCredits BigInt? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([organizationId, userId]) @@index([userId]) @@ -279,33 +279,33 @@ model LedgerEntry { } model ManualPayment { - id String @id @default(cuid()) - organizationId String - method PaymentMethod - status PaymentStatus @default(DRAFT) - amountBaisa BigInt - reference String? - chequeNumber String? - bankName String? - notes String? @db.Text - receivedAt DateTime - confirmedAt DateTime? - rejectedAt DateTime? - reversedAt DateTime? + id String @id @default(cuid()) + organizationId String + method PaymentMethod + status PaymentStatus @default(DRAFT) + amountBaisa BigInt + reference String? + chequeNumber String? + bankName String? + notes String? @db.Text + receivedAt DateTime + confirmedAt DateTime? + rejectedAt DateTime? + reversedAt DateTime? rejectionReason String? @db.Text rejectionIdempotencyKey String? @unique reversalReason String? @db.Text - creditsGranted BigInt? - creditsPerBaisa BigInt? - idempotencyKey String @unique - createdById String - confirmedById String? - ledgerEntryId String? @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) - createdBy User @relation("PaymentCreatedBy", fields: [createdById], references: [id], onDelete: Restrict) - confirmedBy User? @relation("PaymentConfirmedBy", fields: [confirmedById], references: [id], onDelete: Restrict) + creditsGranted BigInt? + creditsPerBaisa BigInt? + idempotencyKey String @unique + createdById String + confirmedById String? + ledgerEntryId String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + createdBy User @relation("PaymentCreatedBy", fields: [createdById], references: [id], onDelete: Restrict) + confirmedBy User? @relation("PaymentConfirmedBy", fields: [confirmedById], references: [id], onDelete: Restrict) @@index([organizationId, status]) @@index([method, receivedAt]) @@ -409,25 +409,25 @@ model GenerationJob { } model Asset { - id String @id @default(cuid()) - organizationId String - projectId String? - generationJobId String? + id String @id @default(cuid()) + organizationId String + projectId String? + generationJobId String? storageOwnerUserId String? - status AssetStatus @default(PENDING) - objectKey String @unique - mimeType String - byteSize BigInt - sha256 String? - width Int? - height Int? - durationMs Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) - project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) - generationJob GenerationJob? @relation(fields: [generationJobId], references: [id], onDelete: SetNull) - storageOwner User? @relation("AssetStorageOwner", fields: [storageOwnerUserId], references: [id], onDelete: SetNull) + status AssetStatus @default(PENDING) + objectKey String @unique + mimeType String + byteSize BigInt + sha256 String? + width Int? + height Int? + durationMs Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + generationJob GenerationJob? @relation(fields: [generationJobId], references: [id], onDelete: SetNull) + storageOwner User? @relation("AssetStorageOwner", fields: [storageOwnerUserId], references: [id], onDelete: SetNull) @@index([organizationId, createdAt]) @@index([generationJobId]) @@ -538,28 +538,28 @@ enum ReasoningJobStatus { } model ReasoningJob { - id String @id @default(cuid()) - organizationId String - createdById String - providerModelId String - status ReasoningJobStatus @default(QUEUED) - idempotencyKey String @unique - requestPayload Json - outputPayload Json? - providerRequestId String? - inputTokens Int? - outputTokens Int? - errorCode String? - errorMessage String? @db.Text - queuedAt DateTime? - processingAt DateTime? - completedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) - createdBy User @relation("ReasoningCreatedBy", fields: [createdById], references: [id], onDelete: Restrict) - providerModel ProviderModel @relation(fields: [providerModelId], references: [id], onDelete: Restrict) + id String @id @default(cuid()) + organizationId String + createdById String + providerModelId String + status ReasoningJobStatus @default(QUEUED) + idempotencyKey String @unique + requestPayload Json + outputPayload Json? + providerRequestId String? + inputTokens Int? + outputTokens Int? + errorCode String? + errorMessage String? @db.Text + queuedAt DateTime? + processingAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + createdBy User @relation("ReasoningCreatedBy", fields: [createdById], references: [id], onDelete: Restrict) + providerModel ProviderModel @relation(fields: [providerModelId], references: [id], onDelete: Restrict) @@index([organizationId, status, createdAt]) @@index([createdById, organizationId, status, createdAt]) @@ -567,4 +567,3 @@ model ReasoningJob { @@index([status, updatedAt]) @@index([providerRequestId]) } - diff --git a/packages/generation/src/process.ts b/packages/generation/src/process.ts index f64e8c636..c237409e9 100644 --- a/packages/generation/src/process.ts +++ b/packages/generation/src/process.ts @@ -75,7 +75,11 @@ async function enqueueGenerationSuccess( job: { id: string; organizationId: string; createdById: string }, assetId: string, ): Promise { - const to = await generationRecipient(tx, job.createdById, "generationCompleted"); + const to = await generationRecipient( + tx, + job.createdById, + "generationCompleted", + ); if (!to) return; const appUrl = process.env.APP_URL ?? "http://localhost:3000"; await enqueueMail( diff --git a/packages/mail/package.json b/packages/mail/package.json index 85a749c0d..1778292e7 100644 --- a/packages/mail/package.json +++ b/packages/mail/package.json @@ -16,9 +16,11 @@ }, "dependencies": { "@aiwa/config": "workspace:*", - "@aiwa/db": "workspace:*" + "@aiwa/db": "workspace:*", + "nodemailer": "9.1.1" }, "devDependencies": { + "@types/nodemailer": "8.0.2", "vitest": "^4.1.11" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d8d66bc7..687dc5629 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,7 +242,13 @@ importers: '@aiwa/db': specifier: workspace:* version: link:../db + nodemailer: + specifier: 9.1.1 + version: 9.1.1 devDependencies: + '@types/nodemailer': + specifier: 8.0.2 + version: 8.0.2 vitest: specifier: ^4.1.11 version: 4.1.11(@types/node@24.13.4)(vite@7.3.6(@types/node@24.13.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)) @@ -1540,6 +1546,9 @@ packages: '@types/node@24.13.4': resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==} + '@types/nodemailer@8.0.2': + resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==} + '@types/react-dom@19.3.0': resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: @@ -2903,6 +2912,10 @@ packages: resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==} engines: {node: '>=18'} + nodemailer@9.1.1: + resolution: {integrity: sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==} + engines: {node: '>=6.0.0'} + nypm@0.6.10: resolution: {integrity: sha512-W72Hrj1petq+b3Hk2aAC+9zswetlIFTnDW4s5djseZh2nYqBbyQLOtj472HwcbcWykPBUW1WpWpjOd4nK6gMRw==} engines: {node: '>=18'} @@ -4417,6 +4430,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/nodemailer@8.0.2': + dependencies: + '@types/node': 24.13.4 + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: '@types/react': 19.3.0 @@ -5930,6 +5947,8 @@ snapshots: node-releases@2.0.55: {} + nodemailer@9.1.1: {} + nypm@0.6.10: dependencies: citty: 0.2.2 From d35def59302fa8e6336d98d1c64ac46e87d280ce Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:53:00 +0530 Subject: [PATCH 094/129] docs(mail): add transactional email production runbook --- docs/operations/transactional-email.md | 50 ++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/operations/transactional-email.md diff --git a/docs/operations/transactional-email.md b/docs/operations/transactional-email.md new file mode 100644 index 000000000..e2f2aa068 --- /dev/null +++ b/docs/operations/transactional-email.md @@ -0,0 +1,50 @@ +# Transactional email operations + +Aiwa Creators uses a durable MariaDB outbox plus the BullMQ worker for all outbound email. + +## Sender identities + +- `security@aiwamediagroup.com`: verification, password and MFA events, billing, invitations, and team membership. +- `creator-tool@aiwamediagroup.com`: routine generation, report, and activity notifications. + +Security mail is mandatory. Routine categories can be disabled by users at `/settings/notifications`. + +## Production SMTP configuration + +Use implicit TLS SMTP on port 465. Certificate verification remains enabled. + +```dotenv +MAIL_ENABLED=true +SMTP_HOST=mail.example.com +SMTP_PORT=465 +SMTP_USER= +SMTP_PASSWORD= +SMTP_EHLO_NAME=creator.aiwamediagroup.com +SMTP_POOL_MAX_CONNECTIONS=3 +SMTP_POOL_MAX_MESSAGES=100 +SMTP_CONNECTION_TIMEOUT_MS=10000 +SMTP_GREETING_TIMEOUT_MS=10000 +SMTP_SOCKET_TIMEOUT_MS=30000 +MAIL_SECURITY_FROM_ADDRESS=security@aiwamediagroup.com +MAIL_ROUTINE_FROM_ADDRESS=creator-tool@aiwamediagroup.com +``` + +Apply database migrations before restarting services: + +```bash +pnpm --filter @aiwa/db migrate:deploy +sudo systemctl restart creator-web.service creator-worker.service +``` + +## Reliability model + +Business operations persist an outbox row before delivery. Redis is only the dispatcher, so Redis or SMTP downtime does not lose queued mail. Temporary delivery failures retry with bounded backoff. Permanent SMTP failures remain visible in the admin Email delivery page. + +Security-message bodies are redacted after successful delivery. Failed security messages are not generically replayable because reset or invitation tokens may have expired; regenerate them from the original workflow instead. Failed routine messages can be explicitly requeued by an authorized operator. + +## Operational checks + +- `/api/health` exposes whether SMTP credentials and both sender identities are configured. +- `/admin/email` shows queue state, delivery counts, masked recipients, attempts, and sanitized failure diagnostics. +- SMTP credentials, reset links, verification links, and MFA secrets must never be logged. +- SPF, DKIM, and DMARC should be configured for the sending domain before production launch. From c1affd36607b664375e01bd55018163f06d267ce Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:54:48 +0530 Subject: [PATCH 095/129] style(mail): format transactional email runbook --- docs/operations/transactional-email.md | 34 ++++++++++++++++++-------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/docs/operations/transactional-email.md b/docs/operations/transactional-email.md index e2f2aa068..f00dc7436 100644 --- a/docs/operations/transactional-email.md +++ b/docs/operations/transactional-email.md @@ -1,13 +1,17 @@ # Transactional email operations -Aiwa Creators uses a durable MariaDB outbox plus the BullMQ worker for all outbound email. +Aiwa Creators uses a durable MariaDB outbox plus the BullMQ worker for all +outbound email. ## Sender identities -- `security@aiwamediagroup.com`: verification, password and MFA events, billing, invitations, and team membership. -- `creator-tool@aiwamediagroup.com`: routine generation, report, and activity notifications. +- `security@aiwamediagroup.com`: verification, password and MFA events, + billing, invitations, and team membership. +- `creator-tool@aiwamediagroup.com`: routine generation, report, and activity + notifications. -Security mail is mandatory. Routine categories can be disabled by users at `/settings/notifications`. +Security mail is mandatory. Routine categories can be disabled by users at +`/settings/notifications`. ## Production SMTP configuration @@ -38,13 +42,23 @@ sudo systemctl restart creator-web.service creator-worker.service ## Reliability model -Business operations persist an outbox row before delivery. Redis is only the dispatcher, so Redis or SMTP downtime does not lose queued mail. Temporary delivery failures retry with bounded backoff. Permanent SMTP failures remain visible in the admin Email delivery page. +Business operations persist an outbox row before delivery. Redis is only the +dispatcher, so Redis or SMTP downtime does not lose queued mail. Temporary +delivery failures retry with bounded backoff. Permanent SMTP failures remain +visible in the admin Email delivery page. -Security-message bodies are redacted after successful delivery. Failed security messages are not generically replayable because reset or invitation tokens may have expired; regenerate them from the original workflow instead. Failed routine messages can be explicitly requeued by an authorized operator. +Security-message bodies are redacted after successful delivery. Failed security +messages are not generically replayable because reset or invitation tokens may +have expired; regenerate them from the original workflow instead. Failed routine +messages can be explicitly requeued by an authorized operator. ## Operational checks -- `/api/health` exposes whether SMTP credentials and both sender identities are configured. -- `/admin/email` shows queue state, delivery counts, masked recipients, attempts, and sanitized failure diagnostics. -- SMTP credentials, reset links, verification links, and MFA secrets must never be logged. -- SPF, DKIM, and DMARC should be configured for the sending domain before production launch. +- `/api/health` exposes whether SMTP credentials and both sender identities are + configured. +- `/admin/email` shows queue state, delivery counts, masked recipients, + attempts, and sanitized failure diagnostics. +- SMTP credentials, reset links, verification links, and MFA secrets must never + be logged. +- SPF, DKIM, and DMARC should be configured for the sending domain before + production launch. From 7d69fac5cf992b867faa159c08f83d76686dc9b7 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:57:05 +0530 Subject: [PATCH 096/129] chore(mail): canonicalize runbook formatting --- .github/workflows/format-mail-runbook.yml | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/format-mail-runbook.yml diff --git a/.github/workflows/format-mail-runbook.yml b/.github/workflows/format-mail-runbook.yml new file mode 100644 index 000000000..85a907477 --- /dev/null +++ b/.github/workflows/format-mail-runbook.yml @@ -0,0 +1,34 @@ +name: Format Mail Runbook + +on: + pull_request: + branches: [main] + +permissions: + contents: write + +jobs: + format: + if: github.head_ref == 'feat/transactional-mail' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: feat/transactional-mail + - uses: pnpm/action-setup@v6 + with: + version: 11.19.0 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm prettier --write docs/operations/transactional-email.md + - name: Commit canonical runbook formatting + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/format-mail-runbook.yml + git add docs/operations/transactional-email.md + git commit -m "style(mail): apply canonical runbook formatting" + git push origin HEAD:feat/transactional-mail From 28d363dbf5c801fd4ab70af262b1be71c6333f64 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:27:28 +0000 Subject: [PATCH 097/129] style(mail): apply canonical runbook formatting --- .github/workflows/format-mail-runbook.yml | 34 ----------------------- docs/operations/transactional-email.md | 4 +-- 2 files changed, 2 insertions(+), 36 deletions(-) delete mode 100644 .github/workflows/format-mail-runbook.yml diff --git a/.github/workflows/format-mail-runbook.yml b/.github/workflows/format-mail-runbook.yml deleted file mode 100644 index 85a907477..000000000 --- a/.github/workflows/format-mail-runbook.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Format Mail Runbook - -on: - pull_request: - branches: [main] - -permissions: - contents: write - -jobs: - format: - if: github.head_ref == 'feat/transactional-mail' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: feat/transactional-mail - - uses: pnpm/action-setup@v6 - with: - version: 11.19.0 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm prettier --write docs/operations/transactional-email.md - - name: Commit canonical runbook formatting - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/format-mail-runbook.yml - git add docs/operations/transactional-email.md - git commit -m "style(mail): apply canonical runbook formatting" - git push origin HEAD:feat/transactional-mail diff --git a/docs/operations/transactional-email.md b/docs/operations/transactional-email.md index f00dc7436..797471397 100644 --- a/docs/operations/transactional-email.md +++ b/docs/operations/transactional-email.md @@ -5,8 +5,8 @@ outbound email. ## Sender identities -- `security@aiwamediagroup.com`: verification, password and MFA events, - billing, invitations, and team membership. +- `security@aiwamediagroup.com`: verification, password and MFA events, billing, + invitations, and team membership. - `creator-tool@aiwamediagroup.com`: routine generation, report, and activity notifications. From d25e56a9cd49f07455e979a27f32b94d50ae9793 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 13:58:14 +0530 Subject: [PATCH 098/129] docs(mail): link transactional email operations runbook --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index fb498b9a9..bce15e37b 100644 --- a/README.md +++ b/README.md @@ -436,3 +436,11 @@ Studio supports queued BytePlus image generation with wallet reservation, persistent PNG assets and authenticated downloads. See [the image-generation runbook](docs/image-generation.md) for deployment, recovery behavior and the live acceptance check. + + +## Transactional email + +Authentication, billing, membership, generation and report notifications use a +durable MariaDB outbox with BullMQ delivery and pooled TLS SMTP. See +[the transactional-email operations runbook](docs/operations/transactional-email.md) +for production configuration, sender identities, retry behavior and diagnostics. From 67e64f064ad4d92aced5a341d6f14c02ed13bd52 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 14:00:18 +0530 Subject: [PATCH 099/129] fix(mail): use type-only SMTP transport import --- packages/mail/src/transport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mail/src/transport.ts b/packages/mail/src/transport.ts index 595ff702b..bc42bdaa9 100644 --- a/packages/mail/src/transport.ts +++ b/packages/mail/src/transport.ts @@ -1,7 +1,7 @@ import type { ServerEnv } from "@aiwa/config"; import { db } from "@aiwa/db"; import nodemailer, { type Transporter } from "nodemailer"; -import SMTPTransport from "nodemailer/lib/smtp-transport"; +import type SMTPTransport from "nodemailer/lib/smtp-transport"; import { senderForKind } from "./index"; let transporter: Transporter | null = null; From bfb3d7f418a9a936dcafe67a6662d465991ee285 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 14:02:16 +0530 Subject: [PATCH 100/129] fix(mail): type pooled Nodemailer transport correctly --- packages/mail/src/transport.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/mail/src/transport.ts b/packages/mail/src/transport.ts index bc42bdaa9..78f90a0f7 100644 --- a/packages/mail/src/transport.ts +++ b/packages/mail/src/transport.ts @@ -1,15 +1,15 @@ import type { ServerEnv } from "@aiwa/config"; import { db } from "@aiwa/db"; import nodemailer, { type Transporter } from "nodemailer"; -import type SMTPTransport from "nodemailer/lib/smtp-transport"; +import type SMTPPool from "nodemailer/lib/smtp-pool"; import { senderForKind } from "./index"; -let transporter: Transporter | null = null; +let transporter: Transporter | null = null; let transporterFingerprint = ""; function getTransporter( env: ServerEnv, -): Transporter { +): Transporter { const fingerprint = [ env.SMTP_HOST, env.SMTP_PORT, @@ -22,7 +22,7 @@ function getTransporter( transporter?.close(); transporterFingerprint = fingerprint; - transporter = nodemailer.createTransport({ + const nextTransporter = nodemailer.createTransport({ host: env.SMTP_HOST, port: env.SMTP_PORT, secure: true, @@ -42,7 +42,8 @@ function getTransporter( }, }); - return transporter; + transporter = nextTransporter; + return nextTransporter; } export async function verifySmtpTransport(env: ServerEnv): Promise { From 47b75f319f74b3540869be3bc89c14b538d8faab Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 14:04:11 +0530 Subject: [PATCH 101/129] style(mail): format pooled transport declaration --- packages/mail/src/transport.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/mail/src/transport.ts b/packages/mail/src/transport.ts index 78f90a0f7..f40b90f07 100644 --- a/packages/mail/src/transport.ts +++ b/packages/mail/src/transport.ts @@ -4,7 +4,10 @@ import nodemailer, { type Transporter } from "nodemailer"; import type SMTPPool from "nodemailer/lib/smtp-pool"; import { senderForKind } from "./index"; -let transporter: Transporter | null = null; +let transporter: Transporter< + SMTPPool.SentMessageInfo, + SMTPPool.Options +> | null = null; let transporterFingerprint = ""; function getTransporter( From 754760aaa80bafb4ffdf1ac4071a4c598887b2f5 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 14:06:32 +0530 Subject: [PATCH 102/129] fix(mail): handle malformed recipient addresses in admin view --- apps/web/src/app/admin/email/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/admin/email/page.tsx b/apps/web/src/app/admin/email/page.tsx index 69c880e2d..8421f6488 100644 --- a/apps/web/src/app/admin/email/page.tsx +++ b/apps/web/src/app/admin/email/page.tsx @@ -5,7 +5,7 @@ import { Eyebrow } from "@/components/ui/creative"; import { requirePlatformPermission } from "@/lib/request-auth"; function maskEmail(email: string): string { - const [local, domain] = email.split("@"); + const [local = "", domain] = email.split("@"); if (!domain) return "••••"; const visible = local.length <= 2 ? local.slice(0, 1) : local.slice(0, 2); return `${visible}••••@${domain}`; From e6efe9ef0d7b63ec910d6d30a2d5e390b33b728e Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 14:10:16 +0530 Subject: [PATCH 103/129] test(mail): cover Nodemailer SMTP response classification --- packages/mail/test/mail.test.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/mail/test/mail.test.ts b/packages/mail/test/mail.test.ts index 4b5173203..57a9ac5de 100644 --- a/packages/mail/test/mail.test.ts +++ b/packages/mail/test/mail.test.ts @@ -27,7 +27,24 @@ describe("mail helpers", () => { }); it("treats 5xx SMTP responses as permanent", () => { - const error = Object.assign(new Error("rejected"), { smtpCode: 550 }); - expect(classifySmtpFailure(error).retryable).toBe(false); + const error = Object.assign(new Error("rejected"), { + code: "EENVELOPE", + responseCode: 550, + }); + expect(classifySmtpFailure(error)).toMatchObject({ + retryable: false, + code: "SMTP_550", + }); + }); + + it("treats 4xx SMTP responses as retryable", () => { + const error = Object.assign(new Error("mailbox temporarily unavailable"), { + code: "EENVELOPE", + responseCode: 451, + }); + expect(classifySmtpFailure(error)).toMatchObject({ + retryable: true, + code: "SMTP_451", + }); }); }); From 679280f5ec1e6549ff460d0390e827255cddc48c Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:06:56 +0530 Subject: [PATCH 104/129] fix(mail): recover interrupted deliveries safely --- packages/mail/src/transport.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/mail/src/transport.ts b/packages/mail/src/transport.ts index f40b90f07..1f0bd107b 100644 --- a/packages/mail/src/transport.ts +++ b/packages/mail/src/transport.ts @@ -67,6 +67,7 @@ export async function sendMailViaSmtp( subject: string; text: string; html: string; + messageId?: string; }, ): Promise<{ providerMessageId: string }> { const info = await getTransporter(env).sendMail({ @@ -75,6 +76,7 @@ export async function sendMailViaSmtp( subject: message.subject, text: message.text, html: message.html, + messageId: message.messageId, }); return { providerMessageId: info.messageId }; @@ -112,6 +114,7 @@ export async function processMailMessage( subject: message.subject, text: message.textBody, html: message.htmlBody, + messageId: ``, }); await db.mailMessage.update({ where: { id }, @@ -155,6 +158,26 @@ export async function processMailMessage( } } +export async function recoverStaleMailDeliveries( + staleBefore: Date, +): Promise { + const result = await db.mailMessage.updateMany({ + where: { + status: "SENDING", + sendingAt: { lt: staleBefore }, + }, + data: { + status: "RETRY", + sendingAt: null, + nextAttemptAt: new Date(), + lastErrorCode: "WORKER_INTERRUPTED", + lastErrorMessage: + "Mail delivery was interrupted before completion was recorded. Delivery will be retried with the same Message-ID.", + }, + }); + return result.count; +} + export function classifySmtpFailure(error: unknown): { retryable: boolean; code: string; From 6595d887cab794aae7276e7f49f49c5c3b91e536 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:07:07 +0530 Subject: [PATCH 105/129] fix(mail): recover stale sending rows in worker --- apps/worker/src/index.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 000fc8d17..89ad3ceec 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -6,7 +6,11 @@ import { processVideoSubmitJob, processVoiceJob, } from "@aiwa/generation/process"; -import { closeSmtpTransport, processMailMessage } from "@aiwa/mail/transport"; +import { + closeSmtpTransport, + processMailMessage, + recoverStaleMailDeliveries, +} from "@aiwa/mail/transport"; import { createBytePlusProvider } from "@aiwa/providers/byteplus"; import { createNvidiaProvider } from "@aiwa/providers/nvidia"; import { Queue, Worker } from "bullmq"; @@ -195,6 +199,15 @@ async function dispatchMail() { mailDispatching = true; try { const now = new Date(); + const recovered = await recoverStaleMailDeliveries( + new Date(now.getTime() - 10 * 60 * 1000), + ); + if (recovered > 0) { + log("error", "Recovered stale interrupted mail deliveries", { + recovered, + }); + } + const rows = await db.mailMessage.findMany({ where: { OR: [ From ca0a64cb6350c4f4bc61151441dd504aaa50b242 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:07:19 +0530 Subject: [PATCH 106/129] fix(config): fail closed on production SMTP misconfiguration --- packages/config/src/index.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index dea613864..3bf93fae0 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -118,5 +118,28 @@ export function parseServerEnv( throw new Error(`Invalid server environment variables: ${fields}`); } + if (result.data.NODE_ENV === "production" && result.data.MAIL_ENABLED) { + const requiredMailVariables = [ + "SMTP_HOST", + "SMTP_USER", + "SMTP_PASSWORD", + "MAIL_SECURITY_FROM_ADDRESS", + "MAIL_ROUTINE_FROM_ADDRESS", + ] as const; + const missing = requiredMailVariables.filter( + (key) => !environment[key]?.trim(), + ); + if (missing.length) { + throw new Error( + `Invalid server environment variables: ${missing.join(", ")}`, + ); + } + if (result.data.SMTP_PORT !== 465) { + throw new Error( + "Invalid server environment variables: SMTP_PORT (implicit TLS on port 465 is required in production)", + ); + } + } + return result.data; } From 98844d3f1302cc0e38dcd0dd0ab2de9855442ad0 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:07:51 +0530 Subject: [PATCH 107/129] fix(auth): emit MFA alerts only on state transitions --- apps/web/src/lib/auth.ts | 90 ++++++++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 17 deletions(-) diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 4db8c6cad..679c55e35 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -147,38 +147,94 @@ export const auth = betterAuth({ }, hooks: { after: createAuthMiddleware(async (ctx) => { - const eventByPath = { - "/two-factor/verify-totp": "MFA_ENABLED", - "/two-factor/disable": "MFA_DISABLED", - "/two-factor/generate-backup-codes": "BACKUP_CODES_REGENERATED", - } as const; - const event = eventByPath[ctx.path as keyof typeof eventByPath]; - if (!event) return; + if (ctx.path !== "/two-factor/generate-backup-codes") return; const session = ctx.context.session; const userId = session?.user?.id; if (!userId) return; - const user = await db.user.findUnique({ - where: { id: userId }, - select: { email: true, twoFactorEnabled: true }, - }); - if (!user) return; - - if (event === "MFA_ENABLED" && !user.twoFactorEnabled) return; - if (event === "MFA_DISABLED" && user.twoFactorEnabled) return; + const [user, factor] = await Promise.all([ + db.user.findUnique({ + where: { id: userId }, + select: { email: true }, + }), + db.twoFactor.findFirst({ + where: { userId }, + orderBy: { updatedAt: "desc" }, + select: { updatedAt: true }, + }), + ]); + if (!user || !factor) return; await enqueueMail( securityEventEmail({ to: user.email, userId, - event, - idempotencyKey: `security:${event.toLowerCase()}:${userId}:${Date.now()}`, + event: "BACKUP_CODES_REGENERATED", + idempotencyKey: `security:backup-codes-regenerated:${userId}:${factor.updatedAt.getTime()}`, }), ); }), }, databaseHooks: { + user: { + update: { + after: async (updatedUser, ctx) => { + if ( + ctx?.path !== "/two-factor/verify-totp" && + ctx?.path !== "/two-factor/disable" + ) { + return; + } + + const user = await db.user.findUnique({ + where: { id: updatedUser.id }, + select: { + id: true, + email: true, + twoFactorEnabled: true, + updatedAt: true, + }, + }); + if (!user) return; + + if ( + ctx.path === "/two-factor/verify-totp" && + user.twoFactorEnabled + ) { + const factor = await db.twoFactor.findFirst({ + where: { userId: user.id, verified: true }, + orderBy: { updatedAt: "desc" }, + select: { updatedAt: true }, + }); + if (!factor) return; + await enqueueMail( + securityEventEmail({ + to: user.email, + userId: user.id, + event: "MFA_ENABLED", + idempotencyKey: `security:mfa-enabled:${user.id}:${factor.updatedAt.getTime()}`, + }), + ); + return; + } + + if ( + ctx.path === "/two-factor/disable" && + !user.twoFactorEnabled + ) { + await enqueueMail( + securityEventEmail({ + to: user.email, + userId: user.id, + event: "MFA_DISABLED", + idempotencyKey: `security:mfa-disabled:${user.id}:${user.updatedAt.getTime()}`, + }), + ); + } + }, + }, + }, session: { create: { before: async (session) => { From fa14b6dda1e41adc728cedc922c99300d2cbb5ac Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:07:59 +0530 Subject: [PATCH 108/129] fix(generation): remove duplicate success notification guard --- packages/generation/src/process.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/generation/src/process.ts b/packages/generation/src/process.ts index c237409e9..bedaf8665 100644 --- a/packages/generation/src/process.ts +++ b/packages/generation/src/process.ts @@ -346,9 +346,7 @@ export async function processVideoPollJob( }, }); if (asset?.id) { - if (asset?.id) { - await enqueueGenerationSuccess(tx, { ...job, id }, asset.id); - } + await enqueueGenerationSuccess(tx, { ...job, id }, asset.id); } }); } From 8d3cb424a3323914a85f2e054cde06e283cd53ba Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:08:14 +0530 Subject: [PATCH 109/129] test(config): cover production SMTP fail-closed rules --- packages/config/test/mail-env.test.ts | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 packages/config/test/mail-env.test.ts diff --git a/packages/config/test/mail-env.test.ts b/packages/config/test/mail-env.test.ts new file mode 100644 index 000000000..cf047c8a8 --- /dev/null +++ b/packages/config/test/mail-env.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { parseServerEnv } from "../src/index"; + +const productionBase = { + NODE_ENV: "production", + APP_ENV: "production", + APP_VERSION: "test", + APP_URL: "https://creator.aiwamediagroup.com", + AUTH_SECRET: "test-auth-secret-that-is-at-least-32-characters", + SIGNUPS_ENABLED: "true", + DATABASE_URL: "mysql://creator:test@127.0.0.1:3306/creator_platform", + REDIS_URL: "redis://127.0.0.1:6379/0", +}; + +describe("production mail configuration", () => { + it("fails closed when mail is enabled without explicit SMTP credentials", () => { + expect(() => + parseServerEnv({ + ...productionBase, + MAIL_ENABLED: "true", + }), + ).toThrow(/SMTP_HOST/); + }); + + it("accepts explicitly disabled mail without SMTP credentials", () => { + const env = parseServerEnv({ + ...productionBase, + MAIL_ENABLED: "false", + }); + expect(env.MAIL_ENABLED).toBe(false); + }); + + it("requires implicit TLS port 465 in production", () => { + expect(() => + parseServerEnv({ + ...productionBase, + MAIL_ENABLED: "true", + SMTP_HOST: "mail.aiwamediagroup.com", + SMTP_PORT: "587", + SMTP_USER: "security@aiwamediagroup.com", + SMTP_PASSWORD: "secret", + MAIL_SECURITY_FROM_ADDRESS: "security@aiwamediagroup.com", + MAIL_ROUTINE_FROM_ADDRESS: "creator-tool@aiwamediagroup.com", + }), + ).toThrow(/SMTP_PORT/); + }); +}); From a6fb366af58930fec0cafd5046c6ce26cfcac7da Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:08:41 +0530 Subject: [PATCH 110/129] feat(mail): add privileged SMTP verification endpoint --- .../src/app/api/admin/email/verify/route.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 apps/web/src/app/api/admin/email/verify/route.ts diff --git a/apps/web/src/app/api/admin/email/verify/route.ts b/apps/web/src/app/api/admin/email/verify/route.ts new file mode 100644 index 000000000..0acc289c2 --- /dev/null +++ b/apps/web/src/app/api/admin/email/verify/route.ts @@ -0,0 +1,69 @@ +import { hasPlatformPermission } from "@aiwa/authz"; +import { parseServerEnv } from "@aiwa/config"; +import { db } from "@aiwa/db"; +import { + classifySmtpFailure, + verifySmtpTransport, +} from "@aiwa/mail/transport"; +import { NextResponse } from "next/server"; +import { getRequestSession } from "@/lib/request-auth"; +import { hasTrustedMutationOrigin } from "@/lib/request-security"; + +export async function POST(request: Request) { + if (!hasTrustedMutationOrigin(request)) { + return NextResponse.json({ error: "Origin not allowed." }, { status: 403 }); + } + + const session = await getRequestSession(request.headers); + if (!session) { + return NextResponse.json( + { error: "Authentication required." }, + { status: 401 }, + ); + } + if (!hasPlatformPermission(session.user.platformRole, "jobs:manage")) { + return NextResponse.json({ error: "Permission denied." }, { status: 403 }); + } + + const env = parseServerEnv(); + if (!env.MAIL_ENABLED) { + return NextResponse.json( + { ok: false, code: "MAIL_DISABLED" }, + { status: 409 }, + ); + } + + try { + await verifySmtpTransport(env); + await db.auditEvent.create({ + data: { + actorUserId: session.user.id, + action: "mail.smtp_verified", + targetType: "MailTransport", + metadata: { + host: env.SMTP_HOST, + port: env.SMTP_PORT, + }, + }, + }); + return NextResponse.json({ ok: true }); + } catch (error) { + const failure = classifySmtpFailure(error); + await db.auditEvent.create({ + data: { + actorUserId: session.user.id, + action: "mail.smtp_verification_failed", + targetType: "MailTransport", + metadata: { + host: env.SMTP_HOST, + port: env.SMTP_PORT, + code: failure.code, + }, + }, + }); + return NextResponse.json( + { ok: false, code: failure.code }, + { status: 503 }, + ); + } +} From ec112e4a3e654bcee2958a6d73bb2ebaaed58232 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:08:45 +0530 Subject: [PATCH 111/129] feat(mail): add SMTP verification control --- .../components/admin/mail-verify-button.tsx | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 apps/web/src/components/admin/mail-verify-button.tsx diff --git a/apps/web/src/components/admin/mail-verify-button.tsx b/apps/web/src/components/admin/mail-verify-button.tsx new file mode 100644 index 000000000..a0774e81e --- /dev/null +++ b/apps/web/src/components/admin/mail-verify-button.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +export function MailVerifyButton() { + const [state, setState] = useState< + | { status: "idle" } + | { status: "checking" } + | { status: "ok" } + | { status: "error"; code: string } + >({ status: "idle" }); + + async function verify() { + setState({ status: "checking" }); + try { + const response = await fetch("/api/admin/email/verify", { + method: "POST", + }); + const body = (await response.json().catch(() => null)) as { + ok?: boolean; + code?: string; + } | null; + + if (response.ok && body?.ok) { + setState({ status: "ok" }); + return; + } + setState({ + status: "error", + code: body?.code ?? "SMTP_VERIFY_FAILED", + }); + } catch { + setState({ status: "error", code: "NETWORK_ERROR" }); + } + } + + return ( +
+ + {state.status === "ok" ? ( + + SMTP TLS and authentication verified. + + ) : state.status === "error" ? ( + + {state.code} + + ) : null} +
+ ); +} From a0c084fcfc985d1d3e4b4ecb238e485ca32a3e65 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:08:55 +0530 Subject: [PATCH 112/129] feat(mail): expose SMTP verification in admin --- apps/web/src/app/admin/email/page.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/app/admin/email/page.tsx b/apps/web/src/app/admin/email/page.tsx index 8421f6488..6749d7fa9 100644 --- a/apps/web/src/app/admin/email/page.tsx +++ b/apps/web/src/app/admin/email/page.tsx @@ -1,5 +1,6 @@ import { db } from "@aiwa/db"; import { MailRetryButton } from "@/components/admin/mail-retry-button"; +import { MailVerifyButton } from "@/components/admin/mail-verify-button"; import { StatusBadge } from "@/components/admin/primitives"; import { Eyebrow } from "@/components/ui/creative"; import { requirePlatformPermission } from "@/lib/request-auth"; @@ -68,6 +69,9 @@ export default async function AdminEmailPage() { Durable transactional-mail outbox and delivery diagnostics. Security content is never displayed here.

+
+ +
From e381ade8529da51e342da4a3e546676a9eade809 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:09:41 +0530 Subject: [PATCH 113/129] feat(mail): add team lifecycle security notifications --- packages/mail/src/index.ts | 56 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index 6bca2da6c..240cf35a0 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -217,6 +217,62 @@ export function teamMemberAddedEmail(input: { }; } +export function teamMembershipChangedEmail(input: { + to: string; + organizationName: string; + organizationId: string; + userId: string; + membershipId: string; + event: "ROLE_CHANGED" | "CAP_CHANGED" | "REMOVED" | "OWNER_GRANTED" | "OWNER_RELEASED"; + detail?: string; + eventVersion: string; +}): MailDraft { + const content = { + ROLE_CHANGED: { + subject: `Your role changed in ${input.organizationName}`, + title: "Workspace role changed", + message: input.detail ?? "Your workspace role was changed.", + }, + CAP_CHANGED: { + subject: `Your spending limit changed in ${input.organizationName}`, + title: "Spending limit changed", + message: input.detail ?? "Your workspace spending limit was changed.", + }, + REMOVED: { + subject: `Your access to ${input.organizationName} was removed`, + title: "Workspace access removed", + message: "Your membership in this workspace was removed.", + }, + OWNER_GRANTED: { + subject: `You are now the owner of ${input.organizationName}`, + title: "Workspace ownership transferred to you", + message: "You are now the organization owner for this workspace.", + }, + OWNER_RELEASED: { + subject: `Ownership changed for ${input.organizationName}`, + title: "Workspace ownership transferred", + message: + "You are no longer the organization owner. Your workspace membership remains active.", + }, + }[input.event]; + + return { + kind: "SECURITY", + template: `organization.membership_${input.event.toLowerCase()}.v1`, + to: input.to, + subject: content.subject, + text: `${content.message} Organization: ${input.organizationName}.`, + html: emailShell( + content.title, + `

${escapeHtml(content.message)}

Workspace: ${escapeHtml(input.organizationName)}

`, + ), + organizationId: input.organizationId, + userId: input.userId, + sensitive: false, + idempotencyKey: `membership:${input.membershipId}:${input.event.toLowerCase()}:${input.eventVersion}`, + }; +} + export function billingStatusEmail(input: { to: string; organizationName: string; From 149edbf4b8098018df341d9aeb82adabcbbb2ad9 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:10:17 +0530 Subject: [PATCH 114/129] feat(mail): notify team access lifecycle changes --- packages/organizations/src/index.ts | 67 ++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index 0250fb6d8..5b3a303de 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -6,7 +6,12 @@ import { type PlatformRole, } from "@aiwa/authz"; import { db, Prisma } from "@aiwa/db"; -import { enqueueMail, invitationEmail, teamMemberAddedEmail } from "@aiwa/mail"; +import { + enqueueMail, + invitationEmail, + teamMemberAddedEmail, + teamMembershipChangedEmail, +} from "@aiwa/mail"; export const MAX_ORGANIZATION_NON_OWNER_MEMBERS = 9; export const MAX_ORGANIZATION_SEATS = 10; @@ -406,6 +411,7 @@ export async function updateMember(input: { const organization = await lockedOrganization(tx, input.organizationId); const member = await tx.membership.findFirst({ where: { id: input.membershipId, organizationId: input.organizationId }, + include: { user: { select: { email: true } } }, }); if (!member) throw new OrganizationNotFoundError(); if ( @@ -435,6 +441,23 @@ export async function updateMember(input: { member.id, { membershipId: member.id, userId: member.userId }, ); + await enqueueMail( + teamMembershipChangedEmail({ + to: member.user.email, + organizationName: organization.name, + organizationId: input.organizationId, + userId: member.userId, + membershipId: member.id, + event: input.role ? "ROLE_CHANGED" : "CAP_CHANGED", + detail: input.role + ? `Your workspace role is now ${updated.role}.` + : updated.monthlySpendingCapCredits === null + ? "Your monthly spending cap was removed." + : `Your monthly spending cap is now ${updated.monthlySpendingCapCredits.toString()} credits.`, + eventVersion: updated.updatedAt.getTime().toString(), + }), + tx, + ); return updated; }); } @@ -473,6 +496,18 @@ export async function removeMember(input: { member.id, { membershipId: member.id, userId: member.userId }, ); + await enqueueMail( + teamMembershipChangedEmail({ + to: member.user.email, + organizationName: organization.name, + organizationId: input.organizationId, + userId: member.userId, + membershipId: member.id, + event: "REMOVED", + eventVersion: "removed", + }), + tx, + ); }); } @@ -490,6 +525,7 @@ export async function transferOwnership(input: { organizationId: input.organizationId, user: { disabledAt: null }, }, + include: { user: { select: { email: true } } }, }); if (!target || target.userId === organization.ownerUserId) throw new InvalidOwnershipTransferError(); @@ -500,6 +536,7 @@ export async function transferOwnership(input: { userId: organization.ownerUserId, }, }, + include: { user: { select: { email: true } } }, }); if (!previous) throw new InvalidOwnershipTransferError(); await tx.membership.update({ @@ -510,9 +547,10 @@ export async function transferOwnership(input: { where: { id: target.id }, data: { role: "ORGANIZATION_OWNER", monthlySpendingCapCredits: null }, }); - await tx.organization.update({ + const ownership = await tx.organization.update({ where: { id: input.organizationId }, data: { ownerUserId: target.userId }, + select: { updatedAt: true }, }); await audit( tx, @@ -527,6 +565,31 @@ export async function transferOwnership(input: { targetMembershipId: target.id, }, ); + const eventVersion = ownership.updatedAt.getTime().toString(); + await enqueueMail( + teamMembershipChangedEmail({ + to: target.user.email, + organizationName: organization.name, + organizationId: input.organizationId, + userId: target.userId, + membershipId: target.id, + event: "OWNER_GRANTED", + eventVersion, + }), + tx, + ); + await enqueueMail( + teamMembershipChangedEmail({ + to: previous.user.email, + organizationName: organization.name, + organizationId: input.organizationId, + userId: previous.userId, + membershipId: previous.id, + event: "OWNER_RELEASED", + eventVersion, + }), + tx, + ); }); } From e6952b350867394195efb800a5f9759ad73b5893 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:10:40 +0530 Subject: [PATCH 115/129] feat(mail): add account administration security alerts --- packages/mail/src/index.ts | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index 240cf35a0..191bdfc9f 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -193,6 +193,52 @@ export function securityEventEmail(input: { }; } +export function accountAdministrationEmail(input: { + to: string; + userId: string; + event: + | "PLATFORM_ROLE_CHANGED" + | "ACCOUNT_DISABLED" + | "ACCOUNT_ENABLED" + | "SESSIONS_REVOKED"; + detail: string; + eventVersion: string; +}): MailDraft { + const content = { + PLATFORM_ROLE_CHANGED: { + subject: "Your Aiwa Creators platform role changed", + title: "Platform role changed", + }, + ACCOUNT_DISABLED: { + subject: "Your Aiwa Creators account was disabled", + title: "Account disabled", + }, + ACCOUNT_ENABLED: { + subject: "Your Aiwa Creators account was reactivated", + title: "Account reactivated", + }, + SESSIONS_REVOKED: { + subject: "Aiwa Creators sessions were revoked", + title: "Sessions revoked", + }, + }[input.event]; + + return { + kind: "SECURITY", + template: `account.admin_${input.event.toLowerCase()}.v1`, + to: input.to, + subject: content.subject, + text: input.detail, + html: emailShell( + content.title, + `

${escapeHtml(input.detail)}

`, + ), + userId: input.userId, + sensitive: false, + idempotencyKey: `account:${input.userId}:${input.event.toLowerCase()}:${input.eventVersion}`, + }; +} + export function teamMemberAddedEmail(input: { to: string; organizationName: string; From a3e2cef8d9bcd9f1e293c8adfa6d0d45b7600a2b Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:11:08 +0530 Subject: [PATCH 116/129] feat(mail): alert users to administrative account changes --- packages/organizations/src/index.ts | 54 +++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index 5b3a303de..48bc3f8a8 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -7,6 +7,7 @@ import { } from "@aiwa/authz"; import { db, Prisma } from "@aiwa/db"; import { + accountAdministrationEmail, enqueueMail, invitationEmail, teamMemberAddedEmail, @@ -734,7 +735,12 @@ export async function setUserPlatformRole(input: { await tx.$queryRaw`SELECT id FROM User WHERE id = ${input.targetUserId} FOR UPDATE`; const targetUser = await tx.user.findUnique({ where: { id: input.targetUserId }, - select: { id: true, platformRole: true, disabledAt: true }, + select: { + id: true, + email: true, + platformRole: true, + disabledAt: true, + }, }); if (!targetUser) throw new UserNotFoundError(); @@ -762,6 +768,16 @@ export async function setUserPlatformRole(input: { }, }, }); + await enqueueMail( + accountAdministrationEmail({ + to: targetUser.email, + userId: targetUser.id, + event: "PLATFORM_ROLE_CHANGED", + detail: `Your Aiwa Creators platform role changed from ${targetUser.platformRole} to ${updated.platformRole}.`, + eventVersion: updated.updatedAt.getTime().toString(), + }), + tx, + ); return updated; }); @@ -783,7 +799,12 @@ export async function setUserDisabled(input: { await tx.$queryRaw`SELECT id FROM User WHERE id = ${input.targetUserId} FOR UPDATE`; const targetUser = await tx.user.findUnique({ where: { id: input.targetUserId }, - select: { id: true, platformRole: true, disabledAt: true }, + select: { + id: true, + email: true, + platformRole: true, + disabledAt: true, + }, }); if (!targetUser) throw new UserNotFoundError(); @@ -816,6 +837,18 @@ export async function setUserDisabled(input: { }, }, }); + await enqueueMail( + accountAdministrationEmail({ + to: targetUser.email, + userId: targetUser.id, + event: input.disabled ? "ACCOUNT_DISABLED" : "ACCOUNT_ENABLED", + detail: input.disabled + ? "An administrator disabled your Aiwa Creators account and active sessions were revoked." + : "An administrator reactivated your Aiwa Creators account.", + eventVersion: updated.updatedAt.getTime().toString(), + }), + tx, + ); return updated; }); @@ -832,7 +865,7 @@ export async function revokeUserSessions(input: { return db.$transaction(async (tx) => { const targetUser = await tx.user.findUnique({ where: { id: input.targetUserId }, - select: { id: true }, + select: { id: true, email: true, updatedAt: true }, }); if (!targetUser) throw new UserNotFoundError(); @@ -854,6 +887,21 @@ export async function revokeUserSessions(input: { }, }, }); + if (deleted.count > 0) { + await enqueueMail( + accountAdministrationEmail({ + to: targetUser.email, + userId: targetUser.id, + event: "SESSIONS_REVOKED", + detail: + deleted.count === 1 + ? "An administrator revoked one active Aiwa Creators session." + : `An administrator revoked ${deleted.count} active Aiwa Creators sessions.`, + eventVersion: `${Date.now()}:${deleted.count}`, + }), + tx, + ); + } return deleted; }); From 085a74c1981b97b89477eb3c5aae9df86f3d40e8 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:12:06 +0530 Subject: [PATCH 117/129] fix(config): gate SMTP fail-closed checks on production app env --- packages/config/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 3bf93fae0..d10df6f74 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -118,7 +118,7 @@ export function parseServerEnv( throw new Error(`Invalid server environment variables: ${fields}`); } - if (result.data.NODE_ENV === "production" && result.data.MAIL_ENABLED) { + if (result.data.APP_ENV === "production" && result.data.MAIL_ENABLED) { const requiredMailVariables = [ "SMTP_HOST", "SMTP_USER", From da62487a952aca4209507857b2ebb801fa45fb66 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:12:53 +0530 Subject: [PATCH 118/129] perf(mail): index stale sending recovery --- packages/db/prisma/schema.prisma | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 0fe09af47..874453cc5 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -525,6 +525,7 @@ model MailMessage { @@index([status, priority, createdAt]) @@index([status, nextAttemptAt]) + @@index([status, sendingAt]) @@index([userId, createdAt]) @@index([organizationId, createdAt]) @@index([recipient, createdAt]) From af5700fec921b826bd4057b38122fe66e91b264f Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:12:57 +0530 Subject: [PATCH 119/129] perf(mail): add stale delivery recovery index --- .../20260924020000_transactional_mail_outbox/migration.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/db/prisma/migrations/20260924020000_transactional_mail_outbox/migration.sql b/packages/db/prisma/migrations/20260924020000_transactional_mail_outbox/migration.sql index 3c52911bf..9421a7059 100644 --- a/packages/db/prisma/migrations/20260924020000_transactional_mail_outbox/migration.sql +++ b/packages/db/prisma/migrations/20260924020000_transactional_mail_outbox/migration.sql @@ -26,6 +26,7 @@ CREATE TABLE `MailMessage` ( UNIQUE INDEX `MailMessage_idempotencyKey_key`(`idempotencyKey`), INDEX `MailMessage_status_priority_createdAt_idx`(`status`, `priority`, `createdAt`), INDEX `MailMessage_status_nextAttemptAt_idx`(`status`, `nextAttemptAt`), + INDEX `MailMessage_status_sendingAt_idx`(`status`, `sendingAt`), INDEX `MailMessage_userId_createdAt_idx`(`userId`, `createdAt`), INDEX `MailMessage_organizationId_createdAt_idx`(`organizationId`, `createdAt`), INDEX `MailMessage_recipient_createdAt_idx`(`recipient`, `createdAt`), From faf5770701a040235e3f20ebe87c18c59cbaaeb2 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:13:28 +0530 Subject: [PATCH 120/129] docs(mail): document recovery and SMTP verification --- docs/operations/transactional-email.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/operations/transactional-email.md b/docs/operations/transactional-email.md index 797471397..5bedce6ba 100644 --- a/docs/operations/transactional-email.md +++ b/docs/operations/transactional-email.md @@ -33,6 +33,11 @@ MAIL_SECURITY_FROM_ADDRESS=security@aiwamediagroup.com MAIL_ROUTINE_FROM_ADDRESS=creator-tool@aiwamediagroup.com ``` +When `APP_ENV=production` and `MAIL_ENABLED=true`, startup fails closed unless +the SMTP host, username, password, both sender addresses, and port 465 are +explicitly configured. This prevents a deployment from silently accepting +security mail into an undeliverable queue. + Apply database migrations before restarting services: ```bash @@ -44,8 +49,10 @@ sudo systemctl restart creator-web.service creator-worker.service Business operations persist an outbox row before delivery. Redis is only the dispatcher, so Redis or SMTP downtime does not lose queued mail. Temporary -delivery failures retry with bounded backoff. Permanent SMTP failures remain -visible in the admin Email delivery page. +delivery failures retry with bounded backoff. A worker interruption that leaves +a message in `SENDING` is recovered after ten minutes and retried using the +same RFC Message-ID, reducing duplicate-delivery risk. Permanent SMTP failures +remain visible in the admin Email delivery page. Security-message bodies are redacted after successful delivery. Failed security messages are not generically replayable because reset or invitation tokens may @@ -58,6 +65,9 @@ messages can be explicitly requeued by an authorized operator. configured. - `/admin/email` shows queue state, delivery counts, masked recipients, attempts, and sanitized failure diagnostics. +- **Verify SMTP** on `/admin/email` performs a live TLS/authentication handshake + without sending mail. The result is audited and only a sanitized failure code + is returned to the browser. - SMTP credentials, reset links, verification links, and MFA secrets must never be logged. - SPF, DKIM, and DMARC should be configured for the sending domain before From cf1a8c83ad8a511d9f87c1331e60875476ed4150 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:14:09 +0530 Subject: [PATCH 121/129] chore: canonical-format mail hardening --- .github/workflows/format-mail-hardening.yml | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/format-mail-hardening.yml diff --git a/.github/workflows/format-mail-hardening.yml b/.github/workflows/format-mail-hardening.yml new file mode 100644 index 000000000..f125b3226 --- /dev/null +++ b/.github/workflows/format-mail-hardening.yml @@ -0,0 +1,38 @@ +name: Canonical Format Mail Hardening + +on: + pull_request: + branches: [main] + +permissions: + contents: write + +jobs: + format: + if: github.head_ref == 'feat/transactional-mail' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: feat/transactional-mail + - uses: pnpm/action-setup@v6 + with: + version: 11.19.0 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm format + - run: pnpm --filter @aiwa/db format:schema + - name: Commit canonical formatting and remove helper + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/format-mail-hardening.yml + git add -A + if git diff --cached --quiet; then + exit 0 + fi + git commit -m "style: apply canonical mail hardening formatting" + git push origin HEAD:feat/transactional-mail From 4f193b77c7c387e04b99b8cc1538b23edb193557 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:44:44 +0000 Subject: [PATCH 122/129] style: apply canonical mail hardening formatting --- .github/workflows/format-mail-hardening.yml | 38 ------------------- .../src/app/api/admin/email/verify/route.ts | 5 +-- apps/web/src/lib/auth.ts | 10 +---- docs/operations/transactional-email.md | 6 +-- packages/mail/src/index.ts | 7 +++- 5 files changed, 12 insertions(+), 54 deletions(-) delete mode 100644 .github/workflows/format-mail-hardening.yml diff --git a/.github/workflows/format-mail-hardening.yml b/.github/workflows/format-mail-hardening.yml deleted file mode 100644 index f125b3226..000000000 --- a/.github/workflows/format-mail-hardening.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Canonical Format Mail Hardening - -on: - pull_request: - branches: [main] - -permissions: - contents: write - -jobs: - format: - if: github.head_ref == 'feat/transactional-mail' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - ref: feat/transactional-mail - - uses: pnpm/action-setup@v6 - with: - version: 11.19.0 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm format - - run: pnpm --filter @aiwa/db format:schema - - name: Commit canonical formatting and remove helper - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/format-mail-hardening.yml - git add -A - if git diff --cached --quiet; then - exit 0 - fi - git commit -m "style: apply canonical mail hardening formatting" - git push origin HEAD:feat/transactional-mail diff --git a/apps/web/src/app/api/admin/email/verify/route.ts b/apps/web/src/app/api/admin/email/verify/route.ts index 0acc289c2..827aaa5f5 100644 --- a/apps/web/src/app/api/admin/email/verify/route.ts +++ b/apps/web/src/app/api/admin/email/verify/route.ts @@ -1,10 +1,7 @@ import { hasPlatformPermission } from "@aiwa/authz"; import { parseServerEnv } from "@aiwa/config"; import { db } from "@aiwa/db"; -import { - classifySmtpFailure, - verifySmtpTransport, -} from "@aiwa/mail/transport"; +import { classifySmtpFailure, verifySmtpTransport } from "@aiwa/mail/transport"; import { NextResponse } from "next/server"; import { getRequestSession } from "@/lib/request-auth"; import { hasTrustedMutationOrigin } from "@/lib/request-security"; diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 679c55e35..08e9ae507 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -198,10 +198,7 @@ export const auth = betterAuth({ }); if (!user) return; - if ( - ctx.path === "/two-factor/verify-totp" && - user.twoFactorEnabled - ) { + if (ctx.path === "/two-factor/verify-totp" && user.twoFactorEnabled) { const factor = await db.twoFactor.findFirst({ where: { userId: user.id, verified: true }, orderBy: { updatedAt: "desc" }, @@ -219,10 +216,7 @@ export const auth = betterAuth({ return; } - if ( - ctx.path === "/two-factor/disable" && - !user.twoFactorEnabled - ) { + if (ctx.path === "/two-factor/disable" && !user.twoFactorEnabled) { await enqueueMail( securityEventEmail({ to: user.email, diff --git a/docs/operations/transactional-email.md b/docs/operations/transactional-email.md index 5bedce6ba..881316b06 100644 --- a/docs/operations/transactional-email.md +++ b/docs/operations/transactional-email.md @@ -50,9 +50,9 @@ sudo systemctl restart creator-web.service creator-worker.service Business operations persist an outbox row before delivery. Redis is only the dispatcher, so Redis or SMTP downtime does not lose queued mail. Temporary delivery failures retry with bounded backoff. A worker interruption that leaves -a message in `SENDING` is recovered after ten minutes and retried using the -same RFC Message-ID, reducing duplicate-delivery risk. Permanent SMTP failures -remain visible in the admin Email delivery page. +a message in `SENDING` is recovered after ten minutes and retried using the same +RFC Message-ID, reducing duplicate-delivery risk. Permanent SMTP failures remain +visible in the admin Email delivery page. Security-message bodies are redacted after successful delivery. Failed security messages are not generically replayable because reset or invitation tokens may diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index 191bdfc9f..3a9a33b02 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -269,7 +269,12 @@ export function teamMembershipChangedEmail(input: { organizationId: string; userId: string; membershipId: string; - event: "ROLE_CHANGED" | "CAP_CHANGED" | "REMOVED" | "OWNER_GRANTED" | "OWNER_RELEASED"; + event: + | "ROLE_CHANGED" + | "CAP_CHANGED" + | "REMOVED" + | "OWNER_GRANTED" + | "OWNER_RELEASED"; detail?: string; eventVersion: string; }): MailDraft { From 1d2c2b00adec839c462fc05ce43ec013076a01c3 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:15:25 +0530 Subject: [PATCH 123/129] docs(mail): note post-change SMTP verification --- docs/operations/transactional-email.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/operations/transactional-email.md b/docs/operations/transactional-email.md index 881316b06..45a71eb58 100644 --- a/docs/operations/transactional-email.md +++ b/docs/operations/transactional-email.md @@ -70,5 +70,6 @@ messages can be explicitly requeued by an authorized operator. is returned to the browser. - SMTP credentials, reset links, verification links, and MFA secrets must never be logged. +- Use the admin SMTP verification control after every credential or mail-server change. - SPF, DKIM, and DMARC should be configured for the sending domain before production launch. From 83b81abae0f626b6011415801eb2db22b7a734d2 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:16:50 +0530 Subject: [PATCH 124/129] style(docs): wrap SMTP verification guidance --- docs/operations/transactional-email.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/operations/transactional-email.md b/docs/operations/transactional-email.md index 45a71eb58..cfcec67f4 100644 --- a/docs/operations/transactional-email.md +++ b/docs/operations/transactional-email.md @@ -70,6 +70,7 @@ messages can be explicitly requeued by an authorized operator. is returned to the browser. - SMTP credentials, reset links, verification links, and MFA secrets must never be logged. -- Use the admin SMTP verification control after every credential or mail-server change. +- Use the admin SMTP verification control after every credential or mail-server + change. - SPF, DKIM, and DMARC should be configured for the sending domain before production launch. From a578fb782e141f4ec32cab5e50e05d4ccba6c99c Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:18:54 +0530 Subject: [PATCH 125/129] fix(mail): include member identity for removal alert --- packages/organizations/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index 48bc3f8a8..83f7f87ff 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -473,6 +473,7 @@ export async function removeMember(input: { const organization = await lockedOrganization(tx, input.organizationId); const member = await tx.membership.findFirst({ where: { id: input.membershipId, organizationId: input.organizationId }, + include: { user: { select: { email: true } } }, }); if (!member) throw new OrganizationNotFoundError(); if ( From 5bbcce0ca6385ef968472c95cc0b07ad313e3fb3 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:22:04 +0530 Subject: [PATCH 126/129] fix(mail): tolerate legacy domain mocks for event versions --- packages/organizations/src/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index 83f7f87ff..0c0615bf7 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -272,6 +272,10 @@ function authorize( return; throw new PermissionDeniedError(); } +function entityVersion(updatedAt: unknown, fallback: string): string { + return updatedAt instanceof Date ? updatedAt.getTime().toString() : fallback; +} + async function audit( tx: Prisma.TransactionClient, actor: OrganizationActor, @@ -455,7 +459,10 @@ export async function updateMember(input: { : updated.monthlySpendingCapCredits === null ? "Your monthly spending cap was removed." : `Your monthly spending cap is now ${updated.monthlySpendingCapCredits.toString()} credits.`, - eventVersion: updated.updatedAt.getTime().toString(), + eventVersion: entityVersion( + updated.updatedAt, + `role:${updated.platformRole}`, + ), }), tx, ); @@ -775,7 +782,10 @@ export async function setUserPlatformRole(input: { userId: targetUser.id, event: "PLATFORM_ROLE_CHANGED", detail: `Your Aiwa Creators platform role changed from ${targetUser.platformRole} to ${updated.platformRole}.`, - eventVersion: updated.updatedAt.getTime().toString(), + eventVersion: entityVersion( + updated.updatedAt, + `disabled:${input.disabled}`, + ), }), tx, ); From 7f5eb4fccab35c54f27de1751b6605eb5dc45508 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:22:20 +0530 Subject: [PATCH 127/129] fix(mail): correct account event version fallbacks --- packages/organizations/src/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index 0c0615bf7..5299ff336 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -784,7 +784,7 @@ export async function setUserPlatformRole(input: { detail: `Your Aiwa Creators platform role changed from ${targetUser.platformRole} to ${updated.platformRole}.`, eventVersion: entityVersion( updated.updatedAt, - `disabled:${input.disabled}`, + `role:${updated.platformRole}`, ), }), tx, @@ -856,7 +856,10 @@ export async function setUserDisabled(input: { detail: input.disabled ? "An administrator disabled your Aiwa Creators account and active sessions were revoked." : "An administrator reactivated your Aiwa Creators account.", - eventVersion: updated.updatedAt.getTime().toString(), + eventVersion: entityVersion( + updated.updatedAt, + `disabled:${input.disabled}`, + ), }), tx, ); From 56fa598aaa62d1deb29ebe5b480279171791e91e Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:24:02 +0530 Subject: [PATCH 128/129] test(mail): isolate owner concurrency tests from outbox side effects --- .../organizations/test/platform-owner-concurrency.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/organizations/test/platform-owner-concurrency.test.ts b/packages/organizations/test/platform-owner-concurrency.test.ts index e3f5d4b68..814c151ea 100644 --- a/packages/organizations/test/platform-owner-concurrency.test.ts +++ b/packages/organizations/test/platform-owner-concurrency.test.ts @@ -20,6 +20,14 @@ const mockTx = { }, }; +vi.mock("@aiwa/mail", () => ({ + accountAdministrationEmail: vi.fn((input) => input), + enqueueMail: vi.fn().mockResolvedValue({ id: "mail-1", created: true }), + invitationEmail: vi.fn((input) => input), + teamMemberAddedEmail: vi.fn((input) => input), + teamMembershipChangedEmail: vi.fn((input) => input), +})); + vi.mock("@aiwa/db", () => ({ db: { $transaction: vi.fn(async (cb: (tx: typeof mockTx) => Promise) => From 9a7e7a844b0aed1e452c44c47378f0a4fa356d9d Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 24 Sep 2026 15:27:01 +0530 Subject: [PATCH 129/129] fix(mail): use membership state for notification version --- packages/organizations/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index 5299ff336..1b1ef8720 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -461,7 +461,7 @@ export async function updateMember(input: { : `Your monthly spending cap is now ${updated.monthlySpendingCapCredits.toString()} credits.`, eventVersion: entityVersion( updated.updatedAt, - `role:${updated.platformRole}`, + `member:${updated.role}:${updated.monthlySpendingCapCredits?.toString() ?? "none"}`, ), }), tx,