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:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-13 10:09:55 -03:00
1 parent b5baf33a63
commit 50a67531ba
30 files changed
+1810 -348

No files matched your search

+136
View File
@@ -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 });
});
});
+62 -20
View File
@@ -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
View File
@@ -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