Skip to content

feat: mailbox source, REPORT, add to collection (#2246, #2247, #2260) - #2262

Merged
cjimti merged 3 commits into
mainfrom
feat/2246-2247-2260-caldav-imap-collection
Oct 23, 2026
Merged

cjimti merged 3 commits into
mainfrom
feat/2246-2247-2260-caldav-imap-collection

Conversation

@cjimti

@cjimti cjimti commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Adds an imap input to webhook sources (config.input, config.imap, auth mode imap, migration 000218_webhook_imap_marks, imap on GET /api/v1/admin/webhooks/sources/{name}), REPORT on api_invoke_endpoint, and Add to collection in the asset viewer (POST /api/v1/portal/collections/{id}/items, GET /api/v1/portal/assets/{id}/collection-targets, asset_id on POST /api/v1/portal/collections). No MCP tool is added. One bug fixed, in internal/cfgmap.

Closes #2246
Closes #2247
Closes #2260

A mailbox as a webhook source (#2247)

A webhook source with config.input: imap is not posted to: the platform checks a mailbox on an interval and files each new message from an allowed, authenticated sender as one event in the raw segments the HTTP receiver writes. The source's table, compaction to Parquet, retention and alerts are the same as for a posted source. The mail reaches a script or an agent only through the table and the resources.

Setting Meaning
auth.mode: imap, auth.username, auth.secret The mailbox login; the password is write-only and encrypted like every source secret
config.imap.host, port, tls IMAPS (993, default) or STARTTLS (143); plain IMAP refused
config.imap.tls_ca_bundle_pem Certificates to trust for a private CA
config.imap.folders Required; no all-folders default
config.imap.allow_senders Required; addresses and @domain entries
config.imap.require_auth_pass, trusted_authserv_id Default on: keep an allowed sender's mail only with an aligned SPF, DKIM or DMARC pass read from the topmost Authentication-Results carrying the trusted id
config.imap.poll_seconds Default 60, 15 to 86,400
config.imap.max_message_bytes, max_attachment_bytes Default 10 MiB and 25 MiB

How it reads (internal/webhook/whimap):

  1. One replica at a time checks a source, under an advisory lock (bgloop loop webhook_imap_poll, every 15 seconds for sources whose interval has passed).
  2. Folders are opened with EXAMINE and messages fetched with BODY.PEEK; nothing is marked seen, moved, deleted or sent.
  3. Each folder keeps its UIDVALIDITY and highest UID read in webhook_imap_marks, with the stored and withheld counts and the last error. A renumbered folder is read again; the event id is the Message-ID, so compaction keeps one copy. At most 200 messages per folder per check, oldest first, and a failed check leaves the watermark where it was.
  4. A message from a sender not on the list, or without the required pass, is counted on its folder and nothing else of it is stored (internal/mailtrust).
  5. A kept message is one event: event_type mail, key the folder, and a payload with what the platform read (folder, uid, dates, from address, authentication result, size, attachments) and, under external, what the sender wrote, cleaned of invisible, bidirectional and control characters (internal/untrusted), with HTML reduced to visible text and hidden elements dropped (internal/imapmail), beside a notice.
  6. Each attachment is a managed resource under webhooks/{source}/attachments/{date}/{folder}-{uidvalidity}-{uid}/ in the source's library, named on the event by uri and resource_id; reading a message again writes a new version. A source's attachments are deleted with the source (windowResources.DeleteAttachments).

Saving a source, on create and on update, signs in and opens each folder and is refused with the server's answer when it cannot (whadmin). A source's input is kept on update. The HTTP receiver skips imap sources, so /hooks/{name} answers 404 for one.

Admin > Webhooks: the source editor offers "A mailbox the platform checks (IMAP)" with Mail server and What is read groups in place of the authentication and events groups; the source's page shows a Mailbox section (server, login, allowed senders, and per folder the messages stored, withheld because the sender is not allowed or not authenticated, last check and last error) in place of the sender address, requests and rejections.

REPORT on api connections (#2246)

api_invoke_endpoint accepts REPORT beside PROPFIND: forwarded unchanged with its XML body and Depth header, honored on a catalog's x-webdav-method carrier operation, and classified as a read by internal/toolwrite. decode: "xml" parses the multistatus. This is what a CalDAV calendar-query or calendar-multiget is sent with. Calendars as a polled source, like mail, is #2263.

Add to collection (#2260)

Add to collection in the asset viewer toolbar opens a picker of the collections the reader can edit (their own, those shared as Editor, every collection for an administrator), most recently updated first, with search. A collection already holding the asset says in which sections; one whose every section holds it cannot be chosen. A collection with several sections asks for the section, the last unused one preselected; New collection creates one with the asset as its first item. The confirmation names the collection and section and opens it.

  • POST /api/v1/portal/collections/{id}/items appends one item at the end of the named section, the last section, or a new untitled section in a collection with none, holding the collection row for the transaction. 409 for an asset already in the section, a full section, or a section no longer in the collection; 403 for a caller who cannot edit the collection or open the asset. The SQL is in internal/portal/collitems.
  • GET /api/v1/portal/assets/{id}/collection-targets lists the editable collections with their sections and has_asset.
  • POST /api/v1/portal/collections takes asset_id; a failed seed removes the new collection.

Bug fixed

  1. cfgmap.Int64 read an integer written as a string ("9993", the form a YAML value filled from the environment arrives in) as the default rather than the value, for every connection kind. It now parses a decimal string; a non-numeric string still falls back.

Dev stack

dovecot (Dovecot 2.4.1 over IMAPS, dev/dovecot, port 9993, certificate generated into dev/.tls/dovecot) and radicale (Radicale 3.5.4.0, dev/radicale, port 5232) run beside the other fixtures. The Dovecot overlay sets default_vsz_limit = unlimited, without which its services fail to start under x86 emulation on an arm machine. dev-smoke checks both through the platform: an imap source saved against Dovecot, and a PROPFIND to Radicale through an api connection.

Readers of this state

  • whsource.Config.input, whsource.Config.imap, whsource.IMAP: WebhookConfig and WebhookIMAP in ui/src/api/admin/types/webhooks.ts, read by webhookForm.ts and imapForm.ts (the editor) and MailboxSection.tsx; MSW ui/src/mocks/handlers/webhooks.ts and ui/src/mocks/data/webhooks.ts.
  • webhookapi.SourceDetail.imap, whimap.Mark: WebhookSourceDetail.imap and WebhookIMAPFolder, read by WebhookDetailPage.tsx and MailboxSection.tsx; the MSW detail handler returns them for a mailbox source. A source's path is empty for one; senderURL returns empty for it and the sender section is not shown.
  • AppendedItem, CollectionTarget, SectionSummary, appendItemRequest, collectionTargetsResponse, createCollectionRequest.asset_id: ui/src/api/portal/types.ts and ui/src/api/portal/hooks/collections.ts, read by AddToCollectionDialog.tsx; MSW ui/src/mocks/handlers.ts. useCreateCollection's other caller (the collections page) does not send asset_id.
  • POST /portal/collections description change: no reader depends on the text.

Tests

  • Acceptance, through the real MCP and REST surfaces on the dev stack, all passing in the recorded runs (build/<n>/acceptance.jsonl):
    • issue_2247_test.go, 6 criteria against Dovecot: an allowed message is one row with its hidden HTML gone and its attachment a readable resource; a stranger's and unauthenticated mail are counted and not stored; the messages stay unseen and in place; a wrong password is refused at save; no mail_* or calendar_* tool is registered; deleting the source removes its attachments.
    • issue_2246_test.go, 1 criterion against Radicale: REPORT with a calendar-query for one day answers 207 with only that day's events.
    • issue_2260_test.go, 3 criteria.
  • Unit: internal/webhook/whimap against go-imap's in-memory server over TLS, whsource, whadmin (the mailbox check on create and update, the input kept, attachments deleted), webhookapi, webhookwire (PutFile, DeleteAttachments), internal/portal/collitems with sqlmock and a real-Postgres append test, and vitest for the mailbox form, editor and detail page and the collection picker.
  • Patch coverage 92.5%. make preverify (migration check on pg16 and pg17, dev-smoke) and make verify passed.

Docs

docs/server/webhooks.md gains "A mailbox as a source"; api-catalogs.md and portal/assets.md are updated; CLAUDE.md, llms.txt and llms-full.txt follow, and dev/README.md lists the two servers.

…, #2260)

Adds REPORT to api_invoke_endpoint, the caldav kind (calendar_list, calendar_events, calendar_event), the imap kind (mail_search, mail_message, mail_thread, mail_attachment), a script setting mail_actions (migration 000218, PUT /api/v1/portal/scripts/{id}/mail-actions), and add to collection from the asset viewer (POST /api/v1/portal/collections/{id}/items, GET /api/v1/portal/assets/{id}/collection-targets, asset_id on POST /api/v1/portal/collections).

The caldav kind reads a calendar account over CalDAV with PROPFIND, REPORT and GET only. Recurring events are expanded in the event's own zone, applying EXDATE and RECURRENCE-ID overrides, and non-IANA zone names are mapped. Author-written fields are returned under external, cleaned of invisible characters (internal/untrusted).

The imap kind reads the folders a connection names, over IMAPS or STARTTLS, with EXAMINE and BODY.PEEK. A message is shown only when its From is on allow_senders and, with require_auth_pass, the receiving server's Authentication-Results records an aligned SPF, DKIM or DMARC pass; other messages are counted and never returned (internal/mailtrust). HTML is reduced to visible text and quoted history is collapsed (internal/imapmail). mail_attachment lands the file in the caller's resource library.

With mail_actions off, a run that has called a mail_* tool is refused every later write-class tool call and every output (internal/mailbarrier, wrapping the run's caller and exporter in platform runs, drafts and endpoint requests); one run makes at most 500 mail calls.

The dev stack runs Radicale and Dovecot for the acceptance criteria, and dev-smoke checks both.

One bug fixed:

1. cfgmap.Int64 read an integer written as a string ("9993", the form a YAML value filled from the environment arrives in) as the default instead of the value.

Closes #2246
Closes #2247
Closes #2260
@codecov

codecov Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.60227% with 57 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.78%. Comparing base (cb0e8b2) to head (9856c96).

Files with missing lines Patch % Lines
internal/webhook/whimap/poller.go 90.17% 16 Missing and 1 partial ⚠️
internal/imapmail/message.go 87.73% 13 Missing ⚠️
internal/imapmail/client.go 91.45% 10 Missing ⚠️
internal/webhook/whimap/message.go 90.14% 7 Missing ⚠️
internal/mailtrust/trust.go 93.33% 5 Missing ⚠️
pkg/portal/collection_items_handler.go 98.18% 2 Missing ⚠️
internal/httpserver/webhookwire/webhookwire.go 90.00% 1 Missing ⚠️
internal/imapmail/body.go 98.64% 1 Missing ⚠️
internal/webhook/receiver/receiver.go 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2262      +/-   ##
==========================================
- Coverage   94.78%   94.78%   -0.01%     
==========================================
  Files        1406     1420      +14     
  Lines      113978   115028    +1050     
==========================================
+ Hits       108036   109026     +990     
- Misses       5924     5983      +59     
- Partials       18       19       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…2247)

Replaces the caldav and imap connection kinds, their tools (calendar_list, calendar_events, calendar_event, mail_search, mail_message, mail_thread, mail_attachment) and the mail_actions script setting with an imap input on webhook sources: config.input imap, config.imap (host, port, tls, tls_ca_bundle_pem, folders, allow_senders, require_auth_pass, trusted_authserv_id, poll_seconds, max_message_bytes, max_attachment_bytes) and auth mode imap with the mailbox password as the write-only secret. Migration 000218 is now webhook_imap_marks; GET /api/v1/admin/webhooks/sources/{name} carries each folder's marks under imap.

One replica at a time (advisory lock) checks each enabled imap source on its interval (internal/webhook/whimap): each folder is opened with EXAMINE and fetched with BODY.PEEK, read above its UID watermark, at most 200 messages per check. A message from an allowed sender with an aligned SPF, DKIM or DMARC pass recorded by the trusted receiving server is written as one event to the raw segments the HTTP receiver writes (event_id the Message-ID, event_type mail, key the folder, sender-written text under external), so the table, compaction, retention and alerts are the same as for a posted source. Other mail is counted on its folder and not stored. Attachments are managed resources under webhooks/{source}/attachments/ in the source's library, named on the event, and are deleted with the source.

Saving an imap source, on create and on update, signs in and opens each folder and is refused when it cannot. A source's input is kept on update. The receiver skips imap sources.

Admin > Webhooks gains a mailbox choice in the editor and a Mailbox section on the source's page with each folder's stored and withheld counts and last error. docs/server/webhooks.md has "A mailbox as a source"; the caldav and imap pages are removed.

REPORT on api_invoke_endpoint stays (#2246). The dev stack keeps Radicale for its acceptance criterion and Dovecot for #2247's, and dev-smoke checks both. Unused parts of internal/imapmail and internal/mailtrust that only the tools called are removed.

Closes #2246
Closes #2247
@cjimti cjimti changed the title feat: caldav and imap connection kinds, add to collection (#2246, #2247, #2260) feat: mailbox source, REPORT, add to collection (#2246, #2247, #2260) Oct 23, 2026
TestPoll_FilesTrustedMailAsEvents searched the JSON of the stored events for "999", a number from a withheld message. That JSON carries each payload as base64 beside content hashes and timestamps, so the search could match by chance (it did in CI) and could never find a withheld sentence. It now searches each event's id and decoded payload for the full withheld sentences, and the unused mustJSON helper is removed.
@cjimti
cjimti merged commit 277fa05 into main Oct 23, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant