From 50a67531ba2b9eb1f8a0db17df7082762af84deb Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Tue, 13 Oct 2026 10:09:55 -0300 Subject: [PATCH] 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 --- services/auth/src/adminScope.test.ts | 136 ++++++ services/auth/src/repository.ts | 82 +++- services/auth/src/router.ts | 24 +- .../application/port/webmail_repository.go | 3 + .../application/usecase/message_render.go | 23 + .../usecase/message_render_test.go | 22 + .../usecase/webmail_compose_test.go | 43 ++ .../application/usecase/webmail_mail.go | 38 ++ .../postgres/webmail_repository.go | 57 +++ .../postgres/webmail_repository_test.go | 41 ++ .../internal/presentation/http/mail_api.go | 47 +- .../presentation/http/mail_api_test.go | 44 ++ .../internal/presentation/http/router.go | 11 + services/webmail/messages/en.json | 15 + services/webmail/messages/es.json | 15 + services/webmail/messages/pt-BR.json | 15 + .../src/app/(user)/user/compose/page.tsx | 119 +++-- .../app/(user)/user/mail/[folder]/page.tsx | 399 ++++++---------- .../webmail/src/components/MessageReader.tsx | 430 ++++++++++++++++++ .../webmail/src/components/RecipientInput.tsx | 145 ++++++ .../src/compose-draft-integrity.test.ts | 2 +- services/webmail/src/lib/mailCounts.ts | 11 +- services/webmail/src/lib/paneWidth.ts | 40 ++ services/webmail/src/lib/recipients.ts | 160 +++++++ services/webmail/src/lib/sanitizer.ts | 34 +- .../webmail/src/mail-state-integrity.test.ts | 8 +- services/webmail/src/mailCounts.test.ts | 19 +- services/webmail/src/paneWidth.test.ts | 28 ++ services/webmail/src/recipients.test.ts | 121 +++++ services/webmail/src/sanitizer.test.ts | 26 +- 30 files changed, 1810 insertions(+), 348 deletions(-) create mode 100644 services/auth/src/adminScope.test.ts create mode 100644 services/webmail/src/components/MessageReader.tsx create mode 100644 services/webmail/src/components/RecipientInput.tsx create mode 100644 services/webmail/src/lib/paneWidth.ts create mode 100644 services/webmail/src/lib/recipients.ts create mode 100644 services/webmail/src/paneWidth.test.ts create mode 100644 services/webmail/src/recipients.test.ts diff --git a/services/auth/src/adminScope.test.ts b/services/auth/src/adminScope.test.ts new file mode 100644 index 0000000..f027f59 --- /dev/null +++ b/services/auth/src/adminScope.test.ts @@ -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 { + 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; + }; + expect(facts.domains.map((d) => d.name)).toEqual([nameA]); + expect(facts.queueByStatus).toEqual({ QUEUED: 1 }); + }); +}); diff --git a/services/auth/src/repository.ts b/services/auth/src/repository.ts index 7e7cf80..45457a2 100644 --- a/services/auth/src/repository.ts +++ b/services/auth/src/repository.ts @@ -348,19 +348,27 @@ export async function setLocale(mailboxId: string, locale: string): Promise> { +export async function dashboardStats(scope: AdminScope): Promise> { + // 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>( `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 { 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> { +export async function dmarcSummary(scope: AdminScope): Promise> { + // 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>( `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> { +export async function queueSummary(scope: AdminScope, 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. + // + // 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> { +export async function preflightSummary(scope: AdminScope): Promise> { // 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( - `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"); diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts index 2c5f71b..78e29d9 100644 --- a/services/auth/src/router.ts +++ b/services/auth/src/router.ts @@ -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 { - const stats = await repo.dashboardStats(); +async function adminDashboard(res: ServerResponse, session: SessionTokenPayload): Promise { + 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 { - sendJson(res, 200, await repo.dmarcSummary()); +async function adminDmarc(res: ServerResponse, session: SessionTokenPayload): Promise { + sendJson(res, 200, await repo.dmarcSummary(scopeOf(session))); } -async function adminQueue(req: IncomingMessage, res: ServerResponse): Promise { +async function adminQueue(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): 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({ + 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 { - sendJson(res, 200, await repo.preflightSummary()); +async function adminPreflight(res: ServerResponse, session: SessionTokenPayload): Promise { + sendJson(res, 200, await repo.preflightSummary(scopeOf(session))); } // ------------------------------------------------ user handlers diff --git a/services/protocols/internal/application/port/webmail_repository.go b/services/protocols/internal/application/port/webmail_repository.go index 5f436f3..f1ed69e 100644 --- a/services/protocols/internal/application/port/webmail_repository.go +++ b/services/protocols/internal/application/port/webmail_repository.go @@ -62,6 +62,9 @@ type WebmailRepository interface { GetMessageBlob(ctx context.Context, mailboxID, folderName string, uid uint32) (uuid.UUID, error) // MarkSeen records that the user opened the message. MarkSeen(ctx context.Context, mailboxID, folderName string, uid uint32, seen bool) error + // MarkFlagged sets or clears the \Flagged flag, which is what the webmail + // calls "important" and every IMAP client calls starred. + MarkFlagged(ctx context.Context, mailboxID, folderName string, uid uint32, flagged bool) error // Expunge soft-deletes one message, keeping the folder counters in step. Expunge(ctx context.Context, mailboxID, folderName string, uid uint32) error // MoveToFolder relocates one message into a named folder and returns its diff --git a/services/protocols/internal/application/usecase/message_render.go b/services/protocols/internal/application/usecase/message_render.go index 1871015..56b92b6 100644 --- a/services/protocols/internal/application/usecase/message_render.go +++ b/services/protocols/internal/application/usecase/message_render.go @@ -34,6 +34,15 @@ type RenderedMessage struct { Text string `json:"text"` HTML string `json:"html"` Attachments []string `json:"attachments"` + // MessageID is the Message-ID header, so a reply can thread under this + // message. Empty when the message has none. + MessageID string `json:"message_id"` + // InReplyTo is the Message-ID this message answers, so reopening a draft + // keeps the reply threaded. + InReplyTo string `json:"in_reply_to"` + // Important is true when the sender asked for priority: Importance: high, + // or X-Priority 1 or 2. + Important bool `json:"important"` // InlineImages maps a part's Content-ID - without the angle brackets, the // way the HTML body spells it in src="cid:..." - to a self-contained // data: URI. @@ -79,6 +88,9 @@ func RenderMessage(raw []byte, uid uint32) RenderedMessage { } } } + out.MessageID = strings.TrimSpace(header.Get("Message-Id")) + out.InReplyTo = strings.TrimSpace(header.Get("In-Reply-To")) + out.Important = isHighPriority(header.Get("Importance"), header.Get("X-Priority")) // A missing Date header parses without error into the zero time, which // would render as the year 0001 in the reader. Left empty, the UI falls // back to the received timestamp it already has. @@ -214,3 +226,14 @@ func ExtractAttachment(entity *message.Entity, targetName string) ([]byte, strin } return nil, "" } + +// isHighPriority reads the two headers clients use to mark urgent mail. +// X-Priority is "1 (Highest)" or "2 (High)"; anything else, or nothing, is +// ordinary. +func isHighPriority(importance, xPriority string) bool { + if strings.EqualFold(strings.TrimSpace(importance), "high") { + return true + } + fields := strings.Fields(xPriority) + return len(fields) > 0 && (fields[0] == "1" || fields[0] == "2") +} diff --git a/services/protocols/internal/application/usecase/message_render_test.go b/services/protocols/internal/application/usecase/message_render_test.go index cc55543..dddd9ba 100644 --- a/services/protocols/internal/application/usecase/message_render_test.go +++ b/services/protocols/internal/application/usecase/message_render_test.go @@ -169,3 +169,25 @@ func TestExtractAttachment_FindsInlineNamedParts(t *testing.T) { t.Errorf("ExtractAttachment = %q/%q", data, ctype) } } + +func TestRenderMessage_ReadsPriorityAndMessageID(t *testing.T) { + cases := map[string]bool{ + "Importance: high\r\n": true, + "Importance: High\r\n": true, + "X-Priority: 1 (Highest)\r\n": true, + "X-Priority: 2 (High)\r\n": true, + "X-Priority: 3 (Normal)\r\n": false, + "Importance: normal\r\n": false, + "": false, + } + for header, want := range cases { + raw := "From: a@b.test\r\nMessage-ID: \r\n" + header + "Subject: s\r\n\r\nbody\r\n" + got := RenderMessage([]byte(raw), 1) + if got.Important != want { + t.Errorf("%q: Important = %v, want %v", header, got.Important, want) + } + if got.MessageID != "" { + t.Errorf("MessageID = %q", got.MessageID) + } + } +} diff --git a/services/protocols/internal/application/usecase/webmail_compose_test.go b/services/protocols/internal/application/usecase/webmail_compose_test.go index 6a3bd6f..161abf0 100644 --- a/services/protocols/internal/application/usecase/webmail_compose_test.go +++ b/services/protocols/internal/application/usecase/webmail_compose_test.go @@ -155,6 +155,9 @@ func (s *stubWebmailRepo) GetMessageBlob(context.Context, string, string, uint32 return uuid.Nil, nil } func (s *stubWebmailRepo) MarkSeen(context.Context, string, string, uint32, bool) error { return nil } +func (s *stubWebmailRepo) MarkFlagged(context.Context, string, string, uint32, bool) error { + return nil +} func (s *stubWebmailRepo) Expunge(_ context.Context, _, _ string, uid uint32) error { s.expunged = append(s.expunged, uid) return s.expungeErr @@ -472,3 +475,43 @@ func TestMoveRejectsAnEmptyTarget(t *testing.T) { t.Error("an empty target should be refused") } } + +// High importance is written as both headers clients read, and only on request. +func TestImportantMessageCarriesPriorityHeaders(t *testing.T) { + in := ComposeInput{From: "a@x.test", To: []string{"b@y.test"}, Subject: "s", Text: "t", Important: true} + got := string(buildMimeMessage(in, []string{"b@y.test"}, "x.test")) + for _, h := range []string{"Importance: high\r\n", "X-Priority: 1\r\n"} { + if !strings.Contains(got, h) { + t.Errorf("missing %q in\n%s", h, got) + } + } + in.Important = false + if got := string(buildMimeMessage(in, []string{"b@y.test"}, "x.test")); strings.Contains(got, "Importance") || strings.Contains(got, "X-Priority") { + t.Error("an ordinary message was marked with a priority") + } +} + +// A reply threads under the original through In-Reply-To and References. +func TestReplyCarriesThreadingHeaders(t *testing.T) { + in := ComposeInput{From: "a@x.test", To: []string{"b@y.test"}, Subject: "Re: s", Text: "t", InReplyTo: ""} + got := string(buildMimeMessage(in, []string{"b@y.test"}, "x.test")) + for _, h := range []string{"In-Reply-To: \r\n", "References: \r\n"} { + if !strings.Contains(got, h) { + t.Errorf("missing %q", h) + } + } +} + +// The reply id comes from the request and lands in a header, so anything that +// could start a new header must be dropped, not written. +func TestInReplyToCannotInjectHeaders(t *testing.T) { + for _, bad := range []string{ + "\r\nBcc: evil@x.test", "\nX: y", "no-brackets@b", "", "", "", "<>", + } { + in := ComposeInput{From: "a@x.test", To: []string{"b@y.test"}, Subject: "s", Text: "t", InReplyTo: bad} + got := string(buildMimeMessage(in, []string{"b@y.test"}, "x.test")) + if strings.Contains(got, "In-Reply-To") || strings.Contains(got, "evil@x.test") { + t.Errorf("accepted %q:\n%s", bad, got) + } + } +} diff --git a/services/protocols/internal/application/usecase/webmail_mail.go b/services/protocols/internal/application/usecase/webmail_mail.go index 76ec21b..361c9f2 100644 --- a/services/protocols/internal/application/usecase/webmail_mail.go +++ b/services/protocols/internal/application/usecase/webmail_mail.go @@ -123,6 +123,15 @@ func (uc *WebmailUseCase) SetSeen(ctx context.Context, address, folder string, u return uc.repo.MarkSeen(ctx, id, folder, uid, seen) } +// SetFlagged marks a message important, or takes the mark off. +func (uc *WebmailUseCase) SetFlagged(ctx context.Context, address, folder string, uid uint32, flagged bool) error { + id, err := uc.mailboxID(ctx, address) + if err != nil { + return err + } + return uc.repo.MarkFlagged(ctx, id, folder, uid, flagged) +} + // ComposeInput is one message written in the webmail. type ComposeInput struct { From string @@ -145,6 +154,12 @@ type ComposeInput struct { // went out and its draft stayed in Drafts: a duplicate of mail already on // its way, which no screen in the webmail could then delete. DraftUID uint32 + // Important sends the message with high priority, in the two spellings + // mail clients read: Importance (RFC 4021) and the older X-Priority. + Important bool + // InReplyTo is the Message-ID of the message being answered. It is what + // lets the recipient's client thread the reply under the original. + InReplyTo string } // ErrNoRecipients rejects a submission with nobody to deliver to, before it @@ -418,6 +433,13 @@ func buildMimeMessage(input ComposeInput, recipients []string, localHost string) if len(input.Cc) > 0 { b.WriteString("Cc: " + strings.Join(input.Cc, ", ") + "\r\n") } + if id := cleanMessageID(input.InReplyTo); id != "" { + b.WriteString("In-Reply-To: " + id + "\r\n") + b.WriteString("References: " + id + "\r\n") + } + if input.Important { + b.WriteString("Importance: high\r\nX-Priority: 1\r\n") + } b.WriteString("Subject: " + encodeHeaderValue(input.Subject) + "\r\n") b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n") b.WriteString(fmt.Sprintf("Message-ID: <%s@%s>\r\n", uuid.NewString(), localHost)) @@ -517,3 +539,19 @@ func mimeEncode(value string) string { clean := strings.NewReplacer("\r", " ", "\n", " ").Replace(value) return mime.BEncoding.Encode("utf-8", clean) } + +// cleanMessageID returns id only when it is a single, well-formed <...> +// Message-ID. The value comes from the request and is written into a header, +// so anything with a line break - which would start a new header - or +// anything that is not an angle-bracketed token is dropped rather than +// repaired. +func cleanMessageID(id string) string { + id = strings.TrimSpace(id) + if len(id) < 3 || len(id) > 998 || id[0] != '<' || id[len(id)-1] != '>' { + return "" + } + if strings.ContainsAny(id, "\r\n\x00 \t,;") || strings.Count(id, "<") != 1 || strings.Count(id, ">") != 1 { + return "" + } + return id +} diff --git a/services/protocols/internal/infrastructure/postgres/webmail_repository.go b/services/protocols/internal/infrastructure/postgres/webmail_repository.go index d7df3ae..f39c8ca 100644 --- a/services/protocols/internal/infrastructure/postgres/webmail_repository.go +++ b/services/protocols/internal/infrastructure/postgres/webmail_repository.go @@ -223,6 +223,63 @@ func (r *WebmailRepository) MarkSeen( return tx.Commit(ctx) } +// MarkFlagged sets or clears \Flagged. Unlike \Seen it moves no counter, but a +// real change still bumps the folder's modseq: that is how an IMAP client +// holding the folder open learns the flag changed without a full resync. +func (r *WebmailRepository) MarkFlagged( + ctx context.Context, mailboxID, folderName string, uid uint32, flagged bool, +) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + var messageID uuid.UUID + var receivedAt any + var folderID string + err = tx.QueryRow(ctx, ` + SELECT e.id, e.received_at, f.id::text FROM email_messages e + JOIN folders f ON f.id = e.folder_id + WHERE e.mailbox_id = $1 AND `+folderCondition+` + AND e.uid = $3 AND e.expunged_at IS NULL + `, mailboxID, folderName, int64(uid)).Scan(&messageID, &receivedAt, &folderID) + if errors.Is(err, pgx.ErrNoRows) { + return ErrMessageNotFound + } + if err != nil { + return err + } + + var changed int64 + if flagged { + tag, err := tx.Exec(ctx, ` + INSERT INTO message_flags (message_id, received_at, flag) + VALUES ($1, $2, '\Flagged') ON CONFLICT DO NOTHING + `, messageID, receivedAt) + if err != nil { + return err + } + changed = tag.RowsAffected() + } else { + tag, err := tx.Exec(ctx, ` + DELETE FROM message_flags WHERE message_id = $1 AND received_at = $2 AND flag = '\Flagged' + `, messageID, receivedAt) + if err != nil { + return err + } + changed = tag.RowsAffected() + } + + if changed > 0 { + if _, err := tx.Exec(ctx, + `UPDATE folders SET highest_modseq = highest_modseq + 1 WHERE id = $1`, folderID); err != nil { + return err + } + } + return tx.Commit(ctx) +} + // Expunge marks one message deleted and keeps the folder counters in step. // // Soft delete, matching the expunged_at column the rest of the schema uses: the diff --git a/services/protocols/internal/infrastructure/postgres/webmail_repository_test.go b/services/protocols/internal/infrastructure/postgres/webmail_repository_test.go index 41cfb31..808e1a3 100644 --- a/services/protocols/internal/infrastructure/postgres/webmail_repository_test.go +++ b/services/protocols/internal/infrastructure/postgres/webmail_repository_test.go @@ -209,3 +209,44 @@ func TestDeleteMovesToTrashThenExpungesFromTrash(t *testing.T) { t.Errorf("the message survived being emptied from Trash: %d rows", len(trashRows)) } } + +// Marking important round-trips through the list, can be undone, and cannot +// reach a message the mailbox does not own. +func TestMarkFlaggedRoundTripsAndStaysInsideTheMailbox(t *testing.T) { + pool := testPool(t) + repo := NewWebmailRepository(pool) + mailboxID := seedMailbox(t, pool) + ctx := context.Background() + + uid := deliver(t, pool, mailboxID, "INBOX", "important one", false) + + flaggedNow := func() bool { + msgs, err := repo.ListMessages(ctx, mailboxID, "inbox", "", 50, 0) + if err != nil || len(msgs) == 0 { + t.Fatalf("list: %v (%d messages)", err, len(msgs)) + } + return msgs[0].Flagged + } + + if err := repo.MarkFlagged(ctx, mailboxID, "inbox", uid, true); err != nil { + t.Fatalf("flag: %v", err) + } + if !flaggedNow() { + t.Error("message is not flagged after MarkFlagged(true)") + } + // Flagging twice is not an error and does not duplicate the flag. + if err := repo.MarkFlagged(ctx, mailboxID, "inbox", uid, true); err != nil { + t.Errorf("flagging twice: %v", err) + } + if err := repo.MarkFlagged(ctx, mailboxID, "inbox", uid, false); err != nil { + t.Fatalf("unflag: %v", err) + } + if flaggedNow() { + t.Error("message is still flagged after MarkFlagged(false)") + } + + other := seedMailbox(t, pool) + if err := repo.MarkFlagged(ctx, other, "inbox", uid, true); err != ErrMessageNotFound { + t.Errorf("another mailbox flagging this UID got %v, want ErrMessageNotFound", err) + } +} diff --git a/services/protocols/internal/presentation/http/mail_api.go b/services/protocols/internal/presentation/http/mail_api.go index 94781ef..c6b6d89 100644 --- a/services/protocols/internal/presentation/http/mail_api.go +++ b/services/protocols/internal/presentation/http/mail_api.go @@ -245,6 +245,32 @@ func (m *mailAPI) handleSeen(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } +func (m *mailAPI) handleFlagged(w http.ResponseWriter, r *http.Request) { + session, ok := m.authenticate(w, r) + if !ok { + return + } + + var body struct { + Folder string `json:"folder"` + UID uint32 `json:"uid"` + Flagged bool `json:"flagged"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON") + return + } + if body.Folder == "" { + body.Folder = "inbox" + } + + if err := m.useCase.SetFlagged(r.Context(), session.Email, body.Folder, body.UID, body.Flagged); err != nil { + m.fail(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + func (m *mailAPI) handleSend(w http.ResponseWriter, r *http.Request) { session, ok := m.authenticate(w, r) if !ok { @@ -411,6 +437,9 @@ func (m *mailAPI) decodeCompose( // DraftUID is the autosaved draft this message came from, so sending // can clear it. Without it the draft outlived the message it became. DraftUID uint32 `json:"draft_uid"` + // Important sends with high priority; InReplyTo threads a reply. + Important bool `json:"important"` + InReplyTo string `json:"in_reply_to"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxComposeBytes)).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON") @@ -426,14 +455,16 @@ func (m *mailAPI) decodeCompose( // letting the body choose From is how a webmail becomes an open relay for // spoofed mail. return usecase.ComposeInput{ - From: sender, - To: body.To, - Cc: body.Cc, - Bcc: body.Bcc, - Subject: body.Subject, - Text: text, - HTML: body.HTML, - DraftUID: body.DraftUID, + From: sender, + To: body.To, + Cc: body.Cc, + Bcc: body.Bcc, + Subject: body.Subject, + Text: text, + HTML: body.HTML, + DraftUID: body.DraftUID, + Important: body.Important, + InReplyTo: body.InReplyTo, }, true } diff --git a/services/protocols/internal/presentation/http/mail_api_test.go b/services/protocols/internal/presentation/http/mail_api_test.go index a4a2a78..497411f 100644 --- a/services/protocols/internal/presentation/http/mail_api_test.go +++ b/services/protocols/internal/presentation/http/mail_api_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -34,6 +35,7 @@ type fakeMailStore struct { addresses map[string]string // address -> mailbox ID seenCalls []string + flaggedCalls []string deletedCalls []string movedCalls []string expungeErr error @@ -80,6 +82,11 @@ func (f *fakeMailStore) MarkSeen(_ context.Context, mailboxID, folder string, ui return nil } +func (f *fakeMailStore) MarkFlagged(_ context.Context, mailboxID, folder string, uid uint32, flagged bool) error { + f.flaggedCalls = append(f.flaggedCalls, fmt.Sprintf("%s/%s/%d/%t", mailboxID, folder, uid, flagged)) + return nil +} + func (f *fakeMailStore) Expunge(_ context.Context, mailboxID, folder string, uid uint32) error { if f.expungeErr != nil { return f.expungeErr @@ -308,6 +315,43 @@ func TestMessageListDefaultsToTheInbox(t *testing.T) { } } +// --- flagging ------------------------------------------------------------ + +// Marking important is scoped to the session's own mailbox, never to an +// identifier the request supplies. +func TestFlaggingActsOnTheSessionsMailbox(t *testing.T) { + store := storeWithMail() + api := newMailAPI(t, store, nil) + + rec := httptest.NewRecorder() + api.handleFlagged(rec, request(http.MethodPost, "/api/v1/mail/flagged", `{"uid":1,"flagged":true}`)) + + if rec.Code != http.StatusOK { + t.Fatalf("answered %d", rec.Code) + } + if len(store.flaggedCalls) != 1 || store.flaggedCalls[0] != "mailbox-1/inbox/1/true" { + t.Errorf("flag call = %v, want [mailbox-1/inbox/1/true]", store.flaggedCalls) + } +} + +func TestFlaggingRefusesARequestWithNoSession(t *testing.T) { + api := newMailAPI(t, storeWithMail(), nil) + rec := httptest.NewRecorder() + api.handleFlagged(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mail/flagged", strings.NewReader(`{"uid":1}`))) + if rec.Code != http.StatusUnauthorized { + t.Errorf("answered %d without a session, want 401", rec.Code) + } +} + +func TestFlaggingRejectsMalformedJson(t *testing.T) { + api := newMailAPI(t, storeWithMail(), nil) + rec := httptest.NewRecorder() + api.handleFlagged(rec, request(http.MethodPost, "/api/v1/mail/flagged", `{`)) + if rec.Code != http.StatusBadRequest { + t.Errorf("answered %d, want 400", rec.Code) + } +} + // --- deleting ------------------------------------------------------------ func TestDeleteMovesToTrashFromAnOrdinaryFolder(t *testing.T) { diff --git a/services/protocols/internal/presentation/http/router.go b/services/protocols/internal/presentation/http/router.go index 47d266b..10227af 100644 --- a/services/protocols/internal/presentation/http/router.go +++ b/services/protocols/internal/presentation/http/router.go @@ -94,6 +94,7 @@ func (r *Router) registerRoutes() { r.mux.HandleFunc("/api/v1/mail/messages", r.handleMailMessages) r.mux.HandleFunc("/api/v1/mail/message/", r.handleMailMessage) r.mux.HandleFunc("/api/v1/mail/seen", r.handleMailSeen) + r.mux.HandleFunc("/api/v1/mail/flagged", r.handleMailFlagged) r.mux.HandleFunc("/api/v1/mail/send", r.handleMailSend) r.mux.HandleFunc("/api/v1/mail/draft", r.handleMailDraft) r.mux.HandleFunc("/api/v1/mail/delete", r.handleMailDelete) @@ -303,6 +304,16 @@ func (r *Router) handleMailSeen(w http.ResponseWriter, req *http.Request) { } } +func (r *Router) handleMailFlagged(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required") + return + } + if r.mailReady(w) { + r.mail.handleFlagged(w, req) + } +} + func (r *Router) handleMailSend(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required") diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 40da3df..dbe7f27 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -209,6 +209,21 @@ "junk": "Spam", "reports": "Reports", "loadImages": "Load images", + "messageActions": "Message actions", + "showFewerRecipients": "Show fewer", + "replyAll": "Reply all", + "markImportant": "Mark as important", + "unmarkImportant": "Remove importance", + "markedImportant": "Marked important", + "importantFilter": "Important", + "highImportance": "High importance", + "highImportanceHint": "Sends the message marked as high priority", + "highImportanceBadge": "High importance", + "noRecipients": "Add at least one recipient.", + "fixRecipients": "Fix the addresses highlighted in red before sending.", + "invalidAddress": "This doesn't look like a valid address", + "removeRecipient": "Remove {name}", + "resizeList": "Resize the message list", "markRead": "Mark as read", "markUnread": "Mark as unread", "messagesInFolder": "{count} messages", diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index 9acb663..a78242f 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -209,6 +209,21 @@ "junk": "Spam", "reports": "Informes", "loadImages": "Cargar imágenes", + "messageActions": "Acciones del mensaje", + "showFewerRecipients": "Mostrar menos", + "replyAll": "Responder a todos", + "markImportant": "Marcar como importante", + "unmarkImportant": "Quitar importancia", + "markedImportant": "Marcado como importante", + "importantFilter": "Importantes", + "highImportance": "Alta importancia", + "highImportanceHint": "Envía el mensaje marcado como prioritario", + "highImportanceBadge": "Alta importancia", + "noRecipients": "Añade al menos un destinatario.", + "fixRecipients": "Corrige las direcciones resaltadas en rojo antes de enviar.", + "invalidAddress": "Esta dirección no parece válida", + "removeRecipient": "Quitar {name}", + "resizeList": "Cambiar el tamaño de la lista de mensajes", "markRead": "Marcar como leído", "markUnread": "Marcar como no leído", "messagesInFolder": "{count} mensajes", diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 30e59b9..9cf5e2f 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -209,6 +209,21 @@ "junk": "Spam", "reports": "Relatórios", "loadImages": "Carregar imagens", + "messageActions": "Ações da mensagem", + "showFewerRecipients": "Mostrar menos", + "replyAll": "Responder a todos", + "markImportant": "Marcar como importante", + "unmarkImportant": "Remover importância", + "markedImportant": "Marcada como importante", + "importantFilter": "Importantes", + "highImportance": "Alta importância", + "highImportanceHint": "Envia a mensagem marcada como prioritária", + "highImportanceBadge": "Alta importância", + "noRecipients": "Adicione pelo menos um destinatário.", + "fixRecipients": "Corrija os endereços destacados em vermelho antes de enviar.", + "invalidAddress": "Este endereço não parece válido", + "removeRecipient": "Remover {name}", + "resizeList": "Redimensionar a lista de mensagens", "markRead": "Marcar como lida", "markUnread": "Marcar como não lida", "messagesInFolder": "{count} mensagens", diff --git a/services/webmail/src/app/(user)/user/compose/page.tsx b/services/webmail/src/app/(user)/user/compose/page.tsx index 3f70695..935c7c6 100644 --- a/services/webmail/src/app/(user)/user/compose/page.tsx +++ b/services/webmail/src/app/(user)/user/compose/page.tsx @@ -16,17 +16,17 @@ import { X, Sparkles, CheckCircle2, + Flag, } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; import { Button } from "../../../../components/ui/Button"; import { signatureToHtml } from "../../../../lib/signature"; +import { RecipientInput } from "../../../../components/RecipientInput"; +import { parseAddress, parseAddressList, prefixSubject } from "../../../../lib/recipients"; -/** Splits a comma or semicolon separated field, dropping empty entries. */ -function splitAddresses(value: string): string[] { - return value - .split(/[,;]/) - .map((entry) => entry.trim()) - .filter(Boolean); +/** Entries from a query-string list, which may be several addresses. */ +function entriesFrom(value: string | undefined): string[] { + return value ? parseAddressList(value).map((p) => p.entry) : []; } interface AttachedFile { @@ -39,16 +39,30 @@ interface AttachedFile { export default function ComposePage({ searchParams, }: { - searchParams?: Promise<{ replyTo?: string; subject?: string; inReplyTo?: string; draft?: string }>; + searchParams?: Promise<{ + replyTo?: string; + cc?: string; + subject?: string; + inReplyTo?: string; + mode?: string; + draft?: string; + }>; }) { const t = useTranslations(); const params = searchParams ? use(searchParams) : {}; - const [to, setTo] = useState(params?.replyTo || ""); - const [cc, setCc] = useState(""); - const [bcc, setBcc] = useState(""); - const [showCcBcc, setShowCcBcc] = useState(false); - const [subject, setSubject] = useState(params?.subject ? `Re: ${params.subject.replace(/^(Re:\s*)+/i, "")}` : ""); + // Recipients are lists of entries, not one string to be split later: a + // reply-all arrives with several people already in To and Cc. + const [to, setTo] = useState(() => entriesFrom(params?.replyTo)); + const [cc, setCc] = useState(() => entriesFrom(params?.cc)); + const [bcc, setBcc] = useState([]); + const [showCcBcc, setShowCcBcc] = useState(() => entriesFrom(params?.cc).length > 0); + const [subject, setSubject] = useState( + params?.subject ? prefixSubject(params.subject, params.mode === "forward" ? "forward" : "reply") : "", + ); + // The Message-ID being answered, so the reply threads under it. + const [inReplyTo, setInReplyTo] = useState(params?.mode === "forward" ? "" : params?.inReplyTo || ""); + const [important, setImportant] = useState(false); const [attachments, setAttachments] = useState([]); const [sending, setSending] = useState(false); const [undoSeconds, setUndoSeconds] = useState(null); @@ -101,8 +115,10 @@ export default function ComposePage({ if (cancelled) return; draftUidRef.current = uid; - setTo((message.to ?? []).join(", ")); - setCc((message.cc ?? []).join(", ")); + setTo(message.to ?? []); + setCc(message.cc ?? []); + setImportant(Boolean(message.important)); + setInReplyTo(message.in_reply_to ?? ""); if ((message.cc ?? []).length > 0) setShowCcBcc(true); setSubject(message.subject ?? ""); const html = message.html || message.text || ""; @@ -123,18 +139,20 @@ export default function ComposePage({ // closing the tab lost the message while the screen had just said otherwise. const saveDraft = useCallback(async () => { const html = editorRef.current?.innerHTML ?? body; - if (!to && !subject && !body.trim() && !html.trim()) return; + if (to.length === 0 && !subject && !body.trim() && !html.trim()) return; setDraftStatus(t("mail.draftSaving")); try { const res = await fetch("/api/v1/mail/draft", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - to: splitAddresses(to), - cc: splitAddresses(cc), - bcc: splitAddresses(bcc), + to, + cc, + bcc, subject, html, + important, + in_reply_to: inReplyTo, replace_uid: draftUidRef.current, }), }); @@ -146,7 +164,7 @@ export default function ComposePage({ } catch { setDraftStatus(t("mail.draftSaveFailed")); } - }, [to, cc, bcc, subject, body, t]); + }, [to, cc, bcc, subject, body, important, inReplyTo, t]); // Debounced so a draft is written after a pause in typing rather than on // every keystroke. Skipped once the message is on its way out. @@ -155,10 +173,10 @@ export default function ComposePage({ // The body alone is enough: a message typed before its recipient is still // worth keeping, and requiring an address here is half of why drafts were // being stored with nothing in them. - if (!to && !subject && !body.trim()) return; + if (to.length === 0 && !subject && !body.trim()) return; const timer = setTimeout(() => void saveDraft(), 2500); return () => clearTimeout(timer); - }, [to, subject, cc, bcc, body, sending, saveDraft]); + }, [to, subject, cc, bcc, body, important, sending, saveDraft]); const executeFormat = (command: string, value: string | undefined = undefined) => { document.execCommand(command, false, value); @@ -183,8 +201,24 @@ export default function ComposePage({ setAttachments((prev) => prev.filter((a) => a.id !== id)); }; + // Anything in a recipient field that is not an address. These stay on screen + // as red chips; sending with one would queue mail to a destination that + // cannot exist and report success. + const invalidRecipients = [...to, ...cc, ...bcc].filter((entry) => !parseAddress(entry).valid); + const hasRecipient = to.length + cc.length + bcc.length > 0; + const handleSend = async (e: React.FormEvent) => { e.preventDefault(); + if (!hasRecipient) { + setSendError(t("mail.noRecipients")); + return; + } + if (invalidRecipients.length > 0) { + setSendError(t("mail.fixRecipients")); + setShowCcBcc(true); + return; + } + setSendError(null); setSending(true); setUndoSeconds(10); @@ -208,11 +242,13 @@ export default function ComposePage({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - to: splitAddresses(to), - cc: splitAddresses(cc), - bcc: splitAddresses(bcc), + to, + cc, + bcc, subject, html: htmlBody, + important, + in_reply_to: inReplyTo, // The draft this message grew out of, so the server can discard it. // Without this the draft outlived the message it became and sat in // Drafts as a duplicate of mail already sent. @@ -261,6 +297,19 @@ export default function ComposePage({ )}
+
- setBcc(e.target.value)} + onChange={setBcc} placeholder="bcc@domain.com" disabled={sending} - className={bareInput} + invalid={bcc.some((e) => !parseAddress(e).valid)} />
diff --git a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx index d45715f..b812fbf 100644 --- a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx +++ b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx @@ -1,25 +1,17 @@ "use client"; -import React, { use, useCallback, useEffect, useMemo, useState } from "react"; -import Link from "next/link"; +import React, { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { motion, AnimatePresence } from "framer-motion"; import { Search, + Star, Paperclip, - Reply, - Forward, Inbox, - Image as ImageIcon, Mail, Clock, - MailOpen, - Download, - FolderInput, - Pencil, RefreshCw, Trash2, - X, } from "lucide-react"; import { useI18n } from "../../../../../i18n/provider"; import { useMailEvents } from "../../../../../lib/useMailEvents"; @@ -30,6 +22,7 @@ import { messageCounts, listHeaderCount, applySeen, + applyFlagged, readerActions, moveTargets, type ListFilter, @@ -38,6 +31,14 @@ import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../.. import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages"; import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody"; import { Button } from "../../../../../components/ui/Button"; +import { + clampListWidth, + DEFAULT_LIST_WIDTH, + readStoredWidth, + storeWidth, +} from "../../../../../lib/paneWidth"; +import { useAccount } from "../../../../../lib/useAccount"; +import { MessageReader } from "../../../../../components/MessageReader"; import { Avatar } from "../../../../../components/Avatar"; interface MessageSummary { @@ -50,6 +51,7 @@ interface MessageSummary { received_at: string; has_attachments: boolean; seen: boolean; + flagged: boolean; spam_verdict: string; dmarc_result: string; } @@ -64,6 +66,8 @@ interface RenderedMessage { text: string; html: string; attachments: string[]; + message_id?: string; + important?: boolean; // The message's own pictures, keyed by Content-ID. Without these every // cid: reference in the body is unresolvable and renders as a broken icon. inline_images: InlineImages; @@ -120,8 +124,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s // Whether this sender has a standing decision, so the banner can offer to // take it back rather than only to grant it. const [rememberSender, setRememberSender] = useState(false); - // Whether the "move to" menu is open. - const [moveOpen, setMoveOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [activeFilter, setActiveFilter] = useState(null); const [loading, setLoading] = useState(true); @@ -130,6 +132,26 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s // clipped every long message and left white space under every short one. const [readerHeight, setReaderHeight] = useState(420); + const account = useAccount("user"); + // The list's width, draggable on a wide screen like the Outlook divider. + // Remembered between visits; below md the panes stack and it does not apply. + const [listWidth, setListWidth] = useState(DEFAULT_LIST_WIDTH); + const [dragging, setDragging] = useState(false); + const paneRowRef = useRef(null); + + useEffect(() => { + const stored = readStoredWidth(); + if (stored !== null) setListWidth(stored); + }, []); + + /** Applies a width within the limits the current window allows. */ + const resizeList = useCallback((width: number, persist: boolean) => { + const container = paneRowRef.current?.getBoundingClientRect().width ?? window.innerWidth; + const next = clampListWidth(width, container); + setListWidth(next); + if (persist) storeWidth(next); + }, []); + const { folders } = useFolders(); const metrics = folderMetrics(folders, folder); const router = useRouter(); @@ -221,6 +243,31 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s [folder] ); + /** + * Marks a message important, or takes the mark off. + * + * Optimistic like read state: the star changes at the click and goes back if + * the server refuses, so a failed request is never left looking like a + * success. + */ + const setFlagged = useCallback( + async (uid: number, flagged: boolean) => { + setMessages((current) => applyFlagged(current, uid, flagged)); + try { + const res = await fetch("/api/v1/mail/flagged", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folder, uid, flagged }), + }); + if (!res.ok) throw new Error(t("errors.serverError")); + } catch (err) { + setMessages((current) => applyFlagged(current, uid, !flagged)); + setError(err instanceof Error ? err.message : t("errors.serverError")); + } + }, + [folder, t] + ); + /** * Deletes one message: to Trash from an ordinary folder, and for good from * Trash itself, which is what the server does with the same call. @@ -259,7 +306,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s async (uid: number, target: string) => { setMessages((current) => current.filter((m) => m.uid !== uid)); setSelected((current) => (current?.uid === uid ? null : current)); - setMoveOpen(false); try { const res = await fetch("/api/v1/mail/move", { method: "POST", @@ -348,6 +394,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s { key: null, label: t("mail.allMessages"), count: header.count }, { key: "unread", label: t("mail.unreadFilter"), count: counts.unread }, { key: "attachment", label: t("mail.attachmentsFilter"), count: counts.withAttachments }, + { key: "important", label: t("mail.importantFilter"), count: counts.important }, ]; @@ -365,10 +412,11 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s const folderTitle = FOLDER_LABELS[folder.toLowerCase()] ?? folder; return ( -
+
{/* Messages List Column */}
{/* Folder heading, with the counts that belong to the folder rather than to the page of it that happens to be loaded. */} @@ -514,6 +562,13 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s {/* An unread row is marked by a dot in a reserved column, so the subject does not shift sideways when it is read. */} + {msg.flagged && ( + + )} {!msg.seen && } @@ -537,250 +592,84 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
+ {/* The divider between the panes. Wide screens only: below md they stack + and there is nothing to divide. A pointer drag, the arrow keys and a + double-click to restore all move it, so it is not mouse-only. */} +
{ + e.currentTarget.setPointerCapture(e.pointerId); + setDragging(true); + }} + onPointerMove={(e) => { + if (!dragging) return; + const left = paneRowRef.current?.getBoundingClientRect().left ?? 0; + resizeList(e.clientX - left, false); + }} + onPointerUp={(e) => { + e.currentTarget.releasePointerCapture(e.pointerId); + setDragging(false); + storeWidth(listWidth); + }} + onDoubleClick={() => resizeList(DEFAULT_LIST_WIDTH, true)} + onKeyDown={(e) => { + if (e.key === "ArrowLeft") resizeList(listWidth - 24, true); + else if (e.key === "ArrowRight") resizeList(listWidth + 24, true); + else if (e.key === "Home") resizeList(0, true); + else if (e.key === "End") resizeList(Infinity, true); + else return; + e.preventDefault(); + }} + className={`group relative hidden w-1.5 flex-none cursor-col-resize touch-none select-none md:block ${ + dragging ? "bg-indigo-500/40" : "bg-white/[0.07] hover:bg-indigo-500/30" + } focus-visible:bg-indigo-500/50 focus-visible:outline-none`} + > + {/* A wider invisible target, so the thin line is easy to grab. */} + +
+ {/* Message Reader Column */} -
+
{selected ? ( - - {/* Toolbar: closing, replying and read state all in one row, so - the reader is not the only pane with no way out of itself. */} -
- {/* A draft is unfinished, so the thing to do with it is carry - on writing. It used to offer Reply and Forward on the - user's own half-written message and no way to edit it. */} - {actions.canEdit && ( - - - - )} - {actions.canReply && ( - - - - )} - {/* Forward carries the subject but no recipient: it used to - prefill the original sender, which sends the message - straight back to whoever wrote it. */} - {actions.canForward && ( - - - - )} - {/* Not offered in Sent or Drafts: nothing delivers to those - folders, so an unread flag there says nothing. */} - {actions.canMarkUnread && ( - - )} - {/* Filing a message elsewhere: the one ordinary action the - reader still had no button for. */} -
- - {moveOpen && ( -
- {moveTargets(folders, folder).map((target) => ( - - ))} -
- )} -
- - -
- -
-

- {selected.subject || t("mail.noSubject")} -

- -
- -
-
- {selected.from} - {selectedRow && selectedRow.size_bytes > 0 && ( - - {formatBytes(selectedRow.size_bytes)} - - )} -
- {/* Recipients are labelled with translated headings; they - used to be introduced by a Portuguese word regardless of - the interface language. */} - {selected.to.length > 0 && ( -
- {t("ui.toRecipients")} - {selected.to.join(", ")} -
- )} - {selected.cc.length > 0 && ( -
- {t("ui.ccRecipients")} - {selected.cc.join(", ")} -
- )} -
- - {selected.date - ? new Date(selected.date).toLocaleString(locale) - : selectedRow - ? new Date(selectedRow.received_at).toLocaleString(locale) - : ""} - -
-
- - {/* Remote content banner, shown only when something was actually - held back rather than over every message. The wording is the - reassurance, not the colour: this is not an error. */} - {(hasBlockedContent || rememberSender) && ( -
- - - {t("mail.remoteContentBlocked")} - - {/* Two separate decisions: show them now, and stop asking - about this sender. Keeping them apart lets a reader look - at one message without granting anything permanently. */} - - -
- )} - - {/* Attachment Download Chips */} - {selected.attachments.length > 0 && ( -
- )} - - {/* Message Body */} - {readerDocument ? ( -
-