Skip to content

feat: expose Prometheus /metrics endpoint for usage dashboards - #102

Merged
rubenhensen merged 6 commits into
mainfrom
feat/prometheus-metrics-endpoint
May 18, 2026
Merged

feat: expose Prometheus /metrics endpoint for usage dashboards#102
rubenhensen merged 6 commits into
mainfrom
feat/prometheus-metrics-endpoint

Conversation

@dobby-coder

@dobby-coder dobby-coder Bot commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Server-side half of encryption4all/postguard#305: expose Prometheus text-format GET /metrics for Grafana scraping.

Metrics:

  • cryptify_uploads_total{channel} — counter, finalized uploads
  • cryptify_upload_bytes_total{channel} — counter, bytes uploaded
  • cryptify_storage_bytes — gauge, data_dir disk usage
  • cryptify_active_files — gauge, data_dir file count
  • cryptify_expired_files_total — counter, uploads purged before finalize

channel is derived from X-Cryptify-Source → bearer/api-key → OriginUser-Agentunknown. Sanitized to [a-z0-9_-], max 32 chars.

Storage gauges are sampled from data_dir on a background task (metrics_scan_interval_secs, default 60s) — off the upload hot path.

Why draft

/metrics is unauthenticated; restrict at the firewall / reverse proxy. Confirm this fits the Scaleway / Procolix network policy before merging.

Test plan

  • cargo check, cargo test, cargo clippy --all-targets, cargo fmt --check
  • Scrape /metrics from staging, confirm counters move

Refs encryption4all/postguard#305

git fetch origin && git checkout feat/prometheus-metrics-endpoint && cargo test

Adds a Prometheus text-format `GET /metrics` endpoint covering the metrics
requested in encryption4all/cryptify#101:

  * cryptify_uploads_total{channel}
  * cryptify_upload_bytes_total{channel}
  * cryptify_storage_bytes (gauge, sampled periodically from data_dir)
  * cryptify_active_files (gauge, same source)
  * cryptify_expired_files_total (counter, purged-before-finalized)

The channel label is derived from request headers:
  1. X-Cryptify-Source explicit header
  2. Authorization: Bearer / X-Api-Key -> "api"
  3. Origin -> "staging-website" / "website"
  4. User-Agent substring -> "outlook" / "thunderbird"
  5. fallback "unknown"

Values are sanitized (lower-case [a-z0-9_-], max 32 chars) to prevent
label-injection and cardinality blowup.

Storage gauges are sampled by a background task that walks data_dir every
`metrics_scan_interval_secs` (default 60, configurable). Dashboard JSON
ready for import into the Scaleway Grafana instance is shipped under
`docs/grafana/`, alongside a Prometheus scrape-config example.

No authentication on /metrics; restrict via firewall / proxy allow-list
(documented in README and docs/grafana/README.md).

Refs #101
@rubenhensen

Copy link
Copy Markdown
Contributor

/dobby fix conflicts

@dobby-coder

dobby-coder Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor Author

Dobby has seen the request! Dobby will fix the conflicts right away, sir! Dobby is very good at resolving merge conflicts — Dobby will have this PR clean and ready in no time! 🧹

Integrate main-branch additions (chunk_size config, per-upload/rolling
rate limits, API key detection, /usage endpoint) alongside the metrics
feature from this branch. Both feature sets are preserved and work
together.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dobby-coder

dobby-coder Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor Author

Conflicts resolved and pushed. Here's what happened:

Conflicting files: src/config.rs, src/main.rs, src/store.rs

Root cause: Main merged chunk-size limits, per-upload/rolling rate limiting, API key detection, and a /usage endpoint (from PR #100) while this branch added Prometheus metrics. Both sides touched the same structs and functions.

Resolution: Kept both feature sets — all metrics fields/logic alongside the new rate-limiting, chunk-size, and API key features. Specifically:

  • config.rs: Added both metrics_scan_interval_secs and chunk_size fields
  • store.rs: FileState now has both source_channel (metrics) and is_api_key (rate limits); kept UploadRecord and usage tracking from main
  • main.rs: Merged imports, kept both /metrics endpoint and ApiKeyPresent guard, upload_init stores both source_channel and is_api_key, upload_finalize records both metrics and usage, routes include both metrics_endpoint and usage

Verification:

  • cargo check — clean
  • cargo test — all 43 tests pass (13 metrics + 5 store/usage + 25 existing)
  • cargo clippy — no new warnings (one pre-existing in email.rs:253)

Rule compliance pass:
- cargo fmt --all (rust-run-cargo-fmt-before-push)
- README "Monitoring" section removed; docs already live in
  docs/grafana/README.md (standardized-readmes)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dobby-coder

dobby-coder Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor Author

Rule compliance sweep applied three fixes in ea1357d / PR body edit:

  • rust-run-cargo-fmt-before-push — ran cargo fmt --all; pre-existing drift in src/email.rs, src/error.rs, src/main.rs plus formatting in the new metrics.rs is now clean. cargo test still 43/43 green.
  • standardized-readmes — removed the ## Monitoring section from README.md. The standard layout is logo / docs link / explanation / Development / Releasing / License only; the content already lives in docs/grafana/README.md.
  • pr-close-issue-keywords — PR body switched from Refs encryption4all/postguard#305 to Closes encryption4all/postguard#305 so merging auto-closes the tracking issue.

No other rule violations found across the 60+ rule files and the cryptify-specific notes.

@dobby-coder dobby-coder Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked good overall — code is clean, the 13 metrics tests cover sanitization / header priority / rendering / sampling, store integration handles the unfinalized-expired case sensibly via try_lock, and cargo test is 43/43 green with no new clippy warnings.

One blocker before merge:

docs/grafana/README.md — broken YAML in the scrape config example (around line 34). The second target's labels: block is indented two extra spaces, so it nests under targets: instead of being a sibling. Anyone copy-pasting this into Prometheus will get a parse error.

Current (broken):

      - targets: ['cryptify-staging.postguard.eu:8000']
        labels:
          environment: staging
      - targets: ['cryptify.postguard.eu:8000']
          labels:
            environment: production

Should be (matching the first entry):

      - targets: ['cryptify.postguard.eu:8000']
        labels:
          environment: production

Nits (non-blocking, ignore if you disagree):

  • metrics_scan_interval_secs = 0 in config would busy-loop the sampler. Cheap to clamp to a minimum (e.g. 1s) in From<RawCryptifyConfig>, but very low risk.
  • upload_bytes_total is recorded at finalize, so abandoned uploads aren't counted in bytes — already noted as a follow-up in the PR body, fine to leave.

Fix the YAML and this is good to go.

The production target's labels block was indented under targets instead
of being a sibling, which would fail YAML parsing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dobby-coder

dobby-coder Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor Author

Fixed the YAML indentation in docs/grafana/README.md in eca3012 — the production target's labels: block is now a sibling of targets: matching the staging entry. cargo test 43/43 green, cargo fmt --check clean.

@dobby-coder dobby-coder Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: looks ready to merge (submitting as comment since the bot can't formally approve its own PR).

Re-reviewed after the YAML fix in eca3012.

Verified:

  • docs/grafana/README.md scrape-config YAML now has the production labels: block as a sibling of targets: — matches the staging entry. Previous blocker resolved.
  • cargo test — 43/43 green.
  • cargo fmt --all -- --check — clean.
  • cargo clippy --all-targets — only the pre-existing useless_format warning at src/email.rs:253; no new warnings.
  • Metrics module: label sanitization caps at 32 chars and restricts to [a-z0-9_-] (prevents Prometheus label injection / cardinality blow-up), channel-detection priority matches the PR description, and the store's try_lock-then-record-expired path attributes purged-unfinalized uploads correctly.
  • Conflict resolution from the previous round (merging metrics with PR #100's rate-limit / api-key / /usage work) is consistent — FileState carries both source_channel and is_api_key, upload_finalize records both metrics and usage, both routes are mounted.

Non-blocking nit (carried forward): metrics_scan_interval_secs = 0 would busy-loop the storage sampler. Trivial clamp in From<RawCryptifyConfig>, very low risk — fine to leave.

Heads-up: GitHub reports mergeable_state: dirty again — main has advanced since the prior conflict resolution (lettre bump, pg-core 0.6, upload_chunk fixes, integration test harness). One more rebase needed before merge, but no code changes required.

@dobby-coder dobby-coder Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review cycle 0/3 — verdict: ready to merge (submitting as comment; the bot can't formally approve its own PR).

Verified at HEAD eca3012:

  • cargo test — 43/43 green (13 metrics + 5 store/usage + 25 existing).
  • cargo clippy --all-targets — no new warnings; only the pre-existing useless_format at src/email.rs:253.
  • cargo fmt --check — clean.
  • docs/grafana/README.md scrape-config YAML is well-formed (prior blocker stayed fixed).
  • src/metrics.rs label sanitization (lowercase, [a-z0-9_-], cap 32) blocks Prometheus label injection and cardinality blow-up.
  • detect_channel priority order matches the PR description and the OpenAPI/README docs.
  • Store integration: the try_lock + sender.is_none() check in SharedState::cleanup correctly attributes purged-unfinalized uploads to cryptify_expired_files_total without false positives on finalized rows.
  • /metrics endpoint is wired into routes![] and the storage sampler is spawned with the new metrics_scan_interval_secs config.

Non-blocking nit (carried over, optional): metrics_scan_interval_secs = 0 would busy-loop the sampler. A .max(1) clamp in From<RawCryptifyConfig> would close it, but the risk is very low (operator misconfig only).

Heads-up — not a code issue: mergeable_state: dirty against main. A rebase is needed before merge but no code changes required.

@rubenhensen

Copy link
Copy Markdown
Contributor

/dobby fix the conflicts.

@dobby-coder

dobby-coder Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

On it, @rubenhensen — this agent is checking the rebase state and will replay the conflict resolution against current main.

Resolves conflicts between the metrics work and main's recent additions
(api_key_tenant tenant tracking, recovery_token / upload_status, idle
TTL, download handler). Keeps both feature sets:

- FileState carries both source_channel (metrics) and the new
  api_key_tenant/api_key_validation_failed/last_chunk/recovery_token
  fields from main; is_api_key is replaced by api_key_tenant.is_some().
- Store::with_idle_ttl now takes (idle_ttl, metrics); Store::new(metrics)
  is the test-only shortcut.
- Purge counts unfinalized expirations via metrics.record_expired().
- build_rocket spawns storage_sampler, mounts metrics_endpoint, and
  manages the Metrics arc alongside pkg_client and the per-tenant store.
- upload_finalize records both per-channel metrics and per-tenant usage.

cargo check --all-targets, cargo clippy --all-targets, cargo test
(100/100), cargo fmt --check all clean.
@dobby-coder

dobby-coder Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

Conflicts resolved in 86e3cf1 (merge of main into the branch).

Conflicting files: src/error.rs, src/main.rs, src/store.rs

Root cause: main grew the API-key tenant model (api_key_tenant: Option<String>, api_key_validation_failed), a per-session recovery_token / upload_status flow, configurable idle TTL, and a download handler — all touching the same structs and build_rocket plumbing as the metrics work on this branch.

Resolution: kept both feature sets, taking main's structure as the base and re-layering metrics on top:

  • FileState carries both source_channel (metrics) and main's new api_key_tenant / api_key_validation_failed / last_chunk / recovery_token. The old boolean is_api_key is gone — state.api_key_tenant.is_some() is the new test (richer, validated against pg-pkg).
  • Store::with_idle_ttl(idle_ttl, metrics) is the production constructor; the test-only Store::new(metrics) uses the default idle window.
  • SharedState holds both idle_ttl and metrics. Purge counts unfinalized evictions via metrics.record_expired() before clearing the expiration_keys entry.
  • build_rocket spawns storage_sampler and mounts metrics_endpoint alongside upload_status and download; manages the Metrics arc next to Store, vk, pkg_client.
  • upload_finalize records both per-channel metrics (metrics.record_upload) and per-tenant usage (store.record_upload(accounting_key, …)).
  • upload_init populates source_channel from the existing ClientHeaders extractor.

Verification:

  • cargo check --all-targets — clean
  • cargo clippy --all-targets — no warnings
  • cargo test — 100 passed / 0 failed
  • cargo fmt --all -- --check — clean

@rubenhensen

Copy link
Copy Markdown
Contributor

I don't like the added docs and the added grafana board. I just need the metrics exposed.

@dobby-coder

dobby-coder Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

Dobby hears master Ruben! Dobby will remove the docs and the Grafana board, keeping only the /metrics endpoint exposed. Dobby is on it!

Remove the Grafana dashboard JSON and scrape-config README per
maintainer feedback — keep only the /metrics endpoint exposure.
@dobby-coder

dobby-coder Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

Dropped docs/grafana/ (README + dashboard JSON) in 627033c — PR body trimmed to match. The PR now only ships the /metrics endpoint and the supporting code. cargo test still green (100 passed).

@rubenhensen
rubenhensen marked this pull request as ready for review May 18, 2026 09:56
@rubenhensen
rubenhensen merged commit ffeedb6 into main May 18, 2026
7 checks passed
@rubenhensen
rubenhensen deleted the feat/prometheus-metrics-endpoint branch May 18, 2026 09:56
@github-actions github-actions Bot mentioned this pull request May 18, 2026
rubenhensen added a commit to encryption4all/postguard-tb-addon that referenced this pull request May 18, 2026
…rd (#121)

Adds X-Cryptify-Source: thunderbird to PG_CLIENT_HEADER so cryptify's
per-channel upload metrics (encryption4all/cryptify#102) classify this
add-in deterministically rather than via the User-Agent substring
fallback.

Thunderbird WebExtensions don't always present a stable Origin
(can be `moz-extension://<uuid>` or null), and the User-Agent fallback
is the last layer of cryptify's detect_channel. Setting the explicit
header at the source removes any environment-dependent ambiguity and is
symmetric with parallel PRs in postguard-website and
postguard-outlook-addon.
rubenhensen added a commit to encryption4all/postguard-outlook-addon that referenced this pull request May 18, 2026
)

Adds the X-Cryptify-Source: outlook header to clientHeaders() so
cryptify's per-channel upload metrics
(encryption4all/cryptify#102) classify this add-in deterministically.

cryptify's detect_channel checks the Origin header before falling back
to User-Agent substring matching. The add-in is served from
addin.*.postguard.eu, which matches cryptify's `contains("postguard.")`
rule and would otherwise shadow the User-Agent "outlook" check —
labeling Outlook uploads as `website` / `staging-website` instead of
`outlook`. Setting the explicit header here removes the ambiguity.
rubenhensen added a commit to encryption4all/postguard-website that referenced this pull request May 18, 2026
…228)

Sets X-Cryptify-Source on the PostGuard SDK so cryptify (≥0.1.27+,
encryption4all/cryptify#102) classifies uploads from this site
deterministically rather than via the Origin-host fallback.

Origin-based detection collided with the Outlook add-in, which is
served from addin.*.postguard.eu — that host matches cryptify's
`contains("postguard.")` rule and shadows the User-Agent "outlook"
check. With the explicit header, the website always reports
`channel="website"` regardless of the deploy host.
rubenhensen added a commit to encryption4all/postguard-js that referenced this pull request Jul 29, 2026
…rkspace SDK (#141)

* Initial commit

* Demo

* Added IRMA icon, enabled plugin when composing email

* Able to use IRMASeal WASM bindings now

* Implemented decryption process further, now able to decrypt an IRMASeal encrypted email

* Updated taskpane UX, added some MS packages for SSO.

* Create unlock

* Delete unlock

* Updated taskpane UX

* Modified again after review comments from Merel

* Forgot to commit the app store badge

* Added MSAL and encryption logic

* Able to send encrypted mail, and decrypt it in Outlook.

* Added irmaseal-mail-utils component to read and compose MIME mails

* Cleaned up code, fixed MSAL silent login.

* Cleaned up code, fixed MSAL silent login.

* Fixed bug

* Cleaned up code

* Added support for encrypting and decrypting attachments

* Attachments downloaded via blob instead of base64 encoded object

* Updated code to handle new irmaseal-client, added cryptify-api-wrapper dependency and consequently code to encrypt attachments larger than a certain threshold with cryptify

* Added logic to handle situation if no attachment is added

* Removed nonce from attachment as it is already part of CT, needed to add logic to extract nonce from CT.

* Changed attachment nonce length to 8, and appended 8 byte counter with all zeros.

* Fixed local imports

* Compose changes due to new encryption scheme

* Implemented new version of irmaseal bindings. Also, store emails as plain.

* Package all assets

* Replacing original mail content with decrypted content

* Removed button in taskpane, updated taskpane HTML, added JWT + caching support.

* Renamed to PostGuard

* Incorporated changes suggested by Merel

* Incorporated discussed changes, cleaned up code, improved error handling.

* Incorporated design improvements

* Fixed some minor bugs and adjusted texsts

* Create README.md

* Update README.md

* Update README.md

* Used stable PKG and changed directory name

* Fixed metrics, added framework for adding attributes

* Added attribute selection component

* Merged branch mobile into metrics_attributes

* Added support for decryption with multiple attributes

* Fixing multiple accounts authentication issue

* Removed console.log for body

* Re-branding, new version of multiple attribute, initial version of settings (but not yet activated)

* Minor UI changes to comply with Merels design.

* Update README.md

* Use new mail-utils and wasm-bindings version, fixed PostGuard headers for different outlook clients.

* Migrated from IRMA to Yivi

* Rewrite PostGuard Outlook add-in v0.2.0

Complete rewrite of the add-in with the following improvements:

- Event-based encryption/decryption via OnMessageSend and OnMessageRead
- Three-tier encrypted delivery: attachment (best-effort), inline armor
  block in email body, and URL fallback link for browser-based decryption
- Works around Outlook Web bug where attachments are silently dropped
  during OnMessageSend by embedding ciphertext in the HTML body
- Compose pane for recipient attribute selection before sending
- Taskpane for reading/decrypting encrypted messages with body armor fallback
- Configurable PKG_URL and POSTGUARD_WEBSITE_URL via environment variables
- Upgraded to pg-wasm 0.5.1

* Use /decrypt URL path instead of /fallback

* Fix decryption flow: init WASM, use read-mode APIs, and display subject in body

- Call pg-wasm default() init before using WASM classes (fixes "wasm is undefined")
- Use item.attachments property instead of getAttachmentsAsync (read-mode API)
- Use event.completed({ emailBody }) for OnMessageRead inline decryption
- Move parseMimeContent to shared utils, prepend subject as heading in body
- Add SupportsPinning and ItemChanged handler for pinned taskpane

* Replace encrypted email template with cryptify-style layout

Drop irmaseal-mail-utils ComposeMail dependency and build the encrypted
placeholder body inline, matching the cryptify server template structure:
PostGuard logo, sender header, optional html_content slot for a future
unencrypted message feature, decrypt button, and link section.

* Fix stale JWT cache causing KEM errors and make URLs configurable

- Add invalidateJwtCache() and retry logic: if cached JWT leads to a
  bad decryption key, invalidate the cache and retry with fresh Yivi auth
- Fix stream consumption: create fresh ReadableStream for unseal() after
  inspect_header() consumes the first one
- Make POSTGUARD_WEBSITE_URL, PKG_URL, POSTGUARD_LOGO_URL configurable
  via env vars with staging defaults (postguard.staging.yivi.app)
- Update encrypted email template to match cryptify style
- Remove duplicate header elements from taskpane and compose HTML

* Open Yivi dialog from launch event for first-time decryption

Instead of bailing out when there is no cached JWT, the OnMessageRead
launch event now opens a Yivi dialog via displayDialogAsync. After auth
the JWT is cached so subsequent opens auto-decrypt without a dialog.
Also adds stale-cache retry logic matching the taskpane flow.

Requires the x-postguard header on encrypted emails for the launch
event to fire (see encryption4all/postguard-tb-addon#52,
encryption4all/cryptify#52).

* Add standardized README

Point to docs.postguard.eu for full documentation and include
development quickstart and release instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add PostGuard logo to README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: update dependencies and resolve security advisories

Security fixes:
- webpack-dev-server: 5.1.0 → 5.2.3 (minor)
  Resolves [GHSA-9jgg-88mc-972h] and [GHSA-4v9v-hfq4-rm2v] — source code
  leak via malicious site on non-Chromium browsers.
- copy-webpack-plugin: 12.0.2 → 14.0.0 (major, dev-only)
  Pulls in serialize-javascript >= 6.0.2 to resolve [GHSA-5c6j-r48x-rmvq]
  (RCE via RegExp.flags) and [GHSA-qj8w-gfj5-8c6v] (CPU DoS).

Pre: 3 advisories (1 moderate, 2 high). Post: 7 low (all in the
office-addin-dev-settings → @microsoft/m365agentstoolkit-cli →
@inquirer/prompts → tmp chain; upstream dependency issue).

Added package-lock.json (was previously untracked).

Build: clean. All high/moderate advisories resolved.

* fix: use pg4ol metric client id to match PKG convention

The PKG server at encryption4all/postguard stores metrics keyed by the
client identifier sent in the X-PostGuard-Client-Version header. The
established convention is 'pg4tb' for the Thunderbird add-on and 'pg4ol'
for the Outlook add-in; both the postguard-tb-addon source and the
pg-pkg metrics tests (pg-pkg/src/middleware/metrics.rs) use these two
ids.

The v0.2.0 rewrite of the Outlook add-in (commit 6d77c4e, 2026-03-12)
introduced 'pg4outlook' as the client id, which silently diverges from
the convention and causes Outlook traffic to be bucketed under a new,
unexpected label in the PKG metrics counters.

Restore the original 'pg4ol' identifier so dashboards and alerts that
filter on 'pg4ol' see Outlook traffic again.

Closes encryption4all/postguard#69

* chore(deps): bump webpack-cli 7, babel-loader 10, css-loader 7, style-loader 4

- webpack-cli 5 -> 7
- babel-loader 9 -> 10
- css-loader 6 -> 7
- style-loader 3 -> 4

These four loaders/CLIs are coupled in this repo's webpack config and
all bump cleanly together. Webpack 5 itself is unchanged.

Verified: `npm run build` (webpack production) green; only the
pre-existing wasm-size warning remains.

Refs encryption4all/dobby#25, encryption4all/postguard-outlook-addon#12

* Add new version of outlook plugin

* ci: add release-please + Docker image build workflow

Adds an automated release pipeline so the add-in can be hosted from
the postguard-ops Kubernetes cluster:

- release-please tracks the npm version and changelog from
  Conventional Commits.
- A multi-stage Dockerfile builds the webpack bundle and serves
  dist/ with nginx, exposing /health for k8s probes.
- The release workflow publishes :edge (staging URLs) on every
  master push and :vX.Y.Z / :latest (production URLs) when a
  release-please PR merges, both to GHCR.
- ADDIN_PUBLIC_URL in webpack.config.js makes the manifest URL
  rewrite environment-driven so one source builds two images.

* Update ci

* ci: bump GitHub Actions to latest majors

- googleapis/release-please-action v4 → v5
- actions/checkout v4 → v6
- docker/setup-buildx-action v3 → v4
- docker/login-action v3 → v4
- docker/build-push-action v6 → v7

* fix(nginx): keep inherited mime.types so HTML serves as text/html

The server-level `types { application/wasm wasm; }` block replaced
the http-level mime.types map for this server, leaving every file —
including taskpane.html — with no Content-Type. Browsers then offered
the file as a download, breaking the ribbon-launched taskpane.

nginx 1.27's bundled `/etc/nginx/mime.types` already includes
`application/wasm wasm;`, so the override is unnecessary; dropping
it restores text/html, application/javascript, etc.

* fix(launchevent): derive Yivi dialog URL from runtime origin

The hardcoded https://localhost:3000/yivi-dialog.html constant was never
rewritten by webpack (only manifest.xml is), so OnMessageSend on
addin.staging.postguard.eu and addin.postguard.eu tried to open a dialog
on localhost. Office.displayDialogAsync rejected it because localhost is
neither same-origin as the source location nor listed in <AppDomains>.

* fix(launchevent): bake add-in origin in at build time, not runtime

The previous fix used new URL("yivi-dialog.html", window.location.href)
to derive the dialog URL. That works on Outlook on Web and new Outlook
on Windows (where launchevent.html is loaded from the add-in origin),
but New Outlook for Mac runs launchevent.js directly via the
JSRuntime.Url <Override type="javascript"> branch — there
window.location resolves to an Office-internal URL, so the dialog URL
becomes garbage and displayDialogAsync rejects with the unhelpful
"An internal error has occurred."

Inject ADDIN_PUBLIC_URL via webpack DefinePlugin instead, alongside the
existing PKG_URL / CRYPTIFY_URL / POSTGUARD_WEBSITE_URL pattern, and
expose it through pkg-client.ts so we keep the "do not read process.env
elsewhere" rule from CLAUDE.md. Document the launchevent runtime
difference in docs/outlook-quirks.md.

* fix(launchevent): surface displayDialogAsync diagnostics in Smart Alert

New Outlook for Mac rejects displayDialogAsync from the OnMessageSend
handler with the unhelpful "An internal error has occurred." and the
Outlook runtime log only shows generic AddinError:9 entries — no way to
tell whether it's a missing requirement set, an unsupported dialog
option, or a real platform bug.

Capture Office.context.platform / host, Mailbox 1.13 isSetSupported,
and the asyncResult.error code/name/message at the rejection point and
include them in the Error message that bubbles up to block(). The
Smart Alert the user sees now contains everything we'd otherwise have
to fish out of Web Inspector or the runtime log.

* fix(launchevent): drop displayInIframe/promptBeforeOpen on Mac

Diagnostic output from a failing send on New Outlook for Mac:

  code=-2147467259 (0x80004005, E_FAIL) | name=Internal Error |
  platform=Mac | host=Outlook | mbx1.13=true |
  url=https://addin.staging.postguard.eu/yivi-dialog.html

mbx1.13=true rules out a missing requirement set. The URL renders fine
when loaded directly in Safari, so the dialog HTML isn't the problem.
That leaves the dialog options as the suspect: displayInIframe: false
combined with promptBeforeOpen: false caused Mac WebKit to reject the
popup before any navigation. Falling back to defaults (popup window,
user-prompt enabled) is what Mac actually permits from a launchevent
runtime. The "PostGuard is opening another window" prompt is harmless
on Web/Windows so we accept it everywhere rather than branching by
platform.

* fix(manifest): allowlist add-in domains for dialogs from launchevent

Outlook on the web returns code=12011 (BlockedNavigation) with the
helpful message "the dialog domain and the add-in host domain are not
in the same security zone." New Outlook for Mac surfaces the same root
cause as the generic E_FAIL we'd been chasing.

The launchevent runtime is hosted in an outlook.office.com iframe, not
on the add-in's domain. From its point of view, displayDialogAsync to
the add-in's own URL is cross-origin and must be allowlisted in
<AppDomains>, even though it is the same domain as <SourceLocation>.
That allowlist rule applies in this context but doesn't apply to
dialogs opened from a regular taskpane, which is why the symptom is
launchevent-only.

Also bump dialog-size floor (covered in 7c5b2f2 already) so we surface
diagnostics rather than fight a different platform restriction next.

* revert(launchevent): drop exploratory diagnostics; keep AppDomains fix

The AppDomains addition in 0a74f9a was the actual fix. Earlier commits
on this branch (9d884ce diagnostics-in-Smart-Alert, f96a9fc dropping
displayInIframe/promptBeforeOpen, plus the size-floor and size-tag in
0a74f9a) were debugging scaffolding that turned out not to be the
issue. Restoring launchevent.ts to its master state and capturing the
investigation as a quirks-doc note keeps the runtime change minimal.

* fix(launchevent): floor dialog size at 30% of screen

Reverting this in the previous cleanup was wrong — it isn't debug
scaffolding, it's protective. Office.js docs claim 1–99% is the valid
range, but in practice Outlook hosts reject very small dialog
percentages with no useful detail. On a 3440×1440 ultrawide our
~300×520 px target resolves to 9% width, which trips the same
"BlockedNavigation" / E_FAIL path AppDomains was already supposed to
clear. 30% is comfortably above whatever the actual host minimum is
and still fits the QR widget.

* fix(launchevent): use iframe-mode dialog on Mac to bypass popup blocker

New Outlook for Mac (WKWebView) blocks the popup that
displayDialogAsync opens from a launchevent runtime, with no per-site
override available — the same code path that works on Outlook on the
web (after the user clicks "allow popups") fails silently here and
Office reports the generic E_FAIL we'd been chasing.

iframe-mode dialogs don't go through the popup blocker. Microsoft's
own #2129 reports iframe-mode broken in Outlook on the web, so keep
popup mode there. Detect Mac via Office.context.platform and branch.

This is the experimental path floated in the PR thread; if it doesn't
render the dialog on Mac (launchevent runtime has no parent surface to
host an iframe), fall back to a Mac-only Smart Alert that defers to
the manual taskpane "Encrypt & Send" flow.

* fix(launchevent): use promptBeforeOpen on Mac for popup gesture

iframe mode (9466f09) didn't render the dialog on Mac — the launchevent
runtime has no parent surface. Switch back to popup mode and instead
flip promptBeforeOpen: true on Mac. The Office-level "open another
window" confirmation that Office shows in this mode counts as the user
gesture WKWebView needs to release the popup it was silently blocking.

Web/Windows keep promptBeforeOpen: false so the existing one-click UX
is unchanged there.

* debug(launchevent): verbose platform diagnostics in dialog rejection

The promptBeforeOpen-on-Mac change relies on Office.context.platform
matching Office.PlatformType.Mac, but the launchevent runtime may not
expose Office.PlatformType the same way the taskpane does. Log the
raw values, types, and the result of the comparison both to console
and into the Smart Alert error message so we can verify isMac is
actually true on Mac without DevTools.

* fix(launchevent): drop promptBeforeOpen: false so Office's prompt fires

Verbose platform diagnostics on Safari (Outlook on the web) showed
isMac=false, promptBeforeOpen=false — and the dialog still failed.
Earlier "Safari worked" tests had run with promptBeforeOpen *defaulted*
to true (because we'd stripped the flag for an earlier experiment),
not with explicit false. The Office-level "PostGuard is opening
another window" prompt isn't UI noise to suppress — it's the user
gesture that lets the popup through the browser's popup blocker.

Drop the explicit promptBeforeOpen: false everywhere and let it
default. One config across Web, Windows, and Mac. The Mac branching
(c1d69fc) becomes unnecessary.

* fix(launchevent): keep Office's prompt on Mac, suppress on Web/Windows

The Office-level "PostGuard is opening another window" prompt is the
only mechanism that produces the user gesture WKWebView needs on
Outlook for Mac to release the dialog popup. There's no per-site
popup permission UI in the Mac app, so the prompt has to fire on
every send — promptBeforeOpen: true.

Web and Windows expose their own browser-level popup permission, so a
user who has accepted once gets a true one-click experience —
promptBeforeOpen: false on those.

* revert(launchevent): drop MIN_DIALOG_PCT floor

The size floor was added when we still thought Outlook hosts were
rejecting small dialog percentages — actually it was the popup
blocker, fixed properly in 8fc3f55 by leaving Office's prompt on for
Mac. With the right popup-gesture path, dialog size is no longer
load-bearing.

* fix(launchevent): re-add MIN_DIALOG_PCT for usable dialog size

Not load-bearing for opening the dialog (promptBeforeOpen is what
fixed that), but on a 3440×1440 ultrawide our 300px target resolves
to 9% width — the Yivi QR ends up uncomfortably small even when the
host renders it. 30% keeps the dialog big enough to use across screen
sizes.

* fix(launchevent): keep Office's prompt on every platform

8fc3f55's promptBeforeOpen: isMac was wrong. It happened to look like
it worked on Web because the Safari instance under test still had a
cached popup permission for outlook.office.com from earlier runs;
suppressing the Office-level prompt only works in that narrow case.
Brand-new Web sessions and every Mac send fail silently without it.

c0d45d2's "let it default to true everywhere" was the actually-correct
state. Restore it.

* fix(launchevent): only show Office's popup prompt on Mac

New Outlook for Mac has no per-site popup-permission UI, so the
Office-level prompt is the only user gesture that releases the
popup — keep it on there. Web and Windows expose browser-level
popup permission; once granted, the popup opens without an extra
Office prompt and the user gets a one-click send.

First-time Web users still see the browser's native popup-blocker
indicator on the first send and accept there once.

* debug(launchevent): surface isMac result in Smart Alert error

Previous platform diagnostic was log-only (DevTools-only). Append the
raw platform, Office.PlatformType.Mac value, types, and the resulting
isMac/promptBeforeOpen booleans to the rejection error so the Smart
Alert shows them directly. Lets us tell whether isMac evaluated true
on Mac without attaching a debugger.

* debug(launchevent): log platform check at Office.onReady

Previously the isMac diagnostic only fired inside runEncryptDialog,
which means it requires actually clicking Send and reaching the
dialog-open step before the user can see it in DevTools. Move an
identical check up to Office.onReady so the platform values appear
right after script load, regardless of whether a send has been tried
yet.

* fix(launchevent): keep Office's prompt on every platform

Diagnostic on Web confirmed that suppressing the prompt only worked
in browser sessions that had already granted host popup permission;
fresh sessions and every Mac install fail silently without it. The
Office-level "PostGuard is opening another window" confirmation is
the user gesture that releases the popup, and is needed everywhere.

Two clicks per send is the price of always-works.

* fix(launchevent): branch promptBeforeOpen on Apple WebKit, not platform

Office.context.platform reports "OfficeOnline" for every browser on
Outlook on the web, so it can't distinguish Safari from Chrome — and
Chrome/Edge/Firefox don't need the prompt, only Safari and WKWebView
do. Switch to a User-Agent check that matches AppleWebKit without
Chrome/Edg/OPR. That covers Safari (Outlook on the web on Mac) and
the WKWebView New Outlook for Mac runs on, while letting Blink/Gecko
browsers skip the extra prompt for a one-click send.

* revert(launchevent): drop MIN_DIALOG_PCT floor

Not load-bearing now that promptBeforeOpen-on-WebKit fixed the actual
popup blocker; restore the original Math.max(1, …) clamp.

* fix(launchevent): try-without-prompt, fall back to prompt; Safari hint

Two-part change driven by the popup-blocker dance:

1. runEncryptDialog now attempts displayDialogAsync with
   promptBeforeOpen: false first. Browsers that allow popups from the
   Outlook host (Chrome/Edge/Firefox by default, and Safari once the
   user has granted permission) get a one-click send. On failure —
   typically Apple WebKit silently blocking the popup — fall through
   to a second attempt with promptBeforeOpen: true so the Office
   confirmation fires and the user's click on Allow becomes the
   gesture that releases the popup.

2. yivi-dialog.html shows a Safari-only inline hint pointing at
   Settings → Websites → Pop-ups → Allow so the user can opt out of
   the recurring confirmation. Match Safari's UA specifically (has
   "Safari/<ver>" suffix); WKWebView in Outlook for Mac omits that
   token and has no per-site setting reachable, so the hint stays
   hidden there.

* ci: attach production manifest.xml to GitHub Release

After release-please cuts a release and build-release pushes the
versioned Docker image, build the manifest one more time with the
production ADDIN_PUBLIC_URL and upload it to the GitHub Release as
manifest.xml. Lets admins centralizing deployment via the Microsoft
365 Admin Center pull a stable, version-pinned manifest URL instead
of relying on whatever the live addin.postguard.eu host is currently
serving (which moves with each release).

* chore(master): release 0.1.1

* fix(launchevent): skip optimistic attempt on Outlook for Mac

Outlook for Mac (the native WKWebView app, distinct from Outlook on
the web in Safari) has no per-site popup-permission UI reachable
from a launchevent runtime. The try-without-prompt-then-retry-with
flow always loses the first attempt there — every send pays for a
silent failure plus a second displayDialogAsync round-trip with no
upside. Detect the native Mac client via Office.context.platform ===
Office.PlatformType.Mac and go straight to promptBeforeOpen: true.

Outlook on the web in Safari keeps the optimistic path: there the
user can grant popup permission once via Safari's settings (the
inline hint inside the dialog points the way) and get one-click
sends afterwards.

* fix(launchevent): always prompt on WebKit, drop retry and Safari hint

Refines the Mac fix: the prompt is needed on every Apple WebKit
client (Outlook for Mac and Safari on the web), not just the native
Mac client. Detect via UA — AppleWebKit without Chrome/Edg/OPR — and
set promptBeforeOpen: true unconditionally there. Skip the prompt on
Blink and Gecko browsers, which open the popup without intervention.

The optimistic-attempt-then-retry flow goes away in both branches:
on WebKit we know we need the prompt up front, on other engines we
know we don't. Single displayDialogAsync call per send.

Drop the inline Safari hint in yivi-dialog.html since Safari users
now also see the prompt every time and don't need to be told to
configure popup permission.

* chore(master): release 0.1.2

* fix(launchevent): Mac fallback to taskpane; retry pattern + Safari hint

Three coordinated changes that match the actual platform support
matrix instead of trying to make every platform behave the same:

1. Outlook for Mac (native WKWebView): block on every send. The
   launchevent runtime there rejects displayDialogAsync with E_FAIL
   regardless of options, sizing, or AppDomains — confirmed against
   open OfficeDev/office-js issues #3138, #3085, and #5681. Block
   the OnSend with a Smart Alert pointing the user at the existing
   taskpane "Encrypt & Send" button, which doesn't go through the
   broken displayDialogAsync path.

2. Web/Windows: bring back the optimistic-then-retry flow.
   displayDialogAsync first fires with promptBeforeOpen: false for
   one-click on Chrome/Edge/Firefox and on returning Safari users
   whose host popup permission is already granted. On failure
   (typically Safari with no granted permission), retry once with
   promptBeforeOpen: true so the Office prompt fires and the user's
   click on Allow becomes the gesture that releases the popup.

3. yivi-dialog now shows a Safari-only inline hint pointing at
   Settings → Websites → Pop-ups so the user can opt out of the
   recurring prompt by granting permission once. Safari is matched
   on UA — Outlook for Mac's WKWebView omits the "Safari/<ver>"
   token and has no per-site setting reachable, so the hint stays
   hidden there.

* docs(launchevent): track upstream Mac issue office-js#6677

Add the upstream tracking link to both the code comment in
launchevent.ts (next to the Mac platform-fallback branch) and to
docs/outlook-quirks.md as its own section. Future maintainers can
remove the platform check and update the docs once Microsoft ships a
fix on that issue.

* fix(taskpane): broaden isComposeMode for Outlook for Mac

Mac's Hx-rewrite layer wraps Office.js methods in proxies that fail
the strict `typeof item.subject.setAsync === 'function'` check we used
to gate compose-vs-read detection. Result: isComposeMode() returned
false on Mac, mountReadView() ran instead of mountComposeView(), and
the Encrypt & Send button never rendered, even though the user was
composing a draft.

Switch to `typeof item.subject !== 'string'`. In read mode subject is
the literal subject string; in compose mode it is the Subject
wrapper object regardless of how Mac's proxy layer shapes the inner
methods. Direct, doesn't depend on method-shape detection.

Also add a bootstrap diagnostic log (platform, host, compose,
subjectType) so the next failing case surfaces what the runtime
actually saw.

* fix(taskpane): drop the hidden attribute on the Encrypt & Send button

The button has carried `hidden` in the markup since it was first added,
but nothing in compose-view.ts ever removes it. On Outlook for Mac
(WKWebView) `[hidden]` is honored strictly, so the button never
rendered after the compose view mounted. Visibility doesn't need to be
toggled at all — the existing renderToggleUI() already manages the
button's enabled/disabled state based on toggle, recipients, and
encrypted-state. Just leave it visible.

* fix(ui): add focus-visible and active states to interactive elements

Adds keyboard focus indicators (:focus-visible) to buttons, the encrypt
toggle, attribute inputs, the delete-attribute button, and the add-attribute
chips. Adds :active press feedback on the primary and secondary buttons.

Found while sweeping the taskpane UI for design AI-slop tells (encryption4all/dobby#44):
keyboard focus states were entirely missing (#31 on the design checklist) and
buttons had no press feedback (#34). The rest of the UI is in good shape -
no purple/violet accents, custom dark-blue palette, system fonts, no generic
shadcn defaults.

* fix(launchevent): only deflect Mac when message isn't already encrypted

The Mac platform check was firing right after the encrypt-on-send
header read, before we checked whether the draft already had a
postguard.encrypted attachment. So a Mac user who had clicked Encrypt
& Send in the taskpane (the workaround we ourselves direct them at)
got blocked on the subsequent native Send with the message telling
them to click the same taskpane button they had just used.

Move the Mac block inside the `!alreadyEncrypted` branch where we'd
otherwise call encryptAndApply (the dialog flow). Mac users who have
already encrypted via the taskpane fall through the existing
recipients-still-match check and the send proceeds normally.

* fix(taskpane): show Encrypt & Send button only on Outlook for Mac

The button is the workaround for the launchevent dialog being broken
on Mac (office-js#6677). On every other client the OnMessageSend
launchevent opens the dialog automatically when the user hits the
native Send button, so a separate Encrypt & Send button in the
taskpane is just redundant noise that competes with the native flow
and confuses the user about which path to take.

Hide the button unless Office.context.platform === Mac.

* chore(master): release 0.1.3

* chore: update dependencies (minor/patch)

Applied minor/patch bumps via `ncu --target minor`. Build passes.
Yivi packages remain pinned at ^1.0.0-beta.4 (org standard).

Major bumps (babel-loader 9→10, dotenv 16→17, typescript 5→6,
webpack-cli 5→7) deferred per repo notes.

Refs #33

* chore(deps): upgrade TypeScript to ^6.0.3

TS 6 deprecates `baseUrl` (with no `paths` it was a no-op anyway) and
`moduleResolution: node` (now `node10`). Drop the unused baseUrl, switch
moduleResolution to `bundler` (the right fit for a webpack + esnext build),
and add `skipLibCheck` to skip type-checking of declaration files in
node_modules — required because bundler resolution surfaces a transitive
fdir/picomatch types issue, and is standard practice anyway.

* fix(a11y): WCAG 2.2 AA fixes for taskpane and yivi dialog

- Spinner uses role="status" + aria-live="polite" with sr-only text instead of aria-label on a non-interactive div (1.1.1, 4.1.3)
- Encrypt toggle: aria-labelledby="pg-toggle-label" links the visible label; decorative track gets aria-hidden (4.1.2, 2.5.3)
- BCC warning + dialog error get role="alert" so injected text is announced (4.1.3)
- Decrypted iframe gets a title="Decrypted message body" (4.1.2, 2.4.1)
- pg-status-hidden no longer uses display:none (which suppresses ARIA live announcements). Collapse via height:0+overflow:hidden so role="status" updates are still picked up (4.1.3)
- Policy editor: <label> now associated with its input via htmlFor; delete-attribute aria-label uses translated label and "Remove" key (3.3.2, 1.3.1, 3.1.2, 4.1.2)
- Read-view "Attachments" header is now an actual <h4> instead of a 12px muted div, and pulls from i18n (1.3.1, 2.4.6, 3.1.2)
- .pg-policy-add-chip: min-height 24px so tap target meets minimum (2.5.8)
- Add errorRetry/dialogClose/decryptedAttachmentsHeading/removeRecipient/loading translation keys

Closes #36

* chore: migrate ESLint to flat config, fix all lint issues, add CI

`.eslintrc.json` was inert — office-addin-lint runs ESLint 9, which
ignores the legacy format. Replace it with `eslint.config.mjs` that
inherits the bundled office-addins recommended config and supplies the
browser + Office globals the lint rules were missing.

Disable `no-undef` for TS files (TypeScript already covers this and the
ESLint rule can't see ambient DOM types like RequestInit/BlobPart) and
configure unused-vars to honour the `_`-prefix convention.

Auto-fix prettier formatting violations, drop the now-redundant
`/* global Office */` comment headers, remove unused
NOT_ENCRYPTED_MESSAGE and POSTGUARD_HEADER imports, and drop dead
eslint-disable directives.

Add `.github/workflows/ci.yml`: runs lint (--max-warnings=0), tsc
--noEmit, webpack build and manifest validate on every PR and on master
pushes, so warnings/errors of this kind can't accumulate again.

* chore: release 0.1.4

Cuts a patch release covering everything merged since v0.1.3:
  - dependency upgrades (PR #14, #34)
  - TypeScript 6 upgrade + ESLint flat-config + CI (PR #35)

These were all chore commits, so release-please didn't open a release
PR on its own. The Release-As footer below tells it to release the
next merge as 0.1.4.

Release-As: 0.1.4

* chore(master): release 0.1.4

* docs(readme): document release-please flow and CI

The Releasing section claimed releases were manual; they have been
release-please-driven for a while. Replace it with the actual flow
(conventional commits → release PR → tagged Docker image + manifest.xml
asset on the GitHub Release), plus a note on the Release-As escape
hatch for chore-only releases under 0.x.

Also surface the standard scripts (validate, lint) and the CI gate.

* chore(master): release 0.1.5

* fix(launchevent,yivi-dialog): surface real error message instead of "[object Object]"

Outlook for Mac's WKWebView surfaces some encryption failures as plain
object rejections (and Office.AsyncResult.error has shape `{code, name,
message}`), not Error instances. The dialog and launchevent both used
naive `String(...)` coercion, which collapses to "[object Object]" and
loses every diagnostic clue. Result on the wire: "Encryption failed:
[object Object]".

- New shared `lib/stringify-error.ts` helper. Prefers `Error.message`
  (with stack), passes strings through, reads `.message` off plain
  objects, falls back to JSON.stringify.
- yivi-dialog: replace the local `String(err)` coercion with the helper.
- launchevent: use the helper at the three remaining sites that touched
  unknowns — `displayDialogAsync` rejection, `encrypt-error` payload
  passthrough (string fast-path preserved), and the `encryptAndApply`
  catch in the OnMessageSend handler.

Doesn't fix the underlying Mac-only encryption failure, just makes its
message visible so the next failure mode can be diagnosed.

* chore(master): release 0.1.6

* chore(deps): bump @e4a/pg-js to ^1.5.0

Picks up encryption4all/postguard-js#52 (resumable downloads via
Range header on transient failures) and #47 (chunk PUT retry/backoff
with exponential jitter, surfacing of UploadSessionExpiredError).

Public-API note: pg-js's `downloadFileWithRetry` now returns the
ReadableStream synchronously rather than wrapped in a Promise. The
addon does not call that function directly — it consumes pg-js via
`PostGuard.open()` / `Opened.decrypt()` which is unchanged.

`npm run lint` and `npm run build` clean (entrypoint-size warnings
are pre-existing). No code changes — the SDK boundary in
read-view.ts/compose-view.ts is unchanged at the 1.x minor.

* chore: bump @e4a/pg-js to 1.6.0

* feat(taskpane): add Dutch (nl) translations and reorder Sign above Manage Access

- Add nl bundle to src/lib/i18n.ts so Office hosts running in Dutch get
  localized strings (#51). The locale lookup at the bottom of i18n.ts
  already keys off Office.context.displayLanguage, so adding the bundle
  is the only piece that was missing.
- Localize a few previously hardcoded strings (read-view From/Date meta,
  the read-mode noop subtitle, the Yivi Cancel button, Retry button)
  via new keys so they participate in the same lookup.
- Move the Sign section above Manage Access in taskpane.html so the
  sender-side step appears before the recipient-side step, matching the
  natural compose flow (#52). With many recipients this also keeps the
  Encrypt & Send button reachable without scrolling past Manage Access.

* fix(launchevent): prompt before opening Yivi dialog by default (#48)

Inverts displayDialogAsync defaults in runEncryptDialog: open with
promptBeforeOpen:true, so the user's "Allow" click is itself the
gesture that releases the popup. The previous optimistic-first path
tripped Safari's popup blocker before any retry could attach a real
gesture, surfacing a confusing security error.

A new Settings view (taskpane gear button) lets users opt back into
the optimistic open via the roaming-setting pg.allowOptimisticDialog
once they have permanently allowed pop-ups for the add-in's origin.
If the optimistic attempt still fails we recover with a single
prompted retry so the send isn't lost.

Closes #48

* fix(launchevent,yivi): enlarge dialog and center QR widget

Bumps the launchevent Yivi dialog target from 300×520 to 460×640 so
the QR widget (~290px) has comfortable margins alongside the title,
Safari hint, and Cancel button.

Switches .pg-yivi-host to a column flex with full width and auto
side margins on children, so the SDK's stacked elements (auth tabs,
QR, helper links) all sit horizontally centered in both the
launchevent dialog and the taskpane compose Yivi view.

* fix(yivi): center QR inside Yivi host by styling SDK classes

@privacybydesign/yivi-web ships no stylesheet, so the .yivi-web-header,
.yivi-web-content, .yivi-web-centered, and .yivi-web-qr-code elements it
injects render as plain block divs and the QR ends up left-aligned.

Apply column-flex centering down the SDK tree and constrain the QR
SVG to a max-width with auto margins so it sits centered in both the
launchevent dialog and the taskpane compose Yivi view.

* fix(yivi,deps): use @privacybydesign/yivi-css for Yivi widget styling

@privacybydesign/yivi-web ships no stylesheet, which left the QR
widget and surrounding elements unstyled (left-aligned, no spacing).
Pull in @privacybydesign/yivi-css@1.0.0-beta.7, copy yivi.min.css
into dist via CopyWebpackPlugin, and link it from both taskpane.html
and yivi-dialog.html so the official styling drives the Yivi layout
in both the launchevent dialog and the taskpane compose view. The
hand-rolled .yivi-web-* rules in taskpane.css are dropped — only the
.pg-yivi-host outer min-height remains.

The html-loader rule gets a urlFilter so it leaves the yivi.min.css
href untouched at compile time and the browser resolves it from the
same dist/ directory as the HTML page at runtime.

Also bumps @e4a/pg-js to ^1.6.0.

* fix(taskpane): move Settings entry to a labeled footer button

Replace the top-right floating gear with a "⚙ Settings" button in a
pg-footer pinned at the bottom of the taskpane via the existing
pg-app column flex. Footer is hidden on transient views (loading/
yivi/error) and on the settings view itself, matching the previous
gear visibility rules.

Also adds Dutch translations for settingsTitle / Open / Back / the
optimistic-dialog label + help text.

* fix(compose): use optional sign attributes via Yivi (#49, #56)

Previously state.signAttributes carried { t, v } pairs that were
forwarded to pg.sign.yivi as required-disclosure entries. When the
user's Yivi credentials did not match the pre-typed value the Yivi
session silently dropped the extra attribute (or the signing failed),
which is what #49 and #56 surface.

Match the postguard-website pattern:
- AttributeRequest.v is now optional and AttributeRequest carries an
  optional boolean.
- policy-editor gains a mode flag. In "sign" mode it renders selected
  attributes as filled chips (no value input) and stores
  { t, optional: true }; "manage" mode keeps the value-input rows.
- The sign call uses includeSender: true and forwards the optional
  attributes directly. createEnvelope no longer guesses senderAttributes
  pre-disclosure.

Adds .pg-policy-selected-chip / -remove styles for the new chip UI.

* fix(launchevent,dialog): forward optional sign attrs through send pipeline

The OnMessageSend launchevent runs in a separate WebView from the
compose taskpane, so state.signAttributes in compose-view memory was
never reaching the Yivi session created in yivi-dialog.ts — the QR
asked only for the sender's email no matter what the user picked in
the Sign panel.

Persist the selected sign attributes on the draft as a JSON internet
header (x-pg-sign-attributes), restore them in compose-view on mount,
read them in the launchevent handler, marshal them through the
encrypt-request payload, and consume them in yivi-dialog.ts to build
pg.sign.yivi({ attributes, includeSender: true }).

Closes the user-visible part of #49/#56 for the one-click send path.

* feat(sign): prefill sender attributes in Settings; sign with optional fallback

Previous flow asked the user to pick sign attributes per compose draft
and persisted them in an internet header — discoverability was poor and
the per-draft choice repeated work.

New flow:
- buildSignAttributes() in lib/settings always returns the three Yivi
  attributes Name / Date of birth / Mobile. Each is sent as { t, v }
  (mandatory match) when the user has prefilled a value in Settings,
  otherwise as { t, optional: true } so the Yivi app prompts and the
  user can skip.
- Settings view grows Sender-attribute inputs (text / date / tel)
  saved in roamingSettings under pg.signPrefills. DOB round-trips
  through the same DD-MM-YYYY / YYYY-MM-DD helpers Manage Access uses.
- Compose view drops the Sign chip section and the per-draft
  x-pg-sign-attributes header. Both the in-taskpane encrypt path and
  the OnMessageSend launchevent now share buildSignAttributes() —
  roamingSettings is available in both runtimes.
- Bundles Dutch translations for the new Settings strings.

* fix(settings): persist prefills on change, serialize+retry saveAsync

Two issues hit together:
- Each keystroke in the new Settings prefill inputs called
  roamingSettings.saveAsync, and the parallel saves sometimes failed
  with GenericSettingsError code 9019 — the user saw
  "Could not save setting. Try again." and the value never reached
  the launchevent, so prefilled attributes silently fell back to
  optional in the Yivi disclosure.

Fixes:
- storage.ts queues all save/remove calls through a single-flight
  chain so subsequent saveAsync calls wait for the prior one to
  resolve, and retries up to twice with a short backoff on 9019.
- settings-view.ts listens on `change` (blur) instead of `input`,
  which already removes the rapid-fire churn at the source.

* fix(settings): explicit Save button for sender-attribute prefills

Auto-saving prefills on the `change` event lost data when the user
clicked Back without first blurring the text field — change never
fires, the typed value stays in the DOM but never reaches
roamingSettings, and at sign time buildSignAttributes() falls back
to { t, optional: true }. Verified end-to-end via the SDK source
(postguard-js/src/signing/yivi.ts) and the PKG handler
(postguard/pg-pkg/src/handlers/start.rs) — the { t, v } shape does
require a mandatory disclosure match, so the missing values were
indeed the cause.

Adds a Save button next to Back in the Settings view. It serializes
all three prefill inputs through setSignPrefills in one shot and
flashes a "Saved." status for 2s. The advanced toggle keeps its
immediate-persist behaviour — single discrete clicks don't suffer
the same blur-before-Back problem.

* fix(settings): Save returns to the prior view after persisting

Save used to only persist and flash "Saved." — the user had to click
Back as a separate step. Fold the navigation into the Save flow: on
success, showView(returnTo) takes the user back automatically; the
"Saved." banner remains visible on the destination view for 2s. On
failure we leave the user on the Settings view so they can retry.

* fix(yivi-dialog): forward sign-attribute values to pg.sign.yivi

The dialog mapped every incoming entry to { t, optional: true },
discarding the v field that the launchevent had carefully built
from the user's Settings prefills. So even when buildSignAttributes()
emitted { t: fullname, v: "Ruben Hensen" }, the dialog handed
pg.sign.yivi a stripped { t, optional: true } and Yivi prompted
the user as if the attribute were optional.

Pass the entry through unchanged: { t, v? } stays mandatory,
{ t, optional: true } stays optional. Verified against the Yivi
condiscon docs (docs.yivi.app/condiscon) that mixing mandatory
and optional disjunctions in a single request is supported.

Also enriches the launchevent log so the actual prefilled value
shows up next to each attribute — useful when diagnosing
credential-value mismatches.

* chore(deps): bump dotenv to ^17.4.2

Major bump from 16.6.1. Only usage is require("dotenv").config()
in webpack.config.js; default behaviour remains compatible.

Closes #64

* fix(settings): wire listeners once, refresh values directly without cloning

Each mountSettingsView call cloned every input element via cloneNode,
replaced it in the DOM, and re-attached listeners. cloneNode behaviour
for the input.value property is browser/WebView dependent — across
Outlook's WebView2 (Windows) / WKWebView (Mac) / Edge (web) the value
sometimes copied and sometimes didn't, leaving subsequent edits
referring to a stale element graph. Typing a new value, hitting Save,
and reopening Settings showed the previous saved value instead of the
just-typed one.

Switch to a once-only wiring pattern: a module-level `wired` flag
guards listener attachment; every mount only refreshes label
textContent and input.value from roamingSettings. Listeners read the
inputs by id at click time so the latest user input is always picked
up. Includes a console.log of the save payload + readback so the path
is debuggable if anything else surfaces.

* fix(settings): cache prefills in module-level state to dodge stale get

Logs from new Outlook (WebView2) show roamingSettings.get returning the
previous value from a later read site even though an earlier readback
right after the synchronous .set returned the just-set value. The
side-effect: after Save + showView(returnView), reopening Settings ran
refreshValues -> getSignPrefills -> stale value -> input reset to the
old text. The user re-clicked Save (no change), saving the stale
value back over the good one.

Cache the prefills in a module-level variable populated on first read
and overwritten synchronously inside setSignPrefills. saveAsync still
persists to roamingSettings as before — readers in the same runtime
session just don't go through Office's quirky get path.

Note: requestIdleCallback errors in the console are from OWA's own
bundle (owa.90378.m.0f432b25.js), not ours — WebView's missing
requestIdleCallback API. Safe to ignore.

* fix(compose): narrow attr.v before calling extraAttribute

AttributeRequest.v is optional now that sign-side entries can omit it,
so tsc --noEmit (run in CI) flagged the Manage Access recipient loop:
  src/taskpane/compose-view.ts(507,42): error TS18048: 'attr.v' is possibly 'undefined'.

Skip the entry when v is missing or empty. The policy editor already
filters empty rows out of state.policy before this code path, so this
narrows the type for the compiler without changing runtime behaviour.

* chore(master): release 0.2.0

* feat(compose): default encryption off and add global toggle + status banner

PostGuard previously opted new drafts in by default. Flip the default so
the user has to enable encryption explicitly, expose the toggle as a
mailbox-wide roaming setting (compose taskpane + Settings view), and
surface a persistent in-message banner that mirrors the current state so
the user always sees whether the next send will be encrypted.

* fix(compose): force-repaint status banner via remove + replace

notificationMessages.replaceAsync silently no-ops in new Outlook compose
mode when called with the same key, so the user-visible banner text stays
on whichever message we first set. Remove the entry before re-adding it
so the toggle actually swaps between the on/off banners.

* feat(launchevent): set status banner on compose open via OnNewMessageCompose

The encryption-status banner previously only appeared once the user
clicked the PostGuard ribbon button, because only the taskpane was
setting notificationMessages. Wire an OnNewMessageCompose launch event
that fires at compose-open and seeds the banner from the per-draft
header (if any) or the mailbox-wide setting, so the user sees the
PostGuard state immediately on every new message, reply, and forward.

* feat(compose): per-draft x-pg-encrypt-on-send is the send-time authority

Restructure the encryption-state model so the global Encryption setting
in Settings is purely a default-seed for new drafts, and the per-draft
x-pg-encrypt-on-send header is the only thing OnMessageSend looks at.

- OnNewMessageCompose seeds the header from the global default if the
  draft does not yet have one, and writes the in-message banner from
  the resulting per-draft value.
- The compose toggle writes only the header (not the global setting).
- OnMessageSend reads only the header. If anything but \"true\", or if
  the header read or any unexpected error happens, the send is
  released immediately so a PostGuard failure cannot block an
  unencrypted email from going out.

* fix(launchevent): block the send on any failure once encryption is committed

If x-pg-encrypt-on-send is \"true\" the user has explicitly opted in to
having this message encrypted, so silently releasing it in cleartext on
a PostGuard failure is the worst possible outcome. Track a
committedToEncrypt latch that flips the instant we read the header as
\"true\"; from then on the timeout fallback, any unhandled exception
inside the encrypt callback, and any encryption error all route to a
Smart Alert that refuses the send. The off / indeterminate paths still
release immediately so a broken add-in never blocks an unencrypted send.

* fix(launchevent): address dobby review on PR #67

- Prettier: re-format src/launchevent/launchevent.ts (--fix) so the
  Lint, typecheck & build CI job is green.
- onNewMessageComposeHandler: when the header read fails, still attempt
  the seed write. If the seed write also fails, paint the banner as
  off — never as on. OnMessageSend reads only the header, so a banner
  that says on with no header on the message would mean the email goes
  out in cleartext on Send.
- blockAfterTimeout: drop the unused event parameter (onFire already
  closes over it).

* chore(master): release 0.3.0

* chore: bump @e4a/pg-js and webpack-dev-server

- @e4a/pg-js ^1.6.0 → ^1.6.2
- webpack-dev-server ^5.2.3 → ^5.2.4

Closes #73

* chore: remove unused export utf8ToBytes from encoding.ts

The function had no callers anywhere in src/.

Closes #76

* refactor: route remaining error coercions through stringifyError

Replaces ad-hoc String(e) and `err instanceof Error ? err.message : ...`
patterns with the canonical stringifyError helper from src/lib/stringify-error.ts.
On Outlook for Mac's WKWebView, rejections are often plain
{code, name, message} objects rather than Error instances; the ad-hoc
patterns collapsed those to "[object Object]" or skipped to a generic
fallback, losing diagnostic detail. For the three taskpane callers that
supplied a localized fallback, the helper output is preferred when
non-empty and falls back to the localized string otherwise.

Closes #75

* ci: add prettier format check

Catches formatting drift in PRs before it lands on master. Uses
prettier --check directly (rather than npm run prettier, which wraps
office-addin-lint prettier in --write mode and would silently fix drift
in CI instead of failing).

Closes #77

* feat: surface UploadSessionExpiredError distinctly, bump pg-js to 1.7.0

Bump @e4a/pg-js ^1.6.2 → ^1.7.0. The new release adds resumeUpload and
FileState.recoveryToken; the idempotent retry path (prevToken) and
UploadSessionExpiredError were already available in 1.6.x and the addon
benefits transparently through pg.email.createEnvelope.

When pg-js raises UploadSessionExpiredError (cryptify's structured 404
saying the upload session is gone — TTL expired, server restart, unknown
UUID, or wrong recovery_token), route to a dedicated user-facing message
in both flows:

- launchevent / yivi-dialog (one-click Encrypt & Send): the dialog tags
  the encrypt-error payload with code: "upload_session_expired"; the
  runEncryptDialog dispatcher copies the tag onto the rejection Error so
  the outer blockSend path uses encryptionFailureMessage(), which skips
  the generic "PostGuard encryption failed:" prefix for this case.
- taskpane / compose-view (manual Encrypt & Send): the catch branches on
  err instanceof UploadSessionExpiredError and shows the i18n string
  instead of the raw pg-js diagnostic.

Adds uploadSessionExpiredError (EN + NL) to the i18n bundle.

Cross-restart resume (capture recovery_token from upload_init, call
GET /status on next launch) is not implemented here: createEnvelope in
pg-js 1.7.0 does not yet expose recoveryToken on EnvelopeResult, so the
addon has no way to persist it. That will need an upstream API addition
in postguard-js — tracked separately.

Closes #82

* refactor: use stringifyError helper in compose-view catch

Routes the encryptAndPrepareSend catch through the shared
stringifyError() helper instead of bare err.message, so plain-object
rejections from Office.js / WKWebView surface their diagnostics
instead of falling back to the generic encryptionError string.

Matches the convention used in launchevent.ts and yivi-dialog.ts.

* fix: localise upload-session-expired Smart Alert text

Use t("uploadSessionExpiredError") in encryptionFailureMessage so the
NL string isn't dead code on the launchevent Smart Alert path. The EN
string in the dialog is unchanged (dialog UI isn't localized yet).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: bump @e4a/pg-js to 1.7.1

* chore: bump yivi-css and @types/office-* to latest

- @privacybydesign/yivi-css 1.0.0-beta.7 -> 1.0.1
- @types/office-js 1.0.589 -> 1.0.591
- @types/office-runtime 1.0.36 -> 1.0.37

* feat: persist upload recoveryToken via pg-js onUploadInit

Bumps @e4a/pg-js to ^1.8.0 (adds onUploadInit + resumeUpload). Wires
the new callback in both createEnvelope call sites (yivi-dialog and
compose-view) so the {uuid, recoveryToken} pair from upload_init is
persisted before any chunk PUT — to roamingSettings for the taskpane
flow and to localStorage for the dialog. The entry is cleared on
successful completion.

On dialog / compose-view init, any stale entry is probed via
resumeUpload — UploadSessionExpiredError clears it silently; live
sessions log their uploaded byte count and are also cleared, because
pg-js 1.8.0 does not yet accept a pre-resumed FileState back into
createEnvelope.

Closes #82.

* chore(master): release 0.4.0

* ci: add semantic PR title check workflow (#94)

* chore(deps): override applicationinsights+otel chain to clear GHSA-q7rr-3cgh-j5r3 (#93)

* chore(deps): override applicationinsights+otel chain to clear GHSA-q7rr-3cgh-j5r3

Floats applicationinsights to ^3.15.0 (transitive via
office-addin-debugging > office-addin-usage-data@2.0.8) AND pins the
@opentelemetry/* chain to ^0.218.0 plus protobufjs to ^8.3.0 so the
upgraded applicationinsights doesn't drag the audit *up* from 3 to 16
highs. The override is scoped to office-addin-usage-data@2.0.8 to avoid
breaking the older @microsoft/teamsfx-core > office-addin-usage-data@1.6.14
branch which still pins applicationinsights ^1.7.3.

Verified:
- npm audit: 0 vulnerabilities (was 3 high)
- npm run lint, npm run build, npm run validate, npx tsc --noEmit: all green
- Affects devDependency chain only; addin runtime is unchanged.

Closes #86

* fix: move otel/protobufjs overrides to top level so npm ci stays in sync

npm ci was failing on CI because scoping the @opentelemetry/* and
protobufjs overrides under office-addin-usage-data@2.0.8 left the
package-lock.json's package.json reflection partial (npm install
regenerated the tree but npm ci's stricter validation flagged
0.217.0/protobufjs@7.5.9 as 'missing'). Top-level overrides resolve the
same transitive chain and keep package.json/package-lock.json in sync.

The applicationinsights override stays scoped to office-addin-usage-data@2.0.8
so the older @microsoft/teamsfx-core > office-addin-usage-data@1.6.14
branch (applicationinsights ^1.7.3) is unaffected; opentelemetry/protobufjs
don't appear under that branch, so top-level overrides are safe.

Verified: npm ci, npm audit (0 vulns), lint, build, validate, tsc --noEmit.

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>

* fix(compose): enforce values on required decryption attributes (#57) (#92)

Previously the policy editor silently dropped any Manage Access row whose
value was empty before storing the policy, so adding "Surname" or "Mobile
number" without typing a value produced an email-only ciphertext — the
decryption Yivi session never challenged for those attributes.

Now empty rows stay in state.policy, surface as a red-bordered input with
an inline hint, disable the Encrypt button, and show a top-level status
error. encryptAndPrepareSend also short-circuits with the same message as a
defensive backstop.

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>

* fix: sync From-address selector to encrypt flow (#90)

* fix: sync From-address selector to encrypt flow

Both the OnMessageSend handler and the compose taskpane read the sender
from Office.context.mailbox.userProfile.emailAddress, which is the
mailbox's default account and does not change when the user picks a
different From in the compose form. Yivi then asks for a signature on
the wrong identity.

Add getComposeFromAsync() in office-helpers, backed by item.from.getAsync
(Mailbox 1.7+; manifest already requires 1.8), with a fallback to the
userProfile address. Wire it into launchevent.ts and compose-view.ts.

read-view.ts keeps getSenderEmail() — it's identifying the viewing user
against the message sender, which is the userProfile semantic.

Closes #53

* fix: surface item.from.getAsync errors via stringifyError

Replaces the silent catch in getComposeFromAsync with a console.warn that
runs the unknown error through stringifyError, matching the repo
convention for Office.js callbacks where rejections may be plain objects.

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>

* feat(metrics): tag cryptify uploads with X-Cryptify-Source: outlook (#96)

Adds the X-Cryptify-Source: outlook header to clientHeaders() so
cryptify's per-channel upload metrics
(encryption4all/cryptify#102) classify this add-in deterministically.

cryptify's detect_channel checks the Origin header before falling back
to User-Agent substring matching. The add-in is served from
addin.*.postguard.eu, which matches cryptify's `contains("postguard.")`
rule and would otherwise shadow the User-Agent "outlook" check —
labeling Outlook uploads as `website` / `staging-website` instead of
`outlook`. Setting the explicit header here removes the ambiguity.

* chore: update dependencies (#98)

Patch bumps for the office-addin family:

- eslint-plugin-office-addins 4.0.8 → 4.0.9
- office-addin-cli 2.0.8 → 2.0.9
- office-addin-debugging 6.1.0 → 6.1.1
- office-addin-dev-certs 2.0.8 → 2.0.9
- office-addin-lint 3.0.8 → 3.0.9
- office-addin-manifest 2.1.4 → 2.1.5
- office-addin-prettier-config 2.0.3 → 2.0.4

Verified: npm ci, lint, prettier, tsc --noEmit, build, validate.

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>

* chore(deps): bump webpack 5.106.2 → 5.107.0 (#100)

Closes #99

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>

* chore(security): bump uuid, @e4a/pg-js, webpack (#102)

Add top-level `uuid: ^11.1.1` override to clear GHSA-w5hq-g745-h8pq
(uuid v3/v5/v6 missing buffer bounds check) across all transitive
paths (msal-node, teamsfx-core, office-addin-manifest,
office-addin-usage-data, sockjs, webpack-dev-server).

Bump @e4a/pg-js 1.8.0 → 1.10.0 and webpack 5.107.0 → 5.107.1.

`npm audit` reports 0 vulnerabilities; `npm ci`, lint, prettier,
tsc, build, and manifest validate all pass locally.

Closes #101

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>

* refactor: centralize duplicated constants and helpers (#104) (#107)

* refactor: extract shared header constants to src/lib/pg-headers.ts

HEADER_ENCRYPT_ON_SEND, HEADER_ENCRYPTED_RECIPIENTS, HEADER_POSTGUARD,
POSTGUARD_VERSION, and ENCRYPTION_STATUS_NOTIFICATION_KEY were
independently declared in launchevent.ts and compose-view.ts. The two
runtimes are separate WebViews exchanging messages by header — a name
drift between them silently breaks the handshake (the failure mode that
caused #103). Move all five into a single module and import from there.

Also drop the unused POSTGUARD_HEADER / POSTGUARD_HEADER_VALUE exports
from mime.ts (no callers; superseded by the same values under the names
callers already use).

* refactor: move guessContentType into src/lib/mime.ts

Identical 17-line function with the same extension→MIME map lived in
both launchevent.ts and compose-view.ts. mime.ts already exports related
MIME helpers; it's the natural home.

* refactor: extract keyForEmails helper to pg-headers.ts

Both launchevent.ts and compose-view.ts independently lowercase-sort-
join their recipient list to build the x-pg-encrypted-recipients header
value. The two implementations must agree exactly or the OnMessageSend
stale-recipients check rejects every send. Pull the canonical form into
a shared keyForEmails(emails: string[]) and have both callers route
through it.

* refactor: extract byId DOM helper to src/lib/dom.ts

The identical throwing byId<T extends HTMLElement> was duplicated in
compose-view.ts, read-view.ts, and settings-view.ts. Move to a shared
dom util. taskpane.ts has a separately-typed byId (returns null) used
only inside its own bootstrap; leave it alone.

* refactor: reuse office-helpers wrappers in launchevent

office-helpers.ts already had promise-wrapped versions of every
Office.js call launchevent.ts had re-rolled locally — getSubject,
setSubject, getBody, setBody, getRecipients, addBase64Attachment,
getAttachmentContentCompose, removeAttachment, setItemHeaders,
saveItem. Add an optional `item?: Office.MessageCompose` arg to each
so launchevent's narrowed item can flow through; taskpane callers omit
it and fall back to getItem().

The one behavioral wrinkle: launchevent's getRecipientsAsync swallowed
errors and returned []; office-helpers' getRecipients rejects. Preserve
the old semantics with .catch(() => []) at the call site — the
no-recipients block past committedToEncrypt is the right outcome there
regardless of why the read failed.

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>

* fix: dialog client-version header uses pg4ol and live package version (#105)

The yivi-dialog runtime built the X-PostGuard-Client-Version header
inline as `Outlook,1.0,pg4outlook,0.1.0`, missing both the PR #11 fix
that switched the client-id token to `pg4ol` and the X-Cryptify-Source
header that pkg-client's clientHeaders() adds. Three files also held a
local `const ADDIN_VERSION = "0.1.0"` that has never matched
package.json, so PKG / Cryptify dashboards could not distinguish
releases.

Wire process.env.ADDIN_VERSION through webpack DefinePlugin from
package.json (same mechanism already used for PKG_URL / CRYPTIFY_URL
/ ADDIN_PUBLIC_URL), export a single ADDIN_VERSION from
src/lib/pkg-client.ts, drop the three stale local constants, and route
the dialog through clientHeaders() like the taskpane already does.

Closes #103

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>

* fix(read-view): ensure decrypted body meets WCAG 2.2 AA contrast (#58) (#109)

The decrypted email body is rendered via `iframe.srcdoc`. Without an
explicit colour scheme the rendered document inherits the Outlook host
theme: in dark mode the host inverts the iframe's white background to a
dark blue while leaving the email's default black text untouched, which
produces unreadable black-on-blue text (well below AA's 4.5:1).

Extract `wrapHtml`/`escapeHtml` into a pure `src/lib/render-body.ts`
helper and pin the rendered body to a light colour scheme with an
explicit white background (#ffffff) and dark default text (#1a1a1a,
17.4:1 contrast). The base style is low-specificity so HTML emails that
set their own colours still win; we only guarantee a readable default.
Also set `color-scheme: light` on the `.pg-decrypted-body` iframe element
so the host does not invert the element box.

Add unit tests (Node's built-in test runner, no new dependencies) for the
render helper, plus an `npm test` script.

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat: show decrypted email in a popup dialog instead of the taskpane (#110)

* feat: show decrypted email in a popup dialog instead of the taskpane

The cramped Outlook taskpane is a poor fit for reading a full decrypted
message. After the in-memory decrypt + Yivi flow completes, marshal the
plaintext into a dedicated, roomier popup window via the Office.js dialog
API (displayDialogAsync + messageChild/messageParent), rendering it in a
normal-email-like layout (header fields, badges, body, attachments).

- New read-dialog page (HTML + TS entry, wired into webpack) reuses the
  shared wrapHtml() body renderer and the same sandboxed (no-scripts)
  iframe + the existing dialog-chunk protocol. No new sanitizer/styling.
- Decrypted content is passed in memory only (chunked messageChild); it
  is never written to disk or anywhere persistent. Closing the dialog
  drops it; the taskpane keeps it on in-memory state so the user can
  re-open the window without decrypting again.
- If the dialog cannot be opened (popup blocked, host limitation), fall
  back to the existing inline taskpane rendering so the message is still
  readable.

Closes #72

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(read-dialog): announce decrypted-message loading status to screen readers

The dialog's #…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant