feat(auth): trusted senders table and API
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc0adcd94d
commit
b536ed13e7
16 files changed
+20820
No files matched your search
@@ -1365,3 +1365,41 @@ export async function traceLogsByQueueId(queueId: string): Promise<unknown[]> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------- trusted senders
|
||||
|
||||
/** A mailbox cannot grow this list without bound. */
|
||||
export const MAX_TRUSTED_SENDERS = 5000;
|
||||
|
||||
/** The address an input resolves to, or "" when it is not one. */
|
||||
export function normaliseSender(raw: string): string {
|
||||
const angled = raw.match(/<([^>]+)>/);
|
||||
const address = (angled ? angled[1] : raw).trim().toLowerCase();
|
||||
return address.length <= 320 && /^[^\s@<>]+@[^\s@<>]+$/.test(address) ? address : "";
|
||||
}
|
||||
|
||||
export async function listTrustedSenders(mailboxId: string): Promise<string[]> {
|
||||
const rows = await query<{ address: string }>(
|
||||
`SELECT address FROM trusted_senders WHERE mailbox_id = $1 ORDER BY address`,
|
||||
[mailboxId],
|
||||
);
|
||||
return rows.map((row) => row.address);
|
||||
}
|
||||
|
||||
/** Returns false when the list is full; trusting someone already trusted is a no-op. */
|
||||
export async function addTrustedSender(mailboxId: string, address: string): Promise<boolean> {
|
||||
const rows = await query<{ n: string }>(
|
||||
`SELECT COUNT(*) AS n FROM trusted_senders WHERE mailbox_id = $1`,
|
||||
[mailboxId],
|
||||
);
|
||||
if (Number(rows[0]?.n ?? 0) >= MAX_TRUSTED_SENDERS) return false;
|
||||
await query(
|
||||
`INSERT INTO trusted_senders (mailbox_id, address) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[mailboxId, address],
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function removeTrustedSender(mailboxId: string, address: string): Promise<void> {
|
||||
await query(`DELETE FROM trusted_senders WHERE mailbox_id = $1 AND address = $2`, [mailboxId, address]);
|
||||
}
|
||||
@@ -1194,6 +1194,47 @@ describeDb("moving between the surfaces", () => {
|
||||
expect(first.body.token).not.toBe(second.body.token);
|
||||
}, 60000);
|
||||
|
||||
describe("trusted senders", () => {
|
||||
let token = "";
|
||||
beforeAll(async () => {
|
||||
token = (await post("/api/v1/auth/user/login", { email: plainEmail(), password: PASSWORD })).body.token as unknown as string;
|
||||
}, 60000);
|
||||
|
||||
const send = async (method: string, path: string, body?: unknown) => {
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
return { status: res.status, body: (await res.json().catch(() => ({}))) as Record<string, any> };
|
||||
};
|
||||
|
||||
it("remembers a sender once however it is spelled, and forgets it on request", async () => {
|
||||
expect((await send("PUT", "/api/v1/user/trusted-senders", { address: "Jenny <Jenny@Example.test>" })).status).toBe(200);
|
||||
expect((await send("PUT", "/api/v1/user/trusted-senders", { address: "jenny@example.test" })).status).toBe(200);
|
||||
expect((await send("GET", "/api/v1/user/trusted-senders")).body.senders).toEqual(["jenny@example.test"]);
|
||||
|
||||
expect((await send("DELETE", "/api/v1/user/trusted-senders?address=JENNY@example.test")).status).toBe(200);
|
||||
expect((await send("GET", "/api/v1/user/trusted-senders")).body.senders).toEqual([]);
|
||||
}, 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);
|
||||
}, 60000);
|
||||
|
||||
it("keeps one mailbox's list from another's", async () => {
|
||||
await send("PUT", "/api/v1/user/trusted-senders", { address: "private@example.test" });
|
||||
const other = (await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD })).body.token as unknown as string;
|
||||
const res = await fetch(`${base}/api/v1/user/trusted-senders`, { headers: { Authorization: `Bearer ${other}` } });
|
||||
expect(((await res.json()) as { senders: string[] }).senders).not.toContain("private@example.test");
|
||||
}, 60000);
|
||||
|
||||
it("needs a session", async () => {
|
||||
expect((await get("/api/v1/user/trusted-senders")).status).toBe(401);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
// Several webmail accounts at once, and a session that renews itself.
|
||||
describe("several accounts and sliding sessions", () => {
|
||||
const emailOf = (local: string) => `${local}@authtest-${DOMAIN_ID}.invalid`;
|
||||
|
||||
@@ -173,6 +173,9 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
|
||||
}
|
||||
if (url === "/api/v1/user/preferences" && method === "GET") return userGetPreferences(res, session!);
|
||||
if (url === "/api/v1/user/preferences" && method === "POST") return userUpdatePreferences(req, res, session!);
|
||||
if (url === "/api/v1/user/trusted-senders" && method === "GET") return trustedSendersList(res, session!);
|
||||
if (url === "/api/v1/user/trusted-senders" && method === "PUT") return trustedSenderAdd(req, res, session!);
|
||||
if (url === "/api/v1/user/trusted-senders" && method === "DELETE") return trustedSenderRemove(req, res, session!);
|
||||
if (url === "/api/v1/user/sieve" && method === "GET") return userGetSieve(res, session!);
|
||||
if (url === "/api/v1/user/sieve" && method === "POST") return userSaveSieve(req, res, session!);
|
||||
if (url === "/api/v1/user/vacation" && method === "POST") return userSaveVacation(req, res, session!);
|
||||
@@ -1195,6 +1198,28 @@ async function userGetPreferences(res: ServerResponse, session: SessionTokenPayl
|
||||
sendJson(res, 200, await repo.getUserPreferences(session.sub));
|
||||
}
|
||||
|
||||
async function trustedSendersList(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
sendJson(res, 200, { senders: await repo.listTrustedSenders(session.sub) });
|
||||
}
|
||||
|
||||
async function trustedSenderAdd(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
const body = await parseJsonBody(req);
|
||||
const address = repo.normaliseSender(typeof body?.address === "string" ? body.address : "");
|
||||
if (!address) return sendJson(res, 400, { error: "INVALID_INPUT", message: "A valid email address is required" });
|
||||
if (!(await repo.addTrustedSender(session.sub, address))) {
|
||||
return sendJson(res, 409, { error: "LIMIT_REACHED", message: "The trusted senders list is full" });
|
||||
}
|
||||
sendJson(res, 200, { ok: true, address });
|
||||
}
|
||||
|
||||
async function trustedSenderRemove(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
const raw = new URL(req.url || "/", "http://localhost").searchParams.get("address") ?? "";
|
||||
const address = repo.normaliseSender(raw);
|
||||
if (!address) return sendJson(res, 400, { error: "INVALID_INPUT", message: "A valid email address is required" });
|
||||
await repo.removeTrustedSender(session.sub, address);
|
||||
sendJson(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
async function userUpdatePreferences(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
const body = await parseJsonBody(req);
|
||||
const signature = typeof body?.signature === "string" ? body.signature : "";
|
||||
|
||||
Reference in new issue
Block a user