From 997dd8848fe9d2194229d8310a3471bf55dea2ff Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Thu, 22 Oct 2026 16:40:36 -0300 Subject: [PATCH] feat(auth): conversation_view preference, on by default Co-Authored-By: Claude Sonnet 5.5 --- ...0018_conversation_view_preference.down.sql | 1 + .../0018_conversation_view_preference.up.sql | 3 +++ services/auth/src/repository.ts | 23 ++++++++++++++----- services/auth/src/router.test.ts | 15 ++++++++++++ services/auth/src/router.ts | 3 ++- 5 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 migrations/0018_conversation_view_preference.down.sql create mode 100644 migrations/0018_conversation_view_preference.up.sql diff --git a/migrations/0018_conversation_view_preference.down.sql b/migrations/0018_conversation_view_preference.down.sql new file mode 100644 index 0000000..aeff770 --- /dev/null +++ b/migrations/0018_conversation_view_preference.down.sql @@ -0,0 +1 @@ +ALTER TABLE mailboxes DROP COLUMN IF EXISTS conversation_view; diff --git a/migrations/0018_conversation_view_preference.up.sql b/migrations/0018_conversation_view_preference.up.sql new file mode 100644 index 0000000..8e251fa --- /dev/null +++ b/migrations/0018_conversation_view_preference.up.sql @@ -0,0 +1,3 @@ +-- Whether the webmail lists mail as conversations. On by default: it is what +-- people coming from other mail programs expect, and it is one switch away. +ALTER TABLE mailboxes ADD COLUMN conversation_view BOOLEAN NOT NULL DEFAULT true; diff --git a/services/auth/src/repository.ts b/services/auth/src/repository.ts index 406bf84..c66f072 100644 --- a/services/auth/src/repository.ts +++ b/services/auth/src/repository.ts @@ -1195,21 +1195,32 @@ export async function deleteAlias(scope: AdminScope, aliasId: string): Promise { - const row = await queryOne<{ signature: string | null; auto_save_drafts: boolean | null }>( - `SELECT signature, auto_save_drafts FROM mailboxes WHERE id = $1`, +export async function getUserPreferences( + mailboxId: string, +): Promise<{ signature: string; auto_save_drafts: boolean; conversation_view: boolean }> { + const row = await queryOne<{ signature: string | null; auto_save_drafts: boolean | null; conversation_view: boolean | null }>( + `SELECT signature, auto_save_drafts, conversation_view FROM mailboxes WHERE id = $1`, [mailboxId], ); return { signature: row?.signature ?? "", auto_save_drafts: row?.auto_save_drafts ?? true, + conversation_view: row?.conversation_view ?? true, }; } -export async function updateUserPreferences(mailboxId: string, signature: string, autoSaveDrafts: boolean): Promise { +/** conversationView is left as it is when the caller does not say. */ +export async function updateUserPreferences( + mailboxId: string, + signature: string, + autoSaveDrafts: boolean, + conversationView: boolean | null = null, +): Promise { const rows = await query<{ id: string }>( - `UPDATE mailboxes SET signature = $2, auto_save_drafts = $3, updated_at = NOW() WHERE id = $1 RETURNING id`, - [mailboxId, signature, autoSaveDrafts], + `UPDATE mailboxes SET signature = $2, auto_save_drafts = $3, + conversation_view = COALESCE($4::boolean, conversation_view), updated_at = NOW() + WHERE id = $1 RETURNING id`, + [mailboxId, signature, autoSaveDrafts, conversationView], ); return rows.length === 1; } diff --git a/services/auth/src/router.test.ts b/services/auth/src/router.test.ts index faab669..b531ef1 100644 --- a/services/auth/src/router.test.ts +++ b/services/auth/src/router.test.ts @@ -1218,6 +1218,21 @@ describeDb("moving between the surfaces", () => { expect((await send("GET", "/api/v1/user/trusted-senders")).body.senders).toEqual([]); }, 60000); + it("lists conversations by default and remembers a switch back, without being reset by other saves", async () => { + const prefs = async () => + (await send("GET", "/api/v1/user/preferences")).body as { conversation_view: boolean; signature: string }; + expect((await prefs()).conversation_view).toBe(true); + + await send("POST", "/api/v1/user/preferences", { signature: "x", auto_save_drafts: true, conversation_view: false }); + expect((await prefs()).conversation_view).toBe(false); + + // A save from an older screen that does not mention it must not flip it back. + await send("POST", "/api/v1/user/preferences", { signature: "y", auto_save_drafts: true }); + const after = await prefs(); + expect(after.conversation_view).toBe(false); + expect(after.signature).toBe("y"); + }, 60000); + it("refuses something that is not an address", async () => { expect((await send("PUT", "/api/v1/user/trusted-senders", { address: "not an address" })).status).toBe(400); expect((await send("PUT", "/api/v1/user/trusted-senders", {})).status).toBe(400); diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts index 4493e72..82c2be5 100644 --- a/services/auth/src/router.ts +++ b/services/auth/src/router.ts @@ -1224,7 +1224,8 @@ async function userUpdatePreferences(req: IncomingMessage, res: ServerResponse, const body = await parseJsonBody(req); const signature = typeof body?.signature === "string" ? body.signature : ""; const autoSaveDrafts = body?.auto_save_drafts !== false; - await repo.updateUserPreferences(session.sub, signature, autoSaveDrafts); + const conversationView = typeof body?.conversation_view === "boolean" ? body.conversation_view : null; + await repo.updateUserPreferences(session.sub, signature, autoSaveDrafts, conversationView); sendJson(res, 200, { ok: true }); }