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 // which meant the console showed a healthy system no matter what the server
// was actually doing. // 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>>( const row = await queryOne<Record<string, string>>(
`SELECT `SELECT
(SELECT count(*) FROM email_messages WHERE received_at > NOW() - INTERVAL '24 hours')::text AS inbound_24h, (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 created_at > NOW() - INTERVAL '24 hours')::text AS outbound_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 status IN ('QUEUED','DEFERRED','SENDING'))::text AS queue_depth, (SELECT count(*) FROM outbound_jobs WHERE ${msg.sql} AND status IN ('QUEUED','DEFERRED','SENDING'))::text AS queue_depth,
(SELECT count(*) FROM outbound_jobs WHERE status = 'BOUNCED' (SELECT count(*) FROM outbound_jobs WHERE ${msg.sql} AND status = 'BOUNCED'
AND created_at > NOW() - INTERVAL '24 hours')::text AS bounced_24h, 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 WHERE ${dom.sql} AND dns_status = 'VERIFIED')::text AS domains_verified,
(SELECT count(*) FROM domains)::text AS domains_total, (SELECT count(*) FROM domains WHERE ${dom.sql})::text AS domains_total,
(SELECT count(*) FROM mailboxes WHERE is_active)::text AS mailboxes_active, (SELECT count(*) FROM mailboxes WHERE ${mbx.sql} AND is_active)::text AS mailboxes_active,
(SELECT COALESCE(sum(used_bytes),0) FROM mailboxes)::text AS storage_used_bytes, (SELECT COALESCE(sum(used_bytes),0) FROM mailboxes WHERE ${mbx.sql})::text AS storage_used_bytes,
(SELECT COALESCE(sum(quota_bytes),0) FROM mailboxes)::text AS storage_quota_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); 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] }; 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[]> { export async function listDomains(scope: AdminScope): Promise<unknown[]> {
const { sql, params } = scopeClause(scope, "d.id"); const { sql, params } = scopeClause(scope, "d.id");
return query( return query(
@@ -466,18 +488,29 @@ export async function listMailboxes(
return { items, total: Number(totalRow?.count ?? 0) }; 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>>( const totals = await queryOne<Record<string, string>>(
`SELECT COALESCE(sum(count),0)::text AS total_messages, `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 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 dkim_result = 'pass'),0)::text AS dkim_pass_count,
COALESCE(sum(count) FILTER (WHERE disposition = 'none'),0)::text AS dmarc_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( const sources = await query(
`SELECT source_ip::text AS ip, sum(count)::int AS count, `SELECT source_ip::text AS ip, sum(count)::int AS count,
max(spf_result) AS spf, max(dkim_result) AS dkim 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 { return {
total_messages: Number(totals?.total_messages ?? 0), 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 * had backed up could not be inspected past its own head - which is precisely
* when someone needs to look at it. * 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 // 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 // page leads with, and filtering them to the current search would make the
// counts change as somebody types. // 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 }>( 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 where: string[] = [scoped.sql];
const args: unknown[] = []; const args: unknown[] = [...scoped.params];
if (filter.search) { if (filter.search) {
args.push(`%${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 // 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 // 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. // 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 }>( 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 // 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 // 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, params,
); );
const queueScope = mailboxScope(scope, "mailbox_id");
const queue = await query<{ status: string; count: string }>( 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"); 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") { if (url.startsWith("/api/v1/admin/diagnostics/user/") && method === "GET") {
return adminDiagnosticsUser(res, session!, url.substring("/api/v1/admin/diagnostics/user/".length)); 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 === "/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.split("?")[0] === "/api/v1/admin/mailboxes" && method === "GET") return adminMailboxes(req, res, session!);
if (url === "/api/v1/admin/dmarc" && method === "GET") return adminDmarc(res); if (url === "/api/v1/admin/dmarc" && method === "GET") return adminDmarc(res, session!);
if (url.split("?")[0] === "/api/v1/admin/queue" && method === "GET") return adminQueue(req, res); 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); if (url === "/api/v1/admin/preflight" && method === "GET") return adminPreflight(res, session!);
return sendJson(res, 404, { error: "NOT_FOUND", message: "API endpoint not found" }); return sendJson(res, 404, { error: "NOT_FOUND", message: "API endpoint not found" });
} catch (err) { } catch (err) {
@@ -870,8 +870,8 @@ async function adminQueueAction(
sendJson(res, 200, { ok: true }); sendJson(res, 200, { ok: true });
} }
async function adminDashboard(res: ServerResponse): Promise<void> { async function adminDashboard(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
const stats = await repo.dashboardStats(); const stats = await repo.dashboardStats(scopeOf(session));
sendJson(res, 200, stats); sendJson(res, 200, stats);
} }
@@ -990,16 +990,16 @@ function scopeOf(session: SessionTokenPayload): repo.AdminScope {
return { role: session.role, domainId: session.domainId }; return { role: session.role, domainId: session.domainId };
} }
async function adminDmarc(res: ServerResponse): Promise<void> { async function adminDmarc(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
sendJson(res, 200, await repo.dmarcSummary()); 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 url = new URL(req.url || "/", "http://localhost");
const page = parsePageParams(url); const page = parsePageParams(url);
const status = (url.searchParams.get("status") || "").toUpperCase(); const status = (url.searchParams.get("status") || "").toUpperCase();
const summary = await repo.queueSummary({ const summary = await repo.queueSummary(scopeOf(session), {
search: page.search, search: page.search,
status: status || null, status: status || null,
offset: page.offset, offset: page.offset,
@@ -1014,8 +1014,8 @@ async function adminQueue(req: IncomingMessage, res: ServerResponse): Promise<vo
}); });
} }
async function adminPreflight(res: ServerResponse): Promise<void> { async function adminPreflight(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
sendJson(res, 200, await repo.preflightSummary()); sendJson(res, 200, await repo.preflightSummary(scopeOf(session)));
} }
// ------------------------------------------------ user handlers // ------------------------------------------------ user handlers
@@ -62,6 +62,9 @@ type WebmailRepository interface {
GetMessageBlob(ctx context.Context, mailboxID, folderName string, uid uint32) (uuid.UUID, error) GetMessageBlob(ctx context.Context, mailboxID, folderName string, uid uint32) (uuid.UUID, error)
// MarkSeen records that the user opened the message. // MarkSeen records that the user opened the message.
MarkSeen(ctx context.Context, mailboxID, folderName string, uid uint32, seen bool) error 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 soft-deletes one message, keeping the folder counters in step.
Expunge(ctx context.Context, mailboxID, folderName string, uid uint32) error Expunge(ctx context.Context, mailboxID, folderName string, uid uint32) error
// MoveToFolder relocates one message into a named folder and returns its // MoveToFolder relocates one message into a named folder and returns its
@@ -34,6 +34,15 @@ type RenderedMessage struct {
Text string `json:"text"` Text string `json:"text"`
HTML string `json:"html"` HTML string `json:"html"`
Attachments []string `json:"attachments"` 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 // 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 // way the HTML body spells it in src="cid:..." - to a self-contained
// data: URI. // 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 // 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 // would render as the year 0001 in the reader. Left empty, the UI falls
// back to the received timestamp it already has. // back to the received timestamp it already has.
@@ -214,3 +226,14 @@ func ExtractAttachment(entity *message.Entity, targetName string) ([]byte, strin
} }
return nil, "" 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")
}
@@ -169,3 +169,25 @@ func TestExtractAttachment_FindsInlineNamedParts(t *testing.T) {
t.Errorf("ExtractAttachment = %q/%q", data, ctype) 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: <m1@b.test>\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 != "<m1@b.test>" {
t.Errorf("MessageID = %q", got.MessageID)
}
}
}
@@ -155,6 +155,9 @@ func (s *stubWebmailRepo) GetMessageBlob(context.Context, string, string, uint32
return uuid.Nil, nil return uuid.Nil, nil
} }
func (s *stubWebmailRepo) MarkSeen(context.Context, string, string, uint32, bool) error { return 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 { func (s *stubWebmailRepo) Expunge(_ context.Context, _, _ string, uid uint32) error {
s.expunged = append(s.expunged, uid) s.expunged = append(s.expunged, uid)
return s.expungeErr return s.expungeErr
@@ -472,3 +475,43 @@ func TestMoveRejectsAnEmptyTarget(t *testing.T) {
t.Error("an empty target should be refused") 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: "<orig-1@y.test>"}
got := string(buildMimeMessage(in, []string{"b@y.test"}, "x.test"))
for _, h := range []string{"In-Reply-To: <orig-1@y.test>\r\n", "References: <orig-1@y.test>\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{
"<a@b>\r\nBcc: evil@x.test", "<a@b>\nX: y", "no-brackets@b", "<a b@c>", "<a@b><c@d>", "", "<>",
} {
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)
}
}
}
@@ -123,6 +123,15 @@ func (uc *WebmailUseCase) SetSeen(ctx context.Context, address, folder string, u
return uc.repo.MarkSeen(ctx, id, folder, uid, seen) 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. // ComposeInput is one message written in the webmail.
type ComposeInput struct { type ComposeInput struct {
From string From string
@@ -145,6 +154,12 @@ type ComposeInput struct {
// went out and its draft stayed in Drafts: a duplicate of mail already on // 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. // its way, which no screen in the webmail could then delete.
DraftUID uint32 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 // 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 { if len(input.Cc) > 0 {
b.WriteString("Cc: " + strings.Join(input.Cc, ", ") + "\r\n") 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("Subject: " + encodeHeaderValue(input.Subject) + "\r\n")
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\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)) 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) clean := strings.NewReplacer("\r", " ", "\n", " ").Replace(value)
return mime.BEncoding.Encode("utf-8", clean) 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
}
@@ -223,6 +223,63 @@ func (r *WebmailRepository) MarkSeen(
return tx.Commit(ctx) 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. // 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 // Soft delete, matching the expunged_at column the rest of the schema uses: the
@@ -209,3 +209,44 @@ func TestDeleteMovesToTrashThenExpungesFromTrash(t *testing.T) {
t.Errorf("the message survived being emptied from Trash: %d rows", len(trashRows)) 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)
}
}
@@ -245,6 +245,32 @@ func (m *mailAPI) handleSeen(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) 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) { func (m *mailAPI) handleSend(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r) session, ok := m.authenticate(w, r)
if !ok { if !ok {
@@ -411,6 +437,9 @@ func (m *mailAPI) decodeCompose(
// DraftUID is the autosaved draft this message came from, so sending // DraftUID is the autosaved draft this message came from, so sending
// can clear it. Without it the draft outlived the message it became. // can clear it. Without it the draft outlived the message it became.
DraftUID uint32 `json:"draft_uid"` 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 { if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxComposeBytes)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON") 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 // letting the body choose From is how a webmail becomes an open relay for
// spoofed mail. // spoofed mail.
return usecase.ComposeInput{ return usecase.ComposeInput{
From: sender, From: sender,
To: body.To, To: body.To,
Cc: body.Cc, Cc: body.Cc,
Bcc: body.Bcc, Bcc: body.Bcc,
Subject: body.Subject, Subject: body.Subject,
Text: text, Text: text,
HTML: body.HTML, HTML: body.HTML,
DraftUID: body.DraftUID, DraftUID: body.DraftUID,
Important: body.Important,
InReplyTo: body.InReplyTo,
}, true }, true
} }
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -34,6 +35,7 @@ type fakeMailStore struct {
addresses map[string]string // address -> mailbox ID addresses map[string]string // address -> mailbox ID
seenCalls []string seenCalls []string
flaggedCalls []string
deletedCalls []string deletedCalls []string
movedCalls []string movedCalls []string
expungeErr error expungeErr error
@@ -80,6 +82,11 @@ func (f *fakeMailStore) MarkSeen(_ context.Context, mailboxID, folder string, ui
return nil 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 { func (f *fakeMailStore) Expunge(_ context.Context, mailboxID, folder string, uid uint32) error {
if f.expungeErr != nil { if f.expungeErr != nil {
return f.expungeErr 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 ------------------------------------------------------------ // --- deleting ------------------------------------------------------------
func TestDeleteMovesToTrashFromAnOrdinaryFolder(t *testing.T) { func TestDeleteMovesToTrashFromAnOrdinaryFolder(t *testing.T) {
@@ -94,6 +94,7 @@ func (r *Router) registerRoutes() {
r.mux.HandleFunc("/api/v1/mail/messages", r.handleMailMessages) r.mux.HandleFunc("/api/v1/mail/messages", r.handleMailMessages)
r.mux.HandleFunc("/api/v1/mail/message/", r.handleMailMessage) r.mux.HandleFunc("/api/v1/mail/message/", r.handleMailMessage)
r.mux.HandleFunc("/api/v1/mail/seen", r.handleMailSeen) 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/send", r.handleMailSend)
r.mux.HandleFunc("/api/v1/mail/draft", r.handleMailDraft) r.mux.HandleFunc("/api/v1/mail/draft", r.handleMailDraft)
r.mux.HandleFunc("/api/v1/mail/delete", r.handleMailDelete) 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) { func (r *Router) handleMailSend(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost { if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required") writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
+15
View File
@@ -209,6 +209,21 @@
"junk": "Spam", "junk": "Spam",
"reports": "Reports", "reports": "Reports",
"loadImages": "Load images", "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", "markRead": "Mark as read",
"markUnread": "Mark as unread", "markUnread": "Mark as unread",
"messagesInFolder": "{count} messages", "messagesInFolder": "{count} messages",
+15
View File
@@ -209,6 +209,21 @@
"junk": "Spam", "junk": "Spam",
"reports": "Informes", "reports": "Informes",
"loadImages": "Cargar imágenes", "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", "markRead": "Marcar como leído",
"markUnread": "Marcar como no leído", "markUnread": "Marcar como no leído",
"messagesInFolder": "{count} mensajes", "messagesInFolder": "{count} mensajes",
+15
View File
@@ -209,6 +209,21 @@
"junk": "Spam", "junk": "Spam",
"reports": "Relatórios", "reports": "Relatórios",
"loadImages": "Carregar imagens", "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", "markRead": "Marcar como lida",
"markUnread": "Marcar como não lida", "markUnread": "Marcar como não lida",
"messagesInFolder": "{count} mensagens", "messagesInFolder": "{count} mensagens",
@@ -16,17 +16,17 @@ import {
X, X,
Sparkles, Sparkles,
CheckCircle2, CheckCircle2,
Flag,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "../../../../i18n/provider"; import { useTranslations } from "../../../../i18n/provider";
import { Button } from "../../../../components/ui/Button"; import { Button } from "../../../../components/ui/Button";
import { signatureToHtml } from "../../../../lib/signature"; 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. */ /** Entries from a query-string list, which may be several addresses. */
function splitAddresses(value: string): string[] { function entriesFrom(value: string | undefined): string[] {
return value return value ? parseAddressList(value).map((p) => p.entry) : [];
.split(/[,;]/)
.map((entry) => entry.trim())
.filter(Boolean);
} }
interface AttachedFile { interface AttachedFile {
@@ -39,16 +39,30 @@ interface AttachedFile {
export default function ComposePage({ export default function ComposePage({
searchParams, 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 t = useTranslations();
const params = searchParams ? use(searchParams) : {}; const params = searchParams ? use(searchParams) : {};
const [to, setTo] = useState(params?.replyTo || ""); // Recipients are lists of entries, not one string to be split later: a
const [cc, setCc] = useState(""); // reply-all arrives with several people already in To and Cc.
const [bcc, setBcc] = useState(""); const [to, setTo] = useState<string[]>(() => entriesFrom(params?.replyTo));
const [showCcBcc, setShowCcBcc] = useState(false); const [cc, setCc] = useState<string[]>(() => entriesFrom(params?.cc));
const [subject, setSubject] = useState(params?.subject ? `Re: ${params.subject.replace(/^(Re:\s*)+/i, "")}` : ""); const [bcc, setBcc] = useState<string[]>([]);
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<AttachedFile[]>([]); const [attachments, setAttachments] = useState<AttachedFile[]>([]);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [undoSeconds, setUndoSeconds] = useState<number | null>(null); const [undoSeconds, setUndoSeconds] = useState<number | null>(null);
@@ -101,8 +115,10 @@ export default function ComposePage({
if (cancelled) return; if (cancelled) return;
draftUidRef.current = uid; draftUidRef.current = uid;
setTo((message.to ?? []).join(", ")); setTo(message.to ?? []);
setCc((message.cc ?? []).join(", ")); setCc(message.cc ?? []);
setImportant(Boolean(message.important));
setInReplyTo(message.in_reply_to ?? "");
if ((message.cc ?? []).length > 0) setShowCcBcc(true); if ((message.cc ?? []).length > 0) setShowCcBcc(true);
setSubject(message.subject ?? ""); setSubject(message.subject ?? "");
const html = message.html || message.text || ""; 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. // closing the tab lost the message while the screen had just said otherwise.
const saveDraft = useCallback(async () => { const saveDraft = useCallback(async () => {
const html = editorRef.current?.innerHTML ?? body; 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")); setDraftStatus(t("mail.draftSaving"));
try { try {
const res = await fetch("/api/v1/mail/draft", { const res = await fetch("/api/v1/mail/draft", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
to: splitAddresses(to), to,
cc: splitAddresses(cc), cc,
bcc: splitAddresses(bcc), bcc,
subject, subject,
html, html,
important,
in_reply_to: inReplyTo,
replace_uid: draftUidRef.current, replace_uid: draftUidRef.current,
}), }),
}); });
@@ -146,7 +164,7 @@ export default function ComposePage({
} catch { } catch {
setDraftStatus(t("mail.draftSaveFailed")); 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 // 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. // 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 // 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 // worth keeping, and requiring an address here is half of why drafts were
// being stored with nothing in them. // 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); const timer = setTimeout(() => void saveDraft(), 2500);
return () => clearTimeout(timer); 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) => { const executeFormat = (command: string, value: string | undefined = undefined) => {
document.execCommand(command, false, value); document.execCommand(command, false, value);
@@ -183,8 +201,24 @@ export default function ComposePage({
setAttachments((prev) => prev.filter((a) => a.id !== id)); 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) => { const handleSend = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!hasRecipient) {
setSendError(t("mail.noRecipients"));
return;
}
if (invalidRecipients.length > 0) {
setSendError(t("mail.fixRecipients"));
setShowCcBcc(true);
return;
}
setSendError(null);
setSending(true); setSending(true);
setUndoSeconds(10); setUndoSeconds(10);
@@ -208,11 +242,13 @@ export default function ComposePage({
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
to: splitAddresses(to), to,
cc: splitAddresses(cc), cc,
bcc: splitAddresses(bcc), bcc,
subject, subject,
html: htmlBody, html: htmlBody,
important,
in_reply_to: inReplyTo,
// The draft this message grew out of, so the server can discard it. // 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 // Without this the draft outlived the message it became and sat in
// Drafts as a duplicate of mail already sent. // Drafts as a duplicate of mail already sent.
@@ -261,6 +297,19 @@ export default function ComposePage({
)} )}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setImportant((v) => !v)}
aria-pressed={important}
disabled={sending}
title={t("mail.highImportanceHint")}
className={`inline-flex h-10 items-center gap-2 rounded-xl px-3.5 text-sm shadow-edge transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
important ? "bg-rose-900/70 text-rose-100" : "bg-dark-card text-slate-200 hover:bg-white/[0.07]"
}`}
>
<Flag className="h-4 w-4" fill={important ? "currentColor" : "none"} />
<span>{t("mail.highImportance")}</span>
</button>
<Link href="/user/mail/inbox"> <Link href="/user/mail/inbox">
<Button variant="secondary" size="md"> <Button variant="secondary" size="md">
{t("mail.discard")} {t("mail.discard")}
@@ -304,15 +353,13 @@ export default function ComposePage({
<label htmlFor="compose-to" className={fieldLabel}> <label htmlFor="compose-to" className={fieldLabel}>
{t("ui.toRecipients")} {t("ui.toRecipients")}
</label> </label>
<input <RecipientInput
id="compose-to" id="compose-to"
type="text"
value={to} value={to}
onChange={(e) => setTo(e.target.value)} onChange={setTo}
placeholder="recipient@example.com" placeholder="recipient@example.com"
required
disabled={sending} disabled={sending}
className={bareInput} invalid={to.some((e) => !parseAddress(e).valid)}
/> />
<button <button
type="button" type="button"
@@ -329,28 +376,26 @@ export default function ComposePage({
<label htmlFor="compose-cc" className={fieldLabel}> <label htmlFor="compose-cc" className={fieldLabel}>
{t("mail.ccLabel")} {t("mail.ccLabel")}
</label> </label>
<input <RecipientInput
id="compose-cc" id="compose-cc"
type="text"
value={cc} value={cc}
onChange={(e) => setCc(e.target.value)} onChange={setCc}
placeholder="cc@domain.com" placeholder="cc@domain.com"
disabled={sending} disabled={sending}
className={bareInput} invalid={cc.some((e) => !parseAddress(e).valid)}
/> />
</div> </div>
<div className={fieldRow}> <div className={fieldRow}>
<label htmlFor="compose-bcc" className={fieldLabel}> <label htmlFor="compose-bcc" className={fieldLabel}>
{t("mail.bccLabel")} {t("mail.bccLabel")}
</label> </label>
<input <RecipientInput
id="compose-bcc" id="compose-bcc"
type="text"
value={bcc} value={bcc}
onChange={(e) => setBcc(e.target.value)} onChange={setBcc}
placeholder="bcc@domain.com" placeholder="bcc@domain.com"
disabled={sending} disabled={sending}
className={bareInput} invalid={bcc.some((e) => !parseAddress(e).valid)}
/> />
</div> </div>
</> </>
@@ -1,25 +1,17 @@
"use client"; "use client";
import React, { use, useCallback, useEffect, useMemo, useState } from "react"; import React, { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { import {
Search, Search,
Star,
Paperclip, Paperclip,
Reply,
Forward,
Inbox, Inbox,
Image as ImageIcon,
Mail, Mail,
Clock, Clock,
MailOpen,
Download,
FolderInput,
Pencil,
RefreshCw, RefreshCw,
Trash2, Trash2,
X,
} from "lucide-react"; } from "lucide-react";
import { useI18n } from "../../../../../i18n/provider"; import { useI18n } from "../../../../../i18n/provider";
import { useMailEvents } from "../../../../../lib/useMailEvents"; import { useMailEvents } from "../../../../../lib/useMailEvents";
@@ -30,6 +22,7 @@ import {
messageCounts, messageCounts,
listHeaderCount, listHeaderCount,
applySeen, applySeen,
applyFlagged,
readerActions, readerActions,
moveTargets, moveTargets,
type ListFilter, type ListFilter,
@@ -38,6 +31,14 @@ import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../..
import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages"; import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages";
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody"; import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
import { Button } from "../../../../../components/ui/Button"; 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"; import { Avatar } from "../../../../../components/Avatar";
interface MessageSummary { interface MessageSummary {
@@ -50,6 +51,7 @@ interface MessageSummary {
received_at: string; received_at: string;
has_attachments: boolean; has_attachments: boolean;
seen: boolean; seen: boolean;
flagged: boolean;
spam_verdict: string; spam_verdict: string;
dmarc_result: string; dmarc_result: string;
} }
@@ -64,6 +66,8 @@ interface RenderedMessage {
text: string; text: string;
html: string; html: string;
attachments: string[]; attachments: string[];
message_id?: string;
important?: boolean;
// The message's own pictures, keyed by Content-ID. Without these every // 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. // cid: reference in the body is unresolvable and renders as a broken icon.
inline_images: InlineImages; 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 // Whether this sender has a standing decision, so the banner can offer to
// take it back rather than only to grant it. // take it back rather than only to grant it.
const [rememberSender, setRememberSender] = useState(false); const [rememberSender, setRememberSender] = useState(false);
// Whether the "move to" menu is open.
const [moveOpen, setMoveOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeFilter, setActiveFilter] = useState<ListFilter>(null); const [activeFilter, setActiveFilter] = useState<ListFilter>(null);
const [loading, setLoading] = useState(true); 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. // clipped every long message and left white space under every short one.
const [readerHeight, setReaderHeight] = useState(420); 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<HTMLDivElement>(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 { folders } = useFolders();
const metrics = folderMetrics(folders, folder); const metrics = folderMetrics(folders, folder);
const router = useRouter(); const router = useRouter();
@@ -221,6 +243,31 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
[folder] [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 * 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. * 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) => { async (uid: number, target: string) => {
setMessages((current) => current.filter((m) => m.uid !== uid)); setMessages((current) => current.filter((m) => m.uid !== uid));
setSelected((current) => (current?.uid === uid ? null : current)); setSelected((current) => (current?.uid === uid ? null : current));
setMoveOpen(false);
try { try {
const res = await fetch("/api/v1/mail/move", { const res = await fetch("/api/v1/mail/move", {
method: "POST", 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: null, label: t("mail.allMessages"), count: header.count },
{ key: "unread", label: t("mail.unreadFilter"), count: counts.unread }, { key: "unread", label: t("mail.unreadFilter"), count: counts.unread },
{ key: "attachment", label: t("mail.attachmentsFilter"), count: counts.withAttachments }, { 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; const folderTitle = FOLDER_LABELS[folder.toLowerCase()] ?? folder;
return ( return (
<div className="flex flex-1 overflow-hidden"> <div ref={paneRowRef} className="flex flex-1 overflow-hidden">
{/* Messages List Column */} {/* Messages List Column */}
<div <div
className={`${selected ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:w-[380px] md:flex-none lg:w-[420px] xl:w-[460px] 2xl:w-[500px]`} style={{ "--list-w": `${listWidth}px` } as React.CSSProperties}
className={`${selected ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg md:w-[var(--list-w)] md:flex-none`}
> >
{/* Folder heading, with the counts that belong to the folder rather {/* Folder heading, with the counts that belong to the folder rather
than to the page of it that happens to be loaded. */} 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 {/* An unread row is marked by a dot in a reserved column, so
the subject does not shift sideways when it is read. */} the subject does not shift sideways when it is read. */}
{msg.flagged && (
<Star
className="mt-1 h-3.5 w-3.5 flex-none text-amber-300"
fill="currentColor"
aria-label={t("mail.markedImportant")}
/>
)}
<span className="mt-3 w-[7px] flex-none"> <span className="mt-3 w-[7px] flex-none">
{!msg.seen && <span className="block h-[7px] w-[7px] rounded-full bg-indigo-500" />} {!msg.seen && <span className="block h-[7px] w-[7px] rounded-full bg-indigo-500" />}
</span> </span>
@@ -537,250 +592,84 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
</div> </div>
</div> </div>
{/* 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. */}
<div
role="separator"
aria-orientation="vertical"
aria-label={t("mail.resizeList")}
aria-valuenow={listWidth}
tabIndex={0}
onPointerDown={(e) => {
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. */}
<span className="absolute inset-y-0 -left-1.5 -right-1.5" />
</div>
{/* Message Reader Column */} {/* Message Reader Column */}
<div className={`${selected ? "flex" : "hidden md:flex"} flex-1 flex-col overflow-y-auto bg-dark-bg`}> <div className={`${selected ? "flex" : "hidden md:flex"} min-w-0 flex-1 flex-col overflow-hidden bg-dark-bg`}>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{selected ? ( {selected ? (
<motion.article <MessageReader
key={selected.uid} key={selected.uid}
initial={{ opacity: 0, y: 8 }} message={selected}
animate={{ opacity: 1, y: 0 }} row={selectedRow}
exit={{ opacity: 0, y: -8 }} ownAddress={account?.email ?? ""}
transition={{ duration: 0.15 }} folder={folder}
className="mx-auto flex w-full max-w-[860px] flex-col gap-5 px-5 pb-10 pt-6 sm:px-7 xl:max-w-[1000px] 2xl:max-w-[1140px]" actions={actions}
> moveTargets={moveTargets(folders, folder).map((target) => ({
{/* Toolbar: closing, replying and read state all in one row, so name: target.name,
the reader is not the only pane with no way out of itself. */} label: FOLDER_LABELS[(target.special_use || target.name).toLowerCase()] ?? target.name,
<div className="flex flex-wrap items-center gap-2"> }))}
{/* A draft is unfinished, so the thing to do with it is carry document={readerDocument}
on writing. It used to offer Reply and Forward on the frameHeight={readerHeight}
user's own half-written message and no way to edit it. */} hasBlockedContent={hasBlockedContent}
{actions.canEdit && ( imagesLoaded={loadRemoteImages}
<Link href={`/user/compose?draft=${selected.uid}`}> senderRemembered={rememberSender}
<Button variant="primary" size="sm"> onClose={() => setSelected(null)}
<Pencil className="h-3.5 w-3.5" /> onToggleFlagged={() => void setFlagged(selected.uid, !(selectedRow?.flagged ?? false))}
<span>{t("mail.continueDraft")}</span> onToggleSeen={() => void setSeen(selected.uid, !(selectedRow?.seen ?? true), { persist: true })}
</Button> onDelete={() => void deleteMessage(selected.uid)}
</Link> onMove={(target) => void moveMessage(selected.uid, target)}
)} onToggleImages={() => setLoadRemoteImages(!loadRemoteImages)}
{actions.canReply && ( onToggleRemember={() => {
<Link if (rememberSender) {
href={`/user/compose?replyTo=${encodeURIComponent(selected.from)}&subject=${encodeURIComponent(selected.subject)}`} revokeSender(selected.from);
> setRememberSender(false);
<Button variant="primary" size="sm"> setLoadRemoteImages(false);
<Reply className="h-3.5 w-3.5" /> } else {
<span>{t("mail.reply")}</span> trustSender(selected.from);
</Button> setRememberSender(true);
</Link> setLoadRemoteImages(true);
)} }
{/* 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 && (
<Link href={`/user/compose?subject=${encodeURIComponent(selected.subject)}`}>
<Button variant="secondary" size="sm">
<Forward className="h-3.5 w-3.5" />
<span>{t("mail.forward")}</span>
</Button>
</Link>
)}
{/* Not offered in Sent or Drafts: nothing delivers to those
folders, so an unread flag there says nothing. */}
{actions.canMarkUnread && (
<Button
variant="secondary"
size="sm"
onClick={() => {
const nowSeen = !(selectedRow?.seen ?? true);
void setSeen(selected.uid, nowSeen, { persist: true });
}}
>
<MailOpen className="h-3.5 w-3.5" />
<span>{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}</span>
</Button>
)}
{/* Filing a message elsewhere: the one ordinary action the
reader still had no button for. */}
<div className="relative">
<Button
variant="secondary"
size="sm"
onClick={() => setMoveOpen((open) => !open)}
title={t("mail.moveTo")}
>
<FolderInput className="h-3.5 w-3.5" />
<span>{t("mail.moveTo")}</span>
</Button>
{moveOpen && (
<div className="absolute left-0 top-full z-20 mt-1 min-w-[190px] overflow-hidden rounded-xl bg-dark-panel py-1 shadow-edge">
{moveTargets(folders, folder).map((target) => (
<button
key={target.name}
type="button"
onClick={() => void moveMessage(selected.uid, target.name)}
className="block w-full px-3.5 py-2 text-left text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07]"
>
{FOLDER_LABELS[(target.special_use || target.name).toLowerCase()] ?? target.name}
</button>
))}
</div>
)}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => void deleteMessage(selected.uid)}
title={actions.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
<span>{actions.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete")}</span>
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setSelected(null)}
className="ml-auto h-9 w-9 flex-none p-0"
title={t("mail.backToList")}
aria-label={t("mail.backToList")}
>
<X className="h-4 w-4" />
</Button>
</div>
<header className="flex flex-col gap-3.5">
<h1 className="text-[25px] font-medium leading-tight tracking-[-0.015em] text-slate-100 [text-wrap:pretty]">
{selected.subject || t("mail.noSubject")}
</h1>
<div className="flex items-start gap-3">
<Avatar
address={selectedRow?.sender_address ?? selected.from}
initials={initials(selectedRow?.from_display_name ?? "", selected.from)}
size={38}
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
/>
<div className="flex min-w-0 flex-1 flex-col gap-[3px]">
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<span className="break-all text-sm text-slate-100">{selected.from}</span>
{selectedRow && selectedRow.size_bytes > 0 && (
<span className="text-[11px] tabular-nums text-slate-500">
{formatBytes(selectedRow.size_bytes)}
</span>
)}
</div>
{/* Recipients are labelled with translated headings; they
used to be introduced by a Portuguese word regardless of
the interface language. */}
{selected.to.length > 0 && (
<div className="break-words text-[12.5px] leading-relaxed text-slate-400">
<span className="text-slate-500">{t("ui.toRecipients")} </span>
{selected.to.join(", ")}
</div>
)}
{selected.cc.length > 0 && (
<div className="break-words text-[12.5px] leading-relaxed text-slate-400">
<span className="text-slate-500">{t("ui.ccRecipients")} </span>
{selected.cc.join(", ")}
</div>
)}
</div>
<span className="flex-none text-xs tabular-nums text-slate-400">
{selected.date
? new Date(selected.date).toLocaleString(locale)
: selectedRow
? new Date(selectedRow.received_at).toLocaleString(locale)
: ""}
</span>
</div>
</header>
{/* 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) && (
<div className="flex flex-wrap items-center gap-2 rounded-xl bg-dark-card p-3 shadow-edge">
<ImageIcon className="h-4 w-4 flex-none text-indigo-400" />
<span className="min-w-[200px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
{t("mail.remoteContentBlocked")}
</span>
{/* 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. */}
<Button
variant="ghost"
size="sm"
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
>
{loadRemoteImages ? t("mail.hideImages") : t("mail.loadImages")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (rememberSender) {
revokeSender(selected.from);
setRememberSender(false);
setLoadRemoteImages(false);
} else {
trustSender(selected.from);
setRememberSender(true);
setLoadRemoteImages(true);
}
}}
>
{rememberSender ? t("mail.forgetSender") : t("mail.alwaysFromSender")}
</Button>
</div>
)}
{/* Attachment Download Chips */}
{selected.attachments.length > 0 && (
<div className="flex flex-col gap-2">
<div className="text-[11px] uppercase tracking-[0.08em] text-slate-400">
{t("mail.attachments", { count: selected?.attachments.length ?? 1 })}
</div>
<div className="flex flex-wrap gap-2">
{selected.attachments.map((filename, idx) => (
<a
key={`${filename}-${idx}`}
href={`/api/v1/mail/message/${selected.uid}/attachment/${encodeURIComponent(filename)}?folder=${encodeURIComponent(folder)}`}
download={filename}
className="group flex max-w-full items-center gap-2.5 rounded-xl bg-dark-card px-3 py-2.5 text-slate-200 shadow-edge transition-shadow hover:shadow-edge-accent"
>
<Paperclip className="h-[18px] w-[18px] flex-none text-indigo-400" />
<span className="min-w-0 break-all text-[13px]">{filename}</span>
<Download className="h-4 w-4 flex-none text-slate-500 group-hover:text-indigo-300" />
</a>
))}
</div>
</div>
)}
{/* Message Body */}
{readerDocument ? (
<div className="overflow-hidden rounded-2xl bg-white shadow-edge">
<iframe
title={t("ui.messageBody")}
srcDoc={readerDocument}
/* allow-scripts, and deliberately not allow-same-origin: the
frame stays on an opaque origin - it cannot read a cookie
or touch this document - while still running the few lines
that report its height and open links outward. Message
markup is sanitised before it gets here. */
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
referrerPolicy="no-referrer"
style={{ height: `${readerHeight}px` }}
className="block w-full border-0"
/>
</div>
) : (
<div className="rounded-2xl bg-dark-panel px-6 py-6 shadow-edge">
<pre className="whitespace-pre-wrap break-words font-sans text-[14.5px] leading-relaxed text-slate-200">
{selected.text || t("mail.emptyBody")}
</pre>
</div>
)}
</motion.article>
) : ( ) : (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -0,0 +1,430 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import {
AlertCircle,
ArrowLeft,
ChevronDown,
Download,
FolderInput,
Forward,
Image as ImageIcon,
MailOpen,
Pencil,
Reply,
ReplyAll,
Star,
Trash2,
X,
} from "lucide-react";
import { useI18n } from "../i18n/provider";
import { Avatar } from "./Avatar";
import { offersReplyAll, replyAllRecipients, replyRecipients } from "../lib/recipients";
export interface ReaderMessage {
uid: number;
subject: string;
from: string;
to: string[];
cc: string[];
date: string;
text: string;
attachments: string[];
message_id?: string;
/** The sender asked for priority (Importance: high). */
important?: boolean;
}
export interface ReaderRow {
sender_address: string;
from_display_name: string;
received_at: string;
seen: boolean;
flagged?: boolean;
}
export interface ReaderActions {
canEdit: boolean;
canReply: boolean;
canForward: boolean;
canMarkUnread: boolean;
deleteIsPermanent: boolean;
}
interface MoveTarget {
name: string;
label: string;
}
interface Props {
message: ReaderMessage;
row?: ReaderRow;
/** The signed-in address, left out of every reply-all. */
ownAddress: string;
folder: string;
actions: ReaderActions;
moveTargets: MoveTarget[];
/** The document the body frame renders, or null for a text-only message. */
document: string | null;
frameHeight: number;
/** Whether anything was held back, so the privacy bar is not always shown. */
hasBlockedContent: boolean;
imagesLoaded: boolean;
senderRemembered: boolean;
onClose: () => void;
onToggleSeen: () => void;
onToggleFlagged: () => void;
onDelete: () => void;
onMove: (target: string) => void;
onToggleImages: () => void;
onToggleRemember: () => void;
}
/** The sender's name and address, from a header like `Jenny <jenny@x.test>`. */
function splitSender(from: string): { name: string; address: string } {
const match = from.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>\s*$/);
if (match) return { name: match[1].trim(), address: match[2].trim() };
return { name: "", address: from.trim() };
}
/** The initials shown in the sender avatar, from a display name or an address. */
function initialsOf(name: string, address: string): string {
const source = (name || address || "?").trim();
const parts = source.replace(/[<>"]/g, "").split(/[\s.@_-]+/).filter(Boolean);
return (parts.slice(0, 2).map((p) => p[0]).join("") || "?").toUpperCase();
}
/** A short type tag and colour for an attachment, from its extension. */
function attachmentKind(filename: string): { tag: string; className: string } {
const ext = (filename.split(".").pop() ?? "").toLowerCase();
if (ext === "pdf") return { tag: "PDF", className: "bg-rose-700" };
if (["png", "jpg", "jpeg", "gif", "webp", "bmp"].includes(ext)) return { tag: "IMG", className: "bg-sky-700" };
if (["doc", "docx", "odt", "rtf", "txt"].includes(ext)) return { tag: "DOC", className: "bg-blue-700" };
if (["xls", "xlsx", "ods", "csv"].includes(ext)) return { tag: "XLS", className: "bg-emerald-700" };
if (["zip", "rar", "7z", "gz", "tar"].includes(ext)) return { tag: "ZIP", className: "bg-amber-700" };
return { tag: (ext || "FILE").slice(0, 4).toUpperCase(), className: "bg-slate-600" };
}
// One look for every control on the command bar, so the bar reads as a single
// strip. The label hides on narrow screens and the icon stays, which is what
// keeps six actions on one row at phone width.
const commandClass =
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
const infoLinkClass =
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
export function MessageReader(props: Props) {
const { message, row, folder, actions } = props;
const { t, locale } = useI18n();
const [moveOpen, setMoveOpen] = useState(false);
const [recipientsOpen, setRecipientsOpen] = useState(false);
const { name, address } = splitSender(message.from);
const displayName = name || row?.from_display_name || address;
const showAddress = displayName !== address;
const when = message.date || row?.received_at || "";
const whenText = when
? new Date(when).toLocaleString(locale, {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "";
// Collapsed, the recipient line names the first two and counts the rest: a
// newsletter sent to forty people pushed the message itself off the screen.
const visibleTo = recipientsOpen ? message.to : message.to.slice(0, 2);
const hiddenCount = message.to.length - visibleTo.length + (recipientsOpen ? 0 : message.cc.length);
const showCc = recipientsOpen && message.cc.length > 0;
const composeHref = (recipients: { to: string[]; cc: string[] }, mode: "reply" | "replyAll") => {
const query = new URLSearchParams({ mode, subject: message.subject });
if (recipients.to.length > 0) query.set("replyTo", recipients.to.join(", "));
if (recipients.cc.length > 0) query.set("cc", recipients.cc.join(", "));
if (message.message_id) query.set("inReplyTo", message.message_id);
return `/user/compose?${query.toString()}`;
};
const replyHref = composeHref(replyRecipients(message, props.ownAddress), "reply");
const replyAllHref = composeHref(replyAllRecipients(message, props.ownAddress), "replyAll");
// Offered only when it would reach someone a plain reply would not: with
// one other person on the thread the two buttons would do the same thing.
const canReplyAll = actions.canReply && offersReplyAll(message, props.ownAddress);
// Forward carries the subject but no recipient: prefilling the original
// sender would send the message straight back to whoever wrote it.
const forwardHref = `/user/compose?mode=forward&subject=${encodeURIComponent(message.subject)}`;
const flagged = Boolean(row?.flagged);
const deleteLabel = actions.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete");
return (
<motion.article
key={message.uid}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
className="flex min-h-0 flex-1 flex-col"
>
{/* Command bar. It stays put while the message scrolls under it, so
reply and delete are never a scroll away from a long newsletter. */}
<div
role="toolbar"
aria-label={t("mail.messageActions")}
className="flex flex-none flex-wrap items-center gap-0.5 bg-dark-bg px-2 py-1.5 shadow-[0_1px_0_0_rgba(233,233,237,0.09)] sm:px-4"
>
<button
type="button"
onClick={props.onClose}
className={`${commandClass} md:hidden`}
title={t("mail.backToList")}
aria-label={t("mail.backToList")}
>
<ArrowLeft className="h-4 w-4" />
</button>
{actions.canEdit && (
<Link href={`/user/compose?draft=${message.uid}`} className={`${commandClass} text-indigo-300`}>
<Pencil className="h-4 w-4" />
<span className="hidden xl:inline">{t("mail.continueDraft")}</span>
</Link>
)}
{actions.canReply && (
<Link href={replyHref} className={`${commandClass} text-indigo-300`} title={t("mail.reply")}>
<Reply className="h-4 w-4" />
<span className="hidden xl:inline">{t("mail.reply")}</span>
</Link>
)}
{canReplyAll && (
<Link href={replyAllHref} className={commandClass} title={t("mail.replyAll")}>
<ReplyAll className="h-4 w-4" />
<span className="hidden 2xl:inline">{t("mail.replyAll")}</span>
</Link>
)}
{actions.canForward && (
<Link href={forwardHref} className={commandClass} title={t("mail.forward")}>
<Forward className="h-4 w-4" />
<span className="hidden 2xl:inline">{t("mail.forward")}</span>
</Link>
)}
<span aria-hidden className="mx-1 h-5 w-px bg-white/[0.12]" />
<button type="button" onClick={props.onDelete} className={commandClass} title={deleteLabel}>
<Trash2 className="h-4 w-4" />
<span className="hidden 2xl:inline">{deleteLabel}</span>
</button>
<div className="relative">
<button
type="button"
onClick={() => setMoveOpen((open) => !open)}
aria-expanded={moveOpen}
aria-haspopup="menu"
className={commandClass}
title={t("mail.moveTo")}
>
<FolderInput className="h-4 w-4" />
<span className="hidden 2xl:inline">{t("mail.moveTo")}</span>
<ChevronDown className="hidden h-3 w-3 2xl:block" />
</button>
{moveOpen && (
<div
role="menu"
className="absolute right-0 top-full z-20 mt-1 min-w-[190px] overflow-hidden rounded-xl bg-dark-panel py-1 shadow-edge md:left-0 md:right-auto"
>
{props.moveTargets.map((target) => (
<button
key={target.name}
type="button"
role="menuitem"
onClick={() => {
setMoveOpen(false);
props.onMove(target.name);
}}
className="block w-full px-3.5 py-2.5 text-left text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:bg-white/[0.07] focus-visible:outline-none"
>
{target.label}
</button>
))}
</div>
)}
</div>
{/* Not offered in Sent or Drafts: nothing delivers to those folders,
so an unread flag there says nothing. */}
{actions.canMarkUnread && (
<button
type="button"
onClick={props.onToggleSeen}
className={commandClass}
title={row?.seen === false ? t("mail.markRead") : t("mail.markUnread")}
>
<MailOpen className="h-4 w-4" />
<span className="hidden 2xl:inline">
{row?.seen === false ? t("mail.markRead") : t("mail.markUnread")}
</span>
</button>
)}
{/* The importance mark, available in every folder: a message in Sent
or Archive can be worth flagging too. */}
<button
type="button"
onClick={props.onToggleFlagged}
aria-pressed={flagged}
className={`${commandClass} ${flagged ? "text-amber-300" : ""}`}
title={flagged ? t("mail.unmarkImportant") : t("mail.markImportant")}
>
<Star className="h-4 w-4" fill={flagged ? "currentColor" : "none"} />
<span className="hidden 2xl:inline">{flagged ? t("mail.unmarkImportant") : t("mail.markImportant")}</span>
</button>
<button
type="button"
onClick={props.onClose}
className={`${commandClass} ml-auto hidden md:inline-flex`}
title={t("mail.backToList")}
aria-label={t("mail.backToList")}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-4 px-4 pb-10 pt-5 sm:px-7 2xl:max-w-[1140px]">
{message.important && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-rose-900/60 px-2.5 py-0.5 text-xs font-medium text-rose-200 shadow-[inset_0_0_0_1px_rgba(244,63,94,0.45)]">
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
{t("mail.highImportanceBadge")}
</span>
)}
<h1 className="text-[19px] font-semibold leading-snug tracking-[-0.01em] text-slate-100 [overflow-wrap:anywhere] [text-wrap:pretty] sm:text-[22px]">
{message.subject || t("mail.noSubject")}
</h1>
{/* The sender block: name first and the address second, because the
name is what a reader recognises and a long address used to be
the loudest thing in the header. */}
<header className="flex items-start gap-3">
<Avatar
address={row?.sender_address ?? address}
initials={initialsOf(displayName, address)}
size={44}
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-[15px] font-semibold text-slate-100 [overflow-wrap:anywhere]">{displayName}</span>
{showAddress && (
<span className="text-[13px] text-slate-400 [overflow-wrap:anywhere]">&lt;{address}&gt;</span>
)}
</div>
<div className="text-[12.5px] leading-relaxed text-slate-400 [overflow-wrap:anywhere]">
{visibleTo.length > 0 && (
<>
<span className="text-slate-500">{t("ui.toRecipients")}: </span>
{visibleTo.join(", ")}
</>
)}
{showCc && (
<div>
<span className="text-slate-500">{t("ui.ccRecipients")}: </span>
{message.cc.join(", ")}
</div>
)}
{(hiddenCount > 0 || recipientsOpen) && (
<button
type="button"
onClick={() => setRecipientsOpen((open) => !open)}
aria-expanded={recipientsOpen}
className="ml-1 inline-flex items-center gap-0.5 rounded px-1 text-indigo-300 hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
>
{recipientsOpen ? t("mail.showFewerRecipients") : `+${hiddenCount}`}
<ChevronDown className={`h-3 w-3 transition-transform ${recipientsOpen ? "rotate-180" : ""}`} />
</button>
)}
</div>
{/* On a phone the date drops under the recipients rather than
squeezing the name. */}
<span className="text-xs tabular-nums text-slate-500 sm:hidden">{whenText}</span>
</div>
<span className="hidden flex-none pt-0.5 text-xs tabular-nums text-slate-400 sm:block">{whenText}</span>
</header>
{/* One quiet line, shown only when something was actually held back.
The wording is the reassurance, not the colour: this is not an
error. Two separate decisions - look now, or stop asking about
this sender - so a reader can see one message without granting
anything permanently. */}
{(props.hasBlockedContent || props.senderRemembered) && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg bg-dark-card px-3 py-2 shadow-edge">
<ImageIcon className="h-4 w-4 flex-none text-indigo-400" aria-hidden />
<span className="min-w-[180px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
{props.imagesLoaded ? t("mail.imagesEnabled") : t("mail.remoteContentBlocked")}
</span>
<button type="button" onClick={props.onToggleImages} className={infoLinkClass}>
{props.imagesLoaded ? t("mail.hideImages") : t("mail.loadImages")}
</button>
<button type="button" onClick={props.onToggleRemember} className={infoLinkClass}>
{props.senderRemembered ? t("mail.forgetSender") : t("mail.alwaysFromSender")}
</button>
</div>
)}
{message.attachments.length > 0 && (
<section aria-label={t("mail.attachments", { count: message.attachments.length })}>
<div className="flex gap-2 overflow-x-auto pb-1">
{message.attachments.map((filename, idx) => {
const kind = attachmentKind(filename);
return (
<a
key={`${filename}-${idx}`}
href={`/api/v1/mail/message/${message.uid}/attachment/${encodeURIComponent(filename)}?folder=${encodeURIComponent(folder)}`}
download={filename}
className="group flex h-10 max-w-[260px] flex-none items-center gap-2 rounded-lg bg-dark-card px-2.5 text-slate-200 shadow-edge transition-shadow hover:shadow-edge-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
>
<span className={`rounded px-1.5 py-0.5 text-[10px] font-bold text-white ${kind.className}`}>
{kind.tag}
</span>
<span className="min-w-0 truncate text-[12.5px]">{filename}</span>
<Download className="h-3.5 w-3.5 flex-none text-slate-500 group-hover:text-indigo-300" />
</a>
);
})}
</div>
</section>
)}
{props.document ? (
<div className="overflow-hidden rounded-xl bg-white shadow-edge">
<iframe
title={t("ui.messageBody")}
srcDoc={props.document}
/* allow-scripts, and deliberately not allow-same-origin: the
frame stays on an opaque origin - it cannot read a cookie
or touch this document - while still running the few lines
that report its height and open links outward. Message
markup is sanitised before it gets here. */
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
referrerPolicy="no-referrer"
style={{ height: `${props.frameHeight}px` }}
className="block w-full border-0"
/>
</div>
) : (
<div className="rounded-xl bg-dark-panel px-5 py-5 shadow-edge sm:px-6">
<pre className="whitespace-pre-wrap break-words font-sans text-[14.5px] leading-relaxed text-slate-200">
{message.text || t("mail.emptyBody")}
</pre>
</div>
)}
</div>
</div>
</motion.article>
);
}
@@ -0,0 +1,145 @@
"use client";
import React, { useRef, useState } from "react";
import { X } from "lucide-react";
import { useTranslations } from "../i18n/provider";
import { dedupe, isValidAddress, parseAddress, parseAddressList } from "../lib/recipients";
interface Props {
id: string;
/** Entries as they will be sent: `Name <addr>` or a bare address. */
value: string[];
onChange: (next: string[]) => void;
placeholder?: string;
disabled?: boolean;
/** Outlines the field when something in it cannot be sent. */
invalid?: boolean;
}
/**
* An address field that turns each recipient into a chip.
*
* Commit points are the ones people expect from a mail client: a comma, a
* semicolon, Enter, a space after something that is already an address, and
* leaving the field. A pasted list becomes several chips at once. A chip that
* is not an address stays visible and red rather than being dropped, so a
* typo is something the sender sees instead of mail that quietly goes nowhere.
*/
export function RecipientInput({ id, value, onChange, placeholder, disabled, invalid }: Props) {
const t = useTranslations();
const [draft, setDraft] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const commit = (text: string) => {
const parsed = parseAddressList(text);
if (parsed.length === 0) return;
onChange(dedupe([...value, ...parsed.map((p) => p.entry)]));
};
const flush = () => {
if (draft.trim()) commit(draft);
setDraft("");
};
const remove = (index: number) => {
onChange(value.filter((_, i) => i !== index));
inputRef.current?.focus();
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value;
// Only a separator that is not inside a quoted name or an <address> ends a
// recipient; parseAddressList knows the difference, so it is asked rather
// than splitting here.
if (/[,;]$/.test(text)) {
const body = text.slice(0, -1);
const unbalanced = (body.match(/"/g)?.length ?? 0) % 2 === 1 || (body.includes("<") && !body.includes(">"));
if (!unbalanced) {
commit(body);
setDraft("");
return;
}
}
setDraft(text);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
// Enter in this field must never submit the message.
e.preventDefault();
flush();
} else if (e.key === " " && isValidAddress(draft.trim())) {
e.preventDefault();
flush();
} else if (e.key === "Backspace" && draft === "" && value.length > 0) {
onChange(value.slice(0, -1));
}
};
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const text = e.clipboardData.getData("text");
// A single-line input swallows line breaks on paste, so a column of
// addresses from a spreadsheet would arrive glued together.
if (/[\n\r,;\s]/.test(text.trim())) {
e.preventDefault();
commit(draft + text);
setDraft("");
}
};
return (
<div
// Clicking anywhere in the row focuses the input, like a text field.
onClick={() => inputRef.current?.focus()}
className={`flex min-h-[34px] min-w-[160px] flex-1 flex-wrap items-center gap-1.5 rounded-lg px-1 py-0.5 ${
invalid ? "ring-1 ring-rose-500/70" : ""
}`}
>
{value.map((entry, index) => {
const parsed = parseAddress(entry);
return (
<span
key={`${entry}-${index}`}
title={parsed.valid ? parsed.address : t("mail.invalidAddress")}
className={`inline-flex max-w-full items-center gap-1 rounded-full py-0.5 pl-2.5 pr-1 text-[12.5px] ${
parsed.valid
? "bg-indigo-900/70 text-indigo-100 shadow-[inset_0_0_0_1px_#5d5294]"
: "bg-rose-900/60 text-rose-100 shadow-[inset_0_0_0_1px_rgba(244,63,94,0.5)]"
}`}
>
<span className="truncate">{parsed.label}</span>
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
remove(index);
}}
aria-label={t("mail.removeRecipient", { name: parsed.label })}
className="flex h-5 w-5 flex-none items-center justify-center rounded-full hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300"
>
<X className="h-3 w-3" />
</button>
)}
</span>
);
})}
<input
ref={inputRef}
id={id}
type="text"
inputMode="email"
autoComplete="off"
value={draft}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={flush}
placeholder={value.length === 0 ? placeholder : undefined}
disabled={disabled}
aria-invalid={invalid || undefined}
className="min-h-[28px] min-w-[140px] flex-1 border-0 bg-transparent px-1 text-sm text-slate-100 placeholder-slate-500 focus:outline-none"
/>
</div>
);
}
@@ -56,7 +56,7 @@ describe("what the autosave actually stores", () => {
it("does not save a draft that has no content anywhere", () => { it("does not save a draft that has no content anywhere", () => {
const compose = code(COMPOSE); const compose = code(COMPOSE);
expect(compose).toMatch(/if \(!to && !subject && !body/); expect(compose).toMatch(/if \(to\.length === 0 && !subject && !body/);
}); });
}); });
+10 -1
View File
@@ -21,6 +21,8 @@ export interface MessageSummary {
uid: number; uid: number;
seen: boolean; seen: boolean;
has_attachments: boolean; has_attachments: boolean;
/** Marked important (\\Flagged). */
flagged?: boolean;
} }
export interface FolderMetrics { export interface FolderMetrics {
@@ -28,7 +30,7 @@ export interface FolderMetrics {
total: number; total: number;
} }
export type ListFilter = "unread" | "attachment" | null; export type ListFilter = "unread" | "attachment" | "important" | null;
/** Clamps a counter to something displayable: no negatives, no NaN. */ /** Clamps a counter to something displayable: no negatives, no NaN. */
function counter(value: unknown): number { function counter(value: unknown): number {
@@ -73,17 +75,20 @@ export function messageCounts(messages: MessageSummary[]): {
loaded: number; loaded: number;
unread: number; unread: number;
withAttachments: number; withAttachments: number;
important: number;
} { } {
return { return {
loaded: messages.length, loaded: messages.length,
unread: messages.filter((m) => !m.seen).length, unread: messages.filter((m) => !m.seen).length,
withAttachments: messages.filter((m) => m.has_attachments).length, withAttachments: messages.filter((m) => m.has_attachments).length,
important: messages.filter((m) => m.flagged).length,
}; };
} }
export function filterMessages<T extends MessageSummary>(messages: T[], filter: ListFilter): T[] { export function filterMessages<T extends MessageSummary>(messages: T[], filter: ListFilter): T[] {
if (filter === "unread") return messages.filter((m) => !m.seen); if (filter === "unread") return messages.filter((m) => !m.seen);
if (filter === "attachment") return messages.filter((m) => m.has_attachments); if (filter === "attachment") return messages.filter((m) => m.has_attachments);
if (filter === "important") return messages.filter((m) => m.flagged);
return messages; return messages;
} }
@@ -121,6 +126,10 @@ export function applySeen<T extends MessageSummary>(messages: T[], uid: number,
return messages.map((m) => (m.uid === uid ? { ...m, seen } : m)); return messages.map((m) => (m.uid === uid ? { ...m, seen } : m));
} }
export function applyFlagged<T extends MessageSummary>(messages: T[], uid: number, flagged: boolean): T[] {
return messages.map((m) => (m.uid === uid ? { ...m, flagged } : m));
}
/** What the sidebar shows beside one folder. */ /** What the sidebar shows beside one folder. */
export interface FolderBadge { export interface FolderBadge {
/** How many unread messages the folder holds. */ /** How many unread messages the folder holds. */
+40
View File
@@ -0,0 +1,40 @@
/**
* The width of the message list, which the reader can drag to change.
*
* Both panes share one row, so a width has to leave the other pane enough room
* to be usable: a list dragged to the full window would leave no reader, and
* one dragged to nothing would leave subjects unreadable. The limits live here
* so they are tested rather than scattered through the drag handler.
*/
export const PANE_KEY = "lm_list_width";
export const DEFAULT_LIST_WIDTH = 420;
export const MIN_LIST_WIDTH = 300;
/** What the reader keeps whatever the list is dragged to. */
export const MIN_READER_WIDTH = 520;
export function clampListWidth(width: number, containerWidth: number): number {
if (Number.isNaN(width)) return DEFAULT_LIST_WIDTH;
const max = Math.max(MIN_LIST_WIDTH, containerWidth - MIN_READER_WIDTH);
return Math.round(Math.min(max, Math.max(MIN_LIST_WIDTH, width)));
}
/** The stored width, or null when there is none or it is not a number. */
export function readStoredWidth(): number | null {
try {
const raw = window.localStorage.getItem(PANE_KEY);
const value = raw === null ? NaN : Number(raw);
return Number.isFinite(value) ? value : null;
} catch {
// Storage can throw (private window, blocked site data); the default holds.
return null;
}
}
export function storeWidth(width: number): void {
try {
window.localStorage.setItem(PANE_KEY, String(Math.round(width)));
} catch {
// Not remembered this session; the divider still works.
}
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Reading and answering address lists.
*
* The compose field used to split on every comma, which cuts a display name
* like `"Silva, João" <joao@x.test>` in half and sends to two broken
* addresses. Reply-all had no home at all: the composer took one `replyTo`
* and nothing else, so answering a thread meant retyping everyone on it.
*/
export interface ParsedAddress {
/** The entry as it will be sent: `Name <addr>` or a bare address. */
entry: string;
/** The mailbox itself, lowercased for comparison. */
address: string;
/** What a chip shows: the name when there is one, else the address. */
label: string;
valid: boolean;
}
const MAILBOX = /^[^\s@<>(),;:"\\[\]]+@[^\s@<>(),;:"\\[\]]+\.[^\s@<>(),;:"\\[\]]+$/;
export function isValidAddress(address: string): boolean {
return address.length <= 254 && MAILBOX.test(address.trim());
}
/** The mailbox inside `Name <addr>`, or the text itself when it is bare. */
export function addressOf(entry: string): string {
const angled = entry.match(/<([^>]*)>\s*$/);
return (angled ? angled[1] : entry).trim().toLowerCase();
}
function nameOf(entry: string): string {
const angled = entry.match(/^\s*"?(.*?)"?\s*<[^>]*>\s*$/);
return angled ? angled[1].replace(/\\(.)/g, "$1").trim() : "";
}
/**
* Splits text on commas, semicolons and line breaks that are not inside a
* quoted name or an angle-bracketed address.
*/
function splitOutsideQuotes(text: string): string[] {
const parts: string[] = [];
let current = "";
let quoted = false;
let angled = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quoted && ch === "\\" && i + 1 < text.length) {
current += ch + text[++i];
continue;
}
if (ch === '"') quoted = !quoted;
else if (!quoted && ch === "<") angled = true;
else if (!quoted && ch === ">") angled = false;
if (!quoted && !angled && (ch === "," || ch === ";" || ch === "\n" || ch === "\r")) {
parts.push(current);
current = "";
} else {
current += ch;
}
}
parts.push(current);
return parts.map((p) => p.trim()).filter(Boolean);
}
export function parseAddress(raw: string): ParsedAddress {
const entry = raw.trim();
const address = addressOf(entry);
const name = nameOf(entry);
return { entry, address, label: name || address, valid: isValidAddress(address) };
}
/**
* Parses pasted or typed text into addresses.
*
* A run of bare addresses separated only by spaces - what copying from a mail
* header or a spreadsheet produces - is split as well, but only when every
* piece is itself an address, so a display name with spaces is never broken.
*/
export function parseAddressList(text: string): ParsedAddress[] {
const out: ParsedAddress[] = [];
for (const part of splitOutsideQuotes(text)) {
const pieces = part.split(/\s+/);
if (pieces.length > 1 && !/[<>"]/.test(part) && pieces.every((p) => isValidAddress(p))) {
for (const p of pieces) out.push(parseAddress(p));
} else {
out.push(parseAddress(part));
}
}
return out;
}
/** The list without repeats, comparing mailboxes and keeping the first entry. */
export function dedupe(entries: string[]): string[] {
const seen = new Set<string>();
return entries.filter((entry) => {
const key = addressOf(entry);
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
interface Thread {
from: string;
to: string[];
cc: string[];
}
export interface Recipients {
to: string[];
cc: string[];
}
/**
* Who a plain reply goes to: the sender. Answering a message the user wrote
* themselves - from Sent - means following up with its recipients, not
* writing to oneself.
*/
export function replyRecipients(message: Thread, own: string): Recipients {
const me = own.trim().toLowerCase();
if (me && addressOf(message.from) === me) {
return { to: dedupe(message.to), cc: [] };
}
return { to: [message.from], cc: [] };
}
/**
* Who reply-all goes to: the sender and everyone else who was addressed.
*
* The user's own address is dropped everywhere - mailing oneself on every
* reply is the classic reply-all mistake - and nobody is listed twice, in
* To and again in Cc.
*/
export function replyAllRecipients(message: Thread, own: string): Recipients {
const me = own.trim().toLowerCase();
const notMe = (entry: string) => addressOf(entry) !== me;
const base = replyRecipients(message, own);
const to = dedupe([...base.to, ...message.to].filter(notMe));
const taken = new Set(to.map(addressOf));
const cc = dedupe(message.cc.filter(notMe)).filter((entry) => !taken.has(addressOf(entry)));
return { to, cc };
}
/** Whether reply-all would reach anyone a plain reply would not. */
export function offersReplyAll(message: Thread, own: string): boolean {
const single = replyRecipients(message, own);
const all = replyAllRecipients(message, own);
const reached = new Set(single.to.map(addressOf));
return [...all.to, ...all.cc].some((entry) => !reached.has(addressOf(entry)));
}
/** `Re:` or `Fwd:` once, however many times the subject has been through. */
export function prefixSubject(subject: string, mode: "reply" | "forward"): string {
if (!subject) return "";
if (mode === "forward") return `Fwd: ${subject.replace(/^((Fwd?|Enc|Rv):\s*)+/i, "")}`;
return `Re: ${subject.replace(/^((Re|Res):\s*)+/i, "")}`;
}
+27 -7
View File
@@ -53,7 +53,7 @@ export function sanitizeEmailHtml(rawHtml: string): string {
], ],
ALLOWED_ATTR: [ ALLOWED_ATTR: [
"align", "alt", "bgcolor", "border", "cellpadding", "cellspacing", "cite", "align", "alt", "bgcolor", "border", "cellpadding", "cellspacing", "cite",
"class", "color", "colspan", "dir", "height", "href", "lang", "rowspan", "background", "class", "color", "colspan", "dir", "height", "href", "lang", "rowspan",
"src", "style", "title", "width", "target", "rel", "data-blocked-src", "src", "style", "title", "width", "target", "rel", "data-blocked-src",
], ],
// style, link, meta and base are gone: a stylesheet in a message can cover // style, link, meta and base are gone: a stylesheet in a message can cover
@@ -67,24 +67,44 @@ export function sanitizeEmailHtml(rawHtml: string): string {
// message's own inline parts, and rejecting them stripped the src from // message's own inline parts, and rejecting them stripped the src from
// every embedded picture. image/svg+xml is deliberately absent: an SVG is // every embedded picture. image/svg+xml is deliberately absent: an SVG is
// a document that carries script. // a document that carries script.
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|cid|tel):|data:image\/(?:png|gif|jpe?g|webp|bmp|x-icon|vnd\.microsoft\.icon);)/i, //
// The last two alternatives are DOMPurify's own default for values that are
// not a URL at all ("600", "50%", "center", "#fff"). A custom pattern
// replaces that default, and without them every attribute whose value is
// not a link - width, height, colspan, align, bgcolor, cellpadding - was
// stripped, which is what flattened table layouts. A value with an
// unlisted scheme (javascript:, vbscript:, data:text/html) matches neither:
// the scheme name has to be followed by something other than a colon.
ALLOWED_URI_REGEXP:
/^(?:(?:https?|mailto|cid|tel):|data:image\/(?:png|gif|jpe?g|webp|bmp|x-icon|vnd\.microsoft\.icon);|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
}); });
} }
/** Marks a CSS url() the browser cannot fetch, and that unblocking can undo. */
const BLOCKED_SCHEME = "lm-blocked-";
/** /**
* Replaces remote image sources with data-blocked-src so a tracking pixel * Holds remote images back so a tracking pixel cannot fire before the reader
* cannot fire before the reader asks for images. * asks for images.
*
* Every rewrite is reversible. Backgrounds used to be deleted outright, so
* choosing "show images" restored the <img> tags and never the banner behind
* them - the part of a marketing message that carries its look.
*/ */
export function blockRemoteImages(html: string): string { export function blockRemoteImages(html: string): string {
if (!html) return ""; if (!html) return "";
return html return html
.replace(/(<img\b[^>]*?\b)src\s*=\s*("|')(https?:[^"']*)\2/gi, "$1data-blocked-src=$2$3$2") .replace(/(<img\b[^>]*?\b)src\s*=\s*("|')(https?:[^"']*)\2/gi, "$1data-blocked-src=$2$3$2")
.replace(/(<[^>]+)background\s*=\s*("|')https?:[^"']*\2/gi, "$1") .replace(/(<[^>]+?\s)background\s*=\s*("|')(https?:[^"']*)\2/gi, "$1data-blocked-background=$2$3$2")
.replace(/url\(\s*['"]?https?:[^)]*\)/gi, "none"); // An unknown scheme is never fetched, and is not a valid image either.
.replace(/url\(\s*(['"]?)(https?:)/gi, `url($1${BLOCKED_SCHEME}$2`);
} }
/** Restores the blocked sources once the reader opts in. */ /** Restores the blocked sources once the reader opts in. */
export function unblockRemoteImages(html: string): string { export function unblockRemoteImages(html: string): string {
if (!html) return ""; if (!html) return "";
return html.replace(/(<img[^>]+)data-blocked-src\s*=\s*("|')([^"']+)\2/gi, "$1src=$2$3$2"); return html
.replace(/(<img[^>]+)data-blocked-src\s*=\s*("|')([^"']+)\2/gi, "$1src=$2$3$2")
.replace(/(<[^>]+?\s)data-blocked-background\s*=\s*("|')([^"']+)\2/gi, "$1background=$2$3$2")
.replace(new RegExp(`${BLOCKED_SCHEME}(https?:)`, "gi"), "$1");
} }
@@ -121,8 +121,12 @@ describe("the reader pane", () => {
const page = code(MAIL_PAGE); const page = code(MAIL_PAGE);
expect(page).not.toContain("h-[500px]"); expect(page).not.toContain("h-[500px]");
expect(page).toContain("lm:reader-height"); expect(page).toContain("lm:reader-height");
// The height report needs script inside the frame, which sandbox="" forbids. // The frame itself lives in the reader component. Its height report needs
expect(page).toContain("allow-scripts"); // script inside it, which sandbox="" forbids - and it must never be given
// the app's own origin.
const reader = code(resolve(process.cwd(), "src/components/MessageReader.tsx"));
expect(reader).toContain("allow-scripts");
expect(reader).not.toContain("allow-same-origin");
}); });
// Every one of these was a Portuguese string typed into a component that had // Every one of these was a Portuguese string typed into a component that had
+16 -3
View File
@@ -4,6 +4,7 @@ import {
badgeCount, badgeCount,
messageCounts, messageCounts,
filterMessages, filterMessages,
applyFlagged,
listHeaderCount, listHeaderCount,
applySeen, applySeen,
type FolderSummary, type FolderSummary,
@@ -80,16 +81,28 @@ describe("the number on a sidebar folder", () => {
const messages: MessageSummary[] = [ const messages: MessageSummary[] = [
{ uid: 1, seen: false, has_attachments: true }, { uid: 1, seen: false, has_attachments: true },
{ uid: 2, seen: true, has_attachments: false }, { uid: 2, seen: true, has_attachments: false },
{ uid: 3, seen: false, has_attachments: false }, { uid: 3, seen: false, has_attachments: false, flagged: true },
]; ];
describe("important messages", () => {
it("filters to the flagged ones", () => {
expect(filterMessages(messages, "important").map((m) => m.uid)).toEqual([3]);
});
it("sets and clears the flag on one row only", () => {
const on = applyFlagged(messages, 1, true);
expect(on.map((m) => !!m.flagged)).toEqual([true, false, true]);
expect(applyFlagged(on, 1, false).map((m) => !!m.flagged)).toEqual([false, false, true]);
});
});
describe("counting the loaded page", () => { describe("counting the loaded page", () => {
it("counts what is on it by unread and by attachment", () => { it("counts what is on it by unread and by attachment", () => {
expect(messageCounts(messages)).toEqual({ loaded: 3, unread: 2, withAttachments: 1 }); expect(messageCounts(messages)).toEqual({ loaded: 3, unread: 2, withAttachments: 1, important: 1 });
}); });
it("counts an empty page as zeros", () => { it("counts an empty page as zeros", () => {
expect(messageCounts([])).toEqual({ loaded: 0, unread: 0, withAttachments: 0 }); expect(messageCounts([])).toEqual({ loaded: 0, unread: 0, withAttachments: 0, important: 0 });
}); });
}); });
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { clampListWidth, DEFAULT_LIST_WIDTH, MIN_LIST_WIDTH, MIN_READER_WIDTH } from "./lib/paneWidth";
describe("clampListWidth", () => {
it("keeps a width that fits", () => {
expect(clampListWidth(500, 1400)).toBe(500);
});
it("never lets the list shrink below its minimum", () => {
expect(clampListWidth(50, 1400)).toBe(MIN_LIST_WIDTH);
});
it("always leaves the reader its minimum width", () => {
expect(clampListWidth(5000, 1400)).toBe(1400 - MIN_READER_WIDTH);
});
it("holds the minimum on a window too narrow for both", () => {
expect(clampListWidth(900, 600)).toBe(MIN_LIST_WIDTH);
});
it("treats infinity as the widest allowed", () => {
expect(clampListWidth(Infinity, 1400)).toBe(1400 - MIN_READER_WIDTH);
});
it("falls back to the default for a value that is not a number", () => {
expect(clampListWidth(NaN, 1400)).toBe(DEFAULT_LIST_WIDTH);
});
});
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import {
parseAddressList,
isValidAddress,
addressOf,
dedupe,
replyRecipients,
replyAllRecipients,
offersReplyAll,
prefixSubject,
} from "./lib/recipients";
describe("parseAddressList", () => {
it("splits on commas, semicolons and line breaks", () => {
const list = parseAddressList("a@x.test, b@x.test;c@x.test\nd@x.test");
expect(list.map((p) => p.address)).toEqual(["a@x.test", "b@x.test", "c@x.test", "d@x.test"]);
});
it("does not cut a quoted name that contains a comma", () => {
const list = parseAddressList('"Silva, João" <joao@x.test>, ana@x.test');
expect(list).toHaveLength(2);
expect(list[0].address).toBe("joao@x.test");
expect(list[0].label).toBe("Silva, João");
expect(list[0].valid).toBe(true);
});
it("splits bare addresses separated only by spaces", () => {
expect(parseAddressList("a@x.test b@x.test").map((p) => p.address)).toEqual(["a@x.test", "b@x.test"]);
});
it("keeps a display name with spaces whole", () => {
const list = parseAddressList("Maria da Silva <maria@x.test>");
expect(list).toHaveLength(1);
expect(list[0].label).toBe("Maria da Silva");
});
it("flags what is not an address", () => {
const list = parseAddressList("not-an-address, ok@x.test");
expect(list.map((p) => p.valid)).toEqual([false, true]);
});
it("ignores empty segments", () => {
expect(parseAddressList(" , ;; ")).toEqual([]);
});
});
describe("isValidAddress / addressOf", () => {
it("accepts ordinary addresses and rejects malformed ones", () => {
expect(isValidAddress("a.b+c@sub.example.test")).toBe(true);
for (const bad of ["a@", "@x.test", "a b@x.test", "a@x", "a@@x.test", "<a@x.test>"]) {
expect(isValidAddress(bad)).toBe(false);
}
});
it("reads the mailbox out of a display form, lowercased", () => {
expect(addressOf('Jenny <Jenny@X.test>')).toBe("jenny@x.test");
expect(addressOf("BARE@x.test")).toBe("bare@x.test");
});
});
const thread = {
from: "Alice <alice@x.test>",
to: ["me@mail.test", "Bob <bob@x.test>"],
cc: ["carol@x.test", "ME@mail.test"],
};
describe("replyRecipients", () => {
it("answers the sender", () => {
expect(replyRecipients(thread, "me@mail.test")).toEqual({ to: ["Alice <alice@x.test>"], cc: [] });
});
it("answers the original recipients when the user wrote the message", () => {
const sent = { from: "Me <me@mail.test>", to: ["bob@x.test"], cc: [] };
expect(replyRecipients(sent, "me@mail.test").to).toEqual(["bob@x.test"]);
});
});
describe("replyAllRecipients", () => {
it("reaches everyone but the user, with no repeats", () => {
const got = replyAllRecipients(thread, "me@mail.test");
expect(got.to).toEqual(["Alice <alice@x.test>", "Bob <bob@x.test>"]);
expect(got.cc).toEqual(["carol@x.test"]);
});
it("never lists someone in both To and Cc", () => {
const got = replyAllRecipients(
{ from: "a@x.test", to: ["me@mail.test"], cc: ["A@x.test", "d@x.test"] },
"me@mail.test",
);
expect(got.to).toEqual(["a@x.test"]);
expect(got.cc).toEqual(["d@x.test"]);
});
it("drops the user even with different casing", () => {
const got = replyAllRecipients(thread, "Me@Mail.Test");
expect([...got.to, ...got.cc].some((e) => addressOf(e) === "me@mail.test")).toBe(false);
});
});
describe("offersReplyAll", () => {
it("is false when only the sender and the user are on the thread", () => {
expect(offersReplyAll({ from: "a@x.test", to: ["me@mail.test"], cc: [] }, "me@mail.test")).toBe(false);
});
it("is true when anyone else was addressed", () => {
expect(offersReplyAll(thread, "me@mail.test")).toBe(true);
});
});
describe("dedupe / prefixSubject", () => {
it("keeps the first of a repeated mailbox", () => {
expect(dedupe(["A <a@x.test>", "a@x.test", "b@x.test"])).toEqual(["A <a@x.test>", "b@x.test"]);
});
it("prefixes once", () => {
expect(prefixSubject("Re: Re: hello", "reply")).toBe("Re: hello");
expect(prefixSubject("hello", "forward")).toBe("Fwd: hello");
expect(prefixSubject("Fwd: hello", "forward")).toBe("Fwd: hello");
expect(prefixSubject("", "reply")).toBe("");
});
});
+24 -2
View File
@@ -68,7 +68,29 @@ describe("remote content blocking", () => {
}); });
it("neutralises CSS url() references and background attributes", () => { it("neutralises CSS url() references and background attributes", () => {
expect(blockRemoteImages(`<div style="background:url(https://t.test/p.png)">x</div>`)).not.toContain("t.test"); // Nothing may be left that a browser would fetch: no http(s) url() and no
expect(blockRemoteImages(`<table background="https://t.test/p.png"><tr><td>x</td></tr></table>`)).not.toContain("t.test"); // live background attribute.
const css = blockRemoteImages(`<div style="background:url(https://t.test/p.png)">x</div>`);
expect(css).not.toMatch(/url\(\s*['"]?https?:/i);
const attr = blockRemoteImages(`<table background="https://t.test/p.png"><tr><td>x</td></tr></table>`);
expect(attr).not.toMatch(/\sbackground=/i);
});
it("restores backgrounds when the reader opts in", () => {
const html = `<table background="https://t.test/a.png"><tr><td style="background-image:url('https://t.test/b.png')">x</td></tr></table>`;
expect(unblockRemoteImages(blockRemoteImages(html))).toBe(html);
});
it("keeps layout attributes that are not URLs", () => {
const out = sanitizeEmailHtml(`<table width="600" cellpadding="0" bgcolor="#fff"><tr><td colspan="2" align="center"><img src="https://t.test/a.png" width="600" height="20"></td></tr></table>`);
for (const attr of ['width="600"', 'cellpadding="0"', 'bgcolor="#fff"', 'colspan="2"', 'align="center"', 'height="20"']) {
expect(out).toContain(attr);
}
});
it("still refuses scripting schemes in attributes", () => {
const out = sanitizeEmailHtml(`<a href="javascript:alert(1)">x</a><a href="data:text/html,<script>1</script>">y</a>`);
expect(out).not.toMatch(/javascript:/i);
expect(out).not.toMatch(/data:text/i);
}); });
}); });