From 9719ced1d36dc48066f2fb8a423cb8977f930e2d Mon Sep 17 00:00:00 2001 From: Edgard Navarro-Clemons Date: Fri, 11 Sep 2026 16:26:48 -0700 Subject: [PATCH] fix(mailboxes): give each personal mailbox its own name and avatar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming any personal mailbox renamed all of them. The account profile and every personal mailbox shared one identity: syncPersonalIdentity wrote the profile name and avatar to every row matching (userId, type = 'personal'), PATCH /api/mailboxes/[id] redirected a rename into that sync instead of the mailbox row, and both the list response and the client overrode whatever was stored with the account name. Outgoing mail and message lists did the same, so a secondary mailbox also sent under the account owner's name. Only the primary mailbox — the one at the account address — carries the account identity now. Every other personal mailbox keeps the name and avatar it was given. The rule lives in one place, lib/profile/identity-utils.ts, and the API routes, the sender, and the client all go through it. Existing secondary mailboxes keep whatever name the old blanket update left on them until they are renamed; the rename now sticks. Fixes #36 Co-Authored-By: Claude Opus 5 --- src/app/api/mailboxes/[id]/avatar/route.ts | 27 +++- src/app/api/mailboxes/[id]/route.ts | 21 +-- src/app/api/mailboxes/route.ts | 3 +- src/app/api/messages/route.ts | 3 +- src/components/mailbox-provider-utils.ts | 8 ++ src/components/mailbox-provider.tsx | 10 +- src/components/mailbox-selector.tsx | 3 +- src/lib/email/sender.ts | 6 +- src/lib/profile/identity-utils.ts | 49 +++++++ src/lib/profile/sync.ts | 73 ++++++---- tests/mailbox-identity-scope.test.mjs | 149 +++++++++++++++++++++ 11 files changed, 301 insertions(+), 51 deletions(-) create mode 100644 src/lib/profile/identity-utils.ts create mode 100644 tests/mailbox-identity-scope.test.mjs 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/); +});