diff --git a/services/auth/src/pagination.test.ts b/services/auth/src/pagination.test.ts new file mode 100644 index 0000000..5ab4a2c --- /dev/null +++ b/services/auth/src/pagination.test.ts @@ -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); + }); +}); diff --git a/services/auth/src/pagination.ts b/services/auth/src/pagination.ts new file mode 100644 index 0000000..7d6a796 --- /dev/null +++ b/services/auth/src/pagination.ts @@ -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(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)), + }; +} diff --git a/services/auth/src/repository.ts b/services/auth/src/repository.ts index 229370b..78c6d72 100644 --- a/services/auth/src/repository.ts +++ b/services/auth/src/repository.ts @@ -401,16 +401,68 @@ export async function listDomains(scope: AdminScope): Promise { ); } -export async function listMailboxes(scope: AdminScope): Promise { +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"); - 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, - 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 FROM mailboxes m JOIN domains d ON d.id = m.domain_id - WHERE ${sql} ORDER BY m.email_address LIMIT 200`, - params, + WHERE ${clause} + 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> { @@ -435,18 +487,63 @@ export async function dmarcSummary(): Promise> { }; } -export async function queueSummary(): Promise> { +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> { + // 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 }>( `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( `SELECT id, envelope_from, envelope_to, destination_domain, status, attempt, - next_attempt_at, last_smtp_code, last_error, tls_policy_used - FROM outbound_jobs ORDER BY created_at DESC LIMIT 50`, + next_attempt_at, last_smtp_code, last_error, tls_policy_used, created_at + FROM outbound_jobs WHERE ${clause} + ORDER BY created_at DESC + LIMIT $${args.length - 1} OFFSET $${args.length}`, + args, ); + return { by_status: Object.fromEntries(byStatus.map((r) => [r.status, Number(r.count)])), recent, + total: Number(totalRow?.count ?? 0), }; } @@ -605,6 +702,72 @@ export async function setMailboxActive(scope: AdminScope, mailboxId: string, isA 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 { + 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. * @@ -719,6 +882,41 @@ export async function listAliases(scope: AdminScope): Promise { ); } +/** + * 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 { + 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 async function createAlias( diff --git a/services/auth/src/router.test.ts b/services/auth/src/router.test.ts index 709af1f..f4dcebb 100644 --- a/services/auth/src/router.test.ts +++ b/services/auth/src/router.test.ts @@ -671,6 +671,144 @@ describeDb("auth API against a real database", () => { }); // 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", () => { it("ignores anything outside /api/v1/", async () => { const { handleApiRequest: handler } = await import("./router.js"); diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts index 19c84bc..576ad86 100644 --- a/services/auth/src/router.ts +++ b/services/auth/src/router.ts @@ -11,6 +11,7 @@ import { type Surface, } from "./surfaceAccess.js"; import * as repo from "./repository.js"; +import { parsePageParams, paged } from "./pagination.js"; const CHALLENGE_TTL_SECONDS = 300; // 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") { 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") { const id = url.slice("/api/v1/admin/mailboxes/".length, -"/active".length); 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/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/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); 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))); } -async function adminMailboxes(res: ServerResponse, session: SessionTokenPayload): Promise { - sendJson(res, 200, await repo.listMailboxes(scopeOf(session))); +async function adminMailboxes(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise { + 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 { + 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); + sendJson(res, 200, { ok: true }); +} + +/** The aliases that deliver to one user. */ +async function adminMailboxAliases( + res: ServerResponse, session: SessionTokenPayload, id: string, +): Promise { + 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. */ @@ -750,8 +824,24 @@ async function adminDmarc(res: ServerResponse): Promise { sendJson(res, 200, await repo.dmarcSummary()); } -async function adminQueue(res: ServerResponse): Promise { - sendJson(res, 200, await repo.queueSummary()); +async function adminQueue(req: IncomingMessage, res: ServerResponse): Promise { + 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 {