feat(webmail,auth): leitor estilo Outlook, responder a todos, importantes e isolamento do admin de dominio
Webmail - Leitor de mensagem em componente proprio, com barra de comandos fixa, cabecalho compacto, faixa de imagens bloqueadas e chips de anexo. - Divisor arrastavel entre lista e leitor (mouse, setas, duplo clique), largura persistida e limites que preservam os dois paineis. - Destinatarios em chips (Para, Cc, Cco): aceita nomes com virgula, colagem de listas, validacao e bloqueio de envio com endereco invalido. - Responder a todos, sem o proprio endereco e sem repetidos; In-Reply-To e References nas respostas; prefixo Re:/Fwd: sem repeticao. - Marcar como importante (estrela, filtro, linha da lista) e enviar com alta importancia (Importance/X-Priority), com etiqueta no leitor. - Sanitizador: a regexp de URI customizada descartava todo atributo que nao fosse link (width, colspan, bgcolor...), o que desmontava tabelas de e-mail; imagens de fundo agora sao bloqueadas de forma reversivel. Protocols - POST /api/v1/mail/flagged (\Flagged) com bump de modseq para IMAP. - Renderizacao expoe message_id, in_reply_to e important. - Cabecalhos de resposta validados contra injecao. Auth - As views do admin de dominio eram globais: dashboard, fila de saida, DMARC, preflight e diagnostico mostravam dados de todos os dominios. Agora todas respeitam o escopo da sessao; super admin segue vendo tudo. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b5baf33a63
commit
50a67531ba
30 files changed
+1810
-348
No files matched your search
@@ -0,0 +1,136 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
process.env.JWT_SECRET ||= "a-test-signing-secret-long-enough-for-the-guard";
|
||||
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 repo = await import("./repository.js");
|
||||
const { query, closePool } = await import("./db.js");
|
||||
|
||||
// Two tenants. A domain admin of A must never see anything that belongs to B,
|
||||
// which is what the console's home page did: every figure on it was global.
|
||||
const A = "aaaaaaaa-0000-0000-0000-00000000000a";
|
||||
const B = "bbbbbbbb-0000-0000-0000-00000000000b";
|
||||
const MBX_A = "aaaaaaaa-1111-0000-0000-00000000000a";
|
||||
const MBX_B1 = "bbbbbbbb-1111-0000-0000-00000000000b";
|
||||
const MBX_B2 = "bbbbbbbb-2222-0000-0000-00000000000b";
|
||||
const nameA = `scope-a-${A}.invalid`;
|
||||
const nameB = `scope-b-${B}.invalid`;
|
||||
|
||||
const adminOfA = { role: "DOMAIN_ADMIN", domainId: A };
|
||||
const superAdmin = { role: "SUPER_ADMIN", domainId: A };
|
||||
|
||||
const BLOB = "cccccccc-0000-0000-0000-00000000000c";
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
await query(`DELETE FROM dmarc_reports WHERE domain IN ($1, $2)`, [nameA, nameB]);
|
||||
await query(`DELETE FROM outbound_jobs WHERE envelope_from LIKE '%scope-test.invalid'`);
|
||||
await query(`DELETE FROM domains WHERE id IN ($1, $2)`, [A, B]);
|
||||
await query(`DELETE FROM message_blobs WHERE id = $1`, [BLOB]);
|
||||
}
|
||||
|
||||
describeDb("admin views are confined to the caller's domain", () => {
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
for (const [id, name] of [[A, nameA], [B, nameB]] as const) {
|
||||
await query(
|
||||
`INSERT INTO domains (id, name, punycode_name, dns_status) VALUES ($1, $2, $3, 'VERIFIED')`,
|
||||
[id, name, name],
|
||||
);
|
||||
}
|
||||
for (const [id, dom, local, quota] of [
|
||||
[MBX_A, A, "a1", 1000],
|
||||
[MBX_B1, B, "b1", 5000],
|
||||
[MBX_B2, B, "b2", 5000],
|
||||
] as const) {
|
||||
await query(
|
||||
`INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, quota_bytes, used_bytes)
|
||||
VALUES ($1, $2, $3, $4, 'x', $5, 10)`,
|
||||
[id, dom, local, `${local}@${dom === A ? nameA : nameB}`, quota],
|
||||
);
|
||||
}
|
||||
await query(
|
||||
`INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes)
|
||||
VALUES ($1, repeat('c', 64), 'local', 'scope-test', 1)`,
|
||||
[BLOB],
|
||||
);
|
||||
// One job for A, three for B; two of B's are failing.
|
||||
for (const [mailbox, status] of [
|
||||
[MBX_A, "QUEUED"], [MBX_B1, "QUEUED"], [MBX_B1, "BOUNCED"], [MBX_B2, "DEFERRED"],
|
||||
] as const) {
|
||||
await query(
|
||||
`INSERT INTO outbound_jobs (blob_id, mailbox_id, envelope_from, envelope_to, destination_domain, status)
|
||||
VALUES ($3, $1, 'x@scope-test.invalid', 'to@remote.test', 'remote.test', $2)`,
|
||||
[mailbox, status, BLOB],
|
||||
);
|
||||
}
|
||||
// A DMARC report for each domain: one matched by id, one only by name.
|
||||
const ra = await query<{ id: string }>(
|
||||
`INSERT INTO dmarc_reports (domain_id, domain) VALUES ($1, $2) RETURNING id`, [A, nameA]);
|
||||
const rb = await query<{ id: string }>(
|
||||
`INSERT INTO dmarc_reports (domain_id, domain) VALUES (NULL, $1) RETURNING id`, [nameB]);
|
||||
for (const [report, count] of [[ra[0].id, 7], [rb[0].id, 100]] as const) {
|
||||
await query(
|
||||
`INSERT INTO dmarc_report_records (report_id, source_ip, count, disposition, dkim_result, spf_result, header_from)
|
||||
VALUES ($1, '203.0.113.9', $2, 'none', 'pass', 'pass', 'x.test')`,
|
||||
[report, count],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await closePool();
|
||||
});
|
||||
|
||||
it("the dashboard counts only the admin's own domain", async () => {
|
||||
const stats = await repo.dashboardStats(adminOfA);
|
||||
expect(stats.domains_total).toBe(1);
|
||||
expect(stats.domains_verified).toBe(1);
|
||||
expect(stats.mailboxes_active).toBe(1);
|
||||
expect(stats.storage_quota_bytes).toBe(1000);
|
||||
expect(stats.queue_depth).toBe(1);
|
||||
});
|
||||
|
||||
it("a super admin still sees every domain", async () => {
|
||||
const stats = await repo.dashboardStats(superAdmin);
|
||||
expect(Number(stats.domains_total)).toBeGreaterThanOrEqual(2);
|
||||
expect(Number(stats.queue_depth)).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("the outbound queue hides other tenants' jobs, in the totals and the list", async () => {
|
||||
const mine = await repo.queueSummary(adminOfA, { search: "", status: null, offset: 0, pageSize: 50 });
|
||||
expect(mine.by_status).toEqual({ QUEUED: 1 });
|
||||
expect(mine.total).toBe(1);
|
||||
|
||||
const all = await repo.queueSummary(superAdmin, { search: "scope-test", status: null, offset: 0, pageSize: 50 });
|
||||
expect(all.total).toBe(4);
|
||||
});
|
||||
|
||||
it("a status filter cannot widen the queue past the domain", async () => {
|
||||
const bounced = await repo.queueSummary(adminOfA, { search: "", status: "BOUNCED", offset: 0, pageSize: 50 });
|
||||
expect(bounced.total).toBe(0);
|
||||
});
|
||||
|
||||
it("DMARC totals come from the admin's own domain only", async () => {
|
||||
const mine = await repo.dmarcSummary(adminOfA);
|
||||
expect(mine.total_messages).toBe(7);
|
||||
const all = await repo.dmarcSummary(superAdmin);
|
||||
expect(Number(all.total_messages)).toBeGreaterThanOrEqual(107);
|
||||
});
|
||||
|
||||
it("preflight and diagnostics list only the admin's domain", async () => {
|
||||
const preflight = (await repo.preflightSummary(adminOfA)) as { checks: { target: string }[] };
|
||||
expect(preflight.checks.map((c) => c.target)).toEqual([nameA]);
|
||||
|
||||
const facts = (await repo.serverDiagnosticFacts(adminOfA)) as {
|
||||
domains: { name: string }[];
|
||||
queueByStatus: Record<string, number>;
|
||||
};
|
||||
expect(facts.domains.map((d) => d.name)).toEqual([nameA]);
|
||||
expect(facts.queueByStatus).toEqual({ QUEUED: 1 });
|
||||
});
|
||||
});
|
||||
@@ -348,19 +348,27 @@ export async function setLocale(mailboxId: string, locale: string): Promise<void
|
||||
// which meant the console showed a healthy system no matter what the server
|
||||
// was actually doing.
|
||||
|
||||
export async function dashboardStats(): Promise<Record<string, unknown>> {
|
||||
export async function dashboardStats(scope: AdminScope): Promise<Record<string, unknown>> {
|
||||
// Every figure is confined to the caller's domain. They were all global, so
|
||||
// a domain admin saw the whole server's totals - how many domains existed,
|
||||
// how much mail every other tenant received - which is both wrong for them
|
||||
// and a view into tenants they have no business seeing.
|
||||
const dom = scopeClause(scope, "id");
|
||||
const msg = mailboxScope(scope, "mailbox_id");
|
||||
const mbx = scopeClause(scope, "domain_id");
|
||||
const row = await queryOne<Record<string, string>>(
|
||||
`SELECT
|
||||
(SELECT count(*) FROM email_messages WHERE received_at > NOW() - INTERVAL '24 hours')::text AS inbound_24h,
|
||||
(SELECT count(*) FROM outbound_jobs WHERE created_at > NOW() - INTERVAL '24 hours')::text AS outbound_24h,
|
||||
(SELECT count(*) FROM outbound_jobs WHERE status IN ('QUEUED','DEFERRED','SENDING'))::text AS queue_depth,
|
||||
(SELECT count(*) FROM outbound_jobs WHERE status = 'BOUNCED'
|
||||
(SELECT count(*) FROM email_messages WHERE ${msg.sql} AND received_at > NOW() - INTERVAL '24 hours')::text AS inbound_24h,
|
||||
(SELECT count(*) FROM outbound_jobs WHERE ${msg.sql} AND created_at > NOW() - INTERVAL '24 hours')::text AS outbound_24h,
|
||||
(SELECT count(*) FROM outbound_jobs WHERE ${msg.sql} AND status IN ('QUEUED','DEFERRED','SENDING'))::text AS queue_depth,
|
||||
(SELECT count(*) FROM outbound_jobs WHERE ${msg.sql} AND status = 'BOUNCED'
|
||||
AND created_at > NOW() - INTERVAL '24 hours')::text AS bounced_24h,
|
||||
(SELECT count(*) FROM domains WHERE dns_status = 'VERIFIED')::text AS domains_verified,
|
||||
(SELECT count(*) FROM domains)::text AS domains_total,
|
||||
(SELECT count(*) FROM mailboxes WHERE is_active)::text AS mailboxes_active,
|
||||
(SELECT COALESCE(sum(used_bytes),0) FROM mailboxes)::text AS storage_used_bytes,
|
||||
(SELECT COALESCE(sum(quota_bytes),0) FROM mailboxes)::text AS storage_quota_bytes`,
|
||||
(SELECT count(*) FROM domains WHERE ${dom.sql} AND dns_status = 'VERIFIED')::text AS domains_verified,
|
||||
(SELECT count(*) FROM domains WHERE ${dom.sql})::text AS domains_total,
|
||||
(SELECT count(*) FROM mailboxes WHERE ${mbx.sql} AND is_active)::text AS mailboxes_active,
|
||||
(SELECT COALESCE(sum(used_bytes),0) FROM mailboxes WHERE ${mbx.sql})::text AS storage_used_bytes,
|
||||
(SELECT COALESCE(sum(quota_bytes),0) FROM mailboxes WHERE ${mbx.sql})::text AS storage_quota_bytes`,
|
||||
scope.role === "SUPER_ADMIN" ? [] : [scope.domainId],
|
||||
);
|
||||
|
||||
const num = (k: string) => Number(row?.[k] ?? 0);
|
||||
@@ -391,6 +399,20 @@ function scopeClause(scope: AdminScope, column: string): { sql: string; params:
|
||||
return { sql: `${column} = $1`, params: [scope.domainId] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The same confinement for a table that belongs to a mailbox rather than to a
|
||||
* domain: outbound jobs and stored messages carry mailbox_id, and the domain is
|
||||
* reached through it. The parameter is always $1, so a query that uses this
|
||||
* must pass scope.params first.
|
||||
*/
|
||||
function mailboxScope(scope: AdminScope, column: string): { sql: string; params: unknown[] } {
|
||||
if (scope.role === "SUPER_ADMIN") return { sql: "TRUE", params: [] };
|
||||
return {
|
||||
sql: `${column} IN (SELECT id FROM mailboxes WHERE domain_id = $1)`,
|
||||
params: [scope.domainId],
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDomains(scope: AdminScope): Promise<unknown[]> {
|
||||
const { sql, params } = scopeClause(scope, "d.id");
|
||||
return query(
|
||||
@@ -466,18 +488,29 @@ export async function listMailboxes(
|
||||
return { items, total: Number(totalRow?.count ?? 0) };
|
||||
}
|
||||
|
||||
export async function dmarcSummary(): Promise<Record<string, unknown>> {
|
||||
export async function dmarcSummary(scope: AdminScope): Promise<Record<string, unknown>> {
|
||||
// A report names the domain it is about, either by id or - when the domain
|
||||
// was not recognised at ingestion - only by name, so both are matched.
|
||||
const reports =
|
||||
scope.role === "SUPER_ADMIN"
|
||||
? "TRUE"
|
||||
: `report_id IN (SELECT r.id FROM dmarc_reports r
|
||||
WHERE r.domain_id = $1
|
||||
OR lower(r.domain) = (SELECT lower(name) FROM domains WHERE id = $1))`;
|
||||
const params = scope.role === "SUPER_ADMIN" ? [] : [scope.domainId];
|
||||
const totals = await queryOne<Record<string, string>>(
|
||||
`SELECT COALESCE(sum(count),0)::text AS total_messages,
|
||||
COALESCE(sum(count) FILTER (WHERE spf_result = 'pass'),0)::text AS spf_pass_count,
|
||||
COALESCE(sum(count) FILTER (WHERE dkim_result = 'pass'),0)::text AS dkim_pass_count,
|
||||
COALESCE(sum(count) FILTER (WHERE disposition = 'none'),0)::text AS dmarc_pass_count
|
||||
FROM dmarc_report_records`,
|
||||
FROM dmarc_report_records WHERE ${reports}`,
|
||||
params,
|
||||
);
|
||||
const sources = await query(
|
||||
`SELECT source_ip::text AS ip, sum(count)::int AS count,
|
||||
max(spf_result) AS spf, max(dkim_result) AS dkim
|
||||
FROM dmarc_report_records GROUP BY source_ip ORDER BY sum(count) DESC LIMIT 20`,
|
||||
FROM dmarc_report_records WHERE ${reports} GROUP BY source_ip ORDER BY sum(count) DESC LIMIT 20`,
|
||||
params,
|
||||
);
|
||||
return {
|
||||
total_messages: Number(totals?.total_messages ?? 0),
|
||||
@@ -502,16 +535,21 @@ export interface QueueFilter {
|
||||
* 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>> {
|
||||
export async function queueSummary(scope: AdminScope, 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.
|
||||
//
|
||||
// Confined to the caller's domain like everything else here: the queue holds
|
||||
// other tenants' envelope addresses and error text.
|
||||
const scoped = mailboxScope(scope, "mailbox_id");
|
||||
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 WHERE ${scoped.sql} GROUP BY status`,
|
||||
scoped.params,
|
||||
);
|
||||
|
||||
const where: string[] = ["TRUE"];
|
||||
const args: unknown[] = [];
|
||||
const where: string[] = [scoped.sql];
|
||||
const args: unknown[] = [...scoped.params];
|
||||
|
||||
if (filter.search) {
|
||||
args.push(`%${filter.search}%`);
|
||||
@@ -548,12 +586,14 @@ export async function queueSummary(filter: QueueFilter): Promise<Record<string,
|
||||
};
|
||||
}
|
||||
|
||||
export async function preflightSummary(): Promise<Record<string, unknown>> {
|
||||
export async function preflightSummary(scope: AdminScope): Promise<Record<string, unknown>> {
|
||||
// The authoritative preflight lives in the Go service, which owns the
|
||||
// sockets and the resolver. What the console can report on its own is the
|
||||
// persisted state, so it reports that and says where the rest comes from.
|
||||
const domainScope = scopeClause(scope, "id");
|
||||
const domains = await query<{ name: string; dns_status: string; dane_enabled: boolean }>(
|
||||
`SELECT name, dns_status, dane_enabled FROM domains ORDER BY name`,
|
||||
`SELECT name, dns_status, dane_enabled FROM domains WHERE ${domainScope.sql} ORDER BY name`,
|
||||
domainScope.params,
|
||||
);
|
||||
// Each check carries a key and its subject rather than a finished English
|
||||
// sentence. The console builds the label from these, so a reader with the
|
||||
@@ -1087,8 +1127,10 @@ export async function serverDiagnosticFacts(scope: AdminScope): Promise<Record<s
|
||||
params,
|
||||
);
|
||||
|
||||
const queueScope = mailboxScope(scope, "mailbox_id");
|
||||
const queue = 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 WHERE ${queueScope.sql} GROUP BY status`,
|
||||
queueScope.params,
|
||||
);
|
||||
|
||||
const { sql: mSql, params: mParams } = scopeClause(scope, "m.domain_id");
|
||||
|
||||
+12
-12
@@ -216,12 +216,12 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
|
||||
if (url.startsWith("/api/v1/admin/diagnostics/user/") && method === "GET") {
|
||||
return adminDiagnosticsUser(res, session!, url.substring("/api/v1/admin/diagnostics/user/".length));
|
||||
}
|
||||
if (url === "/api/v1/admin/dashboard" && method === "GET") return adminDashboard(res);
|
||||
if (url === "/api/v1/admin/dashboard" && method === "GET") return adminDashboard(res, session!);
|
||||
if (url === "/api/v1/admin/domains" && method === "GET") return adminDomains(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.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/dmarc" && method === "GET") return adminDmarc(res, session!);
|
||||
if (url.split("?")[0] === "/api/v1/admin/queue" && method === "GET") return adminQueue(req, res, session!);
|
||||
if (url === "/api/v1/admin/preflight" && method === "GET") return adminPreflight(res, session!);
|
||||
|
||||
return sendJson(res, 404, { error: "NOT_FOUND", message: "API endpoint not found" });
|
||||
} catch (err) {
|
||||
@@ -870,8 +870,8 @@ async function adminQueueAction(
|
||||
sendJson(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
async function adminDashboard(res: ServerResponse): Promise<void> {
|
||||
const stats = await repo.dashboardStats();
|
||||
async function adminDashboard(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
const stats = await repo.dashboardStats(scopeOf(session));
|
||||
sendJson(res, 200, stats);
|
||||
}
|
||||
|
||||
@@ -990,16 +990,16 @@ function scopeOf(session: SessionTokenPayload): repo.AdminScope {
|
||||
return { role: session.role, domainId: session.domainId };
|
||||
}
|
||||
|
||||
async function adminDmarc(res: ServerResponse): Promise<void> {
|
||||
sendJson(res, 200, await repo.dmarcSummary());
|
||||
async function adminDmarc(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
sendJson(res, 200, await repo.dmarcSummary(scopeOf(session)));
|
||||
}
|
||||
|
||||
async function adminQueue(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
async function adminQueue(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
const url = new URL(req.url || "/", "http://localhost");
|
||||
const page = parsePageParams(url);
|
||||
const status = (url.searchParams.get("status") || "").toUpperCase();
|
||||
|
||||
const summary = await repo.queueSummary({
|
||||
const summary = await repo.queueSummary(scopeOf(session), {
|
||||
search: page.search,
|
||||
status: status || null,
|
||||
offset: page.offset,
|
||||
@@ -1014,8 +1014,8 @@ async function adminQueue(req: IncomingMessage, res: ServerResponse): Promise<vo
|
||||
});
|
||||
}
|
||||
|
||||
async function adminPreflight(res: ServerResponse): Promise<void> {
|
||||
sendJson(res, 200, await repo.preflightSummary());
|
||||
async function adminPreflight(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
sendJson(res, 200, await repo.preflightSummary(scopeOf(session)));
|
||||
}
|
||||
|
||||
// ------------------------------------------------ user handlers
|
||||
|
||||
Reference in new issue
Block a user