diff --git a/.dockerignore b/.dockerignore index 3762c50..effaf43 100644 --- a/.dockerignore +++ b/.dockerignore @@ -29,5 +29,5 @@ dist build # Plans and CI -.claude +.the AI assistant .github diff --git a/.gitattributes b/.gitattributes index f9a7f95..e007f3e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -28,3 +28,15 @@ scripts/embedding-bits-manifest.json text eol=lf # literal "dev" placeholder and /health reported version "unknown". eol=lf so # the substituted SHA is not followed by CRLF on Windows checkouts. COMMIT text eol=lf export-subst + +# N-326-C5 trust-root SSOT: the checked-in FIDO2-sk allowed_signers file +# AND its golden-hash companion MUST be byte-stable across Windows and +# Linux checkouts. `test_repo_trust_root_matches_golden_hash` computes +# `sha256(read_bytes())` and compares against the golden — same drift +# class as the embedding-bits manifest above (CI on Linux sees LF blob +# bytes; Windows CRLF-autocrlf checkout sees `\r\n` → different hash → +# false-positive DRIFT). Pinning LF makes the on-disk bytes match the +# blob bytes on every platform, so the golden hash is one canonical +# value everywhere. +.github/op-approve/allowed_signers text eol=lf +tests/goldens/allowed_signers.sha256 text eol=lf diff --git a/.githooks/pre-push b/.githooks/pre-push index 0242296..9290e99 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,17 +1,69 @@ #!/usr/bin/env bash # -# pre-push: run pip-audit BEFORE the push lands on origin so CI -# never has to fail on a CVE you could have caught locally. +# pre-push guards (run BEFORE the push lands on origin so CI never has to +# fail on something you could have caught locally): +# 1. e2e real-browser suite — when a UI/e2e surface changed +# 2. pip-audit vuln scan — when pip-audit is installed +# 3. requirements.lock drift — when pip-compile is installed # -# Bypass with `git push --no-verify` ONLY in genuine emergencies -# (the hook prints how when it fires). Bypassing is logged in the -# user's local shell history; intentional bypass is auditable. +# Each guard is conditional (skips cleanly if its tool isn't present) and +# bypassable with `git push --no-verify` ONLY in genuine emergencies (the +# hook prints how when it fires). Bypassing is logged in the user's local +# shell history; intentional bypass is auditable. # # Activated by `scripts/install-git-hooks.sh` (runs `git config # core.hooksPath .githooks` so the repo-tracked hooks become live). set -euo pipefail -# Only run if pip-audit is installed; nudge if not. We don't want to +ROOT=$(git rev-parse --show-toplevel) + +# --------------------------------------------------------------------------- +# Guard 1 — e2e real-browser suite. A spec (tests/e2e) that asserts the wrong +# thing — or a UI/web change that breaks one — must not land RED in CI: +# .github/workflows/e2e.yml is the ONLY GitHub job that runs pytest, so a red +# e2e is otherwise unguarded. Runs ONLY when (a) Playwright is importable +# locally AND (b) this push touches an e2e / web / template surface — docs and +# non-UI pushes pay nothing. Placed FIRST so it runs independently of whether +# pip-audit is installed (the pip-audit guard below `exit 0`s when absent). +# Origin (2026-06-20): an N-109 e2e spec asserted `.to_be_visible()` on a +# `data-multiselect`-HIDDEN + {{ m.combo_input( + "host", + acct.config.get('host', ''), + ["imap.gmail.com", "outlook.office365.com", "mail.fastmail.com", "imap.aol.com", "imap.mail.yahoo.com"], + placeholder="e.g. imap.example.com", + attrs="required") }} @@ -62,8 +69,7 @@

Add an email account: {{ acct.name }}

diff --git a/src/email_triage/web/templates/accounts/api_keys.html b/src/email_triage/web/templates/accounts/_api_keys_section.html similarity index 78% rename from src/email_triage/web/templates/accounts/api_keys.html rename to src/email_triage/web/templates/accounts/_api_keys_section.html index f57fe42..fa4cf9b 100644 --- a/src/email_triage/web/templates/accounts/api_keys.html +++ b/src/email_triage/web/templates/accounts/_api_keys_section.html @@ -1,10 +1,12 @@ -{% extends "base.html" %} -{% block title %}My Settings - API Keys - Email Triage{% endblock %} -{% block content %} -

My Settings

-{% set active = "api-keys" %} -{% include "_settings_tabs.html" %} - +{# ─── AUDIENCE ──────────────────────────────────────────────── + AUDIENCE: end-user. API Keys (access tokens) self-service. + Included by profile/security.html (the consolidated Security tab). + Expects in context: keys, all_users, is_admin, new_key_open, user. + Variable names are scoped by the including page via {% with %} so + the `keys` here doesn't collide with the Passkeys section. + No protocol jargon (no "/api/openclaw", no "bearer token"). + ─────────────────────────────────────────────────────────── #} +

API Keys (Access Tokens)

Tokens let an external tool — for example, an AI assistant — read your @@ -22,8 +24,6 @@

API Keys (Access Tokens)

{% endif %}

-

← Back to Accounts

- {# ── Create new token ─────────────────────────────────────────────── #}
@@ -80,7 +80,7 @@

API Keys (Access Tokens)

{# ── Existing tokens ──────────────────────────────────────────────── #}
-

Your tokens

+

Your tokens

{% if keys %} @@ -98,9 +98,9 @@

Your tokens

{% if is_admin %}{% endif %} - - - + + + - + diff --git a/src/email_triage/web/templates/accounts/_watch_folders_imap.html b/src/email_triage/web/templates/accounts/_watch_folders_imap.html index 43f8efa..719f7e0 100644 --- a/src/email_triage/web/templates/accounts/_watch_folders_imap.html +++ b/src/email_triage/web/templates/accounts/_watch_folders_imap.html @@ -1,3 +1,4 @@ +{% import "_macros.html" as m %} {# IMAP per-folder watch picker — rendered inside the Watch + Push tab when ``acct.provider_type == 'imap'``. Lives outside the IMAP connection fieldset (host/port/user/pass) because folder selection @@ -43,7 +44,11 @@ {% else %} {# Fallback: comma-separated text entry when folder discovery is unavailable. The save handler parses this via ``mailboxes_csv`` - in _extract_provider_config. #} + in _extract_provider_config. Plain deliberately, NOT + m.combo_input: this is a COMMA-SEPARATED multi-value field, and a + datalist autocompletes the WHOLE input value — picking a single + folder ("Spam") would clobber the existing list ("INBOX, Sent"). + Single-value suggestions are wrong for a multi-value field. #} @@ -107,10 +104,7 @@

Runtime Settings

@@ -126,10 +120,7 @@

Runtime Settings

@@ -211,21 +202,122 @@

Classifier

AI backend for email classification.
+ {# #44 — model picker replaces the bare free-text Model input. + Submits the SAME name="classifier_model" (single string) + plus a new name="classifier_backup_models" (comma- + separated, ordered). Probes the live [name=classifier_backend] + + [name=classifier_ollama_url] on this form. The free-text + escape hatch keeps non-enumerable backends + offline use + working. Picker lives inside the Value cell so the 3-column + table layout is preserved. #} + + {# #49 — pin the Ollama context-window size (num_ctx). Blank = inherit + the model's loaded default (recommended). Sits next to the Model + picker because it governs both classify capacity AND the #33 + Topic-match warm-prefix cache. #} + + + + + + {# N-378 — Ollama `think` payload option as a CONFIG KNOB. Sits + next to the model picker + num_ctx because it directly gates + the reasoning-mode budget on qwen3-family models. Tri-state: + blank = model default; false = disable thinking (~4-6× faster + on reasoning models); true = force thinking. Requires Ollama + ≥ 0.31.1 when combined with the classify/topic-match format + schema — see the ClassifierConfig docblock. #} + + + + + + {# N-378 belt-and-braces companion. Qwen3-family models honour a + `/no_think` marker at the end of the user message as a per-call + thinking-off directive — independent of the `think` payload + option. Cheap safety net on top of the `think` knob. #} + + + + + + {# N-431 (2026-07-19) — Ollama `keep_alive` payload option, threaded + SSOT to every /api/chat + /api/embeddings site. Absent this + field the daemon applies its built-in default "5m" per call, + silently overriding any operator TTL pin (including a Forever + `-1` pin). Wire form: str "10m" / "1h" / "-1" / "0". #} + + + + {% for key, label in [ @@ -684,10 +744,7 @@

Admin Email

- + - - - + + + @@ -201,18 +202,18 @@

Registered keys

- + - +
{{ k.name }}{{ k.user_name if k.user_name else k.email }}{{ k.created_at[:19] }}{{ k.last_used_at[:19] if k.last_used_at else '—' }}{{ k.expires_at[:19] if k.expires_at else 'Never' }}{% if k.created_at %}{% else %}—{% endif %}{% if k.last_used_at %}{% else %}—{% endif %}{% if k.expires_at %}{% else %}Never{% endif %}
{{ user_label(d.name, d.email) }} {{ d.granted_by_email or '—' }}{{ d.granted_at[:19] }}{% if d.granted_at %}{% else %}—{% endif %} + + + {% endif %} {# Watch status always visible — green pulsing dot when the real-time watch is connected for this account, muted dot otherwise. Gmail / O365 accounts use push or polling @@ -141,11 +172,19 @@ hx-post="/accounts/{{ acct.id }}/test" hx-target="#test-result-{{ acct.id }}" hx-swap="innerHTML">Test + {# N-108: Remove = soft-deactivate (purge creds + is_active=0), NEVER + hard-delete. Only shown for an ACTIVE account; a deactivated one + shows "Disabled" (status column above) and is re-enabled via the + Edit panel's is_active toggle — the EXISTING mechanism, which + restarts the watcher. No parallel reactivate path (it would + overload is_active vs the Edit-pause state). #} + {% if acct.is_active %} + hx-confirm="Remove account '{{ acct.name }}'? It is DEACTIVATED (paused, credentials purged) — NOT deleted: the account record and its tamper-evident audit trail are RETAINED. To re-enable it later, open Edit and RE-ENTER its credentials (they are purged on Remove, so a simple toggle won't reconnect it).">Remove + {% endif %}
{% if sub.last_error %} {{ sub.last_error }} - {{ sub.last_error_at[:19] }} + {% if sub.last_error_at %}{% else %}—{% endif %} {% else %} {% endif %} @@ -169,7 +169,7 @@

AI Backends

{% if _m.last_error %}

Last error: {{ _m.last_error }} - at {{ _m.last_error_at[:19] }} + at {% if _m.last_error_at %}{% else %}—{% endif %}

{% endif %} {% endif %} @@ -205,16 +205,28 @@

AI Backends

@@ -246,9 +258,11 @@

AI Backends

@@ -258,6 +272,12 @@

AI Backends

the Redis URL all surface there. AI Backends scopes to the LLM-side configuration only. #} + {# #37 — set to "1" by the submit-time confirm dialog when the + operator changes the embedding model. The save handler will + NOT apply a model change without this flag (keeps the old + model so recall can't break silently). #} + + + + +
+ +

On-demand encrypted bundle

+

+ + Download an encrypted bundle of this install's state by hand — + e.g. right before a risky upgrade. Independent of the automated + cadence above. + +

diff --git a/src/email_triage/web/templates/admin/config_tabs/general.html b/src/email_triage/web/templates/admin/config_tabs/general.html index 1a05239..c7a59a1 100644 --- a/src/email_triage/web/templates/admin/config_tabs/general.html +++ b/src/email_triage/web/templates/admin/config_tabs/general.html @@ -92,10 +92,7 @@

Runtime Settings

Bulk Triage Rate Bulk Triage Concurrency
Model - + {{ m.model_picker("classifier_model", "classifier_backup_models", config.classifier.model, config.classifier.backup_models, "[name=classifier_backend]", "[name=classifier_ollama_url]") }} Model name (e.g. qwen3.5:35b, gpt-4o, gemini-2.0-flash). + Pick from the probed list, or tick Enter manually to type one. + Backup models are tried in order if the primary can't classify. +
Context window + + + Ollama only. Pins num_ctx for every Ollama call — classify, + the Topic-match prefill, and the explain-this-error / style-learning completion. + Leave blank to inherit the model's loaded default (recommended). A divergent value + forces a one-time model reload; both Topic-match stages share it, so the warm-prefix + cache stays intact. Values below ~8192 may silently degrade multi-Topic matching + (the Topic pre-filter inlines several full Topic Details) and truncate long + explanations — the app logs a warning if you pin one that low. +
Ollama think + + + Ollama only. Sent as the top-level think payload option + on every /api/chat call (classify, topic-match, safety, style-learning). + Reasoning models (qwen3.6:35b) burn the full num_predict budget + on hidden reasoning tokens before emitting JSON; disabling thinking recovers + that latency and prevents finish_reason=length, raw_len=0 empty responses. + Requires Ollama ≥ 0.31.1 when combined with the format schema + (older Ollama drops the schema mask — bug #15260 fixed via PRs #15678/#15392). +
Append /no_think marker + + + Qwen3-family only. Appends the /no_think marker to the end of + every /api/chat user prompt — an independent per-call thinking-off directive + that some Qwen3 tokenizer templates honour even when the payload option isn't + set. No-op on non-Qwen3 models. Safe as a belt-and-braces companion to the + think knob above. +
Ollama keep_alive + + + Ollama only. Sent as the top-level keep_alive payload option + on every /api/chat + /api/embeddings call. Accepts Ollama's native form: + 10m, 1h, -1 (Forever pin), + 0 (unload immediately). Absent, Ollama applies its built-in + 5m default per call — silently overriding any operator TTL + pin (including Forever). Empty saves keep the default (10m).
Ollama URL - + {{ m.combo_input("classifier_ollama_url", config.classifier.ollama_url, ["http://localhost:11434", "http://ollama.home:11434"], type="url", attrs="style='margin:0;padding:0.25rem 0.5rem;font-size:0.9rem;'") }} Base URL for the Ollama API server. @@ -234,9 +326,7 @@

Classifier

OpenAI Base URL - + {{ m.combo_input("classifier_openai_base_url", config.classifier.openai_base_url, ["https://api.openai.com/v1", "https://.openai.azure.com/"], type="url", placeholder="https://api.openai.com/v1", attrs="style='margin:0;padding:0.25rem 0.5rem;font-size:0.9rem;'") }} For OpenAI/Azure backend. Leave blank for default OpenAI endpoint. @@ -294,8 +384,7 @@

Outbound email (SMTP)

Host - + {{ m.combo_input("smtp_host", config.smtp.host, ["smtp.gmail.com", "smtp.office365.com", "smtp.fastmail.com", "smtp.sendgrid.net"], attrs="style='margin:0;padding:0.25rem 0.5rem;font-size:0.9rem;'") }} SMTP server hostname. @@ -304,8 +393,7 @@

Outbound email (SMTP)

Port - + {{ m.combo_input("smtp_port", config.smtp.port, [587, 465, 25, 2525], type="number", attrs="min=1 max=65535 style='margin:0;width:6rem;padding:0.25rem 0.5rem;font-size:0.9rem;'") }} SMTP port (25, 465, 587). @@ -415,12 +503,7 @@

Ingestion cadence

Default poll interval - min + {{ m.combo_input("ingestion_default_poll_interval_minutes", config.ingestion.default_poll_interval_minutes, [10, 30, 60, 120, 240], type="number", attrs="min=" ~ config.ingestion.POLL_MIN ~ " max=" ~ config.ingestion.POLL_MAX ~ " step=" ~ config.ingestion.POLL_STEP ~ " style='margin:0;width:7rem;padding:0.25rem 0.5rem;font-size:0.9rem;'") }} min {{ config.ingestion.POLL_MIN }}–{{ config.ingestion.POLL_MAX }} min in {{ config.ingestion.POLL_STEP }}-min steps. @@ -430,12 +513,7 @@

Ingestion cadence

Push-mode interval (legacy) - min + {{ m.combo_input("ingestion_push_poll_interval_min", config.ingestion.push_poll_interval_min, [10, 30, 60, 120, 240], type="number", attrs="min=" ~ config.ingestion.PUSH_MIN ~ " max=" ~ config.ingestion.PUSH_MAX ~ " step=" ~ config.ingestion.STEP ~ " style='margin:0;width:7rem;padding:0.25rem 0.5rem;font-size:0.9rem;'") }} min Legacy B3 field retained for YAML round-trip. No longer drives ingestion. @@ -444,12 +522,7 @@

Ingestion cadence

Poll-mode interval (legacy) - min + {{ m.combo_input("ingestion_poll_poll_interval_min", config.ingestion.poll_poll_interval_min, [10, 30, 60, 120, 240], type="number", attrs="min=" ~ config.ingestion.POLL_MIN ~ " max=" ~ config.ingestion.POLL_MAX ~ " step=" ~ config.ingestion.STEP ~ " style='margin:0;width:7rem;padding:0.25rem 0.5rem;font-size:0.9rem;'") }} min Legacy B3 field retained for YAML round-trip. No longer drives ingestion. @@ -486,10 +559,7 @@

Style learning

{{ m.help("Hours between auto-scans of opted-in accounts. Default 6. Example: 12 = scan twice a day (lighter load); 1 = scan every hour (fresher style data, higher AI cost).") }}
- hours + {{ m.combo_input("style_learning_capture_interval_hours", style_learning_capture_interval_hours or 6, [1, 2, 4, 6, 12, 24, 48, 72], type="number", attrs="min=1 max=72 step=1 style='margin:0;width:6rem;padding:0.25rem 0.5rem;font-size:0.9rem;'") }} hours Range 1–72 hours. Cadence applies to every account that has Auto-scan on schedule turned on. @@ -501,10 +571,7 @@

Style learning

{{ m.help("Default number of sent messages each mine press reads when no per-account override is set. Default 50. Example: 100 = pull twice as many sent messages on a one-click refresh (over " ~ (inline_limit_ceiling or 50) ~ " runs in the background on the Bulk runs page).") }}
- messages + {{ m.combo_input("style_learning_mine_limit_default", style_learning_mine_limit_default or 50, [10, 25, 50, 100, 200, 500], type="number", attrs="min=1 max=500 step=1 style='margin:0;width:6rem;padding:0.25rem 0.5rem;font-size:0.9rem;'") }} messages Range 1–500. Each user can override this from their Style data page; values over {{ inline_limit_ceiling or 50 }} hand off to a background job so the browser doesn't sit waiting. @@ -613,11 +680,7 @@

Admin Email

Send at - + {{ m.combo_input("health_email_send_at", config.health_email.send_at, ["07:00", "07:15", "08:00", "18:00", "20:00"], placeholder="07:15", attrs="pattern='[0-2][0-9]:[0-5][0-9]' style='margin:0;width:6rem;padding:0.25rem 0.5rem;font-size:0.9rem;'") }} HH:MM in container local time. @@ -626,10 +689,7 @@

Admin Email

Release notes URL - + {{ m.combo_input("admin_email_release_check_url", config.admin_email.release_check_url, ["https://api.github.com/repos/unlimited-data-works-llc/email-triage/releases/latest"], type="url", placeholder="https://api.github.com/repos/unlimited-data-works-llc/email-triage/releases/latest", attrs="style='margin:0;padding:0.25rem 0.5rem;font-size:0.9rem;width:100%;'") }} GitHub Releases API endpoint. Used for the "update @@ -640,7 +700,7 @@

Admin Email

- Content toggles + Content toggles{{ m.help("Pick which sections appear in the daily health email. Each is a self-contained block; turning one off just omits it. Update-failed alerts always send regardless of these.") }}
Error-rate threshold - % + {{ m.combo_input("health_email_error_rate_threshold_pct", config.health_email.error_rate_threshold_pct, [0, 1, 5, 10, 25, 50], type="number", attrs="min=0 max=100 style='margin:0;width:6rem;padding:0.25rem 0.5rem;font-size:0.9rem;'") }} % Triage error rate above this flips the subject to diff --git a/src/email_triage/web/templates/admin/config_tabs/integrations.html b/src/email_triage/web/templates/admin/config_tabs/integrations.html index d50bb32..3dfdaeb 100644 --- a/src/email_triage/web/templates/admin/config_tabs/integrations.html +++ b/src/email_triage/web/templates/admin/config_tabs/integrations.html @@ -17,7 +17,7 @@

{{ err }}

{% endif %} -
+ {{ csrf_input(request) }} {# ── Google OAuth (install-wide client credentials, B1 lift) ──── #} @@ -98,10 +98,7 @@

Google OAuth (install-wide)

Client ID - + {{ m.cred_text_input("google_oauth_web_client_id", config.google_oauth.web_client_id, "xxxx-xxxx.apps.googleusercontent.com", 'style="margin:0;padding:0.25rem 0.5rem;font-size:0.9rem;"') }} Registered redirect URI must be @@ -112,9 +109,7 @@

Google OAuth (install-wide)Client Secret

{% set has_web_secret = config.google_oauth.web_client_secret %} - + {{ m.secret_input("google_oauth_web_client_secret", has_web_secret, "GOCSPX-...", 'style="margin:0;padding:0.25rem 0.5rem;font-size:0.9rem;"') }} Required — Google's token exchange rejects the request without it. @@ -130,10 +125,7 @@

Google OAuth (install-wide)

Client ID - + {{ m.cred_text_input("google_oauth_desktop_client_id", config.google_oauth.desktop_client_id, "xxxx-xxxx.apps.googleusercontent.com", 'style="margin:0;padding:0.25rem 0.5rem;font-size:0.9rem;"') }} No redirect URI registration needed — Google auto-allows loopback @@ -144,9 +136,7 @@

Google OAuth (install-wide)Client Secret

{% set has_desk_secret = config.google_oauth.desktop_client_secret %} - + {{ m.secret_input("google_oauth_desktop_client_secret", has_desk_secret, "GOCSPX-...", 'style="margin:0;padding:0.25rem 0.5rem;font-size:0.9rem;"') }} Required for Desktop clients too. Google requires the secret on @@ -221,10 +211,12 @@

Office 365 OAuth + {{ m.combo_input( + "office365_oauth_tenant_id", + config.office365_oauth.tenant_id, + ["organizations"], + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx or organizations", + attrs="style='margin:0;padding:0.25rem 0.5rem;font-size:0.9rem;'") }}

Org tenant GUID, or organizations for @@ -236,10 +228,7 @@

Office 365 OAuth + {{ m.cred_text_input("office365_oauth_client_id", config.office365_oauth.client_id, "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", 'style="margin:0;padding:0.25rem 0.5rem;font-size:0.9rem;"') }}

Application (client) ID from the app's Overview page. @@ -249,9 +238,7 @@

Office 365 OAuth + {{ m.secret_input("office365_oauth_client_secret", has_o365_secret, "Microsoft client secret value", 'style="margin:0;padding:0.25rem 0.5rem;font-size:0.9rem;"') }}

The secret VALUE (not the secret ID) from Certificates @@ -340,16 +327,54 @@

Outbound webhook (OpenClaw)

+ +

+ Pick the receiver's native shape and email-triage reshapes each + signed emit into the expected wire body. Custom + posts our envelope {"event","timestamp","data":{…}} + verbatim (existing behaviour — HMAC-signed for relay integrations). + +

+

- Leave blank to disable outbound emit entirely. The receiver verifies - the HMAC signature in the X-Email-Triage-Signature - header against the shared secret below. + Leave blank to disable outbound emit entirely. For Custom targets + the receiver verifies the HMAC signature in the + X-Signature-256 header against the shared secret + below. Typed targets (Slack / PagerDuty / …) verify by their own + means; the HMAC still travels but is ignored by the receiver.

@@ -377,14 +402,20 @@

Outbound webhook (OpenClaw)

Used to compute the HMAC-SHA256 signature on every emit. Paste the - same value into your OpenClaw side. {% if outbound_secret_set %}A + same value into your OpenClaw side. {% if outbound_secret_set and not outbound_secret_unreadable %}A secret is stored; leave blank to keep.{% endif %}

+{% if outbound_secret_unreadable %} +

+ ⚠ A secret is stored but cannot be decrypted (secrets master-key + mismatch) — HMAC-signed webhook deliveries are being dropped. + Check the secrets master key, then paste the secret again to re-store it. +

+{% endif %}
NameFingerprintFingerprint{{ m.help("SHA256 fingerprint of the public key (OpenSSH format) — identifies the physical key at login. Immutable after registration.") }} AllowlistStatusExpiresLast usedStatus{{ m.help("active (usable) · expired (past TTL) · revoked (manually disabled). Only active keys allow login.") }}Expires{{ m.help("UTC time the key stops working. No grace period — an expired key is locked out immediately at login.") }}Last used{{ m.help("Most recent SUCCESSFUL login with this key (UTC + the email used). Never updated for failed attempts — helps spot a forgotten or compromised key.") }}
{{ k.email_allowlist | join(", ") }} {% if k.revoked_at %} - revoked {{ k.revoked_at[:10] }} + revoked {% elif not k.active %} expired {% else %} active {% endif %} {{ k.expires_at[:19] }}{% if k.expires_at %}{% else %}—{% endif %} {% if k.last_used_at %} - {{ k.last_used_at[:19] }}
+
as {{ k.last_used_email }} {% else %} — diff --git a/src/email_triage/web/templates/admin/dev_keys.html b/src/email_triage/web/templates/admin/dev_keys.html index e769eef..b6843dc 100644 --- a/src/email_triage/web/templates/admin/dev_keys.html +++ b/src/email_triage/web/templates/admin/dev_keys.html @@ -11,11 +11,11 @@

Developer keypairs

machine.

- Hardware-key-wins rule: if a user has registered - any active hardware key, dev-keypair logins for that email are + Passkey-wins rule: if a user has registered + any active passkey, dev-keypair logins for that email are denied with 403. OTP login remains available regardless. Users - register their own hardware keys at - /profile/hardware-keys + register their own passkeys under + My Settings › Security (each user manages their own; admins do NOT register on behalf of users -- the private side never leaves the user's device).

@@ -38,11 +38,11 @@

Developer keypairs

{% endif %} @@ -100,18 +100,18 @@

Registered keys

{{ k.email_allowlist | join(", ") }} {% if k.revoked_at %} - revoked {{ k.revoked_at[:10] }} + revoked {% if k.revoked_at %}{% else %}—{% endif %} {% elif not k.active %} expired {% else %} active {% endif %} {{ k.expires_at[:19] }}{% if k.expires_at %}{% else %}—{% endif %} {% if k.last_used_at %} - {{ k.last_used_at[:19] }}
+
as {{ k.last_used_email }} {% else %} — diff --git a/src/email_triage/web/templates/admin/integrations.html b/src/email_triage/web/templates/admin/integrations.html deleted file mode 100644 index a7e2696..0000000 --- a/src/email_triage/web/templates/admin/integrations.html +++ /dev/null @@ -1,794 +0,0 @@ -{% extends "base.html" %} -{% import "_macros.html" as m %} -{% block title %}Integrations - Email Triage{% endblock %} -{% block content %} -{# ─── AUDIENCE ──────────────────────────────────────────────── - AUDIENCE: admin (install operator). Not end users. - TECH-SKILL: medium — comfortable with Pub/Sub, Microsoft Graph, - webhook URLs, JWT audiences. Technical jargon - (RFC numbers, OData, JWKS, OIDC) is allowed here. - COPY RULES: no copy rule lifted from end-user pages applies; - this page is intentionally protocol-aware. Do NOT - copy this header onto end-user templates. - See feedback_audience_per_page.md. - ─────────────────────────────────────────────────────────── #} - -

Integrations

-

- Install-level OAuth client credentials, inbound provider push - config (Gmail Pub/Sub + Microsoft Graph), and the outbound - OpenClaw webhook destination. -

- -{% if saved %} -

Settings saved.

-{% endif %} -{% if err %} -

{{ err }}

-{% endif %} - - - {{ csrf_input(request) }} - -
- -{# ── Google OAuth (install-wide client credentials, B1 lift) ──── #} -
-
-

Google OAuth (install-wide)

- - Shared across all Gmail accounts on this install. Two client pairs - because Google's Web application and Desktop - OAuth clients have incompatible redirect-URI rules. Accounts authenticate - using the pair that matches the chosen flow. - Create clients at the - Google Cloud Console. - -
-
-
-
- - - Drop in the client_secret_*.json you downloaded from - Google Cloud Console. Auto-detected as Web (web top-level - key) or Desktop (installed) and populates the matching - fields below. The file is parsed in your browser — nothing is uploaded - until you click Save at the bottom of the page. - -
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
SettingValueDescription
- Primary — push-enabled -  (Web application client) -
Client ID - - - Registered redirect URI must be - {{ config.push.public_url or '' }}/oauth/google/callback. -
Client Secret - {% set has_web_secret = config.google_oauth.web_client_secret %} - - - Required — Google's token exchange rejects the request without it. - {% if has_web_secret %}A secret is stored; leave blank to keep.{% endif %} -
- Fallback — manual paste -  (Desktop client) -
Client ID - - - No redirect URI registration needed — Google auto-allows loopback - for Desktop clients. -
Client Secret - {% set has_desk_secret = config.google_oauth.desktop_client_secret %} - - - Required for Desktop clients too. Google requires the secret on - the token exchange regardless of client type. - {% if has_desk_secret %}A secret is stored; leave blank to keep.{% endif %} -
-
-
-
- -{# ── Office 365 OAuth (install-wide) ─────────────────────────── #} -
-
-

Office 365 OAuth (install-wide)

- - One Microsoft Entra (Azure AD) app registration shared across every - Office 365 account on this install. End-users picking - Personal Microsoft account on their integration page route - to Microsoft's shared common endpoint; everyone else - routes to the tenant below. Register the app in - Azure Portal - with Mail.Read, Mail.ReadWrite, Mail.Send, - and offline_access permissions, then paste the three - values below. - -
-
-
-
- - How do I find these values? - -
    -
  1. - Tenant ID. - Azure Portal → Microsoft Entra ID (formerly - Azure Active Directory) → Overview → - Tenant ID. Or use organizations if - you want to accept sign-ins from any work or school - account. Never use common here — that's - reserved for individual personal Microsoft accounts on - a per-user basis. -
  2. -
  3. - Client ID. - Azure Portal → App Registrations → your app → - OverviewApplication (client) ID. -
  4. -
  5. - Client Secret. - Azure Portal → App Registrations → your app → - Certificates & secretsNew client - secret. Copy the secret value - immediately — Microsoft only shows it once. -
  6. -
  7. - Required permissions. - Mail.Read + Mail.ReadWrite + - Mail.Send + offline_access - (delegated). Add via the API permissions tab; - Grant admin consent if your tenant requires it. -
  8. -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
SettingValueDescription
Tenant ID - - - Org tenant GUID, or organizations for - any work or school account. Don't use common — - that's the per-account "Personal Microsoft account" - routing, set on each account's own integration page. -
Client ID - - - Application (client) ID from the app's Overview page. -
Client Secret - {% set has_o365_secret = config.office365_oauth.client_secret %} - - - The secret VALUE (not the secret ID) from Certificates - & secrets. {% if has_o365_secret %}A secret is stored; - leave blank to keep.{% endif %} -
-
-
-
- -{# ── Gmail Pub/Sub ───────────────────────────────────────────── #} -
-
-

Gmail Pub/Sub

- - Google sends an OIDC-signed JWT to the public webhook URL whenever - a watched mailbox changes. The webhook validates the signature - (RS256 against Google's JWKS), the issuer, the audience claim, and - the service-account email. All four fields below must match what - you set on the Pub/Sub subscription, or every push is rejected at - JWT-verify time. - -
-
- -

- The base URL Google's Pub/Sub push servers can reach (no path, no - trailing slash). The webhook lives at - {{ push.public_url or '<public_url>' }}/webhooks/gmail. - For the homelab default, this is the Tailscale Funnel hostname - (e.g. https://the deploy host.<tailnet>.ts.net). - Internal UI stays at the private domain; the webhook is the only - path Funnel exposes publicly. -

- - -

- Fully-qualified topic resource name. Must already exist in GCP and - have Gmail's publisher service account - (gmail-api-push@system.gserviceaccount.com) bound as - roles/pubsub.publisher. -

- - -

- The service account whose identity Pub/Sub uses to mint OIDC - tokens for each push. The webhook's - email-claim check rejects anything else, even other - valid Google-signed JWTs. -

- - -

- Must equal the Pub/Sub subscription's - --push-auth-token-audience. Leave blank to default to - the public webhook base URL above. Trailing slashes are stripped - automatically; mismatched slashes are the most common cause of - "every push gets rejected" mystery. -

-
-
- -{# ── Outbound webhook (OpenClaw) ─────────────────────────────── #} -
-
-

Outbound webhook (OpenClaw)

- - Email-triage emits HMAC-SHA256-signed POSTs to a single destination - when triage outcomes land. Set the URL to enable; clear the URL to - turn it off entirely. Per-account on/off + quiet-hours chips on - each account's Integrations tab gate the emit at fire time. - -
-
-
- -

- Leave blank to disable outbound emit entirely. The receiver verifies - the HMAC signature in the X-Email-Triage-Signature - header against the shared secret below. -

-
- -
-
- Events to send - {% for event_name, label, desc in outbound_event_catalogue %} - - {% endfor %} -
-

- Tick zero events to keep the destination configured but stop all - emits — useful for a temporary mute without losing the URL + - secret. Per-account pause chips do the same at finer granularity. -

-
- -
- -

- Used to compute the HMAC-SHA256 signature on every emit. Paste the - same value into your OpenClaw side. {% if outbound_secret_set %}A - secret is stored; leave blank to keep.{% endif %} -

- - -

- Off by default. When off, only local URLs (loopback, RFC1918, - .local, or any configured local-URL suffix) are - delivered to. Turning this on lets the install ship signed events - to any HTTPS endpoint — required if your OpenClaw lives off-LAN. -

-
-
-
- -{# ── Classification cache ─────────────────────────────── #} -
-
-

Classification cache (optional — Redis / valkey)

- - Skip the LLM call when the same email subject+sender+body has - already been classified by the same model. Big token savings on - repetitive inbound mail (Iperius backups, monitor alerts, list - mail). Default OFF — paste a Redis URL to opt in. The protocol is - plain Redis 5+; a valkey server speaks the identical - wire protocol and works unchanged. -

- On-LAN only. The URL must point at the operator's - audited LAN host (e.g. redis://your-redis-host.example.local:6379/0) - — cached values carry classifier metadata, never leave the install - boundary. HIPAA-flagged accounts skip the cache - regardless of this setting (defence-in-depth: sender + subject are - PHI shape even if the key is hashed). -
-
-
-
- -

- Blank to disable. Lazy connection — nothing connects until the first - classification. Failures fall through to the live LLM call. -

- - -

- Default 30 days = 2592000 seconds. Range: 1 hour (3600) to 90 days (7776000). -

-
- -
-

- Counters this process: - {{ redis_cache_counters.hits }} hits, - {{ redis_cache_counters.misses }} misses, - {{ redis_cache_counters.errors }} errors. - Resets on every restart. Surfaced live on /health/detail - as classification_cache. -

-
-
-
- -
{# /.et-cards #} - - - - -{# Manual flush — separate form so a stray click here doesn't - accidentally save the rest of the integrations page. CSRF token - on every state-changing POST. Disabled when the cache URL is - empty — flushing an unconfigured cache is a no-op + the button - shouldn't suggest the action is meaningful. #} -
- {{ csrf_input(request) }} - - - Drops every et:cls:* key. Other services sharing - this Redis instance are unaffected.{{ m.help("Use after changing your categories, classifier model, or rules — anything that would make old cached classifications wrong. Without a flush, stale entries roll off naturally over the TTL (default 30 days).") }} - -
-{% if flushed is defined and flushed %} -

- Flushed {{ flushed }} cached classification{% if flushed != 1 %}s{% endif %}. -

-{% endif %} -{% if flush_err %} -

- Flush failed: {{ flush_err }} -

-{% endif %} - -{% if webhook_url %} -

- - Hint: register the subscription with - --push-endpoint={{ webhook_url }} - and - --push-auth-token-audience={{ push.gmail_audience or push.public_url }}. - -

-{% endif %} - -{# ── Watch status tables (outside the save form) ─────────────── #} -
- -
-
-

Watch status — per account

- {% if not watches %} - - One row per Gmail account showing the live Pub/Sub watch lease. - Empty until at least one Gmail account is registered. - - {% endif %} -
-
- {% if watches %} - - - - - - - - - - - - - {% for w in watches %} - - - - - - - - - {% endfor %} - -
AccountOwnerTopicExpiresStatus
{{ w.email_address }} - {% if w.owner_email %} - {{ w.owner_name or w.owner_email }}
- {{ w.owner_email }} - {% else %} - (unknown) - {% endif %} -
- {% if w.topic_name %} - {{ w.topic_name }} - {% else %} - (none — placeholder row) - {% endif %} - - {% if w.hours_until_expiry is not none %} - {% if w.hours_until_expiry > 0 %} - ~{{ "%.1f"|format(w.hours_until_expiry) }}h - {% else %} - expired - {% endif %} - {% else %} - - {% endif %} - - {% if w.status == "healthy" %} - healthy - {% elif w.status == "renewable" %} - renewable - {% elif w.status == "stale" %} - stale - {% elif w.status == "expired" %} - expired - {% else %} - unconfigured - {% endif %} - - - -
- {% else %} -

- No Gmail accounts registered yet. Add one via - /accounts. -

- {% endif %} -
-
- -
-
-

Office 365 Push Subscriptions

- - Microsoft Graph delivers change notifications for - me/mailFolders('Inbox')/messages to the canonical - receiver path - {{ o365_webhook_url or '<public_url>/webhooks/office365' }}. - Each subscription lasts up to ~3 days (Graph's max for mail - resources) and is authenticated by a per-subscription - clientState secret that the receiver compares - constant-time. The legacy /webhooks/graph alias - still works for older subscriptions registered before the canonical - path landed. - -
-
- {% if o365_subscriptions %} - - - - - - - - - - - - - {% for s in o365_subscriptions %} - - - - - - - - - {% endfor %} - -
AccountOwnerSubscriptionResourceExpiresStatus
{{ s.account_label }} - {% if s.owner_email %} - {{ s.owner_name or s.owner_email }}
- {{ s.owner_email }} - {% else %} - (unknown) - {% endif %} -
- {{ s.subscription_id or '(none)' }} - - me/mailFolders('Inbox')/messages - - {% if s.hours_until_expiry is not none %} - {% if s.hours_until_expiry > 0 %} - ~{{ "%.1f"|format(s.hours_until_expiry) }}h - {% else %} - expired - {% endif %} - {% else %} - - {% endif %} - - {% if s.status_bucket == "healthy" %} - healthy - {% elif s.status_bucket == "renewable" %} - renewable - {% elif s.status_bucket == "stale" %} - stale - {% elif s.status_bucket == "expired" %} - expired - {% elif s.status_bucket == "errored" %} - errored - {% else %} - {{ s.status_bucket }} - {% endif %} - {% if s.error_count and s.error_count > 0 %} -
- errors: {{ s.error_count }} - - {% endif %} -
- {% else %} -

- No active Office 365 push subscriptions. Owners can start one - from the per-account edit page (Integrations tab). -

- {% endif %} -
-
- -
{# /.et-cards #} - - - -{% endblock %} diff --git a/src/email_triage/web/templates/admin/retry_queue/_index.html b/src/email_triage/web/templates/admin/retry_queue/_index.html index 6391325..93750b7 100644 --- a/src/email_triage/web/templates/admin/retry_queue/_index.html +++ b/src/email_triage/web/templates/admin/retry_queue/_index.html @@ -85,6 +85,23 @@

Retry queue{{ m.help("Per-message failures the watcher couldn't classify on

Nothing in this view. (That's good — the queue is empty for the selected filter.)

{% else %} +{# #301 reachability — single-state views page via ?offset= so the full #} +{# backlog is reachable; the combined summary links to those views. #} +{% from "_pager.html" import pager %} +{% if page_win %} +{{ pager(page_win, "offset", request, "queue rows") }} +{% endif %} +{% if cap_segments %} +{% for seg in cap_segments %} +

+ Showing the first {{ seg.shown }} of {{ seg.total }} + {{ seg.label }} rows (this summary caps at {{ seg.cap }}); + {{ seg.total - seg.shown }} older row{{ 's' if (seg.total - seg.shown) != 1 else '' }} + not shown here — + view all {{ seg.total }} {{ seg.label }} → +

+{% endfor %} +{% endif %} @@ -151,7 +168,27 @@

Retry queue{{ m.help("Per-message failures the watcher couldn't classify on {% endif %}

- + @@ -529,9 +529,14 @@

Watcher state (current snapshot)

{% endif %} - + {# N-411 anti-irony fold-in: count semantics differ per-provider + (IMAP lifetime vs Gmail/O365 14-day rolling). Surface the + window per-row so an operator comparing accounts side-by-side + can't draw a false conclusion — exactly the operator-facing + accuracy defect class N-411 exists to prevent. #} + - + {% endfor %} diff --git a/src/email_triage/web/templates/admin/watches.html b/src/email_triage/web/templates/admin/watches.html index 3407af4..0190944 100644 --- a/src/email_triage/web/templates/admin/watches.html +++ b/src/email_triage/web/templates/admin/watches.html @@ -64,7 +64,7 @@

All watches{{ m.help("Cross-account view of every match-and-fire watch on th {{ acts | join(" + ") if acts else "(none)" }}

- {% if row.error_class_short %} + {# #7: plain-English flag for a watcher + delivery failure. failure_kind rides the row dict (SELECT *, + column since v35); without this the only signal was the raw + "WatchDeliveryFailed" qualname leaking through error_class_short + — which this page's audience header says operators shouldn't + have to read. The badge replaces that qualname for these rows. #} + {% if row.failure_kind == "watch_delivery_failure" %} + Delivery failure +
+ {% endif %} + {# Suppress the raw error-class qualname for ANY delivery-failure + row — the badge IS the operator-facing reason. This also covers + the compound edge (a routed action errored AND a watch was + undelivered on the same message): the generic re-raise bumps + the row's last_error_class to e.g. "RuntimeError", but + failure_kind stays watch_delivery_failure (COALESCE-preserved), + so the badge renders and the raw RuntimeError qualname does NOT + leak onto a page whose audience header forbids qualnames. The + error MESSAGE below still shows, so detail isn't lost. #} + {% if row.error_class_short + and row.failure_kind != "watch_delivery_failure" %} {{ row.error_class_short }} {% endif %} {% set msg = row.error_msg or row.last_error or "" %} diff --git a/src/email_triage/web/templates/admin/security.html b/src/email_triage/web/templates/admin/security.html index 2f40fed..48ea306 100644 --- a/src/email_triage/web/templates/admin/security.html +++ b/src/email_triage/web/templates/admin/security.html @@ -53,7 +53,7 @@

HIPAA Mode

{% if baa_status.acked %} yes ({{ baa_status.acked_by_email or '?' }} • - {{ baa_status.acked_at or '?' }}) + {% if baa_status.acked_at %}{% else %}?{% endif %}) {% else %} no {% endif %} diff --git a/src/email_triage/web/templates/admin/stats.html b/src/email_triage/web/templates/admin/stats.html index f938815..13edc4c 100644 --- a/src/email_triage/web/templates/admin/stats.html +++ b/src/email_triage/web/templates/admin/stats.html @@ -94,8 +94,8 @@

Operational signals

{{ health.uptime_secs | default(0) }} s
- last_triage {{ m.help("ISO timestamp of the most recent triage_runs row across every account. Compared against now() to derive the 'stale > 24h' degraded flag.") }} - {{ health.last_triage or "—" }} + last_triage {{ m.help("Timestamp of the most recent triage_runs row across every account. Server-rendered as US-local; JS enhances to your browser's local timezone.") }} + {% if health.last_triage %}{% else %}—{% endif %}
db {{ m.help("'ok' when a SELECT 1 on triage.db succeeds within the health budget; 'error' when the DB ping fails or times out.") }} @@ -510,7 +510,7 @@

Watcher state (current snapshot)

Account StatusProcessedProcessed (window) Errors Started
{{ w.status }}{{ w.processed }}{{ w.processed }} ({{ w.count_window or "?" }}) {{ w.errors }}{{ w.started_at or "-" }}{% if w.started_at %}{% else %}—{% endif %}
- {{ w.updated_at[:19] }} + {% if w.updated_at %}{% else %}—{% endif %} {% if w.account_id %} diff --git a/src/email_triage/web/templates/base.html b/src/email_triage/web/templates/base.html index 7b5c3b6..98cb97b 100644 --- a/src/email_triage/web/templates/base.html +++ b/src/email_triage/web/templates/base.html @@ -20,6 +20,11 @@ + {# #130 — base64url SSOT (`window.ETBase64`). Loaded unconditionally + so unauth pages (login + login_dev_keypair) get it too, since + WebAuthn login on the anonymous login page needs URL-safe base64 + decode/encode. Mirror-of Python `email_triage._base64url`. #} + {# CSRF shim — auto-attach X-CSRF-Token header on every state-changing fetch(). Loaded for authenticated callers only; skipped on the login page so unauthenticated /api/csrf-token @@ -123,9 +128,11 @@
  • Triage
  • Routes
  • My Settings
  • +
  • Deadlines
  • Help
  • + {% if user %}
  • Guide me
  • {% endif %} - {% if user.role == "admin" %} + {% if _admin_of(user) %}