Merge feat/trusted-senders: a sender the mailbox trusts skips spam and always shows images, kept on the server
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
d283ff6f8d
32 files changed
+21246
-34
No files matched your search
@@ -0,0 +1,26 @@
|
|||||||
|
# OpenWolf: machine-local state (committed state: cerebrum.md, STATUS.md,
|
||||||
|
# memory.md, buglog.json, anatomy.md, anatomy-index.json, config.json, OPENWOLF.md)
|
||||||
|
hooks/
|
||||||
|
backups/
|
||||||
|
cache/
|
||||||
|
daemon.log
|
||||||
|
daemon.pid
|
||||||
|
dashboard-token
|
||||||
|
token-ledger.json
|
||||||
|
usage-report.json
|
||||||
|
updates/
|
||||||
|
handoff/
|
||||||
|
activity/
|
||||||
|
usage/
|
||||||
|
archive/
|
||||||
|
session-memory/
|
||||||
|
ledger-pending/
|
||||||
|
ledger-sessions/
|
||||||
|
bug-pending/
|
||||||
|
*.pending/
|
||||||
|
suggestions.json
|
||||||
|
cron-state.json
|
||||||
|
_scan-state.json
|
||||||
|
_*.json
|
||||||
|
*.lock
|
||||||
|
*.tmp
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# OpenWolf Operating Protocol
|
||||||
|
|
||||||
|
You are working in an OpenWolf-managed project. These rules apply every turn.
|
||||||
|
|
||||||
|
OpenWolf's hooks handle the bookkeeping: they maintain `.wolf/anatomy.md` and `.wolf/memory.md` after writes, track reads, and surface anatomy hints when you read files. Do not update those two files manually unless your agent has no OpenWolf hooks installed (Gemini CLI, Cursor).
|
||||||
|
|
||||||
|
## STATUS.md: read first, keep fresh
|
||||||
|
|
||||||
|
`.wolf/STATUS.md` is the handoff document. Read it FIRST when resuming a session; it replaces re-reading memory, plans, and code to reconstruct context.
|
||||||
|
|
||||||
|
Keep it fresh: when the user signals a quest is done ("done", "ship it", "next phase", "/clear", "wrap up"), move finished items to the done section, write the next quest (objective, files, decisions), and bump the date. Do this before responding "done" on any multi-file task and before suggesting `/clear`. A stale STATUS.md wastes the next session.
|
||||||
|
|
||||||
|
## File navigation
|
||||||
|
|
||||||
|
1. Before reading an unfamiliar file, grep `.wolf/anatomy.md` for its path to get a one-line description and token estimate. Do NOT read anatomy.md whole; it is an index, not a document.
|
||||||
|
2. If the description answers your question, skip the full read. For large files, prefer Read with offset/limit over whole-file reads.
|
||||||
|
3. If a file is not in anatomy.md, search with Grep/Glob. Regenerate the index with `openwolf scan`.
|
||||||
|
|
||||||
|
## Code generation
|
||||||
|
|
||||||
|
1. Before generating code, check `.wolf/cerebrum.md`: respect `## Do-Not-Repeat` (past mistakes), `## Key Learnings`, and `## User Preferences`.
|
||||||
|
2. Update cerebrum.md whenever you learn something: a user correction or preference, a project convention not obvious from code, an API surprise, a gotcha that would trip a fresh session, a significant decision and its why. The bar is LOW; a redundant entry costs nothing, a missing one repeats the discovery next session.
|
||||||
|
|
||||||
|
## Bug logging
|
||||||
|
|
||||||
|
Before fixing any bug: grep `.wolf/buglog.json` for the error message or filename; the fix may already be known.
|
||||||
|
|
||||||
|
After fixing any bug, failed test, failed build, or user-reported problem: append an entry with `id`, `timestamp`, `error_message`, `file`, `root_cause`, `fix`, `tags`, `occurrences`, `last_seen`. Also log when you edit a file more than twice to get it right. The threshold is LOW.
|
||||||
|
|
||||||
|
## Token discipline
|
||||||
|
|
||||||
|
- Never re-read a file already read this session unless it changed since.
|
||||||
|
- Prefer anatomy descriptions and targeted Grep over full file reads.
|
||||||
|
- If appending to a file, do not read the entire file first.
|
||||||
|
|
||||||
|
## Session end
|
||||||
|
|
||||||
|
Before wrapping up: update `.wolf/STATUS.md`, write a one-line session summary to `.wolf/memory.md` (`| HH:MM | description | file(s) | outcome | ~tokens |`), and record any learnings or bugs from the session in cerebrum.md / buglog.json.
|
||||||
|
|
||||||
|
## On-demand skills
|
||||||
|
|
||||||
|
- `/designqc`: screenshot-based design review of the running app (uses `openwolf designqc`).
|
||||||
|
- `/reframe`: UI framework selection, migration, and anti-generic design audits.
|
||||||
|
- `/security-audit`: security review of the project.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
description: session handoff, regenerate with /handoff when a quest finishes
|
||||||
|
budget_tokens: 1000
|
||||||
|
---
|
||||||
|
# STATUS — LambdaMail
|
||||||
|
|
||||||
|
> Single source of truth for resuming work. Read this FIRST when starting a session.
|
||||||
|
> Update this file at the end of every work phase so the next `/clear` resumes in 1 read.
|
||||||
|
> Last updated: 2026-10-06
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Done
|
||||||
|
|
||||||
|
<!-- Move items here from "🚀 Next phase" when finished. Group by area. -->
|
||||||
|
|
||||||
|
- (nothing yet — fill in as work completes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Next phase
|
||||||
|
|
||||||
|
**Goal:** _<what we're building next, in 1 sentence>_
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
1. _<concrete user-visible outcome>_
|
||||||
|
2. _<...>_
|
||||||
|
|
||||||
|
### Files to create / edit
|
||||||
|
| Type | File | Content |
|
||||||
|
|---|---|---|
|
||||||
|
| new | `path/to/file.ts` | _what it does_ |
|
||||||
|
|
||||||
|
### Closed decisions
|
||||||
|
- _<choice + reasoning>_
|
||||||
|
|
||||||
|
### Open decisions
|
||||||
|
- _<question to ask the user before coding>_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Active architecture
|
||||||
|
|
||||||
|
- **Stack:** _<frameworks, libraries, runtime>_
|
||||||
|
- **Key tables / modules:** _<list>_
|
||||||
|
- **Patterns:** _<conventions enforced project-wide>_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ External blockers (don't block coding)
|
||||||
|
|
||||||
|
- _<env vars, secrets, external accounts, manual steps>_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Useful commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# add the most-used commands here so the next session has them ready
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 References (read IF needed)
|
||||||
|
|
||||||
|
- `.wolf/cerebrum.md` — User Preferences + Do-Not-Repeat + Decision Log
|
||||||
|
- `.wolf/anatomy.md` — token-efficient file index
|
||||||
|
- `.wolf/buglog.json` — known bugs + fixes
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,648 @@
|
|||||||
|
# anatomy.md
|
||||||
|
|
||||||
|
> Auto-maintained by OpenWolf. Last scanned: 2026-10-22T17:41:17.634Z
|
||||||
|
> Files: 439 tracked | Anatomy hits: 0 | Misses: 0
|
||||||
|
|
||||||
|
> Project structure index. Auto-maintained by OpenWolf hooks and daemon.
|
||||||
|
> Run `openwolf scan` to generate, or wait for the first Claude Code session.
|
||||||
|
> Status: Pending initial scan
|
||||||
|
|
||||||
|
## ./
|
||||||
|
|
||||||
|
- `.gitignore` — Git ignore rules (~277 tok)
|
||||||
|
- `.trivyignore` — Findings that have been assessed and deliberately accepted. (~211 tok)
|
||||||
|
- `CLAUDE.md` — OpenWolf (~99 tok)
|
||||||
|
- `docker-compose.build.yaml` — Development overlay: builds the images from source instead of pulling them. (~260 tok)
|
||||||
|
- `docker-compose.yaml` — Docker Compose services (~5222 tok)
|
||||||
|
- `LICENSE` — Project license (~287 tok)
|
||||||
|
- `Makefile` — Make build targets (~1327 tok)
|
||||||
|
- `README.md` — Project documentation (~3456 tok)
|
||||||
|
|
||||||
|
## .github/workflows/
|
||||||
|
|
||||||
|
- `ci.yml` — CI: CI (~2438 tok)
|
||||||
|
- `release.yml` — CI: Release (~1863 tok)
|
||||||
|
|
||||||
|
## config/rspamd/
|
||||||
|
|
||||||
|
- `actions.conf` — Score thresholds from PLAN.md section 10.1. The principle behind them is (~108 tok)
|
||||||
|
- `logging.inc` (~10 tok)
|
||||||
|
- `options.inc` — DNS resolution is needed for RBL, SPF and DKIM lookups. (~33 tok)
|
||||||
|
- `ratelimit.conf` — Rate limiting of PLAN.md section 5.2. (~408 tok)
|
||||||
|
- `worker-normal.inc` — The protocols service reaches Rspamd over the internal network only; the (~40 tok)
|
||||||
|
|
||||||
|
## docs/
|
||||||
|
|
||||||
|
- `DEPLOYMENT.md` — Deploying LambdaMail on Coolify (~2605 tok)
|
||||||
|
- `OPERATIONS.md` — Operating LambdaMail (~2229 tok)
|
||||||
|
|
||||||
|
## migrations/
|
||||||
|
|
||||||
|
- `0001_init_schema.down.sql` — 0001_init_schema.down.sql (~225 tok)
|
||||||
|
- `0001_init_schema.up.sql` — 0001_init_schema.up.sql (~5730 tok)
|
||||||
|
- `0002_add_is_system_to_aliases.down.sql` — Revert is_system column from aliases table (~29 tok)
|
||||||
|
- `0002_add_is_system_to_aliases.up.sql` — Add is_system column to aliases table to track mandatory system security aliases (~45 tok)
|
||||||
|
- `0003_create_report_tables.down.sql` (~47 tok)
|
||||||
|
- `0003_create_report_tables.up.sql` — 0003_create_report_tables.up.sql (~575 tok)
|
||||||
|
- `0004_index_outbound_stale_locks.down.sql` (~14 tok)
|
||||||
|
- `0004_index_outbound_stale_locks.up.sql` — Delivery workers claim a job by moving it to SENDING with a lock deadline. (~105 tok)
|
||||||
|
- `0005_create_acme_tables.down.sql` (~34 tok)
|
||||||
|
- `0005_create_acme_tables.up.sql` — Self-managed ACME state (PLAN.md section 8.3, mode B). (~752 tok)
|
||||||
|
- `0006_add_user_settings_and_system_config.down.sql` — SQL: 2 alter(s) (~44 tok)
|
||||||
|
- `0006_add_user_settings_and_system_config.up.sql` — SQL: tables: system_config, 2 alter(s) (~135 tok)
|
||||||
|
- `0007_reconcile_folder_counters.down.sql` — Irreversible by nature: this migration replaced incorrect counter values and (~90 tok)
|
||||||
|
- `0007_reconcile_folder_counters.up.sql` — Reconcile folders.unread_count and folders.total_count with the messages (~819 tok)
|
||||||
|
- `0008_add_reports_folder.down.sql` — Only removes the folder where it is still empty. A Reports folder holding (~94 tok)
|
||||||
|
- `0008_add_reports_folder.up.sql` — Give every existing mailbox a Reports folder. (~185 tok)
|
||||||
|
- `0009_report_ingestion_idempotency.down.sql` (~30 tok)
|
||||||
|
- `0009_report_ingestion_idempotency.up.sql` — Make storing a report idempotent, keyed on the reporter and their own id (~425 tok)
|
||||||
|
- `0010_vacation_reply_log.down.sql` (~12 tok)
|
||||||
|
- `0010_vacation_reply_log.up.sql` — Who has already been told the mailbox is unattended. (~257 tok)
|
||||||
|
- `0011_backfill_markers.down.sql` (~10 tok)
|
||||||
|
- `0011_backfill_markers.up.sql` — Records one-shot data repairs so they run once rather than on every start. (~176 tok)
|
||||||
|
- `0012_mailbox_avatars.down.sql` (~11 tok)
|
||||||
|
- `0012_mailbox_avatars.up.sql` — The profile photo a mailbox shows inside LambdaMail. (~256 tok)
|
||||||
|
- `0013_domain_bimi.down.sql` (~10 tok)
|
||||||
|
- `0013_domain_bimi.up.sql` — The BIMI logo a domain publishes, and the certificate that vouches for it. (~188 tok)
|
||||||
|
|
||||||
|
## scripts/
|
||||||
|
|
||||||
|
- `dev-seed.sql` — Dev-only fixture data for local SMTP testing. Not a schema migration - (~533 tok)
|
||||||
|
- `lang-lint.mjs` — Fails the build if tracked source contains non-ASCII characters or common (~483 tok)
|
||||||
|
|
||||||
|
## services/auth/
|
||||||
|
|
||||||
|
- `Dockerfile` — Docker container definition (~408 tok)
|
||||||
|
- `package.json` — Node.js package manifest (~138 tok)
|
||||||
|
- `tsconfig.build.json` — TypeScript build configuration (~83 tok)
|
||||||
|
- `tsconfig.json` — TypeScript configuration (~88 tok)
|
||||||
|
- `vitest.config.ts` — Vitest test configuration (~153 tok)
|
||||||
|
|
||||||
|
## services/auth/src/
|
||||||
|
|
||||||
|
- `accounts.ts` — Several webmail accounts signed in at once, and a session that stays alive (~715 tok)
|
||||||
|
- `auth.test.ts` — Declares password (~3292 tok)
|
||||||
|
- `cli.ts` — Operator commands for a fresh deployment. (~2135 tok)
|
||||||
|
- `crypto.ts` — Derives the key-encryption key. Unlike the previous implementation there is (~1465 tok)
|
||||||
|
- `db.ts` — Lazily opens the shared connection pool. It is lazy so that unit tests which (~266 tok)
|
||||||
|
- `diagnostics.test.ts` — Declares UserFacts (~1317 tok)
|
||||||
|
- `diagnostics.ts` — Checks an operator can run by hand, against the whole server or one user. (~1557 tok)
|
||||||
|
- `health.test.ts` — Declares withServer (~470 tok)
|
||||||
|
- `health.ts` — Builds the /health/live and /health/ready handler shared by the auth and (~215 tok)
|
||||||
|
- `imageValidation.test.ts` — Declares png (~741 tok)
|
||||||
|
- `imageValidation.ts` — What an uploaded avatar is allowed to be. (~598 tok)
|
||||||
|
- `mfaHelpers.ts` — Generates single-use recovery codes. Only the hashes are ever stored, so a (~494 tok)
|
||||||
|
- `pagination.test.ts` — Declares at (~661 tok)
|
||||||
|
- `pagination.ts` — Page and filter parameters, read from a URL and made safe for SQL. (~510 tok)
|
||||||
|
- `repository.ts` — Decides whether this account must present a second factor now. (~14732 tok)
|
||||||
|
- `router.test.ts` — Enrolls a real second factor and returns the shared secret. (~16096 tok)
|
||||||
|
- `router.ts` — Cookie scoped to the whole origin: the API lives under /api, so a cookie (~15221 tok)
|
||||||
|
- `server.ts` — Declares port (~236 tok)
|
||||||
|
- `session.ts` — The signing secret. There is deliberately no default. (~1406 tok)
|
||||||
|
- `surfaceAccess.test.ts` — Which surface a login opens, and what it costs to cross between them. (~1640 tok)
|
||||||
|
- `surfaceAccess.ts` — Which surface a login opens, and what crossing between them costs. (~712 tok)
|
||||||
|
- `totp.ts` — Renders the otpauth:// URI an authenticator app scans. (~1037 tok)
|
||||||
|
|
||||||
|
## services/migrations/
|
||||||
|
|
||||||
|
- `Dockerfile` — Docker container definition (~241 tok)
|
||||||
|
|
||||||
|
## services/protocols/
|
||||||
|
|
||||||
|
- `.go-arch-lint.yml` — health: (~99 tok)
|
||||||
|
- `Dockerfile` — Docker container definition (~312 tok)
|
||||||
|
- `go.mod` — Go module definition (~326 tok)
|
||||||
|
- `go.sum` — Go dependency checksums (~2706 tok)
|
||||||
|
|
||||||
|
## services/protocols/cmd/lambdamail-protocols/
|
||||||
|
|
||||||
|
- `acme_mode.go` — tlsaPublisher (26 fields); methods: PublishTlsa (~1204 tok)
|
||||||
|
- `backfill_attachments.go` — Declares attachmentBackfillName (~717 tok)
|
||||||
|
- `backfill_reports.go` (~501 tok)
|
||||||
|
- `bimi_store.go` — bimiStore (4 fields); methods: Save, Load, Delete (~256 tok)
|
||||||
|
- `config.go` — config (52 fields) (~1251 tok)
|
||||||
|
- `disk_other.go` — go:build !unix (~98 tok)
|
||||||
|
- `disk_unix.go` — go:build unix (~183 tok)
|
||||||
|
- `dns_reconciler.go` — domainReconciler (14 fields); methods: ReconcileDomain (~961 tok)
|
||||||
|
- `main.go` — Command lambdamail-protocols is the entry point for the protocols service. (~8598 tok)
|
||||||
|
- `preflight.go` (~994 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/application/port/
|
||||||
|
|
||||||
|
- `arc_sealer.go` — ArcSealer adds an Authenticated Received Chain set to a message (~222 tok)
|
||||||
|
- `auth_repository.go` — Interface: AuthRepository (2 methods) (~205 tok)
|
||||||
|
- `blob_reader.go` — Interface: BlobReader (2 methods) (~65 tok)
|
||||||
|
- `blob_storage.go` — Interface: BlobStorage (2 methods) (~152 tok)
|
||||||
|
- `cert_provider.go` — CertProvider supplies the TLS certificate for STARTTLS. Its signature (~104 tok)
|
||||||
|
- `certificate_store.go` — Interface: CertificateStore (8 methods) (~524 tok)
|
||||||
|
- `content_scanner.go` — Interface: ContentScanner (2 methods) (~93 tok)
|
||||||
|
- `copy_repository.go` — CopiedMessage records one message's UID before and after a COPY/MOVE. (~244 tok)
|
||||||
|
- `dkim_repository.go` — DkimSigningKey is one active signing key, already decrypted and ready to (~304 tok)
|
||||||
|
- `dns_provider.go` — Interface: DnsProvider (6 methods) (~168 tok)
|
||||||
|
- `dns_verifier.go` — Interface: DnsVerifier (2 methods) (~190 tok)
|
||||||
|
- `expunge_repository.go` — ExpungeRepository permanently marks messages as expunged (soft delete - (~208 tok)
|
||||||
|
- `flag_repository.go` — FlagOp mirrors imap.StoreFlagsOp without importing the imap package here (~222 tok)
|
||||||
|
- `imap_folder_repository.go` — ImapFolderRecord is the IMAP-relevant subset of a folder row. (~185 tok)
|
||||||
|
- `inbound_message_repository.go` — Interface: InboundMessageRepository (2 methods) (~626 tok)
|
||||||
|
- `mail_authenticator.go` — Interface: MailAuthenticator (2 methods) (~424 tok)
|
||||||
|
- `mailbox_repository.go` — declares the interfaces the application layer depends on. (~386 tok)
|
||||||
|
- `message_query_repository.go` — Interface: MessageQueryRepository (2 methods) (~211 tok)
|
||||||
|
- `mx_resolver.go` — Interface: MXResolver (2 methods) (~38 tok)
|
||||||
|
- `outbound_repository.go` — Interface: OutboundRepository (5 methods) (~200 tok)
|
||||||
|
- `outbox.go` — Interface: OutboxRepository (4 methods) (~237 tok)
|
||||||
|
- `report_repository.go` — Interface: ReportRepository (8 methods) (~617 tok)
|
||||||
|
- `sieve_repository.go` — SieveScriptRecord represents a persistent Sieve script row in PostgreSQL. (~238 tok)
|
||||||
|
- `system_alias_repository.go` — SystemAliasRepository manages mandatory system security aliases (postmaster, abuse, dmarc, tlsrpt). (~75 tok)
|
||||||
|
- `tls_policy_resolver.go` — Interface: TLSPolicyResolver (2 methods) (~203 tok)
|
||||||
|
- `webmail_repository.go` — Interface: WebmailRepository (13 methods) (~1306 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/application/usecase/
|
||||||
|
|
||||||
|
- `apply_rules_test.go` — stubSieveScripts (35 fields); methods: ActiveScriptFor (~2504 tok)
|
||||||
|
- `apply_rules.go` — ApplyRulesUseCase (20 fields); methods: For (~990 tok)
|
||||||
|
- `backfill_reports_test.go` — stubDeliveredReportStore (35 fields); methods: ListUningestedReports, MoveToReportsFolder, ReadByID (~1565 tok)
|
||||||
|
- `backfill_reports.go` — BackfillReportsUseCase (16 fields); methods: Run (~846 tok)
|
||||||
|
- `imap_session_test.go` — fakeAuthRepository (40 fields); methods: FindByAddress, ListFolders, FindByName, Expunge (~1828 tok)
|
||||||
|
- `imap_session.go` — ImapSessionUseCase (25 fields); methods: SetTrackerManager, GetTrackerManager, Login, ListFolders (~1085 tok)
|
||||||
|
- `inbound_auth_test.go` — stubAuthenticator (19 fields); methods: Authenticate (~1438 tok)
|
||||||
|
- `inbound_scan_headers_test.go` — capturingBlobStorage (9 fields); methods: Store, Scan (~882 tok)
|
||||||
|
- `inbound_system_message_test.go` — junkingScanner (7 fields); methods: Scan (~1144 tok)
|
||||||
|
- `ingest_delivered_reports_test.go` — recordingReportRepo (48 fields); methods: SaveDmarcReport, SaveTlsRptReport (~3334 tok)
|
||||||
|
- `ingest_delivered_reports.go` — IngestDeliveredReportsUseCase (34 fields); methods: Ingest (~1905 tok)
|
||||||
|
- `ingest_reports_test.go` — fakeReportRepository (8 fields); methods: SaveDmarcReport, SaveTlsRptReport (~612 tok)
|
||||||
|
- `ingest_reports.go` — IngestReportsUseCase (15 fields); methods: IngestDmarc, IngestTlsRpt (~347 tok)
|
||||||
|
- `mailbox_tracker_manager.go` — MailboxTrackerManager (9 fields); methods: GetTracker, NotifyNumMessages, NotifyMessageFlags, NotifyExpunge (~585 tok)
|
||||||
|
- `manage_folders_test.go` — stubFolderStore (47 fields); methods: ListFolders, CreateFolder, RenameFolder, DeleteFolder (~2128 tok)
|
||||||
|
- `manage_folders.go` — ManageFoldersUseCase (52 fields); methods: Create, Rename, Delete (~1304 tok)
|
||||||
|
- `managesieve_session_test.go` — fakeSieveRepository (27 fields); methods: GetScript, PutScript, ListScripts, SetActiveScript (~1114 tok)
|
||||||
|
- `managesieve_session.go` — ManageSieveSessionUseCase (45 fields); methods: Login, PutScript, GetScript, ListScripts (~1011 tok)
|
||||||
|
- `message_headers_test.go` — TestExtractMessageHeaders_ReadsSubjectSenderAndSnippet, TestExtractMessageHeaders_DecodesEncodedWords, TestExtractMessageHeaders_FlagsMultipartMixe... (~1604 tok)
|
||||||
|
- `message_headers.go` — MessageHeaders (33 fields) (~1388 tok)
|
||||||
|
- `message_render_test.go` — TestRenderMessage_ExtractsHeadersAndText, TestRenderMessage_LeavesDateEmptyWhenHeaderAbsent, TestRenderMessage_PrefersPlainTextAndListsAttachments,... (~1976 tok)
|
||||||
|
- `message_render.go` — RenderedMessage (43 fields) (~2160 tok)
|
||||||
|
- `outbound_dsn_test.go` — TestOutboundWorker_PermanentSmtpFailureBouncesImmediately, TestOutboundWorker_TemporarySmtpFailureIsDeferred, TestOutboundWorker_DsnToFanOutSenderD... (~1480 tok)
|
||||||
|
- `outbound_local_delivery_test.go` — countingMXResolver (9 fields); methods: LookupMX (~1114 tok)
|
||||||
|
- `outbound_tls_policy_test.go` — stubPolicyResolver (29 fields); methods: Resolve (~1582 tok)
|
||||||
|
- `outbound_worker_test.go` — fakeOutboundRepo (18 fields); methods: Enqueue, FetchNextReady, CountRecipientsSince, UpdateJob (~744 tok)
|
||||||
|
- `outbound_worker.go` — OutboundWorkerUseCase (72 fields); methods: SetRelay, SetPolicyResolver, ProcessBatch (~4121 tok)
|
||||||
|
- `outbox_relay.go` — OutboxRelay (16 fields); methods: Run, PublishBatch (~551 tok)
|
||||||
|
- `pop3_session_test.go` — fakeMessageQueryRepository (12 fields); methods: ListMessages (~706 tok)
|
||||||
|
- `pop3_session.go` — Pop3SessionUseCase (31 fields); methods: Login, GetInbox, ReadBlob, ExpungeMessages (~690 tok)
|
||||||
|
- `preflight_test.go` — TestPreflight_HealthyDeploymentDoesNotBlock, TestPreflight_BlockedPort25IsCriticalAndSuggestsSmarthost, TestPreflight_SmarthostSkipsPort25Check, Te... (~2796 tok)
|
||||||
|
- `preflight.go` — CheckResult (112 fields); methods: Blocking, String, Execute (~3892 tok)
|
||||||
|
- `process_inbound_email_test.go` — fakeMailboxRepository (36 fields); methods: FindActiveByAddress, ResolveDeliveryTargets, FindActiveByAddress, ResolveDeliveryTargets (~2332 tok)
|
||||||
|
- `process_inbound_email.go` — contains the application's use cases (Clean Architecture layer 2). (~4084 tok)
|
||||||
|
- `process_outbound_email_test.go` — fakeAuthRepo (31 fields); methods: FindByAddress, CountRecipientsSince, Sign (~1768 tok)
|
||||||
|
- `process_outbound_email.go` — ProcessOutboundEmailInput (50 fields); methods: Authenticate, Submit (~1739 tok)
|
||||||
|
- `provision_dkim_keys_test.go` — fakeDkimStore (26 fields); methods: FindPublicKey, Insert, FindPublicKey, Insert (~1089 tok)
|
||||||
|
- `provision_dkim_keys.go` — Interface: DkimKeyProvisioner (5 methods) (~1121 tok)
|
||||||
|
- `relay_test.go` — fakeRelay (59 fields) (~3001 tok)
|
||||||
|
- `relay.go` — RelayConfig (42 fields); methods: Configured, Address, SpfInclude (~1588 tok)
|
||||||
|
- `rollover_tlsa_test.go` — TestRollover_StableWhenRenewalIsFarAway, TestRollover_StartsWhenRenewalApproaches, TestRollover_PublishesBothAssociationsWhileInFlight, TestRollove... (~2109 tok)
|
||||||
|
- `rollover_tlsa.go` — RolloverConfig (50 fields); methods: WithClock, Decide, ShouldRenewNow (~2470 tok)
|
||||||
|
- `scanning_pipeline_test.go` — mockScanner (9 fields); methods: Scan (~405 tok)
|
||||||
|
- `scanning_pipeline.go` — ScanningPipeline (13 fields); methods: Scan (~374 tok)
|
||||||
|
- `smtp_rejection.go` — SmtpRejection is a use case's decision to refuse a message, carrying the (~650 tok)
|
||||||
|
- `sync_dns_conflict_test.go` — stubDnsVerifier (32 fields); methods: VerifyRecord (~1548 tok)
|
||||||
|
- `sync_dns_records_test.go` — fakeDnsProvider (26 fields); methods: GetZoneID, ListRecords, CreateRecord, UpdateRecord (~1060 tok)
|
||||||
|
- `sync_dns_records.go` — SyncDnsRecordsInput (75 fields); methods: String, SetVerifier, Execute (~2481 tok)
|
||||||
|
- `vacation_reply_test.go` — TestReplyNamesTheMessageItAnswers, TestReplyCarriesTheConversationChain, TestReplyStartsTheChainWhenThereIsNone, TestReplyOmitsThreadingWhenThereIs... (~3030 tok)
|
||||||
|
- `vacation_reply.go` — pendingVacation (42 fields); methods: SetVacationSender, SetVacationSuppression (~2544 tok)
|
||||||
|
- `vacation_suppression_test.go` — stubVacationLog (18 fields); methods: LastRepliedAt, RecordReply (~1409 tok)
|
||||||
|
- `vacation_suppression.go` — VacationSuppression (19 fields); methods: ShouldReply, Record (~778 tok)
|
||||||
|
- `webmail_compose_test.go` — stubWebmailRepo (47 fields); methods: FindMailboxIDByAddress, ListFolders, ListMessages, GetMessageBlob (~4946 tok)
|
||||||
|
- `webmail_mail.go` — WebmailUseCase (81 fields); methods: WithLocalFiling, MailboxIDFor, ListFolders, ListMessages (~5136 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/domain/bimi/
|
||||||
|
|
||||||
|
- `svg_test.go` — TestValidate_AcceptsAConformingLogo, TestValidate_RequiresTheProfileReceiversLookFor, TestValidate_RequiresASquareViewBox, TestValidate_RequiresExa... (~1630 tok)
|
||||||
|
- `svg.go` — validates the logo a domain publishes for BIMI. (~1575 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/domain/entity/
|
||||||
|
|
||||||
|
- `dkim_key_pair_test.go` — TestNewDkimKeyPair_StartsPending, TestDkimKeyPair_Activate_SetsActive (~139 tok)
|
||||||
|
- `dkim_key_pair.go` — DkimStatus is the lifecycle state of a DkimKeyPair (PLAN.md section 9, dkim_keys.status). (~317 tok)
|
||||||
|
- `dmarc_report_test.go` — TestParseDmarcXmlReport_ValidXML, TestParseTlsRptReport_ValidJSON (~659 tok)
|
||||||
|
- `dmarc_report.go` — DmarcRecord (59 fields) (~1261 tok)
|
||||||
|
- `dmarc_zip_test.go` — TestDmarcReportParsesZippedPayload, TestDmarcReportStillParsesPlainXml, TestDmarcReportRejectsEmptyZip (~708 tok)
|
||||||
|
- `dns_record_idempotency_test.go` — TestBuildDnsRecordSpecs_MtaStsIdIsStableAcrossCalls, TestBuildDnsRecordSpecs_MtaStsIdChangesWithPolicy, TestDnsRecord_EqualsNormalized_DetectsPrior... (~740 tok)
|
||||||
|
- `dns_record_test.go` — TestBuildDnsRecordSpecs_GeneratesMandatoryRecords, TestDkimRecordsAreOmittedWhenThereIsNoKey, TestDkimRecordsArePublishedWhenKeysExist, TestOnlyThe... (~2424 tok)
|
||||||
|
- `dns_record.go` — DnsRecord (47 fields); methods: EqualsNormalized (~2951 tok)
|
||||||
|
- `domain_test.go` — TestNewDomain_StartsActiveWithNoMailboxes, TestDomain_Deactivate_SucceedsWithNoActiveMailboxes, TestDomain_Deactivate_FailsWithActiveMailboxes (~308 tok)
|
||||||
|
- `domain.go` — contains the domain's aggregate roots and entities (Clean Architecture layer 1). (~398 tok)
|
||||||
|
- `folder_test.go` — TestFolder_NextUID_StartsAtOneAndIsMonotonic (~141 tok)
|
||||||
|
- `folder.go` — Folder is an IMAP folder entity. uidNext is strictly monotonic (RFC 3501 section 2.3.1.1), (~168 tok)
|
||||||
|
- `mailbox_test.go` — TestNewMailbox_StartsWithZeroUsage, TestMailbox_RecordUsage_AllowsUpToQuota, TestMailbox_RecordUsage_RejectsOverQuota (~329 tok)
|
||||||
|
- `mailbox.go` — Mailbox (10 fields); methods: Address, UsedBytes, Quota, RecordUsage (~324 tok)
|
||||||
|
- `outbound_job_test.go` — TestCalculateNextBackoff_FollowsRetryTable, TestCalculateNextBackoff_ExpiredJobIsPermanent (~532 tok)
|
||||||
|
- `outbound_job.go` — OutboundJob (35 fields) (~705 tok)
|
||||||
|
- `sieve_script.go` — SieveScript represents a user's Sieve filtering script entity. (~68 tok)
|
||||||
|
- `sieve_validator_test.go` — TestValidateSieveScript_ValidScripts, TestValidateSieveScript_InvalidScripts (~396 tok)
|
||||||
|
- `sieve_validator.go` — ValidateSieveScript (~644 tok)
|
||||||
|
- `tls_policy_enforcement_test.go` — TestTLSPolicy_DaneTakesPrecedenceOverMtaSts, TestTLSPolicy_OpportunisticDoesNotRequireValidation, TestTLSPolicy_MtaStsTestingDoesNotEnforce, TestTL... (~1571 tok)
|
||||||
|
- `tls_policy_test.go` — TestTLSPolicy_Effective_DANETakesPrecedenceOverMTASTS, TestTLSPolicy_Effective_FallsBackToMTASTSWithoutDANE, TestTLSPolicy_Effective_FallsBackToOpp... (~232 tok)
|
||||||
|
- `tls_policy.go` — TLSPolicy (35 fields); methods: Effective, RequiresValidation, CoversHost, MatchesCertificate (~1537 tok)
|
||||||
|
- `tls_rpt_report_test.go` — TestTlsRptReportCarriesThePolicyDomain, TestTlsRptReportParsesGzippedPayload (~603 tok)
|
||||||
|
- `tls_rpt_report.go` — TlsRptPolicy (34 fields) (~718 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/domain/event/
|
||||||
|
|
||||||
|
- `events_test.go` — TestEmailReceived_ImplementsDomainEvent, TestEmailBounced_ImplementsDomainEvent (~138 tok)
|
||||||
|
- `events.go` — contains the domain events listed in PLAN.md section 3. (~1066 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/domain/sieve/
|
||||||
|
|
||||||
|
- `parser.go` — Interface: statement (12 methods) (~2127 tok)
|
||||||
|
- `sieve_test.go` — TestRuleFilesTheMessageInItsFolder, TestRuleThatDoesNotMatchLeavesTheMessageAlone, TestHeaderNameIsCaseInsensitive, TestContainsIsCaseInsensitiveOn... (~4735 tok)
|
||||||
|
- `sieve.go` — evaluates the mail-filtering rules a mailbox owner writes. (~926 tok)
|
||||||
|
- `test.go` — Interface: test (6 methods) (~1741 tok)
|
||||||
|
- `vacation.go` — shouldAutoReply decides whether a message may be answered automatically. (~701 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/domain/valueobject/
|
||||||
|
|
||||||
|
- `autoconfig_xml_test.go` — TestBuildThunderbirdAutoconfigXML, TestBuildOutlookAutodiscoverXML (~220 tok)
|
||||||
|
- `autoconfig_xml.go` — BuildThunderbirdAutoconfigXML, BuildOutlookAutodiscoverXML (~735 tok)
|
||||||
|
- `delivery_status_code_test.go` — TestNewDeliveryStatusCode_AcceptsSuccess, TestNewDeliveryStatusCode_AcceptsPermanentFailure, TestNewDeliveryStatusCode_RejectsInvalidClass (~220 tok)
|
||||||
|
- `delivery_status_code.go` — DeliveryStatusCode (6 fields); methods: String (~206 tok)
|
||||||
|
- `domain_name_test.go` — TestNewDomainName_AcceptsValidFQDN, TestNewDomainName_RejectsEmpty, TestNewDomainName_RejectsLabelWithoutDot, TestNewDomainName_RejectsTrailingHyph... (~398 tok)
|
||||||
|
- `domain_name.go` — DomainName (13 fields); methods: String, IsPunycode (~403 tok)
|
||||||
|
- `dsn_bounce_test.go` — TestBuildDsnReport_PreventsBounceLoopForEmptySender, TestBuildDsnReport_GeneratesValidMIME, TestBuildDsnReport_UsesTheConfiguredMailHost, TestBuild... (~721 tok)
|
||||||
|
- `dsn_bounce.go` — BuildDsnReport (~1106 tok)
|
||||||
|
- `email_address_test.go` — TestNewEmailAddress_AcceptsValidAddress, TestNewEmailAddress_RejectsMissingAtSign, TestNewEmailAddress_RejectsEmptyLocalPart, TestNewEmailAddress_R... (~411 tok)
|
||||||
|
- `email_address.go` — contains the domain's immutable, self-validating value objects. (~430 tok)
|
||||||
|
- `message_id_test.go` — TestNewMessageID_AcceptsAngleBracketForm, TestNewMessageID_RejectsEmpty, TestNewMessageID_RejectsMissingAngleBrackets (~202 tok)
|
||||||
|
- `message_id.go` — MessageID (6 fields); methods: String (~186 tok)
|
||||||
|
- `mta_sts_policy_test.go` — TestMtaStsPolicy_Format (~178 tok)
|
||||||
|
- `mta_sts_policy.go` — MtaStsPolicy (14 fields); methods: Format (~260 tok)
|
||||||
|
- `password_hash_test.go` — TestNewPasswordHash_AcceptsArgon2idPHCString, TestNewPasswordHash_RejectsNonArgon2idScheme, TestNewPasswordHash_RejectsEmpty (~208 tok)
|
||||||
|
- `password_hash.go` — PasswordHash (5 fields); methods: String (~223 tok)
|
||||||
|
- `protocol_security_level_test.go` — TestProtocolSecurityLevel_StringRoundTrip (~120 tok)
|
||||||
|
- `protocol_security_level.go` — ProtocolSecurityLevel is the effective TLS requirement for a listener (see PLAN.md section 4). (~190 tok)
|
||||||
|
- `quota_limit_test.go` — TestNewQuotaLimit_AcceptsPositiveBytes, TestNewQuotaLimit_RejectsZero, TestNewQuotaLimit_RejectsNegative, TestQuotaLimit_Exceeds (~261 tok)
|
||||||
|
- `quota_limit.go` — QuotaLimit is a strictly positive byte quota (see PLAN.md section 3, Mailbox invariant). (~187 tok)
|
||||||
|
- `scan_result_test.go` — TestNewCleanScanResult, TestNewVirusScanResult (~130 tok)
|
||||||
|
- `scan_result.go` — ScanResult (16 fields) (~268 tok)
|
||||||
|
- `spam_verdict_test.go` — TestSpamVerdictFromScore_MatchesThresholds (~164 tok)
|
||||||
|
- `spam_verdict.go` — SpamVerdict classifies a message by Rspamd score (thresholds from PLAN.md section 10.1). (~136 tok)
|
||||||
|
- `tlsa_record_test.go` — TestNewTLSARecord_AcceptsUsage3Selector1MatchingType1, TestNewTLSARecord_RejectsUsage2, TestNewTLSARecord_RejectsWrongLengthHash (~328 tok)
|
||||||
|
- `tlsa_record.go` — TLSARecord models a DANE TLSA RR (RFC 6698/7671). Usage 2 (PKIX-CA) is rejected (~344 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/e2e/
|
||||||
|
|
||||||
|
- `antispam_antivirus_e2e_test.go` — TestAntispamAntivirusEndToEnd (~1692 tok)
|
||||||
|
- `dkim_rotation_e2e_test.go` — TestDkimRotationLeavesAUsableKey (~1352 tok)
|
||||||
|
- `dns_sync_e2e_test.go` — e2eFakeDnsProvider (21 fields); methods: GetZoneID, ListRecords, CreateRecord, UpdateRecord (~1052 tok)
|
||||||
|
- `events_ws_e2e_test.go` — TestEventStreamPushesDeliveredMail, TestEventStreamRequiresUserSession (~1752 tok)
|
||||||
|
- `f2_complete_e2e_test.go` — TestF2CompleteSuiteEndToEnd (~4535 tok)
|
||||||
|
- `f3_complete_e2e_test.go` — fakeDnsProvider (56 fields); methods: GetZoneID, ListRecords, CreateRecord, UpdateRecord (~4487 tok)
|
||||||
|
- `http_services_e2e_test.go` — TestHttpServicesEndToEnd (~1425 tok)
|
||||||
|
- `imap_core_e2e_test.go` — TestImapCoreEndToEnd (~3244 tok)
|
||||||
|
- `imap_idle_condstore_e2e_test.go` — TestImapIdleAndCondStoreEndToEnd (~2089 tok)
|
||||||
|
- `managesieve_e2e_test.go` — TestManageSieveEndToEnd (~1814 tok)
|
||||||
|
- `migrations_test.go` (~595 tok)
|
||||||
|
- `outbound_queue_e2e_test.go` — staticMXResolver (27 fields); methods: LookupMX (~1791 tok)
|
||||||
|
- `pop3_e2e_test.go` — TestPop3EndToEnd (~1926 tok)
|
||||||
|
- `ports_test.go` — TestEmbeddedPostgresPortsAreOutsideTheEphemeralRange, TestEmbeddedPostgresPortsAreUnique, TestEachTestConnectsToThePortItStarted (~989 tok)
|
||||||
|
- `smtp_inbound_e2e_test.go` — contains end-to-end validation of the F0/F1 scope declared in (~2808 tok)
|
||||||
|
- `submission_e2e_test.go` — plainAuthClient (68 fields); methods: Start, Next (~3375 tok)
|
||||||
|
- `webmail_api_e2e_test.go` — TestWebmailApiEndToEnd, TestWebmailApiRejectsCrossMailboxAccess, TestWebmailApiRequiresUserSession (~3572 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/health/
|
||||||
|
|
||||||
|
- `health_test.go` — TestHandler_Live_AlwaysReturns200, TestHandler_Ready_Returns200WhenPingSucceeds, TestHandler_Ready_Returns503WhenPingFails (~334 tok)
|
||||||
|
- `health.go` — exposes liveness/readiness endpoints (PLAN.md section 11.5). (~205 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/infrastructure/acme/
|
||||||
|
|
||||||
|
- `issuer.go` — implements mode B of PLAN.md section 8.3: LambdaMail obtains (~2133 tok)
|
||||||
|
- `manager.go` — Interface: TlsaPublisher (6 methods) (~2176 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/infrastructure/arc/
|
||||||
|
|
||||||
|
- `canonical.go` — implements Authenticated Received Chain (RFC 8617). (~1334 tok)
|
||||||
|
- `lazy_sealer_test.go` — stubKeyRepo (15 fields); methods: FindActiveKeys (~811 tok)
|
||||||
|
- `lazy_sealer.go` — LazySealer (18 fields); methods: Seal (~682 tok)
|
||||||
|
- `sealer_test.go` — TestSealer_StartsChainAtInstanceOneWithCvNone, TestSealer_WritesHeadersInPrescribedOrder, TestSealer_ProducesVerifiableChain, TestSealer_ExtendsExi... (~2324 tok)
|
||||||
|
- `sealer.go` — Sealer (56 fields); methods: WithClock, Seal (~2407 tok)
|
||||||
|
- `verifier.go` — VerifyChain (~1289 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/infrastructure/clamav/
|
||||||
|
|
||||||
|
- `clamav_adapter_test.go` — TestClamAVAdapter_Scan_CleanAndVirus (~480 tok)
|
||||||
|
- `clamav_adapter.go` — ClamAVAdapter (28 fields); methods: Scan (~745 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/infrastructure/cloudflare/
|
||||||
|
|
||||||
|
- `cloudflare_adapter_test.go` — TestCloudflareAdapter_GetZoneID_List_Create_Update_Delete (~835 tok)
|
||||||
|
- `cloudflare_adapter.go` — CloudflareAdapter (115 fields); methods: GetZoneID, ListRecords, CreateRecord, UpdateRecord (~3382 tok)
|
||||||
|
- `cloudflare_hardening_test.go` — TestCloudflareAdapter_CreateRecord_SendsProxiedFalseExplicitly, TestCloudflareAdapter_ReportsHttpErrorStatus, TestCloudflareAdapter_ListRecords_Fol... (~2754 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/infrastructure/diskstorage/
|
||||||
|
|
||||||
|
- `local_disk_blob_reader_test.go` — TestLocalDiskBlobReader_ReadByID_ReturnsStoredContent, TestLocalDiskBlobReader_ReadByID_ReturnsErrorForUnknownID (~327 tok)
|
||||||
|
- `local_disk_blob_reader.go` — LocalDiskBlobReader (7 fields); methods: ReadByID (~248 tok)
|
||||||
|
- `local_disk_blob_storage_test.go` — TestLocalDiskBlobStorage_Store_WritesFileAndInsertsBlobRow, TestLocalDiskBlobStorage_Store_DedupsIdenticalContent (~676 tok)
|
||||||
|
- `local_disk_blob_storage.go` — implements port.BlobStorage on the local filesystem. (~992 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/infrastructure/dkim/
|
||||||
|
|
||||||
|
- `keygen.go` — generates, stores and applies the DKIM signing keys described (~981 tok)
|
||||||
|
- `signer_test.go` — fakeKeyRepo (20 fields); methods: FindActiveKeys (~1127 tok)
|
||||||
|
- `signer.go` — Signer (16 fields); methods: Sign (~732 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/infrastructure/mailauth/
|
||||||
|
|
||||||
|
- `authenticator_test.go` — fakeZone (34 fields); methods: LookupTXT, LookupIPAddr, LookupMX, LookupAddr (~2301 tok)
|
||||||
|
- `authenticator.go` — runs the inbound authentication chain of PLAN.md section (~1950 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/infrastructure/netdns/
|
||||||
|
|
||||||
|
- `mx_resolver.go` — NetMXResolver (10 fields); methods: LookupMX (~197 tok)
|
||||||
|
- `public_verifier_match_test.go` — Comparing a desired record against what a resolver actually answers. (~1121 tok)
|
||||||
|
- `public_verifier.go` — PublicVerifier (35 fields); methods: VerifyRecord (~1718 tok)
|
||||||
|
- `tlsa_resolver.go` — TlsaResolver (14 fields); methods: LookupTLSA (~648 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/infrastructure/postgres/
|
||||||
|
|
||||||
|
- `acme_repository.go` — AcmeRepository (64 fields); methods: LoadCertificate, SaveCertificate, LoadAccountKey, SaveAccountKey (~1995 tok)
|
||||||
|
- `alias_repository_test.go` — TestAliasRepository_EnsureSystemAliases (~342 tok)
|
||||||
|
- `alias_repository.go` — AliasRepository (15 fields); methods: EnsureSystemAliases (~388 tok)
|
||||||
|
- `attachment_backfill.go` — AttachmentBackfill (24 fields); methods: Done, MarkDone, Candidates, SetHasAttachments (~749 tok)
|
||||||
|
- `auth_repository_test.go` — TestAuthRepository_FindByAddress_ReturnsHashForActiveMailbox, TestAuthRepository_FindByAddress_ReturnsNilForUnknownAddress (~475 tok)
|
||||||
|
- `auth_repository.go` — AuthRepository (13 fields); methods: FindByAddress (~299 tok)
|
||||||
|
- `bimi_repository.go` — BimiRepository (24 fields); methods: Save, Load, Delete (~613 tok)
|
||||||
|
- `copy_repository_test.go` — TestCopyRepository_CopyMessages_AllocatesNewUIDsAndPreservesFlags (~1214 tok)
|
||||||
|
- `copy_repository.go` — CopyRepository (54 fields); methods: CopyMessages (~1610 tok)
|
||||||
|
- `delivered_report_store.go` — DeliveredReportStore (65 fields); methods: ListUningestedReports, MoveToReportsFolder (~1366 tok)
|
||||||
|
- `dkim_repository.go` — DkimRepository (73 fields); methods: FindActiveKeys, FindPublicKey, Insert, ListKeys (~2032 tok)
|
||||||
|
- `domain_repository.go` — DomainRepository (18 fields); methods: ActiveDomainNames, SaveDnsStatus (~711 tok)
|
||||||
|
- `domain_status_test.go` — dns_status is constrained by the schema to a fixed set. The verifier answers (~281 tok)
|
||||||
|
- `expunge_repository_test.go` — TestExpungeRepository_Expunge_SetsExpungedAtAndDecrementsCounters (~1063 tok)
|
||||||
|
- `expunge_repository.go` — ExpungeRepository (34 fields); methods: Expunge (~828 tok)
|
||||||
|
- `flag_repository_test.go` — TestFlagRepository_SetFlags_AddThenDelThenSet (~1329 tok)
|
||||||
|
- `flag_repository.go` — FlagRepository (45 fields); methods: SetFlags (~897 tok)
|
||||||
|
- `imap_folder_repository_test.go` — TestImapFolderRepository_FindByName_ReturnsFolderWithUIDData, TestImapFolderRepository_FindByName_ReturnsNilForUnknownFolder, TestImapFolderReposit... (~956 tok)
|
||||||
|
- `imap_folder_repository.go` — ImapFolderRepository (22 fields); methods: FindByName, ListFolders (~466 tok)
|
||||||
|
- `inbound_message_repository_test.go` — TestInboundMessageRepository_Persist_AllocatesSequentialUIDs, TestInboundMessageRepository_Persist_ReturnsErrorWhenNoInboxFolderExists, TestInbound... (~4003 tok)
|
||||||
|
- `inbound_message_repository.go` — InboundMessageRepository (55 fields); methods: PersistAll (~2136 tok)
|
||||||
|
- `mailbox_repository_test.go` — TestMailboxRepository_FindActiveByAddress_ReturnsNilForUnknownAddress, TestMailboxRepository_FindActiveByAddress_ReturnsRecordForSeededMailbox, Tes... (~3391 tok)
|
||||||
|
- `mailbox_repository.go` — MailboxRepository (33 fields); methods: FindActiveByAddress, ResolveDeliveryTargets (~699 tok)
|
||||||
|
- `message_query_repository_test.go` — TestMessageQueryRepository_ListMessages_ReturnsRowsOrderedByUID (~657 tok)
|
||||||
|
- `message_query_repository.go` — MessageQueryRepository (16 fields); methods: ListMessages (~366 tok)
|
||||||
|
- `outbound_repository.go` — OutboundRepository (41 fields); methods: Enqueue, FetchNextReady, CountRecipientsSince, UpdateJob (~1237 tok)
|
||||||
|
- `outbox_repository.go` — OutboxRepository (18 fields); methods: FetchUnpublished, MarkPublished (~415 tok)
|
||||||
|
- `pool.go` — contains pgx-based implementations of the application's (~120 tok)
|
||||||
|
- `report_ingestion_test.go` — dbBlobStore (59 fields); methods: Store (~3519 tok)
|
||||||
|
- `report_repository_test.go` — TestReportRepository_SaveDmarcAndTlsRpt (~406 tok)
|
||||||
|
- `report_repository.go` — ReportRepository (36 fields); methods: SaveDmarcReport, SaveTlsRptReport (~1023 tok)
|
||||||
|
- `sieve_repository_test.go` — TestSieveRepository_CRUD (~627 tok)
|
||||||
|
- `sieve_repository.go` — SieveRepository (55 fields); methods: GetScript, PutScript, ListScripts, SetActiveScript (~1245 tok)
|
||||||
|
- `vacation_log.go` — VacationLog (16 fields); methods: LastRepliedAt, RecordReply (~436 tok)
|
||||||
|
- `webmail_repository_test.go` — TestUnreadCountFollowsWhatWasActuallyRead, TestUnreadCountSelfHealsAfterDrift, TestOwnSentCopyIsNotCountedAsUnread, TestDeleteMovesToTrashThenExpun... (~2027 tok)
|
||||||
|
- `webmail_repository.go` — WebmailRepository (145 fields); methods: FindMailboxIDByAddress, ListFolders, ListMessages, GetMessageBlob (~4616 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/infrastructure/rspamd/
|
||||||
|
|
||||||
|
- `rspamd_adapter_test.go` — TestRspamdAdapter_Scan_MapsActions (~482 tok)
|
||||||
|
- `rspamd_adapter.go` — RspamdResponse (27 fields); methods: Scan (~882 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/infrastructure/tls/
|
||||||
|
|
||||||
|
- `acme_cert_watcher_test.go` — domain (38 fields) (~3205 tok)
|
||||||
|
- `acme_cert_watcher.go` — AcmeCertWatcher (73 fields); methods: Watch, EarliestExpiry, GetCertificate, LastReload (~2987 tok)
|
||||||
|
- `local_file_cert_provider_test.go` — TestNewLocalFileCertProvider_LoadsValidCertAndKey, TestNewLocalFileCertProvider_FailsFastOnMissingFiles, TestNewEphemeralSelfSignedCertProvider_Gen... (~874 tok)
|
||||||
|
- `local_file_cert_provider.go` — implements port.CertProvider by loading a static (~606 tok)
|
||||||
|
- `provider_status_test.go` — TestProviderStatus_ReportsAnEphemeralSelfSignedCertificate, TestProviderStatus_ReportsTheSpkiDigestOfTheServedCertificate (~846 tok)
|
||||||
|
- `provider_status.go` — ProviderStatus (22 fields); methods: HasCertificateFor, EarliestExpiry, LastReload, LastChange (~1114 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/infrastructure/tlspolicy/
|
||||||
|
|
||||||
|
- `resolver_test.go` — rewriteTransport (31 fields); methods: RoundTrip, LookupTLSA (~2177 tok)
|
||||||
|
- `resolver.go` — resolves the outbound transport security policy of (~1949 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/infrastructure/vault/
|
||||||
|
|
||||||
|
- `secret_vault_test.go` — TestSecretVault_SealOpenRoundTrip, TestSecretVault_SealIsNonDeterministic, TestSecretVault_DerivationIsStableAcrossInstances, TestSecretVault_Wrong... (~762 tok)
|
||||||
|
- `secret_vault.go` — seals the reusable secrets the system stores in Postgres - (~998 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/presentation/http/
|
||||||
|
|
||||||
|
- `admin_dkim_api.go` — adminDkimAPI (33 fields); methods: SetAdminDkimAPI (~1675 tok)
|
||||||
|
- `admin_dns_api.go` — Interface: DnsRecordVerifier (21 methods) (~2650 tok)
|
||||||
|
- `admin_dns_reconcile_test.go` — stubReconciler (42 fields); methods: ReconcileDomain, SaveDnsStatus (~2445 tok)
|
||||||
|
- `admin_tls_api_test.go` — TestAcmeCertWatcherSatisfiesTlsStatusSource (~155 tok)
|
||||||
|
- `admin_tls_api.go` — Interface: TlsStatusSource (14 methods) (~1376 tok)
|
||||||
|
- `admin_tls_status_test.go` — stillSource (4 fields) (~513 tok)
|
||||||
|
- `attachment_test.go` — TestAttachmentDownload_DowngradesRenderableContentTypes, TestAttachmentDownload_KeepsHarmlessContentTypes, TestAttachmentDownload_NeutralisesHostil... (~937 tok)
|
||||||
|
- `bimi_api.go` — Interface: BimiStore (24 methods) (~2025 tok)
|
||||||
|
- `events_ws_test.go` — TestEventsReachOnlyTheirOwnMailbox, TestASlowSubscriberIsDroppedRatherThanBlocking, TestSubscriberCountFollowsAddAndRemove, TestPublishWithNoSubscr... (~1693 tok)
|
||||||
|
- `events_ws.go` — EventMessage (30 fields); methods: Publish, SubscriberCount, ServeHTTP, SetEventHub (~1490 tok)
|
||||||
|
- `mail_api_test.go` — fakeMailStore (58 fields); methods: FindMailboxIDByAddress, ListFolders, ListMessages, GetMessageBlob (~5364 tok)
|
||||||
|
- `mail_api.go` — HTTP handlers: writeJSON, writeError (~4631 tok)
|
||||||
|
- `router_test.go` — TestRouter_MtaSts_Autoconfig_SecurityTxt_Ingest, TestRouter_HealthReportsDegradedWithoutFailing, TestRouter_HealthFailsWhenDependencyIsDown (~1153 tok)
|
||||||
|
- `router.go` — Router (45 fields); methods: SetMtaStsMode, ServeHTTP, SetDegradedCheck, SetMailAPI (~3672 tok)
|
||||||
|
- `websession_test.go` — TestWebSessionVerifier_AcceptsTokenIssuedByTheAuthService, TestWebSessionVerifier_RejectsChallengeToken, TestWebSessionVerifier_RejectsWrongSecret,... (~1697 tok)
|
||||||
|
- `websession.go` — WebSession (29 fields); methods: Verify, RequireSurface (~934 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/presentation/imap/
|
||||||
|
|
||||||
|
- `backend_test.go` — fakeAuthRepository (64 fields); methods: FindByAddress, ListFolders, FindByName, ListMessages (~6125 tok)
|
||||||
|
- `backend.go` — session (112 fields); methods: Close, Login, Select, Unselect (~4188 tok)
|
||||||
|
- `search_test.go` — TestSession_Search_MatchesByFlag, TestSession_Search_MatchesByUIDSetIntersection, TestSession_Search_MatchesByHeaderField, TestSession_Search_NotCr... (~1457 tok)
|
||||||
|
- `search.go` — Package imappresentation: this file implements IMAP SEARCH (RFC 3501 (~2395 tok)
|
||||||
|
- `stubs.go` — adapts github.com/emersion/go-imap/v2's (~373 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/presentation/managesieve/
|
||||||
|
|
||||||
|
- `conn_test.go` — fakeAuthRepository (42 fields); methods: FindByAddress, GetScript, PutScript, ListScripts (~1681 tok)
|
||||||
|
- `conn.go` — conn (74 fields) (~2789 tok)
|
||||||
|
- `server.go` — Server (14 fields); methods: ListenAndServe, Serve, Close (~299 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/presentation/pop3/
|
||||||
|
|
||||||
|
- `conn_test.go` — fakeAuthRepository (52 fields); methods: FindByAddress, ListFolders, FindByName, ListMessages (~2304 tok)
|
||||||
|
- `conn.go` — pop3Msg (82 fields) (~2806 tok)
|
||||||
|
- `server.go` — Server (14 fields); methods: ListenAndServe, Serve, Close (~293 tok)
|
||||||
|
|
||||||
|
## services/protocols/internal/presentation/smtp/
|
||||||
|
|
||||||
|
- `backend_test.go` — fakeMailboxRepository (58 fields); methods: FindActiveByAddress, ResolveDeliveryTargets, Store, Scan (~3106 tok)
|
||||||
|
- `backend.go` — adapts github.com/emersion/go-smtp's Backend/ (~1399 tok)
|
||||||
|
- `sasl_login_test.go` — TestLoginServer_PromptsThenAuthenticates, TestLoginServer_AcceptsInitialResponse, TestLoginServer_PropagatesAuthFailure, TestLoginServer_RefusesExt... (~614 tok)
|
||||||
|
- `sasl_login.go` — loginServer (15 fields); methods: Next (~561 tok)
|
||||||
|
- `submission_backend_test.go` — TestSubmissionSession_RefusesCommandsBeforeAuth, TestSubmissionSession_RefusesForeignSender, TestSubmissionSession_AdvertisesPlainAndLogin, TestSub... (~816 tok)
|
||||||
|
- `submission_backend.go` — SubmissionBackend (29 fields); methods: NewSession, AuthMechanisms, Auth, Reset (~1300 tok)
|
||||||
|
|
||||||
|
## services/rspamd/
|
||||||
|
|
||||||
|
- `Dockerfile` — Docker container definition (~147 tok)
|
||||||
|
|
||||||
|
## services/storage/
|
||||||
|
|
||||||
|
- `Dockerfile` — Docker container definition (~466 tok)
|
||||||
|
- `package.json` — Node.js package manifest (~131 tok)
|
||||||
|
- `tsconfig.build.json` — TypeScript build configuration (~83 tok)
|
||||||
|
- `tsconfig.json` — TypeScript configuration (~88 tok)
|
||||||
|
- `vitest.config.ts` — Vitest test configuration (~33 tok)
|
||||||
|
|
||||||
|
## services/storage/src/
|
||||||
|
|
||||||
|
- `health.test.ts` — Declares withServer (~470 tok)
|
||||||
|
- `health.ts` — Builds the /health/live and /health/ready handler shared by the auth and (~215 tok)
|
||||||
|
- `server.ts` — Declares port (~212 tok)
|
||||||
|
|
||||||
|
## services/webmail/
|
||||||
|
|
||||||
|
- `Dockerfile` — Docker container definition (~395 tok)
|
||||||
|
- `next-env.d.ts` — / <reference types="next" /> (~75 tok)
|
||||||
|
- `next.config.ts` — Next.js configuration (~601 tok)
|
||||||
|
- `package.json` — Node.js package manifest (~263 tok)
|
||||||
|
- `postcss.config.js` — PostCSS configuration (~24 tok)
|
||||||
|
- `tailwind.config.js` — Tailwind CSS configuration (~1064 tok)
|
||||||
|
- `tsconfig.json` — TypeScript configuration (~174 tok)
|
||||||
|
- `vitest.config.ts` — Vitest test configuration (~93 tok)
|
||||||
|
|
||||||
|
## services/webmail/messages/
|
||||||
|
|
||||||
|
- `en.json` (~5338 tok)
|
||||||
|
- `es.json` (~5774 tok)
|
||||||
|
- `pt-BR.json` (~5763 tok)
|
||||||
|
|
||||||
|
## services/webmail/public/
|
||||||
|
|
||||||
|
- `.gitkeep` (~0 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/
|
||||||
|
|
||||||
|
- `compose-draft-integrity.test.ts` — The composer's draft handling, and the delete action the mail list was (~1189 tok)
|
||||||
|
- `emailBody.test.ts` — The reader pane showed broken image icons for every message written by a (~1334 tok)
|
||||||
|
- `i18n.test.ts` — loadJson: getLeafKeys (~885 tok)
|
||||||
|
- `initials.test.ts` (~293 tok)
|
||||||
|
- `interface-integrity.test.ts` — Guards the class of defect this interface kept shipping: text that cannot be (~2690 tok)
|
||||||
|
- `mail-state-integrity.test.ts` — The wiring behind the counters, the reader pane and the two surfaces. (~2149 tok)
|
||||||
|
- `mailCounts.test.ts` — The counters the interface shows. (~4397 tok)
|
||||||
|
- `middleware.ts` — Gate for the two web surfaces. (~862 tok)
|
||||||
|
- `remoteImages.test.ts` — Whether a message's remote images load. (~1007 tok)
|
||||||
|
- `rules.test.ts` — Rules, as a person describes them, turned into Sieve and back. (~2014 tok)
|
||||||
|
- `sanitizer.test.ts` — These run under jsdom. Under the default node environment DOMPurify was (~1002 tok)
|
||||||
|
- `sidebarBadge.test.ts` — What the rail actually puts on screen, folder by folder. (~639 tok)
|
||||||
|
- `signature.test.ts` — The signature is edited in one place and injected into the composer in (~1022 tok)
|
||||||
|
- `surfaceGate.test.ts` — Which session, if any, a page needs before it may render. (~606 tok)
|
||||||
|
- `wellknown-routing.test.ts` — The well-known documents senders fetch over HTTPS. (~729 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/
|
||||||
|
|
||||||
|
- `globals.css` — Nocturne - the one visual language for both surfaces. (~2171 tok)
|
||||||
|
- `layout.tsx` — The locale is resolved on the server so the first paint is already in the (~356 tok)
|
||||||
|
- `page.tsx` — The root sends everyone straight into the mail client. (~213 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(admin)/admin/
|
||||||
|
|
||||||
|
- `layout.tsx` — AdminLayout — uses useState (~2246 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(admin)/admin/dashboard/
|
||||||
|
|
||||||
|
- `page.tsx` — Exactly the fields /api/v1/admin/dashboard returns - see dashboardStats(). (~2425 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(admin)/admin/dmarc/
|
||||||
|
|
||||||
|
- `page.tsx` — AdminDmarcPage — renders table — uses useEffect (~1215 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(admin)/admin/domains/
|
||||||
|
|
||||||
|
- `page.tsx` — Domains, read from the database. (~7639 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(admin)/admin/mailboxes/
|
||||||
|
|
||||||
|
- `page.tsx` — The envelope every server-paged list answers with. (~12626 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(admin)/admin/queue/
|
||||||
|
|
||||||
|
- `page.tsx` — The outbound queue, read from the database. (~2956 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(admin)/admin/security/
|
||||||
|
|
||||||
|
- `page.tsx` — Live certificate state from the protocols service, which owns the cert (~6267 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(admin)/admin/tests/
|
||||||
|
|
||||||
|
- `page.tsx` — Checks an operator runs by hand, against the server or one user. (~2274 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(auth)/admin/login/
|
||||||
|
|
||||||
|
- `page.tsx` — AdminLoginPage — renders form — uses useState (~2803 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(auth)/admin/step-up/
|
||||||
|
|
||||||
|
- `page.tsx` — Crossing from the webmail into the console. (~2187 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(auth)/user/login/
|
||||||
|
|
||||||
|
- `page.tsx` — One step, because the mailbox is one step. (~1486 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(user)/user/
|
||||||
|
|
||||||
|
- `layout.tsx` — Creates, renames or deletes one of the user's own folders. (~4684 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(user)/user/compose/
|
||||||
|
|
||||||
|
- `page.tsx` — Splits a comma or semicolon separated field, dropping empty entries. (~5433 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(user)/user/mail/[folder]/
|
||||||
|
|
||||||
|
- `page.tsx` — The initials shown in the sender avatar, from a display name or an address. (~10230 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/(user)/user/settings/
|
||||||
|
|
||||||
|
- `page.tsx` — Writes the whole rule list to the server as one script. (~12580 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/api/v1/[...path]/
|
||||||
|
|
||||||
|
- `route.ts` — Proxies /api/v1/* to the auth service. (~1250 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/app/health/
|
||||||
|
|
||||||
|
- `route.ts` — Liveness probe for the web container. (~210 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/components/
|
||||||
|
|
||||||
|
- `Avatar.tsx` — Rendered size in pixels; the square the circle is cut from. (~542 tok)
|
||||||
|
- `AvatarEditor.tsx` — The frame is square; the mask over it is what makes the photo a circle. (~2510 tok)
|
||||||
|
- `MessageReader.tsx` — The document the body frame renders, or null for a text-only message. (~4736 tok)
|
||||||
|
- `RecipientInput.tsx` — Entries as they will be sent: `Name <addr>` or a bare address. (~1486 tok)
|
||||||
|
- `TotpEnrolment.tsx` — The scannable half of TOTP enrolment, shared by the webmail settings page and (~1644 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/components/ui/
|
||||||
|
|
||||||
|
- `Badge.tsx` — A tag: a small tinted label from the ramps. (~375 tok)
|
||||||
|
- `Button.tsx` — Nocturne buttons are outlined, not filled. (~584 tok)
|
||||||
|
- `Card.tsx` — A panel: a solid ground with a hairline inset ring. (~421 tok)
|
||||||
|
- `Pagination.tsx` — The pager under a server-paged list. (~672 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/i18n/
|
||||||
|
|
||||||
|
- `config.ts` — Picks the best supported locale from an Accept-Language header. (~993 tok)
|
||||||
|
- `LanguageSwitcher.tsx` — Language selector, rendered in both surface layouts (PLAN.md section 21.2). (~275 tok)
|
||||||
|
- `provider.tsx` — Shorthand for components that only need to translate. (~598 tok)
|
||||||
|
|
||||||
|
## services/webmail/src/lib/
|
||||||
|
|
||||||
|
- `avatarCrop.test.ts` — Declares VIEWPORT (~1187 tok)
|
||||||
|
- `avatarCrop.ts` — The geometry behind the circular photo editor. (~1132 tok)
|
||||||
|
- `emailBody.ts` — Turns a message's sanitized HTML into something a frame can actually show. (~1181 tok)
|
||||||
|
- `formatBytes.test.ts` (~434 tok)
|
||||||
|
- `formatBytes.ts` — A byte count as a person reads it. (~398 tok)
|
||||||
|
- `initials.ts` — The two letters an avatar shows. (~236 tok)
|
||||||
|
- `mailCounts.ts` — The arithmetic behind every number the mail screens display. (~2786 tok)
|
||||||
|
- `paging.test.ts` — Declares w (~464 tok)
|
||||||
|
- `paging.ts` — The page numbers a pager should offer. (~273 tok)
|
||||||
|
- `remoteImages.ts` — Which senders may load their remote images. (~863 tok)
|
||||||
|
- `roleLabel.ts` — A role as a person reads it. (~200 tok)
|
||||||
|
- `rules.ts` — Rules as a person describes them, and the Sieve the server runs. (~1672 tok)
|
||||||
|
- `sanitizer.ts` — Sanitizes message HTML with DOMPurify (PLAN.md section 14.2). (~1142 tok)
|
||||||
|
- `signature.ts` — The signature, on its way from the settings screen into a message. (~1107 tok)
|
||||||
|
- `surfaceGate.ts` — Which session a page needs before it can usefully render. (~445 tok)
|
||||||
|
- `useAccount.ts` — Roles that may open the admin console. (~429 tok)
|
||||||
|
- `useDebounced.ts` — Holds a value still until typing stops. (~170 tok)
|
||||||
|
- `useFolders.ts` — The folder list, kept current. (~635 tok)
|
||||||
|
- `useMailEvents.ts` — Subscribes to the server's event stream and calls onEvent for each push. (~641 tok)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"bugs": [
|
||||||
|
{
|
||||||
|
"error_message": "Email layout flattened: width/height/colspan/align/bgcolor stripped; background images never restored after 'show images'",
|
||||||
|
"root_cause": "Custom DOMPurify ALLOWED_URI_REGEXP replaced the default that accepts non-URL values, so every non-URL attribute was rejected; blockRemoteImages deleted url()/background irreversibly",
|
||||||
|
"fix": "Added DOMPurify's default non-URL alternatives to the regexp; block/unblock now rewrite backgrounds reversibly (lm-blocked- scheme, data-blocked-background)",
|
||||||
|
"tags": [
|
||||||
|
"webmail",
|
||||||
|
"sanitizer",
|
||||||
|
"dompurify",
|
||||||
|
"remote-images"
|
||||||
|
],
|
||||||
|
"date": "2026-10-13"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"error_message": "Domain admin dashboard showed server-wide totals (all domains, queue, DMARC, storage)",
|
||||||
|
"root_cause": "dashboardStats, queueSummary, dmarcSummary, preflightSummary and the diagnostics queueByStatus took no AdminScope and queried every tenant; only listDomains/listMailboxes were scoped",
|
||||||
|
"fix": "Each now takes the session's AdminScope; mailboxScope() reaches outbound_jobs/email_messages through mailboxes.domain_id; DMARC matches by domain_id or name. Covered by services/auth/src/adminScope.test.ts against a real Postgres",
|
||||||
|
"tags": [
|
||||||
|
"auth",
|
||||||
|
"authz",
|
||||||
|
"tenant-isolation",
|
||||||
|
"dashboard"
|
||||||
|
],
|
||||||
|
"date": "2026-10-13"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
description: learned preferences, project conventions, and Do-Not-Repeat rules
|
||||||
|
budget_tokens: 2000
|
||||||
|
---
|
||||||
|
# Cerebrum
|
||||||
|
|
||||||
|
> OpenWolf's learning memory. Updated automatically as the AI learns from interactions.
|
||||||
|
> Do not edit manually unless correcting an error.
|
||||||
|
> Last updated: 2026-10-06
|
||||||
|
|
||||||
|
## User Preferences
|
||||||
|
|
||||||
|
<!-- How the user likes things done. Code style, tools, patterns, communication. -->
|
||||||
|
|
||||||
|
## Key Learnings
|
||||||
|
|
||||||
|
- **Project:** LambdaMail
|
||||||
|
- **Description:** A self-hosted mail server with an integrated webmail and admin console, built to
|
||||||
|
|
||||||
|
## Do-Not-Repeat
|
||||||
|
|
||||||
|
<!-- Mistakes made and corrected. Each entry prevents the same mistake recurring. -->
|
||||||
|
<!-- Format: [YYYY-MM-DD] Description of what went wrong and what to do instead. -->
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
<!-- Significant technical decisions with rationale. Why X was chosen over Y. -->
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"openwolf": {
|
||||||
|
"enabled": true,
|
||||||
|
"reads": {
|
||||||
|
"duplicate_mode": "warn",
|
||||||
|
"skeleton_hints": true
|
||||||
|
},
|
||||||
|
"bash": {
|
||||||
|
"filter_mode": "suggest",
|
||||||
|
"governor": {
|
||||||
|
"mode": "replace",
|
||||||
|
"threshold_tokens": 2000,
|
||||||
|
"families": {
|
||||||
|
"grep_flood": "replace",
|
||||||
|
"file_print": "replace",
|
||||||
|
"git_show": "replace",
|
||||||
|
"test": "suggest",
|
||||||
|
"build": "suggest",
|
||||||
|
"unknown": "suggest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"anatomy": {
|
||||||
|
"auto_scan_on_init": true,
|
||||||
|
"rescan_interval_hours": 6,
|
||||||
|
"max_description_length": 100,
|
||||||
|
"max_files": 500,
|
||||||
|
"exclude_patterns": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
".next",
|
||||||
|
".nuxt",
|
||||||
|
".turbo",
|
||||||
|
".vercel",
|
||||||
|
".netlify",
|
||||||
|
".output",
|
||||||
|
"coverage",
|
||||||
|
"__pycache__",
|
||||||
|
".venv",
|
||||||
|
"venv",
|
||||||
|
"site-packages",
|
||||||
|
".pytest_cache",
|
||||||
|
".mypy_cache",
|
||||||
|
".ruff_cache",
|
||||||
|
".tox",
|
||||||
|
"*.pyc",
|
||||||
|
".gradle",
|
||||||
|
"target",
|
||||||
|
".m2",
|
||||||
|
".cargo",
|
||||||
|
".git",
|
||||||
|
".wolf",
|
||||||
|
".cache",
|
||||||
|
".vscode",
|
||||||
|
".idea",
|
||||||
|
".DS_Store",
|
||||||
|
"Thumbs.db",
|
||||||
|
"*.min.js",
|
||||||
|
"*.min.css",
|
||||||
|
"*.map"
|
||||||
|
],
|
||||||
|
"respect_gitignore": true
|
||||||
|
},
|
||||||
|
"token_audit": {
|
||||||
|
"enabled": true,
|
||||||
|
"report_frequency": "weekly",
|
||||||
|
"waste_threshold_percent": 15,
|
||||||
|
"chars_per_token_code": 3.5,
|
||||||
|
"chars_per_token_prose": 4
|
||||||
|
},
|
||||||
|
"cron": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_retry_attempts": 3,
|
||||||
|
"dead_letter_enabled": true,
|
||||||
|
"heartbeat_interval_minutes": 30
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"consolidation_after_days": 7,
|
||||||
|
"max_entries_before_consolidation": 200
|
||||||
|
},
|
||||||
|
"cerebrum": {
|
||||||
|
"max_tokens": 2000,
|
||||||
|
"reflection_frequency": "weekly"
|
||||||
|
},
|
||||||
|
"daemon": {
|
||||||
|
"port": 18810,
|
||||||
|
"log_level": "info"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": 18811,
|
||||||
|
"host": "127.0.0.1"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"session_digest_budget_tokens": 1500,
|
||||||
|
"reinjection_interval": 25,
|
||||||
|
"state_budgets": {
|
||||||
|
".wolf/cerebrum.md": 2000,
|
||||||
|
".wolf/STATUS.md": 1000
|
||||||
|
},
|
||||||
|
"budgets": {
|
||||||
|
"claude": 1500,
|
||||||
|
"codex": 1200,
|
||||||
|
"gemini": 1200,
|
||||||
|
"opencode": 1200,
|
||||||
|
"cursor": 800
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"mode": "quiet"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"mode": "compatible"
|
||||||
|
},
|
||||||
|
"agents": [
|
||||||
|
"claude"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "anatomy-rescan",
|
||||||
|
"name": "Full anatomy rescan",
|
||||||
|
"schedule": "0 */6 * * *",
|
||||||
|
"description": "Re-scans project filesystem and reconciles anatomy.md",
|
||||||
|
"action": {
|
||||||
|
"type": "scan_project"
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"max_attempts": 3,
|
||||||
|
"backoff": "exponential",
|
||||||
|
"base_delay_seconds": 30
|
||||||
|
},
|
||||||
|
"failsafe": {
|
||||||
|
"on_failure": "log_and_continue",
|
||||||
|
"alert_after_consecutive_failures": 2,
|
||||||
|
"dead_letter": true
|
||||||
|
},
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "memory-consolidation",
|
||||||
|
"name": "Consolidate old memory",
|
||||||
|
"schedule": "0 2 * * *",
|
||||||
|
"description": "Compress memory.md entries older than 7 days",
|
||||||
|
"action": {
|
||||||
|
"type": "consolidate_memory",
|
||||||
|
"params": {
|
||||||
|
"older_than_days": 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"max_attempts": 2,
|
||||||
|
"backoff": "exponential",
|
||||||
|
"base_delay_seconds": 60
|
||||||
|
},
|
||||||
|
"failsafe": {
|
||||||
|
"on_failure": "skip_and_retry_next_cycle",
|
||||||
|
"dead_letter": false
|
||||||
|
},
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "token-audit",
|
||||||
|
"name": "Token audit report",
|
||||||
|
"schedule": "0 0 * * 1",
|
||||||
|
"description": "Weekly waste pattern detection",
|
||||||
|
"action": {
|
||||||
|
"type": "generate_token_report"
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"max_attempts": 2,
|
||||||
|
"backoff": "linear",
|
||||||
|
"base_delay_seconds": 60
|
||||||
|
},
|
||||||
|
"failsafe": {
|
||||||
|
"on_failure": "log_and_continue",
|
||||||
|
"dead_letter": true
|
||||||
|
},
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
description: chronological action log per session, consolidated weekly
|
||||||
|
---
|
||||||
|
# Memory
|
||||||
|
|
||||||
|
> Chronological action log. Hooks and AI append to this file automatically.
|
||||||
|
> Old sessions are consolidated by the daemon weekly.
|
||||||
|
|
||||||
|
## Session: 2026-10-06 08:07
|
||||||
|
|
||||||
|
| Time | Action | File(s) | Outcome | ~Tokens |
|
||||||
|
|------|--------|---------|---------|--------|
|
||||||
|
|
||||||
|
## Session: 2026-10-13 09:32
|
||||||
|
|
||||||
|
| Time | Action | File(s) | Outcome | ~Tokens |
|
||||||
|
|------|--------|---------|---------|--------|
|
||||||
|
| 09:40 | Created services/webmail/src/components/MessageReader.tsx | — | ~4736 |
|
||||||
|
| 09:51 | Created services/webmail/src/components/RecipientInput.tsx | — | ~1486 |
|
||||||
|
<!-- session:MWJiNDRiYmMtNDk5NC00MTgwLThlNjQtZmFhMmNiYTcwMjdj -->
|
||||||
|
| 2026-10-13T13:08:21.421Z | Webmail: MessageReader redesign (Outlook-style), sanitizer fix for layout attrs and reversible background blocking, chip recipients, reply-all, starred/important flag (Go /mail/flagged), high-importance send, resizable list/reader divider. Auth: domain-admin views scoped (dashboard, queue, DMARC, preflight, diagnostics). | services/webmail/src/components/MessageReader.tsx,services/webmail/src/components/RecipientInput.tsx,services/webmail/src/lib/recipients.ts,services/webmail/src/lib/paneWidth.ts,services/auth/src/repository.ts,services/auth/src/adminScope.test.ts,services/protocols/internal/infrastructure/postgres/webmail_repository.go | go, auth (incl. DB) and webmail suites pass; Coolify API token in .env returns 401 |
|
||||||
|
| 10:47 | Session end: 2 writes across 2 files (MessageReader.tsx, RecipientInput.tsx) | 2 reads | ~6222 tok |
|
||||||
|
|
||||||
|
## Session: 2026-10-20 19:06
|
||||||
|
|
||||||
|
| Time | Action | File(s) | Outcome | ~Tokens |
|
||||||
|
|------|--------|---------|---------|--------|
|
||||||
|
|
||||||
|
## Session: 2026-10-22 14:28
|
||||||
|
|
||||||
|
| Time | Action | File(s) | Outcome | ~Tokens |
|
||||||
|
|------|--------|---------|---------|--------|
|
||||||
|
| 14:41 | Created services/auth/src/accounts.ts | — | ~715 |
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# OpenWolf
|
||||||
|
|
||||||
|
This project uses OpenWolf for context management. The always-on rules live in `.claude/rules/openwolf.md`; the hooks handle bookkeeping (anatomy index, memory log, read tracking) automatically.
|
||||||
|
|
||||||
|
For the full operating protocol (session handoff, memory discipline, bug logging), load the `openwolf` skill, or read `.wolf/OPENWOLF.md`. Regenerate the session handoff with `/handoff`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS trusted_senders;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Senders a mailbox has said it trusts.
|
||||||
|
--
|
||||||
|
-- Two things hang off one decision: mail from a trusted sender is not filed as
|
||||||
|
-- spam, and its remote images load without asking. Both used to be forgotten:
|
||||||
|
-- moving a message out of Junk taught nothing, so the next reply from the same
|
||||||
|
-- person went to Junk again, and the choice to show images lived in one
|
||||||
|
-- browser's storage.
|
||||||
|
CREATE TABLE trusted_senders (
|
||||||
|
mailbox_id UUID NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||||
|
-- Lowercased, so one person is one row however they spell their address.
|
||||||
|
address VARCHAR(320) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (mailbox_id, address)
|
||||||
|
);
|
||||||
@@ -1365,3 +1365,41 @@ export async function traceLogsByQueueId(queueId: string): Promise<unknown[]> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- trusted senders
|
||||||
|
|
||||||
|
/** A mailbox cannot grow this list without bound. */
|
||||||
|
export const MAX_TRUSTED_SENDERS = 5000;
|
||||||
|
|
||||||
|
/** The address an input resolves to, or "" when it is not one. */
|
||||||
|
export function normaliseSender(raw: string): string {
|
||||||
|
const angled = raw.match(/<([^>]+)>/);
|
||||||
|
const address = (angled ? angled[1] : raw).trim().toLowerCase();
|
||||||
|
return address.length <= 320 && /^[^\s@<>]+@[^\s@<>]+$/.test(address) ? address : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTrustedSenders(mailboxId: string): Promise<string[]> {
|
||||||
|
const rows = await query<{ address: string }>(
|
||||||
|
`SELECT address FROM trusted_senders WHERE mailbox_id = $1 ORDER BY address`,
|
||||||
|
[mailboxId],
|
||||||
|
);
|
||||||
|
return rows.map((row) => row.address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns false when the list is full; trusting someone already trusted is a no-op. */
|
||||||
|
export async function addTrustedSender(mailboxId: string, address: string): Promise<boolean> {
|
||||||
|
const rows = await query<{ n: string }>(
|
||||||
|
`SELECT COUNT(*) AS n FROM trusted_senders WHERE mailbox_id = $1`,
|
||||||
|
[mailboxId],
|
||||||
|
);
|
||||||
|
if (Number(rows[0]?.n ?? 0) >= MAX_TRUSTED_SENDERS) return false;
|
||||||
|
await query(
|
||||||
|
`INSERT INTO trusted_senders (mailbox_id, address) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||||
|
[mailboxId, address],
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTrustedSender(mailboxId: string, address: string): Promise<void> {
|
||||||
|
await query(`DELETE FROM trusted_senders WHERE mailbox_id = $1 AND address = $2`, [mailboxId, address]);
|
||||||
|
}
|
||||||
@@ -1194,6 +1194,47 @@ describeDb("moving between the surfaces", () => {
|
|||||||
expect(first.body.token).not.toBe(second.body.token);
|
expect(first.body.token).not.toBe(second.body.token);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
|
describe("trusted senders", () => {
|
||||||
|
let token = "";
|
||||||
|
beforeAll(async () => {
|
||||||
|
token = (await post("/api/v1/auth/user/login", { email: plainEmail(), password: PASSWORD })).body.token as unknown as string;
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
const send = async (method: string, path: string, body?: unknown) => {
|
||||||
|
const res = await fetch(`${base}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||||
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||||
|
});
|
||||||
|
return { status: res.status, body: (await res.json().catch(() => ({}))) as Record<string, any> };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("remembers a sender once however it is spelled, and forgets it on request", async () => {
|
||||||
|
expect((await send("PUT", "/api/v1/user/trusted-senders", { address: "Jenny <Jenny@Example.test>" })).status).toBe(200);
|
||||||
|
expect((await send("PUT", "/api/v1/user/trusted-senders", { address: "jenny@example.test" })).status).toBe(200);
|
||||||
|
expect((await send("GET", "/api/v1/user/trusted-senders")).body.senders).toEqual(["jenny@example.test"]);
|
||||||
|
|
||||||
|
expect((await send("DELETE", "/api/v1/user/trusted-senders?address=JENNY@example.test")).status).toBe(200);
|
||||||
|
expect((await send("GET", "/api/v1/user/trusted-senders")).body.senders).toEqual([]);
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it("refuses something that is not an address", async () => {
|
||||||
|
expect((await send("PUT", "/api/v1/user/trusted-senders", { address: "not an address" })).status).toBe(400);
|
||||||
|
expect((await send("PUT", "/api/v1/user/trusted-senders", {})).status).toBe(400);
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it("keeps one mailbox's list from another's", async () => {
|
||||||
|
await send("PUT", "/api/v1/user/trusted-senders", { address: "private@example.test" });
|
||||||
|
const other = (await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD })).body.token as unknown as string;
|
||||||
|
const res = await fetch(`${base}/api/v1/user/trusted-senders`, { headers: { Authorization: `Bearer ${other}` } });
|
||||||
|
expect(((await res.json()) as { senders: string[] }).senders).not.toContain("private@example.test");
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it("needs a session", async () => {
|
||||||
|
expect((await get("/api/v1/user/trusted-senders")).status).toBe(401);
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
|
||||||
// Several webmail accounts at once, and a session that renews itself.
|
// Several webmail accounts at once, and a session that renews itself.
|
||||||
describe("several accounts and sliding sessions", () => {
|
describe("several accounts and sliding sessions", () => {
|
||||||
const emailOf = (local: string) => `${local}@authtest-${DOMAIN_ID}.invalid`;
|
const emailOf = (local: string) => `${local}@authtest-${DOMAIN_ID}.invalid`;
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
|
|||||||
}
|
}
|
||||||
if (url === "/api/v1/user/preferences" && method === "GET") return userGetPreferences(res, session!);
|
if (url === "/api/v1/user/preferences" && method === "GET") return userGetPreferences(res, session!);
|
||||||
if (url === "/api/v1/user/preferences" && method === "POST") return userUpdatePreferences(req, res, session!);
|
if (url === "/api/v1/user/preferences" && method === "POST") return userUpdatePreferences(req, res, session!);
|
||||||
|
if (url === "/api/v1/user/trusted-senders" && method === "GET") return trustedSendersList(res, session!);
|
||||||
|
if (url === "/api/v1/user/trusted-senders" && method === "PUT") return trustedSenderAdd(req, res, session!);
|
||||||
|
if (url === "/api/v1/user/trusted-senders" && method === "DELETE") return trustedSenderRemove(req, res, session!);
|
||||||
if (url === "/api/v1/user/sieve" && method === "GET") return userGetSieve(res, session!);
|
if (url === "/api/v1/user/sieve" && method === "GET") return userGetSieve(res, session!);
|
||||||
if (url === "/api/v1/user/sieve" && method === "POST") return userSaveSieve(req, res, session!);
|
if (url === "/api/v1/user/sieve" && method === "POST") return userSaveSieve(req, res, session!);
|
||||||
if (url === "/api/v1/user/vacation" && method === "POST") return userSaveVacation(req, res, session!);
|
if (url === "/api/v1/user/vacation" && method === "POST") return userSaveVacation(req, res, session!);
|
||||||
@@ -1195,6 +1198,28 @@ async function userGetPreferences(res: ServerResponse, session: SessionTokenPayl
|
|||||||
sendJson(res, 200, await repo.getUserPreferences(session.sub));
|
sendJson(res, 200, await repo.getUserPreferences(session.sub));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function trustedSendersList(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||||
|
sendJson(res, 200, { senders: await repo.listTrustedSenders(session.sub) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trustedSenderAdd(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||||
|
const body = await parseJsonBody(req);
|
||||||
|
const address = repo.normaliseSender(typeof body?.address === "string" ? body.address : "");
|
||||||
|
if (!address) return sendJson(res, 400, { error: "INVALID_INPUT", message: "A valid email address is required" });
|
||||||
|
if (!(await repo.addTrustedSender(session.sub, address))) {
|
||||||
|
return sendJson(res, 409, { error: "LIMIT_REACHED", message: "The trusted senders list is full" });
|
||||||
|
}
|
||||||
|
sendJson(res, 200, { ok: true, address });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trustedSenderRemove(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||||
|
const raw = new URL(req.url || "/", "http://localhost").searchParams.get("address") ?? "";
|
||||||
|
const address = repo.normaliseSender(raw);
|
||||||
|
if (!address) return sendJson(res, 400, { error: "INVALID_INPUT", message: "A valid email address is required" });
|
||||||
|
await repo.removeTrustedSender(session.sub, address);
|
||||||
|
sendJson(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
async function userUpdatePreferences(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
async function userUpdatePreferences(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||||
const body = await parseJsonBody(req);
|
const body = await parseJsonBody(req);
|
||||||
const signature = typeof body?.signature === "string" ? body.signature : "";
|
const signature = typeof body?.signature === "string" ? body.signature : "";
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ func run(cfg config) {
|
|||||||
// wired the scripts were stored and never read, so a vacation responder
|
// wired the scripts were stored and never read, so a vacation responder
|
||||||
// that was switched on never replied and no filter ever filed anything.
|
// that was switched on never replied and no filter ever filed anything.
|
||||||
inboundUC.SetRules(usecase.NewApplyRulesUseCase(postgres.NewSieveRepository(pool)))
|
inboundUC.SetRules(usecase.NewApplyRulesUseCase(postgres.NewSieveRepository(pool)))
|
||||||
|
// Senders the mailbox trusts are not filed as spam.
|
||||||
|
inboundUC.SetTrustedSenders(postgres.NewTrustedSenders(pool))
|
||||||
|
|
||||||
// ------------------------------------------------------------ outbound
|
// ------------------------------------------------------------ outbound
|
||||||
var signer port.DkimSigner
|
var signer port.DkimSigner
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TrustedSenders is the list of senders a mailbox has said it trusts.
|
||||||
|
//
|
||||||
|
// Mail from one of them is not filed as spam, which is what makes "this is not
|
||||||
|
// spam" mean something: before, moving a message out of Junk taught nothing and
|
||||||
|
// the next reply from the same person went straight back.
|
||||||
|
type TrustedSenders interface {
|
||||||
|
// IsTrusted reports whether the mailbox trusts this address, which is
|
||||||
|
// already lowercased.
|
||||||
|
IsTrusted(ctx context.Context, mailboxID uuid.UUID, address string) (bool, error)
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ const snippetLength = 200
|
|||||||
type MessageHeaders struct {
|
type MessageHeaders struct {
|
||||||
Subject string
|
Subject string
|
||||||
FromDisplayName string
|
FromDisplayName string
|
||||||
|
// FromAddress is the address in From, lowercased, or empty when it does not parse.
|
||||||
|
FromAddress string
|
||||||
MessageID string
|
MessageID string
|
||||||
Snippet string
|
Snippet string
|
||||||
HasAttachments bool
|
HasAttachments bool
|
||||||
@@ -58,6 +60,7 @@ func ExtractMessageHeaders(payload []byte) MessageHeaders {
|
|||||||
|
|
||||||
if from := msg.Header.Get("From"); from != "" {
|
if from := msg.Header.Get("From"); from != "" {
|
||||||
if addr, err := mail.ParseAddress(from); err == nil {
|
if addr, err := mail.ParseAddress(from); err == nil {
|
||||||
|
out.FromAddress = strings.ToLower(addr.Address)
|
||||||
out.FromDisplayName = truncateUTF8(decodeHeader(addr.Name), 255)
|
out.FromDisplayName = truncateUTF8(decodeHeader(addr.Name), 255)
|
||||||
} else {
|
} else {
|
||||||
// A From that does not parse still often carries a usable name
|
// A From that does not parse still often carries a usable name
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ type ProcessInboundEmailUseCase struct {
|
|||||||
trackerManager *MailboxTrackerManager
|
trackerManager *MailboxTrackerManager
|
||||||
reportIngestor *IngestDeliveredReportsUseCase
|
reportIngestor *IngestDeliveredReportsUseCase
|
||||||
rules *ApplyRulesUseCase
|
rules *ApplyRulesUseCase
|
||||||
|
trusted port.TrustedSenders
|
||||||
submission *ProcessOutboundEmailUseCase
|
submission *ProcessOutboundEmailUseCase
|
||||||
auth port.AuthRepository
|
auth port.AuthRepository
|
||||||
replyHost string
|
replyHost string
|
||||||
@@ -294,6 +295,11 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
|
|||||||
// Each recipient's own rules decide their own copy: two mailboxes on
|
// Each recipient's own rules decide their own copy: two mailboxes on
|
||||||
// this server receiving the same message may file it differently.
|
// this server receiving the same message may file it differently.
|
||||||
folder := targetFolder
|
folder := targetFolder
|
||||||
|
// Before the rules, so a filter still has the last word: the spam
|
||||||
|
// verdict is the filter's guess and the rules are the mailbox's own.
|
||||||
|
if folder == "Junk" && uc.rescuedFromJunk(ctx, recipient.ID, headers.FromAddress, input.Sender, authResult) {
|
||||||
|
folder = "INBOX"
|
||||||
|
}
|
||||||
var decision RuleDecision
|
var decision RuleDecision
|
||||||
if uc.rules != nil {
|
if uc.rules != nil {
|
||||||
decision = uc.rules.For(ctx, recipient.ID, input.RecipientAddresses[i], input.Sender, payload)
|
decision = uc.rules.For(ctx, recipient.ID, input.RecipientAddresses[i], input.Sender, payload)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetTrustedSenders lets a mailbox's trusted senders skip the spam folder.
|
||||||
|
// Optional: without it a spam verdict files the message in Junk for everyone.
|
||||||
|
func (uc *ProcessInboundEmailUseCase) SetTrustedSenders(trusted port.TrustedSenders) {
|
||||||
|
uc.trusted = trusted
|
||||||
|
}
|
||||||
|
|
||||||
|
// rescuedFromJunk reports whether a message the spam filter condemned should
|
||||||
|
// reach the inbox because this mailbox trusts who it is from.
|
||||||
|
//
|
||||||
|
// The From header is what a person trusts, and it is also the easiest field to
|
||||||
|
// forge, so it only counts when the message proves it came from that address:
|
||||||
|
// DMARC passed, or SPF passed for the very domain written in From. Without the
|
||||||
|
// proof, trusting a contact would hand a spammer a way in by writing that
|
||||||
|
// contact's address on their mail.
|
||||||
|
//
|
||||||
|
// Fails closed: a lookup that errors leaves the message in Junk, which is where
|
||||||
|
// it was going anyway.
|
||||||
|
func (uc *ProcessInboundEmailUseCase) rescuedFromJunk(
|
||||||
|
ctx context.Context,
|
||||||
|
mailboxID uuid.UUID,
|
||||||
|
fromAddress, envelopeSender string,
|
||||||
|
auth port.InboundAuthResult,
|
||||||
|
) bool {
|
||||||
|
if uc.trusted == nil || fromAddress == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !provesSender(fromAddress, envelopeSender, auth) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
trusted, err := uc.trusted.IsTrusted(ctx, mailboxID, fromAddress)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("trusted senders: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return trusted
|
||||||
|
}
|
||||||
|
|
||||||
|
func provesSender(fromAddress, envelopeSender string, auth port.InboundAuthResult) bool {
|
||||||
|
if auth.DMARC == port.AuthResultPass {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return auth.SPF == port.AuthResultPass &&
|
||||||
|
domainOf(fromAddress) != "" &&
|
||||||
|
domainOf(fromAddress) == domainOf(normaliseAddress(envelopeSender))
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainOf(address string) string {
|
||||||
|
at := strings.LastIndex(address, "@")
|
||||||
|
if at < 0 || at == len(address)-1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return address[at+1:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
"lambdamail/protocols/internal/domain/valueobject"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeTrusted struct {
|
||||||
|
trusted map[string]bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTrusted) IsTrusted(_ context.Context, _ uuid.UUID, address string) (bool, error) {
|
||||||
|
return f.trusted[address], f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliverAsSpam(t *testing.T, trusted port.TrustedSenders, auth port.InboundAuthResult, envelope string) string {
|
||||||
|
t.Helper()
|
||||||
|
repo := &recordingMessageRepository{}
|
||||||
|
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, repo)
|
||||||
|
uc.SetScanner(&stubScanner{result: &valueobject.ScanResult{Verdict: valueobject.ScanVerdictSpamJunk}})
|
||||||
|
uc.SetAuthenticator(&stubAuthenticator{result: auth})
|
||||||
|
if trusted != nil {
|
||||||
|
uc.SetTrustedSenders(trusted)
|
||||||
|
}
|
||||||
|
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
|
||||||
|
Sender: envelope,
|
||||||
|
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
|
||||||
|
RecipientAddresses: []string{"me@example.test"},
|
||||||
|
Body: bytes.NewBufferString("From: Jenny <Jenny@Friend.test>\r\nSubject: re: hi\r\n\r\nbody"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Handle: %v", err)
|
||||||
|
}
|
||||||
|
return repo.persisted[0].TargetFolderName
|
||||||
|
}
|
||||||
|
|
||||||
|
var passDMARC = port.InboundAuthResult{SPF: "pass", DKIM: "pass", DMARC: "pass"}
|
||||||
|
|
||||||
|
func TestTrustedSenderIsNotFiledAsSpam(t *testing.T) {
|
||||||
|
trusted := &fakeTrusted{trusted: map[string]bool{"jenny@friend.test": true}}
|
||||||
|
if got := deliverAsSpam(t, trusted, passDMARC, "jenny@friend.test"); got != "INBOX" {
|
||||||
|
t.Fatalf("a trusted, authenticated sender was filed in %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUntrustedSenderStillGoesToJunk(t *testing.T) {
|
||||||
|
trusted := &fakeTrusted{trusted: map[string]bool{"someone@else.test": true}}
|
||||||
|
if got := deliverAsSpam(t, trusted, passDMARC, "jenny@friend.test"); got != "Junk" {
|
||||||
|
t.Fatalf("an untrusted sender was filed in %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writing a contact's address in From is all a spammer needs to do, so trust
|
||||||
|
// only counts when the message proves it came from there.
|
||||||
|
func TestForgedTrustedSenderStillGoesToJunk(t *testing.T) {
|
||||||
|
trusted := &fakeTrusted{trusted: map[string]bool{"jenny@friend.test": true}}
|
||||||
|
forged := port.InboundAuthResult{SPF: "fail", DKIM: "none", DMARC: "fail"}
|
||||||
|
if got := deliverAsSpam(t, trusted, forged, "bounce@spammer.test"); got != "Junk" {
|
||||||
|
t.Fatalf("a forged From reached the inbox: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPF that passed for the spammer's own domain proves nothing about From.
|
||||||
|
func TestSPFForAnotherDomainDoesNotProveFrom(t *testing.T) {
|
||||||
|
trusted := &fakeTrusted{trusted: map[string]bool{"jenny@friend.test": true}}
|
||||||
|
other := port.InboundAuthResult{SPF: "pass", DKIM: "none", DMARC: "none"}
|
||||||
|
if got := deliverAsSpam(t, trusted, other, "bounce@spammer.test"); got != "Junk" {
|
||||||
|
t.Fatalf("SPF for a different domain let a forged From in: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A domain with no DMARC record can still be proven by SPF for its own domain.
|
||||||
|
func TestSPFForTheSameDomainProvesFrom(t *testing.T) {
|
||||||
|
trusted := &fakeTrusted{trusted: map[string]bool{"jenny@friend.test": true}}
|
||||||
|
spfOnly := port.InboundAuthResult{SPF: "pass", DKIM: "none", DMARC: "none"}
|
||||||
|
if got := deliverAsSpam(t, trusted, spfOnly, "mailer@friend.test"); got != "INBOX" {
|
||||||
|
t.Fatalf("SPF for the sender's own domain was not accepted: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupFailureLeavesTheMessageInJunk(t *testing.T) {
|
||||||
|
trusted := &fakeTrusted{trusted: map[string]bool{"jenny@friend.test": true}, err: errors.New("db down")}
|
||||||
|
if got := deliverAsSpam(t, trusted, passDMARC, "jenny@friend.test"); got != "Junk" {
|
||||||
|
t.Fatalf("a failed lookup let mail through: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithoutTrustedSendersSpamGoesToJunk(t *testing.T) {
|
||||||
|
if got := deliverAsSpam(t, nil, passDMARC, "jenny@friend.test"); got != "Junk" {
|
||||||
|
t.Fatalf("filed in %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TrustedSenders reads the senders a mailbox trusts. The webmail writes them
|
||||||
|
// through the auth service; delivery only ever asks.
|
||||||
|
type TrustedSenders struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTrustedSenders(pool *pgxpool.Pool) *TrustedSenders {
|
||||||
|
return &TrustedSenders{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TrustedSenders) IsTrusted(ctx context.Context, mailboxID uuid.UUID, address string) (bool, error) {
|
||||||
|
var trusted bool
|
||||||
|
err := t.pool.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM trusted_senders WHERE mailbox_id = $1 AND address = $2
|
||||||
|
)
|
||||||
|
`, mailboxID, address).Scan(&trusted)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("look up a trusted sender: %w", err)
|
||||||
|
}
|
||||||
|
return trusted, nil
|
||||||
|
}
|
||||||
@@ -321,9 +321,15 @@
|
|||||||
"ccLabel": "Cc",
|
"ccLabel": "Cc",
|
||||||
"bccLabel": "Bcc",
|
"bccLabel": "Bcc",
|
||||||
"hideImages": "Hide images",
|
"hideImages": "Hide images",
|
||||||
"alwaysFromSender": "Always from this sender",
|
"alwaysFromSender": "Trust this sender",
|
||||||
"forgetSender": "Stop trusting sender",
|
"forgetSender": "Stop trusting sender",
|
||||||
"moveTo": "Move to"
|
"moveTo": "Move to",
|
||||||
|
"notSpam": "Not spam",
|
||||||
|
"junkNotice": "This message is in Spam. Marking it as not spam moves it to the inbox and trusts this sender, so their next messages arrive there too.",
|
||||||
|
"trustSender": "Trust sender",
|
||||||
|
"trustedSender": "Trusted sender",
|
||||||
|
"trustSenderHint": "Never file this sender's mail as spam, and always show their images",
|
||||||
|
"trustedSenderHint": "Not filed as spam, images always shown. Click to stop trusting."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"actionDiscard": "Discard",
|
"actionDiscard": "Discard",
|
||||||
|
|||||||
@@ -321,9 +321,15 @@
|
|||||||
"ccLabel": "Cc",
|
"ccLabel": "Cc",
|
||||||
"bccLabel": "Cco",
|
"bccLabel": "Cco",
|
||||||
"hideImages": "Ocultar imágenes",
|
"hideImages": "Ocultar imágenes",
|
||||||
"alwaysFromSender": "Siempre de este remitente",
|
"alwaysFromSender": "Confiar en este remitente",
|
||||||
"forgetSender": "Dejar de confiar",
|
"forgetSender": "Dejar de confiar",
|
||||||
"moveTo": "Mover a"
|
"moveTo": "Mover a",
|
||||||
|
"notSpam": "No es spam",
|
||||||
|
"junkNotice": "Este mensaje está en Spam. Al marcarlo como no es spam, pasa a la bandeja de entrada y este remitente se vuelve de confianza, así que sus próximos mensajes llegarán allí también.",
|
||||||
|
"trustSender": "Confiar en el remitente",
|
||||||
|
"trustedSender": "Remitente de confianza",
|
||||||
|
"trustSenderHint": "No enviar nunca el correo de este remitente a spam y mostrar siempre sus imágenes",
|
||||||
|
"trustedSenderHint": "No va a spam y sus imágenes siempre se muestran. Haz clic para dejar de confiar."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"actionDiscard": "Descartar",
|
"actionDiscard": "Descartar",
|
||||||
|
|||||||
@@ -321,9 +321,15 @@
|
|||||||
"ccLabel": "Cc",
|
"ccLabel": "Cc",
|
||||||
"bccLabel": "Cco",
|
"bccLabel": "Cco",
|
||||||
"hideImages": "Ocultar imagens",
|
"hideImages": "Ocultar imagens",
|
||||||
"alwaysFromSender": "Sempre deste remetente",
|
"alwaysFromSender": "Confiar neste remetente",
|
||||||
"forgetSender": "Deixar de confiar",
|
"forgetSender": "Deixar de confiar",
|
||||||
"moveTo": "Mover para"
|
"moveTo": "Mover para",
|
||||||
|
"notSpam": "Não é spam",
|
||||||
|
"junkNotice": "Esta mensagem está no Spam. Ao marcar como não é spam, ela vai para a caixa de entrada e este remetente passa a ser confiável, então as próximas mensagens dele também chegam lá.",
|
||||||
|
"trustSender": "Confiar no remetente",
|
||||||
|
"trustedSender": "Remetente confiável",
|
||||||
|
"trustSenderHint": "Nunca mandar o e-mail deste remetente para o spam e sempre mostrar as imagens dele",
|
||||||
|
"trustedSenderHint": "Não vai para o spam e as imagens sempre aparecem. Clique para deixar de confiar."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"actionDiscard": "Descartar",
|
"actionDiscard": "Descartar",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
} from "../../../../../lib/mailCounts";
|
} from "../../../../../lib/mailCounts";
|
||||||
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer";
|
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer";
|
||||||
import { plainSnippet } from "../../../../../lib/snippet";
|
import { plainSnippet } from "../../../../../lib/snippet";
|
||||||
import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages";
|
import { useTrustedSenders } from "../../../../../lib/trustedSenders";
|
||||||
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 {
|
import {
|
||||||
@@ -122,9 +122,9 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
const [messages, setMessages] = useState<MessageSummary[]>([]);
|
const [messages, setMessages] = useState<MessageSummary[]>([]);
|
||||||
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
||||||
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
|
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
|
||||||
// Whether this sender has a standing decision, so the banner can offer to
|
// The senders this account trusts, kept on the server so the decision
|
||||||
// take it back rather than only to grant it.
|
// follows the account to every browser and also reaches the spam filter.
|
||||||
const [rememberSender, setRememberSender] = useState(false);
|
const trustedSenders = useTrustedSenders();
|
||||||
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);
|
||||||
@@ -333,7 +333,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadRemoteImages(false);
|
setLoadRemoteImages(false);
|
||||||
setRememberSender(false);
|
|
||||||
setReaderHeight(420);
|
setReaderHeight(420);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, {
|
const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, {
|
||||||
@@ -342,10 +341,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
if (!res.ok) throw new Error(t("errors.serverError"));
|
if (!res.ok) throw new Error(t("errors.serverError"));
|
||||||
const message = await res.json();
|
const message = await res.json();
|
||||||
setSelected(message);
|
setSelected(message);
|
||||||
// A sender the reader has already allowed does not get asked again.
|
|
||||||
const trusted = isSenderTrusted(message.from ?? "");
|
|
||||||
setLoadRemoteImages(trusted);
|
|
||||||
setRememberSender(trusted);
|
|
||||||
// Fetching the message is what marks it read on the server, so this only
|
// Fetching the message is what marks it read on the server, so this only
|
||||||
// brings the list and the badges into line with what already happened.
|
// brings the list and the badges into line with what already happened.
|
||||||
void setSeen(uid, true, { persist: false });
|
void setSeen(uid, true, { persist: false });
|
||||||
@@ -362,6 +357,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
searching: searchQuery.trim().length > 0,
|
searching: searchQuery.trim().length > 0,
|
||||||
});
|
});
|
||||||
const selectedRow = messages.find((m) => m.uid === selected?.uid);
|
const selectedRow = messages.find((m) => m.uid === selected?.uid);
|
||||||
|
const senderTrusted = selected ? trustedSenders.isTrusted(selected.from ?? "") : false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The document the reader frame renders.
|
* The document the reader frame renders.
|
||||||
@@ -376,9 +372,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
const sanitized = sanitizeEmailHtml(selected.html);
|
const sanitized = sanitizeEmailHtml(selected.html);
|
||||||
if (!sanitized) return null;
|
if (!sanitized) return null;
|
||||||
const withInline = resolveInlineImages(sanitized, selected.inline_images ?? {});
|
const withInline = resolveInlineImages(sanitized, selected.inline_images ?? {});
|
||||||
const body = loadRemoteImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline);
|
// A trusted sender's images load without asking; anyone else's wait for
|
||||||
|
// the reader's choice, one message at a time.
|
||||||
|
const showImages = loadRemoteImages || trustedSenders.isTrusted(selected.from ?? "");
|
||||||
|
const body = showImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline);
|
||||||
return buildReaderDocument(body);
|
return buildReaderDocument(body);
|
||||||
}, [selected, loadRemoteImages]);
|
}, [selected, loadRemoteImages, trustedSenders]);
|
||||||
|
|
||||||
// Whether anything was actually held back, so the privacy banner is not shown
|
// Whether anything was actually held back, so the privacy banner is not shown
|
||||||
// over a message that has no remote content at all.
|
// over a message that has no remote content at all.
|
||||||
@@ -651,25 +650,29 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
document={readerDocument}
|
document={readerDocument}
|
||||||
frameHeight={readerHeight}
|
frameHeight={readerHeight}
|
||||||
hasBlockedContent={hasBlockedContent}
|
hasBlockedContent={hasBlockedContent}
|
||||||
imagesLoaded={loadRemoteImages}
|
imagesLoaded={loadRemoteImages || senderTrusted}
|
||||||
senderRemembered={rememberSender}
|
senderTrusted={senderTrusted}
|
||||||
|
isJunk={folder.toLowerCase() === "junk"}
|
||||||
onClose={() => setSelected(null)}
|
onClose={() => setSelected(null)}
|
||||||
onToggleFlagged={() => void setFlagged(selected.uid, !(selectedRow?.flagged ?? false))}
|
onToggleFlagged={() => void setFlagged(selected.uid, !(selectedRow?.flagged ?? false))}
|
||||||
onToggleSeen={() => void setSeen(selected.uid, !(selectedRow?.seen ?? true), { persist: true })}
|
onToggleSeen={() => void setSeen(selected.uid, !(selectedRow?.seen ?? true), { persist: true })}
|
||||||
onDelete={() => void deleteMessage(selected.uid)}
|
onDelete={() => void deleteMessage(selected.uid)}
|
||||||
onMove={(target) => void moveMessage(selected.uid, target)}
|
onMove={(target) => void moveMessage(selected.uid, target)}
|
||||||
onToggleImages={() => setLoadRemoteImages(!loadRemoteImages)}
|
onToggleImages={() => setLoadRemoteImages(!loadRemoteImages)}
|
||||||
onToggleRemember={() => {
|
onToggleTrusted={() => {
|
||||||
if (rememberSender) {
|
if (senderTrusted) {
|
||||||
revokeSender(selected.from);
|
void trustedSenders.revoke(selected.from);
|
||||||
setRememberSender(false);
|
|
||||||
setLoadRemoteImages(false);
|
setLoadRemoteImages(false);
|
||||||
} else {
|
} else {
|
||||||
trustSender(selected.from);
|
void trustedSenders.trust(selected.from);
|
||||||
setRememberSender(true);
|
|
||||||
setLoadRemoteImages(true);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onNotSpam={() => {
|
||||||
|
// Two steps, one decision: trusting is what makes the next
|
||||||
|
// reply reach the inbox, moving this one is what is seen now.
|
||||||
|
void trustedSenders.trust(selected.from);
|
||||||
|
void moveMessage(selected.uid, "INBOX");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
FolderInput,
|
FolderInput,
|
||||||
Forward,
|
Forward,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Pencil,
|
Pencil,
|
||||||
Reply,
|
Reply,
|
||||||
@@ -72,14 +74,18 @@ interface Props {
|
|||||||
/** Whether anything was held back, so the privacy bar is not always shown. */
|
/** Whether anything was held back, so the privacy bar is not always shown. */
|
||||||
hasBlockedContent: boolean;
|
hasBlockedContent: boolean;
|
||||||
imagesLoaded: boolean;
|
imagesLoaded: boolean;
|
||||||
senderRemembered: boolean;
|
/** Whether the account trusts this sender: not spam, and images load. */
|
||||||
|
senderTrusted: boolean;
|
||||||
|
/** The message is being read in the spam folder. */
|
||||||
|
isJunk: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onToggleSeen: () => void;
|
onToggleSeen: () => void;
|
||||||
onToggleFlagged: () => void;
|
onToggleFlagged: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onMove: (target: string) => void;
|
onMove: (target: string) => void;
|
||||||
onToggleImages: () => void;
|
onToggleImages: () => void;
|
||||||
onToggleRemember: () => void;
|
onToggleTrusted: () => void;
|
||||||
|
onNotSpam: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The sender's name and address, from a header like `Jenny <jenny@x.test>`. */
|
/** The sender's name and address, from a header like `Jenny <jenny@x.test>`. */
|
||||||
@@ -324,6 +330,22 @@ export function MessageReader(props: Props) {
|
|||||||
<span className="text-[13px] text-slate-400 [overflow-wrap:anywhere]"><{address}></span>
|
<span className="text-[13px] text-slate-400 [overflow-wrap:anywhere]"><{address}></span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onToggleTrusted}
|
||||||
|
aria-pressed={props.senderTrusted}
|
||||||
|
title={props.senderTrusted ? t("mail.trustedSenderHint") : t("mail.trustSenderHint")}
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
||||||
|
props.senderTrusted
|
||||||
|
? "bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30"
|
||||||
|
: "text-slate-400 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] hover:bg-white/[0.07] hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3 w-3" aria-hidden />
|
||||||
|
{props.senderTrusted ? t("mail.trustedSender") : t("mail.trustSender")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="text-[12.5px] leading-relaxed text-slate-400 [overflow-wrap:anywhere]">
|
<div className="text-[12.5px] leading-relaxed text-slate-400 [overflow-wrap:anywhere]">
|
||||||
{visibleTo.length > 0 && (
|
{visibleTo.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -361,18 +383,35 @@ export function MessageReader(props: Props) {
|
|||||||
error. Two separate decisions - look now, or stop asking about
|
error. Two separate decisions - look now, or stop asking about
|
||||||
this sender - so a reader can see one message without granting
|
this sender - so a reader can see one message without granting
|
||||||
anything permanently. */}
|
anything permanently. */}
|
||||||
{(props.hasBlockedContent || props.senderRemembered) && (
|
{props.isJunk && (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg bg-amber-500/10 px-3 py-2.5 shadow-[inset_0_0_0_1px_rgba(245,158,11,0.35)]">
|
||||||
|
<ShieldAlert className="h-4 w-4 flex-none text-amber-400" aria-hidden />
|
||||||
|
<span className="min-w-[180px] flex-1 text-[12.5px] leading-relaxed text-amber-100">
|
||||||
|
{t("mail.junkNotice")}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onNotSpam}
|
||||||
|
className="rounded-md bg-amber-400 px-2.5 py-1 text-[12.5px] font-semibold text-slate-900 transition-colors hover:bg-amber-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200"
|
||||||
|
>
|
||||||
|
{t("mail.notSpam")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{props.hasBlockedContent && (
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg bg-dark-card px-3 py-2 shadow-edge">
|
<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 />
|
<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">
|
<span className="min-w-[180px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
|
||||||
{props.imagesLoaded ? t("mail.imagesEnabled") : t("mail.remoteContentBlocked")}
|
{props.imagesLoaded ? t("mail.imagesEnabled") : t("mail.remoteContentBlocked")}
|
||||||
</span>
|
</span>
|
||||||
|
{/* A trusted sender's images are shown for as long as they are
|
||||||
|
trusted, so hiding them here would do nothing. */}
|
||||||
|
{!props.senderTrusted && (
|
||||||
<button type="button" onClick={props.onToggleImages} className={infoLinkClass}>
|
<button type="button" onClick={props.onToggleImages} className={infoLinkClass}>
|
||||||
{props.imagesLoaded ? t("mail.hideImages") : t("mail.loadImages")}
|
{props.imagesLoaded ? t("mail.hideImages") : t("mail.loadImages")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={props.onToggleRemember} className={infoLinkClass}>
|
)}
|
||||||
{props.senderRemembered ? t("mail.forgetSender") : t("mail.alwaysFromSender")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function senderKey(from: string): string {
|
|||||||
* reader to fail to render. Failing closed means falling back to blocked,
|
* reader to fail to render. Failing closed means falling back to blocked,
|
||||||
* which is the safe direction.
|
* which is the safe direction.
|
||||||
*/
|
*/
|
||||||
function readTrusted(): string[] {
|
export function readTrusted(): string[] {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(TRUSTED_SENDERS_KEY);
|
const raw = window.localStorage.getItem(TRUSTED_SENDERS_KEY);
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
@@ -86,3 +86,12 @@ export function revokeSender(from: string): void {
|
|||||||
if (!key) return;
|
if (!key) return;
|
||||||
writeTrusted(readTrusted().filter((entry) => entry !== key));
|
writeTrusted(readTrusted().filter((entry) => entry !== key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forgets the browser's own copy, once it has been moved to the server. */
|
||||||
|
export function clearLocalTrusted(): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(TRUSTED_SENDERS_KEY);
|
||||||
|
} catch {
|
||||||
|
// Nothing to clear.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ export function plainSnippet(raw: string | null | undefined): string {
|
|||||||
// A block boundary is a space, or "Hello,</p><p>You" would read "Hello,You".
|
// A block boundary is a space, or "Hello,</p><p>You" would read "Hello,You".
|
||||||
.replace(/<\/(?:p|div|li|tr|h[1-6])>|<br\s*\/?>/gi, " ")
|
.replace(/<\/(?:p|div|li|tr|h[1-6])>|<br\s*\/?>/gi, " ")
|
||||||
.replace(/<(style|script|head)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
.replace(/<(style|script|head)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
||||||
.replace(/<[^>]*>/g, "");
|
.replace(/<[^>]*>/g, "")
|
||||||
|
// The server cuts the preview at a fixed length, which can leave the start
|
||||||
|
// of a tag with no end ("...open</p").
|
||||||
|
.replace(/<[^>]*$/, "");
|
||||||
return decodeEntities(withoutTags).replace(/\s+/g, " ").trim();
|
return decodeEntities(withoutTags).replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { senderKey, readTrusted, clearLocalTrusted } from "./remoteImages";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The senders the signed-in account trusts, kept on the server.
|
||||||
|
*
|
||||||
|
* One decision does both jobs: mail from a trusted sender is not filed as
|
||||||
|
* spam, and its remote images load without asking. It used to be a list in one
|
||||||
|
* browser's storage, so another browser, a cleared cache or a private window
|
||||||
|
* asked again - and moving a message out of Junk taught the filter nothing.
|
||||||
|
*
|
||||||
|
* What an earlier version remembered in this browser is moved up the first
|
||||||
|
* time the list loads, so nobody has to decide a second time.
|
||||||
|
*/
|
||||||
|
export function useTrustedSenders() {
|
||||||
|
const [senders, setSenders] = useState<Set<string>>(new Set());
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/user/trusted-senders", { cache: "no-store" });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const known = new Set<string>(((await res.json()) as { senders: string[] }).senders);
|
||||||
|
|
||||||
|
const local = readTrusted().filter((address) => !known.has(address));
|
||||||
|
const moved = await Promise.all(
|
||||||
|
local.map((address) =>
|
||||||
|
fetch("/api/v1/user/trusted-senders", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ address }),
|
||||||
|
}).then((r) => r.ok),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
local.forEach((address, i) => moved[i] && known.add(address));
|
||||||
|
// Only cleared when every entry made it: a failure keeps the browser's
|
||||||
|
// copy, so the next load tries again.
|
||||||
|
if (moved.every(Boolean)) clearLocalTrusted();
|
||||||
|
|
||||||
|
if (!cancelled) setSenders(known);
|
||||||
|
} catch {
|
||||||
|
// Nothing is trusted this session, which is the safe direction.
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setReady(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isTrusted = useCallback((from: string) => senders.has(senderKey(from)), [senders]);
|
||||||
|
|
||||||
|
const change = useCallback(async (from: string, trusted: boolean): Promise<boolean> => {
|
||||||
|
const address = senderKey(from);
|
||||||
|
if (!address) return false;
|
||||||
|
const apply = (on: boolean) =>
|
||||||
|
setSenders((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (on) next.add(address);
|
||||||
|
else next.delete(address);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
apply(trusted);
|
||||||
|
try {
|
||||||
|
const res = trusted
|
||||||
|
? await fetch("/api/v1/user/trusted-senders", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ address }),
|
||||||
|
})
|
||||||
|
: await fetch(`/api/v1/user/trusted-senders?address=${encodeURIComponent(address)}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error("rejected");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
apply(!trusted);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
isTrusted,
|
||||||
|
trust: (from: string) => change(from, true),
|
||||||
|
revoke: (from: string) => change(from, false),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ describe("plainSnippet", () => {
|
|||||||
it("drops a stylesheet instead of previewing its rules", () => {
|
it("drops a stylesheet instead of previewing its rules", () => {
|
||||||
expect(plainSnippet("<style>p{color:red}</style><p>Hi</p>")).toBe("Hi");
|
expect(plainSnippet("<style>p{color:red}</style><p>Hi</p>")).toBe("Hi");
|
||||||
});
|
});
|
||||||
|
it("drops a tag the preview was cut in the middle of", () => {
|
||||||
|
expect(plainSnippet("<p>Open cyber model access</p")).toBe("Open cyber model access");
|
||||||
|
});
|
||||||
it("keeps an entity it does not know", () => {
|
it("keeps an entity it does not know", () => {
|
||||||
expect(plainSnippet("a &madeup; b")).toBe("a &madeup; b");
|
expect(plainSnippet("a &madeup; b")).toBe("a &madeup; b");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user