diff --git a/src/app/api/mailboxes/[id]/avatar/route.ts b/src/app/api/mailboxes/[id]/avatar/route.ts index dda888f..e26d93d 100644 --- a/src/app/api/mailboxes/[id]/avatar/route.ts +++ b/src/app/api/mailboxes/[id]/avatar/route.ts @@ -1,7 +1,7 @@ import { eq } from "drizzle-orm"; import { NextResponse } from "next/server"; import { getDb } from "@/db"; -import { mailboxes, users } from "@/db/schema"; +import { domains, mailboxes, users } from "@/db/schema"; import { requireUser } from "@/lib/auth/cookies"; import { getEnv } from "@/lib/cloudflare"; import { getMailboxAccessLevel } from "@/lib/mailboxes/access"; @@ -11,6 +11,7 @@ import { avatarKeyFor, isUploadedAvatarFile, } from "@/app/api/profile/avatar/utils"; +import { tracksAccountIdentity } from "@/lib/profile/identity-utils"; import { syncPersonalIdentity } from "@/lib/profile/sync"; import type { MailboxAvatarRouteParams } from "./types"; import { mailboxAvatarKeyFor } from "./utils"; @@ -27,13 +28,19 @@ export async function GET(request: Request, { params }: MailboxAvatarRouteParams .select({ avatarKey: mailboxes.avatarKey, type: mailboxes.type, + localPart: mailboxes.localPart, + hostname: domains.hostname, + ownerEmail: users.email, ownerAvatarKey: users.avatarKey, }) .from(mailboxes) + .innerJoin(domains, eq(mailboxes.domainId, domains.id)) .innerJoin(users, eq(mailboxes.userId, users.id)) .where(eq(mailboxes.id, id)) .limit(1); - const avatarKey = mailbox?.type === "personal" ? mailbox.ownerAvatarKey : mailbox?.avatarKey; + const avatarKey = mailbox && tracksAccountIdentity(mailbox, mailbox.ownerEmail) + ? mailbox.ownerAvatarKey + : mailbox?.avatarKey; if (!avatarKey) return new Response("Not found", { status: 404 }); const object = await env.BUCKET.get(avatarKey); @@ -75,18 +82,28 @@ export async function POST(request: Request, { params }: MailboxAvatarRouteParam } const [mailbox] = await db - .select({ userId: mailboxes.userId, type: mailboxes.type, ownerName: users.name }) + .select({ + userId: mailboxes.userId, + type: mailboxes.type, + localPart: mailboxes.localPart, + hostname: domains.hostname, + ownerName: users.name, + ownerEmail: users.email, + }) .from(mailboxes) + .innerJoin(domains, eq(mailboxes.domainId, domains.id)) .innerJoin(users, eq(mailboxes.userId, users.id)) .where(eq(mailboxes.id, id)) .limit(1); if (!mailbox) return NextResponse.json({ error: "Mailbox not found" }, { status: 404 }); - const key = mailbox.type === "personal" ? avatarKeyFor(mailbox.userId) : mailboxAvatarKeyFor(id); + // The primary mailbox shares the account avatar; every other mailbox stores its own. + const identity = tracksAccountIdentity(mailbox, mailbox.ownerEmail); + const key = identity ? avatarKeyFor(mailbox.userId) : mailboxAvatarKeyFor(id); await env.BUCKET.put(key, await file.arrayBuffer(), { httpMetadata: { contentType: file.type }, }); - if (mailbox.type === "personal") { + if (identity) { await syncPersonalIdentity(db, { userId: mailbox.userId, name: mailbox.ownerName, diff --git a/src/app/api/mailboxes/[id]/route.ts b/src/app/api/mailboxes/[id]/route.ts index 586fbcc..8c9c278 100644 --- a/src/app/api/mailboxes/[id]/route.ts +++ b/src/app/api/mailboxes/[id]/route.ts @@ -6,6 +6,7 @@ import { requireUser } from "@/lib/auth/cookies"; import { getEnv } from "@/lib/cloudflare"; import { getMailboxAccessLevel } from "@/lib/mailboxes/access"; import { ensureMailboxDomainRouting, removeMailboxDomainRouting } from "@/lib/mailboxes/domain-addresses"; +import { isPrimaryMailbox, tracksAccountIdentity } from "@/lib/profile/identity-utils"; import { syncPersonalIdentity } from "@/lib/profile/sync"; import { updateMailboxSchema } from "@/lib/validators"; import type { MailboxRouteParams } from "./types"; @@ -26,15 +27,15 @@ export async function GET(request: Request, { params }: MailboxRouteParams) { return NextResponse.json({ error: "Mailbox not found" }, { status: 404 }); } const { avatarKey, ownerName, ownerAvatarKey, ...mailboxDetails } = mailbox; - const personal = mailbox.type === "personal"; + const identity = tracksAccountIdentity(mailbox, user.email); return NextResponse.json({ mailbox: { ...mailboxDetails, - displayName: personal ? ownerName : mailbox.displayName, - hasAvatar: personal ? !!ownerAvatarKey : !!avatarKey, + displayName: identity ? ownerName : mailbox.displayName, + hasAvatar: identity ? !!ownerAvatarKey : !!avatarKey, permission: access.permission, - isPrimary: `${mailbox.localPart}@${mailbox.hostname}` === user.email, + isPrimary: isPrimaryMailbox(mailbox, user.email), }, }); } @@ -58,7 +59,9 @@ export async function PATCH(request: Request, { params }: MailboxRouteParams) { } const updateValues = getMailboxUpdateValues(parsed.data); - if (existing.type === "personal" && "displayName" in parsed.data) { + // Only the primary mailbox carries the account name; renaming any other + // mailbox writes to that mailbox alone. + if (tracksAccountIdentity(existing, user.email) && "displayName" in parsed.data) { const name = parsed.data.displayName?.trim(); if (!name) { return NextResponse.json({ error: "A valid account name is required" }, { status: 400 }); @@ -95,15 +98,15 @@ export async function PATCH(request: Request, { params }: MailboxRouteParams) { const [mailbox] = await selectMailboxForUser(db, user.id, id); const { avatarKey, ownerName, ownerAvatarKey, ...mailboxDetails } = mailbox!; - const personal = mailbox!.type === "personal"; + const identity = tracksAccountIdentity(mailbox!, user.email); return NextResponse.json({ mailbox: { ...mailboxDetails, - displayName: personal ? ownerName : mailbox!.displayName, - hasAvatar: personal ? !!ownerAvatarKey : !!avatarKey, + displayName: identity ? ownerName : mailbox!.displayName, + hasAvatar: identity ? !!ownerAvatarKey : !!avatarKey, permission: access.permission, - isPrimary: `${mailbox!.localPart}@${mailbox!.hostname}` === user.email, + isPrimary: isPrimaryMailbox(mailbox!, user.email), }, }); } diff --git a/src/app/api/mailboxes/route.ts b/src/app/api/mailboxes/route.ts index fcd1721..10fcbaf 100644 --- a/src/app/api/mailboxes/route.ts +++ b/src/app/api/mailboxes/route.ts @@ -6,6 +6,7 @@ import { domains, mailboxAliases, mailboxes, users } from "@/db/schema"; import { requireUser } from "@/lib/auth/cookies"; import { newId } from "@/lib/ids"; import { getLicenseEntitlements } from "@/lib/licenses/service"; +import { tracksAccountIdentity } from "@/lib/profile/identity-utils"; import { mailboxSchema } from "@/lib/validators"; import { ensureMailboxDomainRouting, getMailboxDomainAddresses } from "@/lib/mailboxes/domain-addresses"; import { ensurePersonalMailbox } from "./utils"; @@ -19,7 +20,7 @@ export async function GET(request: Request) { return NextResponse.json({ mailboxes: await Promise.all(rows.map(async (mailbox) => ({ ...mailbox, - ...(mailbox.userId === user.id && mailbox.type === "personal" + ...(mailbox.userId === user.id && tracksAccountIdentity(mailbox, user.email) ? { displayName: user.name, hasAvatar: !!user.avatarKey } : {}), senderAddresses: await getMailboxDomainAddresses(db, mailbox), diff --git a/src/app/api/messages/route.ts b/src/app/api/messages/route.ts index 7025385..f8325c3 100644 --- a/src/app/api/messages/route.ts +++ b/src/app/api/messages/route.ts @@ -9,6 +9,7 @@ import { getContactDisplayNameMap } from "@/lib/contacts/service"; import { getFirstEmailAddressEntry, normalizeEmailAddress } from "@/lib/email/address"; import { buildSnippet } from "@/lib/email/parse"; import { getMailboxAccessLevel, listAccessibleMailboxes } from "@/lib/mailboxes/access"; +import { tracksAccountIdentity } from "@/lib/profile/identity-utils"; export async function GET(request: Request) { const env = getEnv(); @@ -175,7 +176,7 @@ export async function GET(request: Request) { const mailboxNameMap = new Map( accessibleMailboxes.map((mailbox) => [ mailbox.id, - mailbox.userId === user.id && mailbox.type === "personal" + mailbox.userId === user.id && tracksAccountIdentity(mailbox, user.email) ? user.name : mailbox.displayName ?? mailbox.localPart, ]), diff --git a/src/components/mailbox-provider-utils.ts b/src/components/mailbox-provider-utils.ts index e765023..dd456c9 100644 --- a/src/components/mailbox-provider-utils.ts +++ b/src/components/mailbox-provider-utils.ts @@ -8,6 +8,14 @@ let mailboxesRequestSessionToken: string | null = null; let cacheGeneration = 0; export const SELECTED_MAILBOX_STORAGE_KEY = "selected-mailbox-id"; +/** + * The primary mailbox is the one at the account address; it is the only mailbox + * whose name and avatar follow the profile. + */ +export function isIdentityMailbox(mailbox: Pick): boolean { + return mailbox.type === "personal" && !!mailbox.isPrimary; +} + export function clearMailboxesCache() { cacheGeneration += 1; mailboxesCache = null; diff --git a/src/components/mailbox-provider.tsx b/src/components/mailbox-provider.tsx index 91b4c15..8a16d0e 100644 --- a/src/components/mailbox-provider.tsx +++ b/src/components/mailbox-provider.tsx @@ -11,6 +11,7 @@ import type { ReactNode } from "react"; import { clearMailboxesCache, fetchMailboxOptions, + isIdentityMailbox, SELECTED_MAILBOX_STORAGE_KEY, } from "./mailbox-provider-utils"; import { @@ -107,11 +108,12 @@ export function MailboxProvider({ children }: { children: ReactNode }) { function updatePersonalMailboxNames(event: Event) { const { name } = (event as CustomEvent).detail; clearMailboxesCache(); + // Only the primary mailbox follows the profile name; the rest keep their own. setMailboxes((items) => items.map((mailbox) => ( - mailbox.type === "personal" ? { ...mailbox, displayName: name } : mailbox + isIdentityMailbox(mailbox) ? { ...mailbox, displayName: name } : mailbox ))); setSelectedMailboxState((mailbox) => ( - mailbox?.type === "personal" ? { ...mailbox, displayName: name } : mailbox + mailbox && isIdentityMailbox(mailbox) ? { ...mailbox, displayName: name } : mailbox )); } @@ -123,10 +125,10 @@ export function MailboxProvider({ children }: { children: ReactNode }) { function updatePersonalMailboxAvatars() { clearMailboxesCache(); setMailboxes((items) => items.map((mailbox) => ( - mailbox.type === "personal" ? { ...mailbox, hasAvatar: true } : mailbox + isIdentityMailbox(mailbox) ? { ...mailbox, hasAvatar: true } : mailbox ))); setSelectedMailboxState((mailbox) => ( - mailbox?.type === "personal" ? { ...mailbox, hasAvatar: true } : mailbox + mailbox && isIdentityMailbox(mailbox) ? { ...mailbox, hasAvatar: true } : mailbox )); } diff --git a/src/components/mailbox-selector.tsx b/src/components/mailbox-selector.tsx index 93672f9..2927157 100644 --- a/src/components/mailbox-selector.tsx +++ b/src/components/mailbox-selector.tsx @@ -5,6 +5,7 @@ import { usePathname, useRouter } from "next/navigation"; import { CalendarDays, Check, LogOut, Settings, ShieldCheck, UserRound, UsersRound } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useSelectedMailbox } from "@/components/mailbox-provider"; +import { isIdentityMailbox } from "@/components/mailbox-provider-utils"; import { useMessageCounts } from "@/hooks/use-message-counts"; import { authFetch } from "@/lib/auth/client"; import { logoutClientSession } from "@/lib/auth/logout"; @@ -146,7 +147,7 @@ export function MailboxSelector() { const next = { ...current }; const version = Date.now(); for (const mailbox of mailboxes) { - if (mailbox.type === "personal") { + if (isIdentityMailbox(mailbox)) { next[mailbox.id] = `/api/mailboxes/${mailbox.id}/avatar?v=${version}`; } } diff --git a/src/lib/email/sender.ts b/src/lib/email/sender.ts index 3059da2..b8784b3 100644 --- a/src/lib/email/sender.ts +++ b/src/lib/email/sender.ts @@ -4,6 +4,7 @@ import { domains, mailboxes, users } from "@/db/schema"; import { getMailboxAccessLevel } from "@/lib/mailboxes/access"; import { formatEmailAddress, getEmailAddress } from "@/lib/email/address"; import { getMailboxDomainAddresses } from "@/lib/mailboxes/domain-addresses"; +import { resolveMailboxDisplayName } from "@/lib/profile/identity-utils"; export async function getAuthorizedSenderAddress( env: CloudflareEnv, @@ -22,6 +23,7 @@ export async function getAuthorizedSenderAddress( displayName: mailboxes.displayName, type: mailboxes.type, ownerName: users.name, + ownerEmail: users.email, hostname: domains.hostname, domainId: mailboxes.domainId, useAllDomains: mailboxes.useAllDomains, @@ -48,7 +50,9 @@ export async function getAuthorizedSenderAddress( throw new Error("Sender address does not match the selected mailbox"); } const senderAddress = requestedAddress.toLowerCase(); - const senderName = mailbox.type === "personal" ? mailbox.ownerName : mailbox.displayName; + // The primary mailbox sends under the account name; every other mailbox + // sends under its own. + const senderName = resolveMailboxDisplayName(mailbox, mailbox.ownerEmail, mailbox.ownerName); if (access.canSendAs) { return { diff --git a/src/lib/profile/identity-utils.ts b/src/lib/profile/identity-utils.ts new file mode 100644 index 0000000..8a231cd --- /dev/null +++ b/src/lib/profile/identity-utils.ts @@ -0,0 +1,49 @@ +/** + * A user's primary mailbox is the one at their account address. It carries the + * account identity: its name and avatar are the profile's, and editing either + * side keeps both in step. Every other personal mailbox owns its own display + * name and avatar, so renaming one never touches its siblings. + */ +export type MailboxIdentityScope = { + localPart: string; + hostname: string; + type?: string | null; +}; + +export function mailboxAddress(mailbox: Pick): string { + return `${mailbox.localPart}@${mailbox.hostname}`.trim().toLowerCase(); +} + +export function isPrimaryMailbox( + mailbox: Pick, + accountEmail: string | null | undefined, +): boolean { + if (!accountEmail) return false; + return mailboxAddress(mailbox) === accountEmail.trim().toLowerCase(); +} + +/** True when the mailbox's name and avatar come from the account profile. */ +export function tracksAccountIdentity( + mailbox: MailboxIdentityScope, + accountEmail: string | null | undefined, +): boolean { + return mailbox.type === "personal" && isPrimaryMailbox(mailbox, accountEmail); +} + +/** The name to show for a mailbox, before falling back to its local part. */ +export function resolveMailboxDisplayName( + mailbox: MailboxIdentityScope & { displayName: string | null }, + accountEmail: string | null | undefined, + accountName: string | null, +): string | null { + return tracksAccountIdentity(mailbox, accountEmail) ? accountName : mailbox.displayName; +} + +/** The avatar key to serve for a mailbox. */ +export function resolveMailboxAvatarKey( + mailbox: MailboxIdentityScope & { avatarKey: string | null }, + accountEmail: string | null | undefined, + accountAvatarKey: string | null, +): string | null { + return tracksAccountIdentity(mailbox, accountEmail) ? accountAvatarKey : mailbox.avatarKey; +} diff --git a/src/lib/profile/sync.ts b/src/lib/profile/sync.ts index 2c48d90..c211b77 100644 --- a/src/lib/profile/sync.ts +++ b/src/lib/profile/sync.ts @@ -1,8 +1,9 @@ import { and, eq } from "drizzle-orm"; import type { AppDatabase } from "@/db"; -import { contacts, mailboxes, users } from "@/db/schema"; +import { contacts, domains, mailboxes, users } from "@/db/schema"; import { getContactId } from "@/lib/contacts/utils"; import { getMailboxDomainAddresses } from "@/lib/mailboxes/domain-addresses"; +import { tracksAccountIdentity } from "./identity-utils"; import type { PersonalIdentity } from "./sync-types"; export async function syncPersonalIdentity( @@ -14,40 +15,54 @@ export async function syncPersonalIdentity( .set({ name: identity.name, avatarKey: identity.avatarKey }) .where(eq(users.id, identity.userId)); - await db - .update(mailboxes) - .set({ displayName: identity.name, avatarKey: identity.avatarKey }) - .where(and(eq(mailboxes.userId, identity.userId), eq(mailboxes.type, "personal"))); + const [account] = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.id, identity.userId)) + .limit(1); const personalMailboxes = await db - .select() + .select({ mailbox: mailboxes, hostname: domains.hostname }) .from(mailboxes) + .innerJoin(domains, eq(mailboxes.domainId, domains.id)) .where(and(eq(mailboxes.userId, identity.userId), eq(mailboxes.type, "personal"))); - const addresses = new Set( - (await Promise.all( - personalMailboxes.map((mailbox) => getMailboxDomainAddresses(db, mailbox)), - )).flat(), - ); - for (const email of addresses) { - await db - .insert(contacts) - .values({ - id: getContactId(identity.userId, email), - userId: identity.userId, - email, - displayName: identity.name, - avatarKey: identity.avatarKey, - source: "manual", - }) - .onConflictDoUpdate({ - target: [contacts.userId, contacts.email], - set: { - displayName: identity.name, - avatarKey: identity.avatarKey, + for (const { mailbox, hostname } of personalMailboxes) { + // Only the primary mailbox mirrors the profile. Secondary mailboxes keep + // the name and avatar they were given. + const isIdentityMailbox = tracksAccountIdentity( + { localPart: mailbox.localPart, hostname, type: mailbox.type }, + account?.email, + ); + if (isIdentityMailbox) { + await db + .update(mailboxes) + .set({ displayName: identity.name, avatarKey: identity.avatarKey }) + .where(eq(mailboxes.id, mailbox.id)); + } + const displayName = isIdentityMailbox ? identity.name : mailbox.displayName ?? identity.name; + const avatarKey = isIdentityMailbox ? identity.avatarKey : mailbox.avatarKey; + + for (const email of await getMailboxDomainAddresses(db, mailbox)) { + await db + .insert(contacts) + .values({ + id: getContactId(identity.userId, email), + userId: identity.userId, + email, + displayName, + avatarKey, source: "manual", - }, - }); + }) + .onConflictDoUpdate({ + target: [contacts.userId, contacts.email], + set: { + displayName, + avatarKey, + source: "manual", + }, + }); + } } } diff --git a/tests/mailbox-identity-scope.test.mjs b/tests/mailbox-identity-scope.test.mjs new file mode 100644 index 0000000..fc4dd36 --- /dev/null +++ b/tests/mailbox-identity-scope.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + isPrimaryMailbox, + resolveMailboxAvatarKey, + resolveMailboxDisplayName, + tracksAccountIdentity, +} from "../src/lib/profile/identity-utils.ts"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const read = (path) => readFileSync(join(root, path), "utf8"); + +const ACCOUNT_EMAIL = "sam@example.com"; +const primary = { localPart: "sam", hostname: "example.com", type: "personal", displayName: "Sam Reyes", avatarKey: "avatars/user-1" }; +const secondary = { localPart: "billing", hostname: "example.com", type: "personal", displayName: "Billing", avatarKey: "avatars/mbx-2" }; +const shared = { localPart: "support", hostname: "example.com", type: "shared", displayName: "Support", avatarKey: "avatars/mbx-3" }; + +test("only the mailbox at the account address carries the account identity", () => { + assert.equal(isPrimaryMailbox(primary, ACCOUNT_EMAIL), true); + assert.equal(isPrimaryMailbox(secondary, ACCOUNT_EMAIL), false); + + assert.equal(tracksAccountIdentity(primary, ACCOUNT_EMAIL), true); + assert.equal(tracksAccountIdentity(secondary, ACCOUNT_EMAIL), false); + assert.equal(tracksAccountIdentity(shared, ACCOUNT_EMAIL), false); +}); + +test("the address match ignores case and surrounding space", () => { + assert.equal(isPrimaryMailbox({ localPart: "Sam", hostname: "Example.com" }, " SAM@example.com "), true); + assert.equal(isPrimaryMailbox(primary, null), false); + assert.equal(isPrimaryMailbox(primary, undefined), false); +}); + +test("a secondary mailbox resolves to its own name and avatar", () => { + assert.equal(resolveMailboxDisplayName(primary, ACCOUNT_EMAIL, "Sam Reyes"), "Sam Reyes"); + assert.equal(resolveMailboxDisplayName(secondary, ACCOUNT_EMAIL, "Sam Reyes"), "Billing"); + assert.equal(resolveMailboxAvatarKey(primary, ACCOUNT_EMAIL, "avatars/user-1"), "avatars/user-1"); + assert.equal(resolveMailboxAvatarKey(secondary, ACCOUNT_EMAIL, "avatars/user-1"), "avatars/mbx-2"); +}); + +test("renaming the profile leaves sibling mailboxes untouched (issue #36)", () => { + const db = new DatabaseSync(":memory:"); + db.exec(` + CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL, name TEXT, avatar_key TEXT); + CREATE TABLE domains (id TEXT PRIMARY KEY, hostname TEXT NOT NULL); + CREATE TABLE mailboxes ( + id TEXT PRIMARY KEY, user_id TEXT NOT NULL, domain_id TEXT NOT NULL, + local_part TEXT NOT NULL, display_name TEXT, avatar_key TEXT, type TEXT NOT NULL + ); + INSERT INTO users VALUES ('u1', 'sam@example.com', 'Sam Reyes', 'avatars/user-1'); + INSERT INTO domains VALUES ('d1', 'example.com'); + INSERT INTO mailboxes VALUES + ('m1', 'u1', 'd1', 'sam', 'Sam Reyes', 'avatars/user-1', 'personal'), + ('m2', 'u1', 'd1', 'billing', 'Billing', 'avatars/mbx-2', 'personal'), + ('m3', 'u1', 'd1', 'projects', 'Projects', NULL, 'personal'); + `); + + // What syncPersonalIdentity does now: load every personal mailbox with its + // hostname, then write only to the one that carries the account identity. + const account = db.prepare("SELECT email FROM users WHERE id = ?").get("u1"); + const rows = db + .prepare(` + SELECT m.id, m.local_part, m.type, d.hostname + FROM mailboxes m JOIN domains d ON d.id = m.domain_id + WHERE m.user_id = ? AND m.type = 'personal' + `) + .all("u1"); + + const identityRows = rows.filter((row) => + tracksAccountIdentity({ localPart: row.local_part, hostname: row.hostname, type: row.type }, account.email), + ); + assert.deepEqual(identityRows.map((row) => row.id), ["m1"], "exactly one mailbox holds the account identity"); + + for (const row of identityRows) { + db.prepare("UPDATE mailboxes SET display_name = ?, avatar_key = ? WHERE id = ?") + .run("Sam R. Reyes", "avatars/user-2", row.id); + } + + const named = db + .prepare("SELECT id, display_name, avatar_key FROM mailboxes ORDER BY id") + .all() + .map((row) => ({ ...row })); + assert.deepEqual(named, [ + { id: "m1", display_name: "Sam R. Reyes", avatar_key: "avatars/user-2" }, + { id: "m2", display_name: "Billing", avatar_key: "avatars/mbx-2" }, + { id: "m3", display_name: "Projects", avatar_key: null }, + ]); +}); + +test("the identity write is keyed by mailbox id, not by owner and type", () => { + const sync = read("src/lib/profile/sync.ts"); + const updates = [...sync.matchAll(/\.update\(mailboxes\)[\s\S]*?\.where\(([\s\S]*?)\);/g)].map((m) => m[1]); + assert.ok(updates.length > 0, "no mailbox update found in syncPersonalIdentity"); + for (const where of updates) { + assert.ok( + /eq\(mailboxes\.id,/.test(where), + `mailbox identity update must target a single mailbox id, got: ${where.trim()}`, + ); + assert.ok( + !/eq\(mailboxes\.type, "personal"\)/.test(where), + "an update scoped by type fans the account name out to every personal mailbox", + ); + } + assert.match(sync, /tracksAccountIdentity/); +}); + +test("the mailbox routes gate the account name on the identity check", () => { + const detail = read("src/app/api/mailboxes/[id]/route.ts"); + assert.match(detail, /tracksAccountIdentity\(existing, user\.email\) && "displayName" in parsed\.data/); + assert.ok( + !/existing\.type === "personal" && "displayName" in parsed\.data/.test(detail), + "PATCH must not treat every personal mailbox as the account identity", + ); + + const list = read("src/app/api/mailboxes/route.ts"); + assert.match(list, /tracksAccountIdentity\(mailbox, user\.email\)/); + assert.ok( + !/mailbox\.type === "personal"\n?\s*\? \{ displayName: user\.name/.test(list), + "the list response must not overwrite every personal mailbox name with the account name", + ); +}); + +test("outgoing mail and message lists use the mailbox's own name", () => { + const sender = read("src/lib/email/sender.ts"); + assert.match(sender, /resolveMailboxDisplayName\(mailbox, mailbox\.ownerEmail, mailbox\.ownerName\)/); + assert.ok( + !/mailbox\.type === "personal" \? mailbox\.ownerName/.test(sender), + "every personal mailbox would send under the account owner's name", + ); + + const messages = read("src/app/api/messages/route.ts"); + assert.match(messages, /tracksAccountIdentity\(mailbox, user\.email\)/); +}); + +test("the client only repaints the primary mailbox on profile changes", () => { + const provider = read("src/components/mailbox-provider.tsx"); + assert.match(provider, /isIdentityMailbox\(mailbox\)/); + assert.ok( + !/mailbox\.type === "personal" \? \{ \.\.\.mailbox, displayName: name \}/.test(provider), + "a profile rename must not relabel every personal mailbox in the sidebar", + ); + + const utils = read("src/components/mailbox-provider-utils.ts"); + assert.match(utils, /export function isIdentityMailbox/); +});