feat(download): resume via Range header on transient stream failures - #52
Conversation
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.
|
/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 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! |
There was a problem hiding this comment.
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:
!response.ok→NetworkErrorwith the response status (so 5xx routes through toclassifyCryptifyError === 'retry'and 4xx through to'fail', which is correct).response.status !== 206(e.g. a200 OKfrom a Range-stripping proxy) →NetworkError. Sincestatusis a 2xx,classifyCryptifyErrorwill see it as< 500and classify asfail— exactly the silent-rewind protection §4 asked for.parseContentRangeStart(contentRange) !== offset→ alsoNetworkError, 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-378documents this honestly. If a caller cancels the consumer-facingReadableStreamwithout aborting viasignal, 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'ssignalis the primary abort mechanism). Could be tightened later by maintaining an internalAbortControllercombined with the caller's signal — out of scope here.cleanup()is called both on success and incatch— that's fine becauseclearTimeouton 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.
|
Thanks @dobby-coder for the thorough review and the approval. Acknowledging the three non-blocking observations:
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. |
|
🎉 This PR is included in version 1.5.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
|
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. |
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 all dobby checks green — ready for your review and merge when you've run the manual smoke tests called out in the test plan. |
…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 #…
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
ReadableStream.withRetry'sPromise<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'sstart()and turns each underlying GET into oneattempt. Same backoff helpers — different driver.tee()— bytes flow through the source's ownread()→enqueue()path, soreceived += value.byteLengthat the single forwarding site.206+Content-Rangecheck on resume. Range is a request hint, not a contract. Caching proxies and misconfigured CDNs can ignore it and return200 OKwith the full body from byte 0; splicing that onto a consumer at offset N corrupts the file silently. Resume asserts bothstatus === 206and thatContent-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).classifyCryptifyErrorfor stream-level errors — confirmed by test rather than pre-emptively changed.Public-API change
downloadFileWithRetrynow returns theReadableStreamsynchronously (noPromisewrapper). Stream-level errors propagate to the consumer's first failingread()call rather than via the function's promise. The single existing call site insrc/crypto/decrypt.ts:45works unchanged becausePromise.allaccepts 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 firstread()call, which is the right place — they're stream-level events, not function-result events.Test plan
received === 0Content-Rangestart → fail-not-retryNetworkError(404)AbortError, no retrynpx tsc --noEmitcleannpx vitest run— 72 passed (was 66, added 6)ncbetween Cryptify and the browser, kill the connection mid-download → SDK resumes when reconnected, file downloads complete and decrypts cleanlyStreamUnsealer.new(stream, vk)consumes an internally-spliced stream without choking — the only existing consumer atsrc/crypto/decrypt.ts:62Refs
RetryOptions,classifyCryptifyError, jitter helpers reused)Out of scope (tracked separately)
enqueue) — separate axis fromdownloadTimeoutMs, deserves its own design.