Skip to content

feat(download): resume via Range header on transient stream failures - #52

Merged
rubenhensen merged 1 commit into
mainfrom
feat/resumable-downloads
May 7, 2026
Merged

feat(download): resume via Range header on transient stream failures#52
rubenhensen merged 1 commit into
mainfrom
feat/resumable-downloads

Conversation

@rubenhensen

Copy link
Copy Markdown
Contributor

Implements the resumable-download design from #48 — incorporating the review feedback @dobby-coder posted on the issue, which pushed back hard (and correctly) on the original "wrap withRetry" sketch in the issue body.

Shape

  • Source-owned retry loop inside the outer ReadableStream. withRetry's Promise<T> shape resolves before the stream is read, leaving mid-stream errors with no way to re-enter the loop. The new driver lives in the stream's start() and turns each underlying GET into one attempt. Same backoff helpers — different driver.
  • No tee() — bytes flow through the source's own read()enqueue() path, so received += value.byteLength at the single forwarding site.
  • Strict 206 + Content-Range check on resume. Range is a request hint, not a contract. Caching proxies and misconfigured CDNs can ignore it and return 200 OK with the full body from byte 0; splicing that onto a consumer at offset N corrupts the file silently. Resume asserts both status === 206 and that Content-Range's start byte equals the requested offset — either mismatch is classified as fail-not-retry (the upstream isn't going to start honouring Range on the next try).
  • Attempt counter shared across resumes. A flapping connection that delivers some bytes per attempt now exhausts the budget instead of looping forever.
  • Reuses existing classifyCryptifyError for stream-level errors — confirmed by test rather than pre-emptively changed.
  • Stall-watchdog explicitly out of scope (separate axis, would balloon the change).

Public-API change

downloadFileWithRetry now returns the ReadableStream synchronously (no Promise wrapper). Stream-level errors propagate to the consumer's first failing read() call rather than via the function's promise. The single existing call site in src/crypto/decrypt.ts:45 works unchanged because Promise.all accepts non-thenable values.

This is a behavioural shift only for callers that did await downloadFileWithRetry(...) and caught a synchronous rejection for non-stream-level errors (e.g. 404). With the new shape, those errors surface on the consumer's first read() call, which is the right place — they're stream-level events, not function-result events.

Test plan

  • Clean download (1 attempt) — bytes match, no Range header on first GET
  • 502 → 200 retry — fresh GET (no Range) since received === 0
  • Mid-stream failure → 206 resume from offset — bytes contiguous
  • 200 on resume → fail-not-retry (silent-rewind protection)
  • 206 with mismatched Content-Range start → fail-not-retry
  • 404 on first read → no retry, propagates as NetworkError(404)
  • Flapping connection (1 byte per attempt × 3) — exhausts budget, errors after 3
  • Caller-driven abort → AbortError, no retry
  • npx tsc --noEmit clean
  • npx vitest run — 72 passed (was 66, added 6)
  • Manual: against a local cryptify, nc between Cryptify and the browser, kill the connection mid-download → SDK resumes when reconnected, file downloads complete and decrypts cleanly
  • Manual: confirm StreamUnsealer.new(stream, vk) consumes an internally-spliced stream without choking — the only existing consumer at src/crypto/decrypt.ts:62

Refs

Out of scope (tracked separately)

  • Cross-refresh download resume (persisting partial blob in IndexedDB so a refresh picks up where the previous session left off). Most downloads are short enough that in-session retry covers the common case.
  • Idle-stall detection (stall watchdog timer that resets on each enqueue) — separate axis from downloadTimeoutMs, deserves its own design.

Cryptify's `FileServer` already supports HTTP Range, so a stream-level
failure mid-download (network drop, idle timeout) can now resume from
the byte offset reached rather than restarting from zero. The consumer
sees a single contiguous stream regardless of how many internal retries
happened.

Implements the design @dobby-coder proposed on #48,
which pushes back on the original "wrap withRetry" sketch in the issue
body. Key shape changes from that initial sketch:

- **Source-owned retry loop inside the outer `ReadableStream`.**
  `withRetry`'s `Promise<T>` shape resolves before the stream is read,
  leaving mid-stream errors with no way to re-enter the loop. The new
  driver lives in the stream's `start()` and turns each underlying GET
  into one `attempt`. Same backoff helpers (`delayWithFullJitter`,
  `sleep`, `classifyCryptifyError`) — different driver, no parallel
  implementation.
- **No `tee()`** for the byte counter. Bytes flow through the source's
  own `read()` → `enqueue()` path, so `received += value.byteLength`
  at the single forwarding site. One source of truth for the resume
  offset.
- **Strict `206` + `Content-Range` check on resume.** Range is a
  request hint — caching proxies and misconfigured CDNs can ignore it
  and return `200 OK` with the full body from byte 0. Splicing that
  onto a consumer at offset N corrupts the file silently. Resume now
  asserts `status === 206` and that `Content-Range`'s start byte
  equals the requested offset; either mismatch is classified as
  fail-not-retry (the upstream isn't going to start honouring Range
  on the next try).
- **Attempt counter shared across resumes.** A flapping connection
  that delivers some bytes per attempt now exhausts the budget instead
  of looping forever. Test pins this — one byte per attempt, three
  attempts, then errors.

Public-API note: `downloadFileWithRetry` now returns the
`ReadableStream` synchronously (no `Promise` wrapper). Stream-level
errors propagate to the consumer's first failing `read()` call rather
than via the function's promise. The single existing call site in
`src/crypto/decrypt.ts:45` works unchanged because `Promise.all`
accepts non-thenable values.

Tests:
- Clean download (1 attempt) — bytes match, no Range header on first GET.
- 502 → 200 retry — fresh GET (no Range) since `received === 0`.
- Mid-stream failure → 206 resume from offset — bytes contiguous.
- 200 on resume → fail-not-retry (silent-rewind protection).
- 206 with mismatched Content-Range start → fail-not-retry.
- 404 on first read → no retry, propagates as NetworkError(404).
- Flapping (1 byte per attempt × 3) — exhausts budget, errors after 3.
- Caller-driven abort → AbortError, no retry.

Helper test fix worth noting: `controller.error()` empties any queued
chunks, so the test-only `streamThenError` is now pull-based to
guarantee chunks are delivered before the error fires.

72 tests pass; type-check clean.

Closes #48.
Refs #117, #136, #145, #47.
@rubenhensen

Copy link
Copy Markdown
Contributor Author

/dobby please review this implementation against your design feedback on #48. Key things to verify: the source-owned retry loop replaces withRetry on the download path, the strict 206 + Content-Range checks (incl. start-offset match), the shared attempt counter across resumes, and that classifyCryptifyError works as-is for stream-level TypeErrors. The public-API shift to a synchronous ReadableStream return is also worth a sanity check at the decrypt.ts:45 call site.

@dobby-coder

dobby-coder Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

Dobby is happy to help! Dobby will pull #48 to refresh on the design feedback, then check the source-owned retry loop, the strict 206 + Content-Range guards, the shared attempt counter across resumes, the classifyCryptifyError reuse, and the synchronous ReadableStream return at decrypt.ts:45. Dobby will run typecheck and tests too. Findings coming as a PR review!

@dobby-coder dobby-coder Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review against design feedback on #48

Verified the implementation against each point of the design feedback. Built (npm run build), typechecked (tsc --noEmit), and ran tests (vitest run — 72 passed, including the 8 new download-retry cases) on the branch.

✅ 1. Source-owned retry loop replaces withRetry on the download path

downloadFileWithRetry (src/api/cryptify.ts:305-380) is now a synchronous factory that returns a ReadableStream whose start() owns the while(true) loop. Each underlying GET is one attempt. withRetry is no longer in the import-actually-used list for this path — confirmed by reading the file. The chunk-PUT path (storeChunkWithRetry) still uses withRetry, which is right: that one fits the Promise<T> shape.

This matches the §1 sketch from the design feedback essentially line-for-line, including reuse of classifyCryptifyError, delayWithFullJitter, sleep, and withTimeout.

✅ 2. Strict 206 + Content-Range checks (incl. start-offset match)

downloadRange (cryptify.ts:238-277) does the right thing in the right order:

  1. !response.okNetworkError with the response status (so 5xx routes through to classifyCryptifyError === 'retry' and 4xx through to 'fail', which is correct).
  2. response.status !== 206 (e.g. a 200 OK from a Range-stripping proxy) → NetworkError. Since status is a 2xx, classifyCryptifyError will see it as < 500 and classify as fail — exactly the silent-rewind protection §4 asked for.
  3. parseContentRangeStart(contentRange) !== offset → also NetworkError, also fail-not-retry.

parseContentRangeStart (cryptify.ts:283-289) is tight: ^\s*bytes\s+(\d+)- — anything not matching the standard form returns null, which then mismatches offset and trips the assertion. Conservative and correct.

Both branches are covered by tests (tests/api.test.ts:684-734), and I confirmed the 200 → fails test exercises the offset path (after 2 bytes have been delivered) — corruption would be silent without this guard.

✅ 3. Shared attempt counter across resumes

let attempt = 0 is declared at cryptify.ts:312 — outside the while loop — and attempt += 1 runs unconditionally at the top of every iteration (cryptify.ts:320). A flapping connection that delivers one byte per attempt now exhausts the budget. The "shares the attempt counter across resumes" test (api.test.ts:753-827) constructs exactly that adversarial server (one byte per attempt → error) and asserts exactly 3 calls before the consumer's read() rejects. Solid.

✅ 4. classifyCryptifyError works as-is for stream-level TypeErrors

Confirmed by reading src/util/retry.ts:68-79 — the existing classifier already returns 'retry' for TypeError. The test at api.test.ts:654-682 uses new TypeError('network reset') thrown from inside the stream's pull, and that error successfully drives a Range-based resume. No classifier changes needed, as the design feedback predicted (§3).

One small empirical note: in real fetch, mid-stream reads can also surface as DOMException with name === 'TypeError' in some browsers' historical implementations. The classifier's instanceof TypeError may not catch those. Cryptify SDK targets are modern enough that the standard TypeError branch covers the live cases, but it's worth being aware of if a future browser reverts. Not a blocker.

✅ 5. Public-API shift to synchronous ReadableStream return — decrypt.ts:45 sanity check

decrypt.ts:43-46:

const [vk, fileStream] = await Promise.all([
  fetchVerificationKey(pkgUrl, headers),
  downloadFileWithRetry(cryptifyUrl, uuid, retry, signal),
]);

Promise.all accepts non-thenable values and resolves them as-is, so this works unchanged. fileStream is still typed as ReadableStream<Uint8Array>. The PR body's claim that "404 now surfaces on the consumer's first read() rather than the function's promise" is the right framing — and the errors stream on first read with 404 (no retry) test (api.test.ts:736-751) pins that behaviour. StreamUnsealer.new(readable, vk) will see the error via the consumer's read() and reject from within WASM, which is consistent with how it already handles other stream errors.

The only thing this changes for the existing call site is where the error surfaces (consumer vs. caller of downloadFileWithRetry), not whether errors propagate. The single existing call site is fine.


Minor observations (non-blocking)

  • cancel() is a no-op for the in-flight fetch. cryptify.ts:370-378 documents this honestly. If a caller cancels the consumer-facing ReadableStream without aborting via signal, the underlying GET still runs to completion (its body is then dropped). This is a small resource leak per cancellation but matches what the design feedback implied (caller's signal is the primary abort mechanism). Could be tightened later by maintaining an internal AbortController combined with the caller's signal — out of scope here.
  • cleanup() is called both on success and in catch — that's fine because clearTimeout on a fired/cleared timer is a no-op. Worth a one-line comment if the file ever sees a refactor, but not needed now.
  • Stall watchdog still out of scope — the design feedback (§6) explicitly punted this, and the PR body confirms. Good discipline; tracking it separately is the right call.

Verdict

This implementation lands cleanly against the §1–§7 feedback from #48. No changes needed. Recommend marking ready and merging once any remaining manual smoke tests (StreamUnsealer over a spliced stream, killed-mid-download against local cryptify) check out.

@rubenhensen

Copy link
Copy Markdown
Contributor Author

Thanks @dobby-coder for the thorough review and the approval. Acknowledging the three non-blocking observations:

  • cancel() no-op for in-flight fetch — agreed, deliberately out of scope. The caller-driven signal is the documented abort path; tightening this with an internal AbortController combined with the caller's signal is a cleanup worth its own PR.
  • cleanup() called both on success and in catchclearTimeout is idempotent so this is intentional. Won't add a comment unless the file sees a wider refactor.
  • DOMException masquerading as TypeError in legacy browsers — noting it. The classifier's instanceof TypeError covers what fetch actually throws on modern browsers; if a future regression surfaces, we'll widen the branch.

Stall watchdog stays out — separate axis, will track when there's a concrete need.

No code changes, but I wanted to leave the trail visible on the PR for the next reviewer.

@rubenhensen
rubenhensen merged commit 95af57c into main May 7, 2026
2 checks passed
@rubenhensen
rubenhensen deleted the feat/resumable-downloads branch May 7, 2026 19:00
@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 1.5.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

@rubenhensen

Copy link
Copy Markdown
Contributor Author

Thanks @dobby-coder for the approval — no changes needed on this PR. The three non-blocking observations (cancel() no-op for in-flight fetch, cleanup() called on both paths, stall watchdog out of scope) match my reading. Stall detection will be its own issue when there's a concrete need.

rubenhensen added a commit to encryption4all/postguard-website that referenced this pull request May 7, 2026
Picks up encryption4all/postguard-js#52 (resumable downloads via
Range header on transient stream failures). Public-API change worth
flagging for reviewers: `downloadFileWithRetry` now returns the
ReadableStream synchronously rather than wrapped in a Promise — but
the only consumer in this repo (via `Opened.decrypt` / `inspectSealed`
in pg-js itself) is unaffected, since `Promise.all` accepts non-thenable
values as-is. No website-side code change needed.

`npm run check`, `lint`, and `build` clean.
@rubenhensen

Copy link
Copy Markdown
Contributor Author

@rubenhensen all dobby checks green — ready for your review and merge when you've run the manual smoke tests called out in the test plan.

rubenhensen added a commit 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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(download): resume downloads via Range on transient failure

1 participant