Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
129 commits
Select commit Hold shift + click to select a range
71f0ba2
feat(mail): add transactional mail core
baba-dev Sep 24, 2026
c3ba026
feat(mail): add transactional mail core
baba-dev Sep 24, 2026
4bddf7e
feat(mail): add transactional mail core
baba-dev Sep 24, 2026
dac8720
feat(mail): add transactional mail core
baba-dev Sep 24, 2026
b091527
feat(mail): add SMTP environment validation
baba-dev Sep 24, 2026
518cdc7
docs(mail): document SMTP environment
baba-dev Sep 24, 2026
03481eb
feat(mail): add durable mail outbox schema
baba-dev Sep 24, 2026
8b99061
feat(mail): add durable mail outbox migration
baba-dev Sep 24, 2026
e15aa71
feat(mail): route verification through durable outbox
baba-dev Sep 24, 2026
009e5e6
feat(mail): add durable mail processor
baba-dev Sep 24, 2026
7176491
feat(mail): add worker mail dependency
baba-dev Sep 24, 2026
6f767c4
feat(mail): add durable mail worker and dispatcher
baba-dev Sep 24, 2026
eb2800f
feat(mail): add billing security templates
baba-dev Sep 24, 2026
1739cc0
feat(mail): notify newly added team members
baba-dev Sep 24, 2026
af17c3a
feat(mail): add organizations mail dependency
baba-dev Sep 24, 2026
3135e62
feat(mail): send billing security notifications
baba-dev Sep 24, 2026
8e752d4
feat(mail): add payments mail dependency
baba-dev Sep 24, 2026
15bd445
feat(mail): add web mail dependency
baba-dev Sep 24, 2026
7f8a61b
chore(mail): sync workspace lockfile
baba-dev Sep 24, 2026
d107ea6
style(mail): satisfy prettier
baba-dev Sep 24, 2026
715ea70
style(mail): satisfy prettier
baba-dev Sep 24, 2026
2e8452d
style(mail): satisfy prettier
baba-dev Sep 24, 2026
f1acddf
style(mail): satisfy prettier
baba-dev Sep 24, 2026
d6f2993
style(mail): satisfy prettier
baba-dev Sep 24, 2026
64288fc
style(mail): format transactional mail core
baba-dev Sep 24, 2026
16d288d
style(mail): finish prettier layout
baba-dev Sep 24, 2026
ebc2dcc
ci: print exact mail prettier diff
baba-dev Sep 24, 2026
9b0ff43
style(mail): apply exact prettier output
baba-dev Sep 24, 2026
4fb3474
ci: restore standard quality workflow
baba-dev Sep 24, 2026
e38d21a
fix(mail): make retry delay type-safe
baba-dev Sep 24, 2026
f219483
test(mail): update organization notification mocks
baba-dev Sep 24, 2026
30b2339
test(mail): update payment notification mocks
baba-dev Sep 24, 2026
1ce946b
ci: print exact test prettier diff
baba-dev Sep 24, 2026
69acad7
style(mail): apply exact test prettier output
baba-dev Sep 24, 2026
6ab990a
style(mail): apply exact test prettier output
baba-dev Sep 24, 2026
b90d767
ci: restore standard quality workflow
baba-dev Sep 24, 2026
40d9dae
test(mail): use hoisted notification mocks
baba-dev Sep 24, 2026
d3a2a8b
test(mail): use hoisted notification mocks
baba-dev Sep 24, 2026
33ff69d
ci: print payment test prettier diff
baba-dev Sep 24, 2026
6b1834f
style(mail): apply exact payment test prettier output
baba-dev Sep 24, 2026
ef2acfe
ci: restore standard quality workflow
baba-dev Sep 24, 2026
bb67a7f
style(mail): format organization hoisted mock
baba-dev Sep 24, 2026
a293466
test(mail): include organization name in verified member fixture
baba-dev Sep 24, 2026
610d734
chore: generate canonical formatted mail files
baba-dev Sep 24, 2026
4610718
chore: let formatter self-apply and clean up
baba-dev Sep 24, 2026
b0df289
style: apply canonical mail formatting
github-actions[bot] Sep 24, 2026
b3d5eea
feat(mail): add invitation and generation failure templates
baba-dev Sep 24, 2026
3936c66
feat(mail): send durable organization invitation mail
baba-dev Sep 24, 2026
0bfb9b4
feat(mail): notify users of generation outcomes
baba-dev Sep 24, 2026
216a57d
feat(mail): add generation mail dependency
baba-dev Sep 24, 2026
b471fb7
chore(mail): sync generation mail dependency
baba-dev Sep 24, 2026
0a46085
style: format invitation mail import
baba-dev Sep 24, 2026
8e161aa
fix(mail): keep routine notifications non-critical to generation life…
baba-dev Sep 24, 2026
0cfa15e
fix(mail): guard notification enqueue when asset result is unavailable
baba-dev Sep 24, 2026
e14cb6e
chore: format generation mail integration
baba-dev Sep 24, 2026
05c8b2e
style: format generation mail integration
github-actions[bot] Sep 24, 2026
5fb064c
docs(mail): document production transactional email operations
baba-dev Sep 24, 2026
cfaf6a8
chore: format mail operations documentation
baba-dev Sep 24, 2026
38e24b3
style: format transactional mail documentation
github-actions[bot] Sep 24, 2026
5cf2169
docs(mail): clarify shared web and worker SMTP configuration
baba-dev Sep 24, 2026
f7bb002
refactor(mail): keep shared mail API transport-free
baba-dev Sep 24, 2026
d78c6d6
refactor(mail): isolate worker-only SMTP transport
baba-dev Sep 24, 2026
b173cd5
refactor(mail): export worker-only transport entrypoint
baba-dev Sep 24, 2026
dceb521
refactor(mail): load SMTP transport only in worker
baba-dev Sep 24, 2026
4eb40cd
refactor(mail): hash verification idempotency in server auth
baba-dev Sep 24, 2026
6f70db5
test(mail): validate isolated SMTP transport
baba-dev Sep 24, 2026
67e04e6
style(mail): normalize transport split formatting
baba-dev Sep 24, 2026
df42f4e
style(mail): normalize transport split formatting
baba-dev Sep 24, 2026
155aba9
chore: format mail transport split
baba-dev Sep 24, 2026
0efd400
style: format mail transport split
github-actions[bot] Sep 24, 2026
2cc0d0b
docs(mail): reinforce SMTP secret handling
baba-dev Sep 24, 2026
e9e44f4
feat(mail): add password reset and MFA security templates
baba-dev Sep 24, 2026
4d28559
feat(mail): wire password reset and MFA security alerts
baba-dev Sep 24, 2026
ce3049e
feat(mail): add admin email delivery diagnostics
baba-dev Sep 24, 2026
8bac66b
feat(mail): add email diagnostics navigation
baba-dev Sep 24, 2026
5a4d021
feat(mail): expose mail configuration health
baba-dev Sep 24, 2026
d269c67
feat(mail): add routine notification preferences
baba-dev Sep 24, 2026
713977c
feat(mail): add notification preference migration
baba-dev Sep 24, 2026
6e95c55
feat(mail): honor generation notification preferences
baba-dev Sep 24, 2026
18227e8
feat(mail): add routine notification preference API
baba-dev Sep 24, 2026
c7ef9c4
feat(mail): add notification preference controls
baba-dev Sep 24, 2026
a9317c8
feat(mail): add user notification settings page
baba-dev Sep 24, 2026
23f8f73
feat(mail): add safe routine mail replay endpoint
baba-dev Sep 24, 2026
332c3d0
feat(mail): add routine mail replay control
baba-dev Sep 24, 2026
c62dc74
feat(mail): expose safe routine replay in admin
baba-dev Sep 24, 2026
e918633
refactor(mail): use pooled Nodemailer SMTP transport
baba-dev Sep 24, 2026
0e44910
feat(mail): add SMTP pool timeout configuration
baba-dev Sep 24, 2026
afb097e
docs(mail): document SMTP pool settings
baba-dev Sep 24, 2026
73f8d87
feat(mail): close SMTP pool during worker shutdown
baba-dev Sep 24, 2026
f437e08
chore(mail): finalize dependencies with repository toolchain
baba-dev Sep 24, 2026
59cce49
fix(mail): exclude env file from formatter helper
baba-dev Sep 24, 2026
1d1b789
fix(mail): use Prisma formatter for schema
baba-dev Sep 24, 2026
16acd0d
chore(mail): finalize SMTP dependencies and formatting
github-actions[bot] Sep 24, 2026
d35def5
docs(mail): add transactional email production runbook
baba-dev Sep 24, 2026
c1affd3
style(mail): format transactional email runbook
baba-dev Sep 24, 2026
7d69fac
chore(mail): canonicalize runbook formatting
baba-dev Sep 24, 2026
28d363d
style(mail): apply canonical runbook formatting
github-actions[bot] Sep 24, 2026
d25e56a
docs(mail): link transactional email operations runbook
baba-dev Sep 24, 2026
67e64f0
fix(mail): use type-only SMTP transport import
baba-dev Sep 24, 2026
bfb3d7f
fix(mail): type pooled Nodemailer transport correctly
baba-dev Sep 24, 2026
47b75f3
style(mail): format pooled transport declaration
baba-dev Sep 24, 2026
754760a
fix(mail): handle malformed recipient addresses in admin view
baba-dev Sep 24, 2026
e6efe9e
test(mail): cover Nodemailer SMTP response classification
baba-dev Sep 24, 2026
679280f
fix(mail): recover interrupted deliveries safely
baba-dev Sep 24, 2026
6595d88
fix(mail): recover stale sending rows in worker
baba-dev Sep 24, 2026
ca0a64c
fix(config): fail closed on production SMTP misconfiguration
baba-dev Sep 24, 2026
98844d3
fix(auth): emit MFA alerts only on state transitions
baba-dev Sep 24, 2026
fa14b6d
fix(generation): remove duplicate success notification guard
baba-dev Sep 24, 2026
8d3cb42
test(config): cover production SMTP fail-closed rules
baba-dev Sep 24, 2026
a6fb366
feat(mail): add privileged SMTP verification endpoint
baba-dev Sep 24, 2026
ec112e4
feat(mail): add SMTP verification control
baba-dev Sep 24, 2026
a0c084f
feat(mail): expose SMTP verification in admin
baba-dev Sep 24, 2026
e381ade
feat(mail): add team lifecycle security notifications
baba-dev Sep 24, 2026
149edbf
feat(mail): notify team access lifecycle changes
baba-dev Sep 24, 2026
e6952b3
feat(mail): add account administration security alerts
baba-dev Sep 24, 2026
a3e2cef
feat(mail): alert users to administrative account changes
baba-dev Sep 24, 2026
085a74c
fix(config): gate SMTP fail-closed checks on production app env
baba-dev Sep 24, 2026
da62487
perf(mail): index stale sending recovery
baba-dev Sep 24, 2026
af5700f
perf(mail): add stale delivery recovery index
baba-dev Sep 24, 2026
faf5770
docs(mail): document recovery and SMTP verification
baba-dev Sep 24, 2026
cf1a8c8
chore: canonical-format mail hardening
baba-dev Sep 24, 2026
4f193b7
style: apply canonical mail hardening formatting
github-actions[bot] Sep 24, 2026
1d2c2b0
docs(mail): note post-change SMTP verification
baba-dev Sep 24, 2026
83b81ab
style(docs): wrap SMTP verification guidance
baba-dev Sep 24, 2026
a578fb7
fix(mail): include member identity for removal alert
baba-dev Sep 24, 2026
5bbcce0
fix(mail): tolerate legacy domain mocks for event versions
baba-dev Sep 24, 2026
7f5eb4f
fix(mail): correct account event version fallbacks
baba-dev Sep 24, 2026
56fa598
test(mail): isolate owner concurrency tests from outbox side effects
baba-dev Sep 24, 2026
9a7e7a8
fix(mail): use membership state for notification version
baba-dev Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,22 @@ 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 <no-reply@example.com>
# 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
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

# MariaDB. Credentials below are for local Compose only.
MARIADB_DATABASE=creator_platform
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
195 changes: 195 additions & 0 deletions apps/web/src/app/admin/email/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
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";

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 (
<div className="px-4 py-7 sm:px-7 lg:px-9 lg:py-9">
<Eyebrow>Operations</Eyebrow>
<div className="mt-3">
<h1 className="font-display text-4xl font-semibold tracking-tight">
Email delivery
</h1>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
Durable transactional-mail outbox and delivery diagnostics. Security
content is never displayed here.
</p>
<div className="mt-4">
<MailVerifyButton />
</div>
</div>

<div className="mt-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{cards.map(([label, value]) => (
<div
key={label}
className="rounded-2xl border border-border bg-card p-5 shadow-sm"
>
<p className="text-[10px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
{label}
</p>
<p className="mt-2 font-display text-3xl font-semibold tabular-nums">
{value.toLocaleString("en-US")}
</p>
</div>
))}
</div>

<div className="mt-6 overflow-hidden rounded-3xl border border-border bg-card shadow-sm">
<div className="border-b border-border px-5 py-4 sm:px-6">
<h2 className="font-display text-xl font-semibold">
Recent deliveries
</h2>
<p className="mt-1 text-xs text-muted-foreground">
Latest 50 messages. Recipients are masked and mail bodies are
excluded.
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] text-left">
<thead>
<tr className="border-b border-border text-[10px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
<th className="px-5 py-3">Created</th>
<th className="px-3 py-3">Recipient</th>
<th className="px-3 py-3">Channel</th>
<th className="px-3 py-3">Template</th>
<th className="px-3 py-3">Status</th>
<th className="px-3 py-3 text-center">Attempts</th>
<th className="px-3 py-3">Next / sent</th>
<th className="px-3 py-3">Last error</th>
<th className="px-5 py-3 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{recent.map((mail) => (
<tr key={mail.id} className="align-top text-xs">
<td className="px-5 py-4 font-mono text-[10px] text-muted-foreground">
{mail.createdAt.toLocaleString("en-OM")}
</td>
<td className="px-3 py-4 font-mono text-[10px]">
{maskEmail(mail.recipient)}
</td>
<td className="px-3 py-4">
<span className="rounded-lg border border-border bg-muted/60 px-2 py-1 font-mono text-[10px]">
{mail.kind}
</span>
<span className="ml-1 text-[10px] text-muted-foreground">
{mail.priority}
</span>
</td>
<td className="px-3 py-4 font-mono text-[10px] text-muted-foreground">
{mail.template}
</td>
<td className="px-3 py-4">
<StatusBadge tone={statusTone(mail.status)}>
{mail.status}
</StatusBadge>
</td>
<td className="px-3 py-4 text-center font-mono tabular-nums">
{mail.attemptCount}
</td>
<td className="px-3 py-4 font-mono text-[10px] text-muted-foreground">
{(mail.sentAt ?? mail.nextAttemptAt)?.toLocaleString(
"en-OM",
) ?? "—"}
</td>
<td className="max-w-72 px-3 py-4 text-[10px] text-muted-foreground">
{mail.lastErrorCode ? (
<>
<span className="font-mono font-semibold text-foreground">
{mail.lastErrorCode}
</span>
{mail.lastErrorMessage ? (
<span className="mt-1 block line-clamp-2">
{mail.lastErrorMessage}
</span>
) : null}
</>
) : (
"—"
)}
</td>
<td className="px-5 py-4 text-right">
{mail.kind === "ROUTINE" &&
["FAILED", "RETRY"].includes(mail.status) ? (
<MailRetryButton mailId={mail.id} />
) : (
<span className="text-[10px] text-muted-foreground">
—
</span>
)}
</td>
</tr>
))}
{recent.length === 0 ? (
<tr>
<td
colSpan={9}
className="px-5 py-12 text-center text-sm text-muted-foreground"
>
No transactional mail has been queued yet.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
</div>
);
}
6 changes: 6 additions & 0 deletions apps/web/src/app/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
83 changes: 83 additions & 0 deletions apps/web/src/app/api/account/notification-preferences/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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);
}
Loading
Loading