refactor(mail): one definition of the standard folders, and reconcile the drift

The console created Reports, the CLI that makes the first administrator did not,
so that account had no folder for DMARC and TLS-RPT reports. The list now lives
in one SQL function, provision_default_folders (migration 0014), which both call.

The same migration reconciles what the two paths had left wrong in existing
mailboxes: folder counters and the mailbox's used bytes are recomputed from the
rows (only where they differ, so it is safe to run again).

create-admin can now be tested: fail() throws instead of exiting, and the command
runs only when invoked as one. The new test fails on the old code (no Reports).
This commit is contained in:
Lucas Rayan committed 2026-10-16 13:19:55 -03:00
1 parent caf52c0225
commit a8941a6191
6 files changed
+310 -37

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect, afterAll } from "vitest";
process.env.LAMBDAMAIL_MASTER_KEY ||= "test-master-key-at-least-16-chars";
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? "";
const hasDatabase = Boolean(process.env.TEST_DATABASE_URL);
const describeDb = hasDatabase ? describe : describe.skip;
const { createAdmin, CliError } = await import("./cli.js");
const { query, closePool } = await import("./db.js");
const DOMAIN = `cli-${Date.now()}.test`;
const EMAIL = `root@${DOMAIN}`;
describeDb("create-admin (command line)", () => {
afterAll(async () => {
await query(`DELETE FROM domains WHERE name = $1`, [DOMAIN]);
await closePool();
});
it("gives the first administrator every standard folder, Reports included", async () => {
const log = console.log;
console.log = () => undefined;
try {
await createAdmin(EMAIL, "a-long-enough-password", true);
} finally {
console.log = log;
}
// This account used to come out without Reports, so its DMARC and TLS-RPT
// reports fell into the INBOX: the console and this command each had their
// own list of folders and the lists had drifted.
const folders = await query<{ name: string; special_use: string | null }>(
`SELECT f.name, f.special_use FROM folders f JOIN mailboxes m ON m.id = f.mailbox_id WHERE m.email_address = $1`,
[EMAIL],
);
expect(folders.map((f) => f.name).sort()).toEqual(["Archive", "Drafts", "INBOX", "Junk", "Reports", "Sent", "Trash"]);
expect(folders.find((f) => f.name === "Reports")?.special_use).toBeNull();
expect(folders.find((f) => f.name === "Sent")?.special_use).toBe("sent");
});
it("refuses with a readable error instead of exiting the process", async () => {
await expect(createAdmin(EMAIL, "short", true)).rejects.toBeInstanceOf(CliError);
await expect(createAdmin(EMAIL, "a-long-enough-password", true)).rejects.toThrow(/already exists/);
});
});
+22 -25
View File
@@ -13,22 +13,19 @@
* node dist/cli.js reset-mfa <email>
*/
import { pathToFileURL } from "node:url";
import { query, queryOne, closePool } from "./db.js";
import { hashPassword } from "./crypto.js";
const SYSTEM_ALIASES = ["postmaster", "abuse", "dmarc", "tlsrpt"];
const STANDARD_FOLDERS: Array<[string, string]> = [
["INBOX", "inbox"],
["Sent", "sent"],
["Drafts", "drafts"],
["Trash", "trash"],
["Junk", "junk"],
["Archive", "archive"],
];
/** A refusal the operator should read, as opposed to a bug. */
export class CliError extends Error {}
// Thrown rather than exiting here, so the commands can be exercised from a test;
// main() prints it as "error: ..." and exits 1, exactly as this used to.
function fail(message: string): never {
console.error(`error: ${message}`);
process.exit(1);
throw new CliError(message);
}
function parseAddress(email: string): { localPart: string; domain: string } {
@@ -37,7 +34,7 @@ function parseAddress(email: string): { localPart: string; domain: string } {
return { localPart: email.slice(0, at).toLowerCase(), domain: email.slice(at + 1).toLowerCase() };
}
async function createAdmin(email: string, password: string, force: boolean): Promise<void> {
export async function createAdmin(email: string, password: string, force: boolean): Promise<void> {
if (password.length < 12) fail("the password must be at least 12 characters");
const { localPart, domain } = parseAddress(email);
@@ -69,13 +66,10 @@ async function createAdmin(email: string, password: string, force: boolean): Pro
);
// Delivery needs these: a mailbox with no INBOX cannot receive at all, and
// one with no Junk used to fail the spam path outright.
for (const [name, specialUse] of STANDARD_FOLDERS) {
await query(
`INSERT INTO folders (mailbox_id, name, special_use) VALUES ($1, $2, $3)`,
[mailbox!.id, name, specialUse],
);
}
// one with no Junk used to fail the spam path outright. The list lives in the
// database (migration 0014) so the console and this command cannot disagree
// about it, as they once did over the Reports folder.
await query(`SELECT provision_default_folders($1)`, [mailbox!.id]);
// PLAN.md section 7.4b: the published DNS records name these, and reports
// bounce if they do not resolve.
@@ -174,10 +168,13 @@ async function main(): Promise<void> {
}
}
main()
.then(() => closePool())
.catch(async (err) => {
console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
await closePool().catch(() => undefined);
process.exit(1);
});
// Only when run as a command, not when a test imports the functions above
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main()
.then(() => closePool())
.catch(async (err) => {
console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
await closePool().catch(() => undefined);
process.exit(1);
});
}
+3 -12
View File
@@ -1004,18 +1004,9 @@ export async function createMailbox(scope: AdminScope, input: CreateMailboxInput
[input.domainId, localPart, email, hash, input.role, input.quotaBytes],
);
await query(
`INSERT INTO folders (mailbox_id, name, special_use)
SELECT $1, f.name, f.special_use
FROM (VALUES ('INBOX','inbox'), ('Sent','sent'), ('Drafts','drafts'),
('Trash','trash'), ('Junk','junk'), ('Archive','archive'),
-- Where delivered DMARC and TLS-RPT reports are filed.
-- IMAP has no special-use role for these, so it is matched
-- by name. Without the folder the delivery path falls back
-- to INBOX, which is the noise this is here to remove.
('Reports', NULL)) AS f(name, special_use)`,
[created!.id],
);
// The standard folders come from one definition in the database (migration
// 0014), shared with the command that creates the first administrator.
await query(`SELECT provision_default_folders($1)`, [created!.id]);
return { status: "OK", id: created!.id, email };
}