feat(admin): paginacao e filtragem no servidor, e edicao de utilizadores

As listas do painel respondiam com um LIMIT fixo e nenhum filtro: 200 caixas,
50 trabalhos na fila. Numa instalacao maior nao havia forma de chegar ao
resto, e a fila so podia ser inspeccionada pela cabeca -- justamente quando
esta acumulada e que alguem precisa de olhar para ela.

- parsePageParams com testes proprios: pagina e tamanho tem de ser inteiros
  positivos, o tamanho e limitado, e um valor invalido cai no seguro em vez de
  chegar ao SQL como NaN, LIMIT negativo ou OFFSET fraccionario
- o termo de pesquisa vai como parametro ligado, nunca interpolado; ha um
  teste que tenta terminar a instrucao e largar a tabela
- a contagem usa o mesmo filtro da pagina, senao o numero de paginas descreve
  um conjunto de linhas diferente do que e mostrado
- PATCH /admin/mailboxes/:id edita papel, quota, idioma, estado e liberta um
  bloqueio; so um super admin pode conceder super admin, ou um admin de
  dominio promovia-se para fora do seu proprio dominio

Apelidos por utilizador: um apelido nao pertence a um utilizador, pertence ao
dominio e leva uma lista de destinos que pode incluir enderecos noutros
servidores, e um catch-all e do dominio inteiro. O que pode ser mostrado
dentro de um utilizador sao os apelidos que entregam na caixa dele, e e isso
que /admin/mailboxes/:id/aliases responde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-24 09:19:49 -03:00
1 parent 136f9f339a
commit 52bf43b74f
5 files changed
+551 -14

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import { parsePageParams, MAX_PAGE_SIZE } from "./pagination.js";
const at = (qs: string) => new URL(`http://x/api${qs}`);
describe("pagination parameters", () => {
it("defaults to the first page when nothing is asked for", () => {
const p = parsePageParams(at(""));
expect(p.page).toBe(1);
expect(p.offset).toBe(0);
expect(p.search).toBe("");
});
it("turns a page number into the right offset", () => {
const p = parsePageParams(at("?page=3&page_size=25"));
expect(p.pageSize).toBe(25);
expect(p.offset).toBe(50);
});
// A page size taken at face value is a denial of service against your own
// database: one request asking for ten million rows.
it("caps the page size", () => {
expect(parsePageParams(at("?page_size=100000")).pageSize).toBe(MAX_PAGE_SIZE);
});
// Anything that is not a sane positive integer must land on the default
// rather than reaching SQL as NaN, a negative LIMIT, or a fractional OFFSET.
it.each(["0", "-5", "abc", "1.5", "", "1e9", "Infinity", "NaN"])(
"falls back to a safe page for page=%s",
(value) => {
const p = parsePageParams(at(`?page=${value}`));
expect(Number.isInteger(p.page)).toBe(true);
expect(p.page).toBeGreaterThanOrEqual(1);
expect(Number.isInteger(p.offset)).toBe(true);
expect(p.offset).toBeGreaterThanOrEqual(0);
},
);
it.each(["0", "-5", "abc", "1.5", "NaN"])(
"falls back to a safe page size for page_size=%s",
(value) => {
const p = parsePageParams(at(`?page_size=${value}`));
expect(Number.isInteger(p.pageSize)).toBe(true);
expect(p.pageSize).toBeGreaterThan(0);
expect(p.pageSize).toBeLessThanOrEqual(MAX_PAGE_SIZE);
},
);
it("trims the search term and keeps it as plain text", () => {
// It reaches SQL as a bound parameter, so the wildcards a user types are
// theirs to type; what must not happen is it arriving untrimmed and
// matching nothing.
expect(parsePageParams(at("?search=%20%20ana%20%20")).search).toBe("ana");
});
it("ignores an over-long search term instead of sending it to the database", () => {
const p = parsePageParams(at(`?search=${"a".repeat(500)}`));
expect(p.search.length).toBeLessThanOrEqual(200);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Page and filter parameters, read from a URL and made safe for SQL.
*
* The admin lists used to answer with a hardcoded LIMIT and no filter at all,
* so a console with more than a couple of hundred rows simply could not reach
* the rest of them.
*/
export const DEFAULT_PAGE_SIZE = 25;
/** Asking for a million rows must not be a thing one request can do. */
export const MAX_PAGE_SIZE = 200;
/** Long enough for an address, short enough not to be a payload. */
export const MAX_SEARCH_LENGTH = 200;
export interface PageRequest {
page: number;
pageSize: number;
offset: number;
search: string;
}
/**
* A positive integer, or the fallback. Anything else - NaN, a float, a
* negative, an exponent - would reach SQL as an invalid LIMIT or OFFSET.
*/
function positiveInt(raw: string | null, fallback: number, max?: number): number {
if (raw === null) return fallback;
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) return fallback;
const value = Number(trimmed);
if (!Number.isSafeInteger(value) || value < 1) return fallback;
return max !== undefined ? Math.min(value, max) : value;
}
export function parsePageParams(url: URL): PageRequest {
const page = positiveInt(url.searchParams.get("page"), 1);
const pageSize = positiveInt(url.searchParams.get("page_size"), DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE);
const search = (url.searchParams.get("search") ?? "").trim().slice(0, MAX_SEARCH_LENGTH);
return { page, pageSize, offset: (page - 1) * pageSize, search };
}
/** The envelope every paginated list answers with. */
export function paged<T>(items: T[], total: number, p: PageRequest) {
return {
items,
total,
page: p.page,
page_size: p.pageSize,
total_pages: Math.max(1, Math.ceil(total / p.pageSize)),
};
}
+206 -8
View File
@@ -401,16 +401,68 @@ export async function listDomains(scope: AdminScope): Promise<unknown[]> {
); );
} }
export async function listMailboxes(scope: AdminScope): Promise<unknown[]> { export interface UserListFilter {
search: string;
active: boolean | null;
role: string | null;
offset: number;
pageSize: number;
}
/**
* Lists users, filtered and paged in the database.
*
* It used to answer with a hardcoded LIMIT 200 and no filter, so an
* installation with more users than that had no way to reach the rest, and
* finding one meant scrolling.
*/
export async function listMailboxes(
scope: AdminScope,
filter: UserListFilter,
): Promise<{ items: unknown[]; total: number }> {
const { sql, params } = scopeClause(scope, "m.domain_id"); const { sql, params } = scopeClause(scope, "m.domain_id");
return query( const where: string[] = [sql];
const args: unknown[] = [...params];
if (filter.search) {
// Bound as a parameter, never interpolated. The wildcards are added here
// so the caller searches for a substring without having to know SQL.
args.push(`%${filter.search}%`);
where.push(`(m.email_address ILIKE $${args.length} OR d.name ILIKE $${args.length})`);
}
if (filter.active !== null) {
args.push(filter.active);
where.push(`m.is_active = $${args.length}`);
}
if (filter.role) {
args.push(filter.role);
where.push(`m.role = $${args.length}`);
}
const clause = where.join(" AND ");
// Counted with the same filter, or the page count would describe a
// different set of rows than the page itself.
const totalRow = await queryOne<{ count: string }>(
`SELECT count(*)::text AS count
FROM mailboxes m JOIN domains d ON d.id = m.domain_id
WHERE ${clause}`,
args,
);
args.push(filter.pageSize, filter.offset);
const items = await query(
`SELECT m.id, m.email_address, m.role, m.is_active, m.quota_bytes, m.used_bytes, `SELECT m.id, m.email_address, m.role, m.is_active, m.quota_bytes, m.used_bytes,
m.locale, m.locked_until, d.name AS domain_name, m.locale, m.locked_until, d.name AS domain_name, m.domain_id,
EXISTS (SELECT 1 FROM mfa_totp t WHERE t.mailbox_id = m.id AND t.status = 'CONFIRMED') AS mfa_enrolled EXISTS (SELECT 1 FROM mfa_totp t WHERE t.mailbox_id = m.id AND t.status = 'CONFIRMED') AS mfa_enrolled
FROM mailboxes m JOIN domains d ON d.id = m.domain_id FROM mailboxes m JOIN domains d ON d.id = m.domain_id
WHERE ${sql} ORDER BY m.email_address LIMIT 200`, WHERE ${clause}
params, ORDER BY m.email_address
LIMIT $${args.length - 1} OFFSET $${args.length}`,
args,
); );
return { items, total: Number(totalRow?.count ?? 0) };
} }
export async function dmarcSummary(): Promise<Record<string, unknown>> { export async function dmarcSummary(): Promise<Record<string, unknown>> {
@@ -435,18 +487,63 @@ export async function dmarcSummary(): Promise<Record<string, unknown>> {
}; };
} }
export async function queueSummary(): Promise<Record<string, unknown>> { export interface QueueFilter {
search: string;
status: string | null;
offset: number;
pageSize: number;
}
/**
* The outbound queue, filtered and paged in the database.
*
* It used to return the 50 most recent jobs and nothing else, so a queue that
* had backed up could not be inspected past its own head - which is precisely
* when someone needs to look at it.
*/
export async function queueSummary(filter: QueueFilter): Promise<Record<string, unknown>> {
// The totals are of the whole queue, deliberately: they are the summary the
// page leads with, and filtering them to the current search would make the
// counts change as somebody types.
const byStatus = await query<{ status: string; count: string }>( const byStatus = await query<{ status: string; count: string }>(
`SELECT status, count(*)::text AS count FROM outbound_jobs GROUP BY status`, `SELECT status, count(*)::text AS count FROM outbound_jobs GROUP BY status`,
); );
const where: string[] = ["TRUE"];
const args: unknown[] = [];
if (filter.search) {
args.push(`%${filter.search}%`);
where.push(
`(envelope_from ILIKE $${args.length} OR envelope_to ILIKE $${args.length}` +
` OR destination_domain ILIKE $${args.length} OR last_error ILIKE $${args.length})`,
);
}
if (filter.status) {
args.push(filter.status);
where.push(`status = $${args.length}`);
}
const clause = where.join(" AND ");
const totalRow = await queryOne<{ count: string }>(
`SELECT count(*)::text AS count FROM outbound_jobs WHERE ${clause}`,
args,
);
args.push(filter.pageSize, filter.offset);
const recent = await query( const recent = await query(
`SELECT id, envelope_from, envelope_to, destination_domain, status, attempt, `SELECT id, envelope_from, envelope_to, destination_domain, status, attempt,
next_attempt_at, last_smtp_code, last_error, tls_policy_used next_attempt_at, last_smtp_code, last_error, tls_policy_used, created_at
FROM outbound_jobs ORDER BY created_at DESC LIMIT 50`, FROM outbound_jobs WHERE ${clause}
ORDER BY created_at DESC
LIMIT $${args.length - 1} OFFSET $${args.length}`,
args,
); );
return { return {
by_status: Object.fromEntries(byStatus.map((r) => [r.status, Number(r.count)])), by_status: Object.fromEntries(byStatus.map((r) => [r.status, Number(r.count)])),
recent, recent,
total: Number(totalRow?.count ?? 0),
}; };
} }
@@ -605,6 +702,72 @@ export async function setMailboxActive(scope: AdminScope, mailboxId: string, isA
return rows.length === 1; return rows.length === 1;
} }
export interface UserUpdate {
role?: string;
quotaBytes?: number;
locale?: string;
isActive?: boolean;
unlock?: boolean;
}
/** Roles a user may hold. Anything else is refused rather than stored. */
export const ALLOWED_USER_ROLES = ["USER", "DOMAIN_ADMIN", "SUPER_ADMIN"] as const;
/**
* Edits a user, refusing anything outside the caller's scope.
*
* The console could only disable or delete a user; everything else - the role,
* the quota, the language, releasing a lockout - meant going to the database
* by hand.
*/
export async function updateMailbox(
scope: AdminScope,
mailboxId: string,
update: UserUpdate,
): Promise<boolean> {
const sets: string[] = [];
const args: unknown[] = [mailboxId];
if (update.role !== undefined) {
args.push(update.role);
sets.push(`role = $${args.length}`);
}
if (update.quotaBytes !== undefined) {
args.push(update.quotaBytes);
sets.push(`quota_bytes = $${args.length}`);
}
if (update.locale !== undefined) {
args.push(update.locale);
sets.push(`locale = $${args.length}`);
}
if (update.isActive !== undefined) {
args.push(update.isActive);
sets.push(`is_active = $${args.length}`);
}
if (update.unlock) {
// Releases a lockout from failed sign-ins without touching the password.
sets.push(`locked_until = NULL`);
}
// Nothing to change is not an error, but it must not become "UPDATE ... SET
// WHERE", which is a syntax error rather than a no-op.
if (sets.length === 0) return false;
sets.push("updated_at = NOW()");
let where = "id = $1";
if (scope.role !== "SUPER_ADMIN") {
args.push(scope.domainId);
where += ` AND domain_id = $${args.length}`;
}
const rows = await query<{ id: string }>(
`UPDATE mailboxes SET ${sets.join(", ")} WHERE ${where} RETURNING id`,
args,
);
return rows.length === 1;
}
/** /**
* Retries or cancels one queued delivery. * Retries or cancels one queued delivery.
* *
@@ -719,6 +882,41 @@ export async function listAliases(scope: AdminScope): Promise<unknown[]> {
); );
} }
/**
* The aliases that deliver to one user.
*
* An alias is not owned by a user: it belongs to a domain and carries a list
* of destinations, which may include addresses on other servers, and a
* catch-all belongs to the whole domain. What can be shown inside a user is
* the set of aliases that land in their mailbox, which is what an operator
* actually wants to know when looking at that person.
*/
export async function listAliasesForMailbox(
scope: AdminScope,
mailboxId: string,
): Promise<unknown[] | null> {
const { sql, params } = scopeClause(scope, "m.domain_id");
const owner = await queryOne<{ email_address: string; domain_id: string }>(
`SELECT m.email_address, m.domain_id FROM mailboxes m WHERE m.id = $${params.length + 1} AND ${sql}`,
[...params, mailboxId],
);
// Out of scope and non-existent are answered the same way, so the console
// of one domain cannot probe for ids belonging to another.
if (!owner) return null;
return query(
`SELECT a.id, a.source_address, a.destination_addresses, a.is_catch_all, a.is_active
FROM aliases a
WHERE a.domain_id = $1
AND EXISTS (
SELECT 1 FROM unnest(a.destination_addresses) AS dest
WHERE lower(dest) = lower($2)
)
ORDER BY a.source_address`,
[owner.domain_id, owner.email_address],
);
}
export type AliasResult = { status: "OK"; id: string } | { status: "FORBIDDEN" } | { status: "DUPLICATE" } | { status: "INVALID" }; export type AliasResult = { status: "OK"; id: string } | { status: "FORBIDDEN" } | { status: "DUPLICATE" } | { status: "INVALID" };
export async function createAlias( export async function createAlias(
+138
View File
@@ -671,6 +671,144 @@ describeDb("auth API against a real database", () => {
}); });
// Guards that need no database, so they still run in a bare checkout. // Guards that need no database, so they still run in a bare checkout.
describeDb("paging, filtering and editing users", () => {
beforeAll(async () => {
await startServer();
await seed();
// Enough rows that a single hardcoded page cannot hold them, which is the
// condition the console could not cope with at all.
const hash = await hashPassword(PASSWORD);
for (let i = 0; i < 30; i++) {
const local = `paged${String(i).padStart(2, "0")}`;
await query(
`INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, role, is_active)
VALUES (gen_random_uuid(), $1, $2, $3, $4, 'USER', $5)
ON CONFLICT DO NOTHING`,
[DOMAIN_ID, local, `${local}@authtest-${DOMAIN_ID}.invalid`, hash, i % 2 === 0],
);
}
});
afterAll(async () => {
server?.close();
await query(`DELETE FROM domains WHERE id = $1`, [DOMAIN_ID]).catch(() => undefined);
await closePool();
});
it("returns one page at a time, with the total of the whole set", async () => {
const admin = await adminSession();
const first = await get("/api/v1/admin/mailboxes?page=1&page_size=10", admin);
expect(first.status).toBe(200);
const body = first.body as unknown as { items: unknown[]; total: number; total_pages: number };
expect(body.items).toHaveLength(10);
// The total describes every row, not the ten that came back; otherwise
// the page control cannot know there is anything after this page.
expect(body.total).toBeGreaterThanOrEqual(32);
expect(body.total_pages).toBeGreaterThan(1);
});
it("gives a different page for a different page number", async () => {
const admin = await adminSession();
const one = await get("/api/v1/admin/mailboxes?page=1&page_size=5", admin);
const two = await get("/api/v1/admin/mailboxes?page=2&page_size=5", admin);
const ids = (r: typeof one) => (r.body as unknown as { items: { id: string }[] }).items.map((i) => i.id);
expect(ids(one)).not.toEqual(ids(two));
expect(ids(one).some((id) => ids(two).includes(id))).toBe(false);
});
it("filters in the database rather than returning everything", async () => {
const admin = await adminSession();
const res = await get("/api/v1/admin/mailboxes?search=paged1", admin);
const body = res.body as unknown as { items: { email_address: string }[]; total: number };
expect(body.total).toBe(10); // paged10..paged19
for (const item of body.items) {
expect(item.email_address).toContain("paged1");
}
});
it("filters by whether the user is enabled", async () => {
const admin = await adminSession();
const res = await get("/api/v1/admin/mailboxes?active=false&page_size=200", admin);
const body = res.body as unknown as { items: { is_active: boolean }[] };
expect(body.items.length).toBeGreaterThan(0);
for (const item of body.items) expect(item.is_active).toBe(false);
});
// A search term is bound as a parameter. If it were interpolated, this ends
// the statement and drops the table.
it("does not let a search term reach the SQL text", async () => {
const admin = await adminSession();
const evil = encodeURIComponent("'; DROP TABLE mailboxes; --");
const res = await get(`/api/v1/admin/mailboxes?search=${evil}`, admin);
expect(res.status).toBe(200);
expect((res.body as unknown as { total: number }).total).toBe(0);
const still = await query(`SELECT count(*)::int AS c FROM mailboxes`);
expect((still[0] as { c: number }).c).toBeGreaterThan(0);
});
it("edits a user instead of only disabling them", async () => {
const admin = await adminSession();
const res = await fetch(`${base}/api/v1/admin/mailboxes/${USER_ID}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin}` },
body: JSON.stringify({ quota_bytes: 12345678, locale: "pt-BR", role: "DOMAIN_ADMIN" }),
});
expect(res.status).toBe(200);
const rows = await query<{ quota_bytes: string; locale: string; role: string }>(
`SELECT quota_bytes::text, locale, role FROM mailboxes WHERE id = $1`,
[USER_ID],
);
expect(Number(rows[0].quota_bytes)).toBe(12345678);
expect(rows[0].locale).toBe("pt-BR");
expect(rows[0].role).toBe("DOMAIN_ADMIN");
await query(`UPDATE mailboxes SET role = 'USER' WHERE id = $1`, [USER_ID]);
});
it("refuses a quota that is not a positive whole number", async () => {
const admin = await adminSession();
const res = await fetch(`${base}/api/v1/admin/mailboxes/${USER_ID}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin}` },
body: JSON.stringify({ quota_bytes: -1 }),
});
expect(res.status).toBe(400);
});
it("lists the aliases that deliver to one user", async () => {
const admin = await adminSession();
const mine = `alias-of-user@authtest-${DOMAIN_ID}.invalid`;
const other = `alias-of-nobody@authtest-${DOMAIN_ID}.invalid`;
await query(
`INSERT INTO aliases (domain_id, source_address, destination_addresses)
VALUES ($1, $2, ARRAY[$3]), ($1, $4, ARRAY['somebody-else@elsewhere.invalid'])
ON CONFLICT DO NOTHING`,
[DOMAIN_ID, mine, userEmail(), other],
);
const res = await get(`/api/v1/admin/mailboxes/${USER_ID}/aliases`, admin);
expect(res.status).toBe(200);
const items = res.body as unknown as { source_address: string }[];
expect(items.map((a) => a.source_address)).toContain(mine);
// An alias pointing somewhere else is not this user's, even though it
// belongs to the same domain.
expect(items.map((a) => a.source_address)).not.toContain(other);
});
it("answers 404 for a user that does not exist rather than leaking the difference", async () => {
const admin = await adminSession();
const res = await get(`/api/v1/admin/mailboxes/44444444-4444-4444-4444-444444444444/aliases`, admin);
expect(res.status).toBe(404);
});
});
describe("surface routing", () => { describe("surface routing", () => {
it("ignores anything outside /api/v1/", async () => { it("ignores anything outside /api/v1/", async () => {
const { handleApiRequest: handler } = await import("./router.js"); const { handleApiRequest: handler } = await import("./router.js");
+96 -6
View File
@@ -11,6 +11,7 @@ import {
type Surface, type Surface,
} from "./surfaceAccess.js"; } from "./surfaceAccess.js";
import * as repo from "./repository.js"; import * as repo from "./repository.js";
import { parsePageParams, paged } from "./pagination.js";
const CHALLENGE_TTL_SECONDS = 300; const CHALLENGE_TTL_SECONDS = 300;
// Long enough to scan a code and type one back, short enough that a token // Long enough to scan a code and type one back, short enough that a token
@@ -177,6 +178,12 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
if (url.startsWith("/api/v1/admin/mailboxes/") && method === "DELETE") { if (url.startsWith("/api/v1/admin/mailboxes/") && method === "DELETE") {
return adminDeleteMailbox(req, res, session!, url.substring("/api/v1/admin/mailboxes/".length)); return adminDeleteMailbox(req, res, session!, url.substring("/api/v1/admin/mailboxes/".length));
} }
if (url.startsWith("/api/v1/admin/mailboxes/") && url.endsWith("/aliases") && method === "GET") {
return adminMailboxAliases(res, session!, url.slice("/api/v1/admin/mailboxes/".length, -"/aliases".length));
}
if (url.startsWith("/api/v1/admin/mailboxes/") && method === "PATCH") {
return adminUpdateMailbox(req, res, session!, url.substring("/api/v1/admin/mailboxes/".length));
}
if (url.startsWith("/api/v1/admin/mailboxes/") && url.endsWith("/active") && method === "PUT") { if (url.startsWith("/api/v1/admin/mailboxes/") && url.endsWith("/active") && method === "PUT") {
const id = url.slice("/api/v1/admin/mailboxes/".length, -"/active".length); const id = url.slice("/api/v1/admin/mailboxes/".length, -"/active".length);
return adminSetMailboxActive(req, res, session!, id); return adminSetMailboxActive(req, res, session!, id);
@@ -189,9 +196,9 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
} }
if (url === "/api/v1/admin/dashboard" && method === "GET") return adminDashboard(res); if (url === "/api/v1/admin/dashboard" && method === "GET") return adminDashboard(res);
if (url === "/api/v1/admin/domains" && method === "GET") return adminDomains(res, session!); if (url === "/api/v1/admin/domains" && method === "GET") return adminDomains(res, session!);
if (url === "/api/v1/admin/mailboxes" && method === "GET") return adminMailboxes(res, session!); if (url.split("?")[0] === "/api/v1/admin/mailboxes" && method === "GET") return adminMailboxes(req, res, session!);
if (url === "/api/v1/admin/dmarc" && method === "GET") return adminDmarc(res); if (url === "/api/v1/admin/dmarc" && method === "GET") return adminDmarc(res);
if (url === "/api/v1/admin/queue" && method === "GET") return adminQueue(res); if (url.split("?")[0] === "/api/v1/admin/queue" && method === "GET") return adminQueue(req, res);
if (url === "/api/v1/admin/preflight" && method === "GET") return adminPreflight(res); if (url === "/api/v1/admin/preflight" && method === "GET") return adminPreflight(res);
return sendJson(res, 404, { error: "NOT_FOUND", message: "API endpoint not found" }); return sendJson(res, 404, { error: "NOT_FOUND", message: "API endpoint not found" });
@@ -737,8 +744,75 @@ async function adminDomains(res: ServerResponse, session: SessionTokenPayload):
sendJson(res, 200, await repo.listDomains(scopeOf(session))); sendJson(res, 200, await repo.listDomains(scopeOf(session)));
} }
async function adminMailboxes(res: ServerResponse, session: SessionTokenPayload): Promise<void> { async function adminMailboxes(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
sendJson(res, 200, await repo.listMailboxes(scopeOf(session))); const url = new URL(req.url || "/", "http://localhost");
const page = parsePageParams(url);
const activeParam = url.searchParams.get("active");
const roleParam = (url.searchParams.get("role") || "").toUpperCase();
const { items, total } = await repo.listMailboxes(scopeOf(session), {
search: page.search,
// Absent means "either"; only an explicit true/false filters.
active: activeParam === "true" ? true : activeParam === "false" ? false : null,
// An unrecognised role filters nothing rather than returning an empty
// page the operator cannot explain.
role: (repo.ALLOWED_USER_ROLES as readonly string[]).includes(roleParam) ? roleParam : null,
offset: page.offset,
pageSize: page.pageSize,
});
sendJson(res, 200, paged(items, total, page));
}
/** Edits a user: role, quota, language, enabled, and releasing a lockout. */
async function adminUpdateMailbox(
req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload, id: string,
): Promise<void> {
const body = await parseJsonBody(req);
const update: repo.UserUpdate = {};
if (typeof body?.role === "string") {
const role = body.role.toUpperCase();
if (!(repo.ALLOWED_USER_ROLES as readonly string[]).includes(role)) {
return sendJson(res, 400, { error: "BAD_REQUEST", message: "Unknown role" });
}
// Only a super admin may hand out super admin, or a domain admin could
// promote themselves out of their own domain.
if (role === "SUPER_ADMIN" && session.role !== "SUPER_ADMIN") {
return sendJson(res, 403, { error: "FORBIDDEN", message: "Only a super admin can grant that role" });
}
update.role = role;
}
if (typeof body?.quota_bytes === "number") {
if (!Number.isSafeInteger(body.quota_bytes) || body.quota_bytes < 0) {
return sendJson(res, 400, { error: "BAD_REQUEST", message: "quota_bytes must be a positive whole number" });
}
update.quotaBytes = body.quota_bytes;
}
if (typeof body?.locale === "string" && body.locale.trim()) {
update.locale = body.locale.trim().slice(0, 16);
}
if (typeof body?.is_active === "boolean") update.isActive = body.is_active;
if (body?.unlock === true) update.unlock = true;
const updated = await repo.updateMailbox(scopeOf(session), id, update);
if (!updated) {
return sendJson(res, 404, { error: "NOT_FOUND", message: "No such user, or nothing to change" });
}
await repo.recordAudit(session.sub, clientIp(req), "user.update", "mailbox", id, update as Record<string, unknown>);
sendJson(res, 200, { ok: true });
}
/** The aliases that deliver to one user. */
async function adminMailboxAliases(
res: ServerResponse, session: SessionTokenPayload, id: string,
): Promise<void> {
const aliases = await repo.listAliasesForMailbox(scopeOf(session), id);
if (aliases === null) {
return sendJson(res, 404, { error: "NOT_FOUND", message: "No such user" });
}
sendJson(res, 200, aliases);
} }
/** The scope travels from the verified session, never from the request. */ /** The scope travels from the verified session, never from the request. */
@@ -750,8 +824,24 @@ async function adminDmarc(res: ServerResponse): Promise<void> {
sendJson(res, 200, await repo.dmarcSummary()); sendJson(res, 200, await repo.dmarcSummary());
} }
async function adminQueue(res: ServerResponse): Promise<void> { async function adminQueue(req: IncomingMessage, res: ServerResponse): Promise<void> {
sendJson(res, 200, await repo.queueSummary()); const url = new URL(req.url || "/", "http://localhost");
const page = parsePageParams(url);
const status = (url.searchParams.get("status") || "").toUpperCase();
const summary = await repo.queueSummary({
search: page.search,
status: status || null,
offset: page.offset,
pageSize: page.pageSize,
});
sendJson(res, 200, {
...summary,
page: page.page,
page_size: page.pageSize,
total_pages: Math.max(1, Math.ceil(Number(summary.total ?? 0) / page.pageSize)),
});
} }
async function adminPreflight(res: ServerResponse): Promise<void> { async function adminPreflight(res: ServerResponse): Promise<void> {