# Public invoice links in Graph email intake

Implemented in the tracked watcher; not installed/enabled in the live Hermes runtime
by this change. No database migration or web deployment required.

## Scope

With an explicitly approved per-mailbox policy, the existing Validar watcher also
enumerates messages without attachments. After the existing Authentication-Results
gate, it fetches body/HTML only to extract document anchors (or document URLs in
plain text). It does not execute HTML, scripts, forms, images, browser sessions,
cookies, login, MFA or CAPTCHA. Navigation/action links are not fetched. Opaque links
without a document-labelled anchor are conservatively left for manual review.

Every URL and redirect requires HTTPS port 443, no userinfo, and an exact approved
public DNS hostname. All returned DNS addresses are checked; connections use a
validated numeric address with TLS certificate verification/SNI for the hostname.
No environment proxy, Graph token, cookie or Referer reaches the supplier. This
follows the allowlist/redirect/DNS separation in the
[OWASP SSRF guidance](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html).

Only direct application/pdf, image/png and image/jpeg responses with matching magic
bytes are accepted, maximum 50 MiB. Compressed HTTP responses, HTML/login screens,
HTTP auth challenges, invalid signatures and disallowed redirects go to manual review.
Maximum 5 redirects, 20 extracted URLs, 10 combined candidates, and a shared 120-second
supplier-download budget per message. Socket reads have bounded timeouts. DNS uses
the operating system resolver timeout. An OS lock prevents overlapping cron runs or
manual retries racing the ledger/cache.

Validated bytes get SHA-256 and a generated filename, then use the same plugin
register → upload (which checks status/hash) flow as Graph attachments. Signed URLs
and email HTML never enter the ledger, report or API metadata. Candidate keys are
URL hashes, not signed URLs. The complete prepared candidate set and file hashes
are persisted before register: API retries reuse the exact envelope and bytes even
if a link changes/expires. A changed/missing prepared file requires manual review.

Mixed email: valid attachments/links can be registered while refused links leave
Sibyla-Retida and safe reason codes. A link-only refusal is retained without API
registration. Transient supplier failures retry; prior terminal messages are not
silently replayed. `--retry` remains an explicit operator action. A pre-existing
in-flight attachment registration with no frozen candidate set is retained for
review, never extended behind the idempotency key.

## Enable for an approved channel

Supplier skill instructions identify which public invoice links are meaningful;
they cannot override transport safety. Do not guess a Moloni domain or allow every
subdomain. Authorize the actual initial and redirect hostnames first.

Create an operator-owned JSON policy with restricted write ACLs. Values correspond
to the selected channel's account, folder, intake client, DownloadDocumentLinks and
download-host scopes; this is an explicit runtime projection, not an automatic
activation of Admin desired state. No secret or signed URL belongs in this file.

```json
{
  "mailbox": "sibyla@gottsolutions.net",
  "folder": "Validar",
  "intake_client_id": "sibyla-channel-intake",
  "download_document_links": true,
  "download_hosts": ["REPLACE-WITH-EXACT-APPROVED-HOST"]
}
```

Set APOLLO_EMAIL_INTAKE_LINK_POLICY to that file in the existing runtime environment.
Mailbox, folder and APOLLO_CHANNEL_INTAKE_CLIENT_ID must match exactly or startup
fails. Missing policy or false download_document_links preserves attachment-only
behavior and does not read email bodies. Empty/wildcard/IP host lists cannot enable it.

Deployment sequence: pause the existing intake cron and wait for its in-flight run
to finish; use `local/hermes/install-email-intake.ps1 -Force -WhatIf`, then install
both scripts with `-Force`. The installer backs up existing scripts and does not
modify cron/config/credentials. Configure the policy, run the installed watcher
`--dry-run`, then resume the SAME cron. Do not create another intake watcher.

Before declaring Moloni accepted, use a designated email with a fresh public URL
and verify the download, hash, single registration/upload, second-run deduplication,
and Outlook outcome. Also verify login/HTML and unapproved-redirect examples are
retained. Synthetic tests are not this live acceptance set.

Rollback: pause intake, disable the link policy, restore both exact script backups
if necessary, and resume the existing cron. Preserve ledger and cache; do not bulk
clear terminal entries or delete registrations. Mixed/partial entries need review.

## Verification

Offline tests cover Graph candidate-query opt-in, URL/parser/DNS bypasses, numeric
connect with hostname TLS, each redirect, loop/hop limits, no credential forwarding,
MIME/signature/size checks, HTML/login/manual cases, dry-run, sender-auth gating,
frozen candidates on retry, mailbox policy binding, and process locking. They use
synthetic documents/providers, no production mailbox or public invoice URL.

Verification on 2026-09-08: watcher/downloader 38/38 tests passed; existing
channel-intake client/envelope 64/64 passed. Installer `-Force -WhatIf` passed
without live file/config/cron changes. No real Moloni endpoint was fetched.
