From 7a11ccebbc1943aba73ac556ff04d3eb1a1064fd Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Wed, 3 Jun 2026 11:50:35 +0200 Subject: [PATCH 1/5] chore: bump @e4a/pg-js to ^2.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the upload-validator removal from encryption4all/postguard-js#87 (released as 2.0.1 via the follow-up fix: commit in #88), which unblocks the staging email-preview modal flow — passing `onUploadInit` no longer throws `TypeError: unknown option "onUploadInit"`. --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 09bba54..aa909b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.7.0", "dependencies": { "@deltablot/dropzone": "^7.4.3", - "@e4a/pg-js": "^2.0.0", + "@e4a/pg-js": "^2.0.1", "@iconify/svelte": "^5.2.1", "@privacybydesign/yivi-css": "^1.0.1", "country-flag-icons": "^1.6.17", @@ -59,9 +59,9 @@ } }, "node_modules/@e4a/pg-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@e4a/pg-js/-/pg-js-2.0.0.tgz", - "integrity": "sha512-kPkaVP+wVnwtoHuugpYDEgXWvhNVjnMcAQRMleLC/8cSglG6T8UmSxeWps5E32/onHcqkxJNY2ygwijCjT/elg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@e4a/pg-js/-/pg-js-2.0.1.tgz", + "integrity": "sha512-bqlXc+RXMPAY71KFu4cuHXACD3RNAfZswuDYXWEvH8Lrphpf834BNTfoWGvrWJpebz4wbccAgAMMaMtSuzwSXA==", "license": "MIT", "dependencies": { "@e4a/pg-wasm": "^0.6.1", diff --git a/package.json b/package.json index b3636e0..ffaa19a 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ }, "dependencies": { "@deltablot/dropzone": "^7.4.3", - "@e4a/pg-js": "^2.0.0", + "@e4a/pg-js": "^2.0.1", "@iconify/svelte": "^5.2.1", "@privacybydesign/yivi-css": "^1.0.1", "country-flag-icons": "^1.6.17", From d2cba06d9f9a4eeab3d5f2a1aaab2554eef9bfcf Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Wed, 3 Jun 2026 11:59:43 +0200 Subject: [PATCH 2/5] fix(i18n): move emailPreview keys out of encryptPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staging email-preview modal calls \`\$_('filesharing.emailPreview.*')\`, but the keys had been added one level deeper at \`filesharing.encryptPanel.emailPreview.*\` — the modal therefore rendered the raw key paths instead of the translated strings. The modal isn't part of the encrypt panel (it pops after a successful upload), so move the block up to live as a sibling of \`encryptPanel\` under \`filesharing\` in both en.json and nl.json. Path now matches the call sites, no component changes needed. --- src/lib/locales/en.json | 30 +++++++++++++++--------------- src/lib/locales/nl.json | 30 +++++++++++++++--------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index f33920d..49c146b 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -229,21 +229,6 @@ "addRecipient": "recipient", "addRecipientButton": "Add another recipient", "emailSenderConfirm": "Send me a confirmation", - "emailPreview": { - "title": "Notification email preview", - "subtitle": "Staging only — cryptify does not actually send notification emails on staging. This is what each recipient would have received.", - "loading": "Loading preview…", - "error": "Could not load preview.", - "empty": "No recipients to preview.", - "close": "Close", - "from": "From", - "replyTo": "Reply to", - "subject": "Subject", - "to": "To", - "confirmationTag": "sender copy", - "iframeTitle": "Notification email body", - "reopen": "Show email preview" - }, "timeremaining": { "estimate": "Estimating...", "unknown": "More then one day left.", @@ -271,6 +256,21 @@ "serverBlocked": "The server rejected this upload because you have used {used} GB of your {limit} GB 2-week limit. Your limit resets on {resets}." } }, + "emailPreview": { + "title": "Notification email preview", + "subtitle": "Staging only — cryptify does not actually send notification emails on staging. This is what each recipient would have received.", + "loading": "Loading preview…", + "error": "Could not load preview.", + "empty": "No recipients to preview.", + "close": "Close", + "from": "From", + "replyTo": "Reply to", + "subject": "Subject", + "to": "To", + "confirmationTag": "sender copy", + "iframeTitle": "Notification email body", + "reopen": "Show email preview" + }, "attributes": { "pbdf.sidn-pbdf.mobilenumber.mobilenumber": "Mobile phone number", "pbdf.sidn-pbdf.mobilenumber.mobilenumber.placeholder": "612345678", diff --git a/src/lib/locales/nl.json b/src/lib/locales/nl.json index da80cfe..7219b35 100644 --- a/src/lib/locales/nl.json +++ b/src/lib/locales/nl.json @@ -228,21 +228,6 @@ "addRecipient": "ontvanger", "addRecipientButton": "Voeg nog een ontvanger toe", "emailSenderConfirm": "Stuur mij een bevestiging", - "emailPreview": { - "title": "Voorbeeld notificatie-e-mail", - "subtitle": "Alleen staging — cryptify verstuurt op staging geen e-mails. Dit is wat elke ontvanger zou hebben gezien.", - "loading": "Voorbeeld laden…", - "error": "Voorbeeld kon niet worden geladen.", - "empty": "Geen ontvangers om weer te geven.", - "close": "Sluiten", - "from": "Van", - "replyTo": "Antwoord aan", - "subject": "Onderwerp", - "to": "Aan", - "confirmationTag": "bevestigingskopie", - "iframeTitle": "Inhoud van de notificatie-e-mail", - "reopen": "Toon e-mailvoorbeeld" - }, "timeremaining": { "estimate": "Estimating...", "unknown": "Nog meer dan een dag", @@ -270,6 +255,21 @@ "serverBlocked": "De server weigerde deze upload omdat je {used} GB van je {limit} GB-limiet (2 weken) hebt gebruikt. De limiet reset op {resets}." } }, + "emailPreview": { + "title": "Voorbeeld notificatie-e-mail", + "subtitle": "Alleen staging — cryptify verstuurt op staging geen e-mails. Dit is wat elke ontvanger zou hebben gezien.", + "loading": "Voorbeeld laden…", + "error": "Voorbeeld kon niet worden geladen.", + "empty": "Geen ontvangers om weer te geven.", + "close": "Sluiten", + "from": "Van", + "replyTo": "Antwoord aan", + "subject": "Onderwerp", + "to": "Aan", + "confirmationTag": "bevestigingskopie", + "iframeTitle": "Inhoud van de notificatie-e-mail", + "reopen": "Toon e-mailvoorbeeld" + }, "attributes": { "pbdf.sidn-pbdf.mobilenumber.mobilenumber": "Mobiel telefoonnummer", "pbdf.sidn-pbdf.mobilenumber.mobilenumber.placeholder": "612345678", From b7819d0d8c80bb5dbb1de3429ed2ee97a78488d6 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Wed, 3 Jun 2026 12:01:12 +0200 Subject: [PATCH 3/5] fix(emailPreview): open in-body links in a new tab via injected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the "Download your files" link (or the link-text link) inside the preview iframe tried to navigate the iframe itself. The sandbox deliberately omits `allow-top-navigation`, so the navigation was blocked and the iframe blanked out — leaving the modal open with an empty body. Splice `` into the rendered email's `` (or prepend it if the template ever stops having one) before handing the HTML to `srcdoc`. Anchors then open in a new top-level tab, which the existing `allow-popups` / `allow-popups-to-escape-sandbox` flags already permit. No cryptify change needed — the real email never needed `target="_blank"`, only the preview surface does. --- .../filesharing/EmailPreviewModal.svelte | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/lib/components/filesharing/EmailPreviewModal.svelte b/src/lib/components/filesharing/EmailPreviewModal.svelte index 178ff30..e5d47aa 100644 --- a/src/lib/components/filesharing/EmailPreviewModal.svelte +++ b/src/lib/components/filesharing/EmailPreviewModal.svelte @@ -83,6 +83,24 @@ function handleKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose() } + + /** Inject `` into the rendered email HTML so + * anchor clicks open in a new top-level tab instead of trying to + * navigate the sandboxed iframe (which has no `allow-top-navigation` + * and would otherwise blank the body). The `allow-popups` / + * `allow-popups-to-escape-sandbox` flags already permit the new + * tab. We try to splice into an existing ``; if cryptify ever + * changes the template shape, the prepended fallback still works + * because browsers tolerate a `` before ``. */ + function withBaseTarget(html: string): string { + const tag = '' + const headIdx = html.search(/]*>/i) + if (headIdx >= 0) { + const end = html.indexOf('>', headIdx) + 1 + return html.slice(0, end) + tag + html.slice(end) + } + return tag + html + } @@ -185,7 +203,7 @@ {/if} From 5b811b244ba6d201691c6174342e23c0c29f0410 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Wed, 3 Jun 2026 12:02:58 +0200 Subject: [PATCH 4/5] feat(emailPreview): HTML / plain-text body switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cryptify already returns both MIME alternatives on `/staging/preview/` (`html` and `text` on each `RenderedEmail`), but the modal only showed the HTML branch. Add a small tab strip above the body that toggles between the two — the plain-text view renders as a monospace `
` (no iframe, no `` injection
needed) so what you see is exactly what a text-only client would
receive.

Defaults to HTML since that's what most clients pick. Tab state is
per-modal-open and resets between recipients via the same active-idx
binding (intentional — switching recipients usually means starting
fresh).
---
 .../filesharing/EmailPreviewModal.svelte      | 88 +++++++++++++++++--
 src/lib/locales/en.json                       |  4 +-
 src/lib/locales/nl.json                       |  4 +-
 3 files changed, 88 insertions(+), 8 deletions(-)

diff --git a/src/lib/components/filesharing/EmailPreviewModal.svelte b/src/lib/components/filesharing/EmailPreviewModal.svelte
index e5d47aa..58ef442 100644
--- a/src/lib/components/filesharing/EmailPreviewModal.svelte
+++ b/src/lib/components/filesharing/EmailPreviewModal.svelte
@@ -44,6 +44,10 @@
     let data = $state(null)
     /** Index into the flat list of [...recipients, confirmation?] */
     let activeIdx = $state(0)
+    /** Which MIME alternative to show in the body. Mail clients pick
+     *  HTML when available; the plain-text branch is what text-only
+     *  clients (or accessibility tools) actually render. */
+    let bodyView: 'html' | 'text' = $state('html')
 
     let allEmails = $derived.by(() => {
         if (!data) return []
@@ -200,12 +204,37 @@
                 
             
 
-            
+            
+ + +
+ + {#if bodyView === 'html'} + + {:else} + + {/if} {/if} @@ -331,6 +360,39 @@ font-size: var(--pg-font-size-sm); } + .body-tabs { + display: flex; + gap: 0.25rem; + padding: 0.5rem 1.5rem 0; + background: var(--pg-soft-background); + border-bottom: 1px solid var(--pg-strong-background); + } + + .body-tab { + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + padding: 0.4rem 0.8rem; + font-family: var(--pg-font-family); + font-size: var(--pg-font-size-sm); + color: var(--pg-text-secondary); + cursor: pointer; + } + + .body-tab:hover { + color: var(--pg-text); + } + + .body-tab.active { + color: var(--pg-text); + border-bottom-color: var(--pg-primary-contrast); + } + + .body-tab:focus-visible { + outline: 2px solid var(--pg-text); + outline-offset: 2px; + } + .email-frame { width: 100%; height: min(70vh, 720px); @@ -338,6 +400,20 @@ background: var(--pg-soft-background); } + .email-text { + margin: 0; + padding: 1rem 1.5rem; + max-height: min(70vh, 720px); + overflow: auto; + background: var(--pg-soft-background); + color: var(--pg-text); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: var(--pg-font-size-sm); + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + } + .state { padding: 2rem 1.5rem; text-align: center; diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index 49c146b..cd9e90e 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -269,7 +269,9 @@ "to": "To", "confirmationTag": "sender copy", "iframeTitle": "Notification email body", - "reopen": "Show email preview" + "reopen": "Show email preview", + "viewHtml": "HTML", + "viewText": "Plain text" }, "attributes": { "pbdf.sidn-pbdf.mobilenumber.mobilenumber": "Mobile phone number", diff --git a/src/lib/locales/nl.json b/src/lib/locales/nl.json index 7219b35..9a585f0 100644 --- a/src/lib/locales/nl.json +++ b/src/lib/locales/nl.json @@ -268,7 +268,9 @@ "to": "Aan", "confirmationTag": "bevestigingskopie", "iframeTitle": "Inhoud van de notificatie-e-mail", - "reopen": "Toon e-mailvoorbeeld" + "reopen": "Toon e-mailvoorbeeld", + "viewHtml": "HTML", + "viewText": "Platte tekst" }, "attributes": { "pbdf.sidn-pbdf.mobilenumber.mobilenumber": "Mobiel telefoonnummer", From cd3d040888fa8dc22002d2cd4959f78394a6034d Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Wed, 3 Jun 2026 16:25:05 +0200 Subject: [PATCH 5/5] chore(dev-stack): dedupe FileInput; bump cryptify + pkg submodules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `FileInput.svelte`: drop duplicate adds in the dropzone. A file already in the list (matched by name + size + lastModified) is silently removed from the dropzone instead of getting a second entry — same behaviour as Gmail's attach UI. - `cryptify` → v0.1.27-9-g63066a1 (picks up the staging preview endpoint that the email-preview modal depends on, plus the `build.rs` Cargo.lock-driven `PG_CORE_VERSION` change). - `postguard` (pg-pkg) → pg-ffi-v0.1.2-9-gcc47b60 (accepts Yivi condiscon in IrmaAuthRequest, which pg-js 2.0 emits for the optional name disjunction). - `docker-compose.yml`: mount `cryptify/build.rs` and `cryptify/Cargo.lock` into the dev container; the bumped cryptify needs both at compile time to set the `X-PostGuard` mail header. --- cryptify | 2 +- docker-compose.yml | 6 ++++++ postguard | 2 +- .../components/filesharing/inputs/FileInput.svelte | 11 +++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/cryptify b/cryptify index 4c30e53..63066a1 160000 --- a/cryptify +++ b/cryptify @@ -1 +1 @@ -Subproject commit 4c30e539a04be1dc08cc1704de35f1ad4320c5af +Subproject commit 63066a1ea0f0a96fb684ff7f7b08537e27db8385 diff --git a/docker-compose.yml b/docker-compose.yml index 4f72948..391921b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,12 @@ services: - ./cryptify/conf/config.dev.toml:/app/config.toml:ro - ./cryptify/src:/app/src - ./cryptify/templates:/app/templates + # build.rs feeds `PG_CORE_VERSION` (used by the X-PostGuard + # mail header) from Cargo.lock at compile time. Both files + # need to be visible inside the container, or cargo bails + # on `env!("PG_CORE_VERSION")`. + - ./cryptify/build.rs:/app/build.rs:ro + - ./cryptify/Cargo.lock:/app/Cargo.lock:ro - cryptify-target:/app/target environment: - RUST_LOG=info diff --git a/postguard b/postguard index fff5796..cc47b60 160000 --- a/postguard +++ b/postguard @@ -1 +1 @@ -Subproject commit fff5796afb31ad0518a5396fdc362d83892f6e23 +Subproject commit cc47b608cbc9f636d6aa9fd07a79d161501f6a1f diff --git a/src/lib/components/filesharing/inputs/FileInput.svelte b/src/lib/components/filesharing/inputs/FileInput.svelte index 33098ed..7fce24d 100644 --- a/src/lib/components/filesharing/inputs/FileInput.svelte +++ b/src/lib/components/filesharing/inputs/FileInput.svelte @@ -87,6 +87,17 @@ }) myDropzone.on('addedfile', (file) => { + const isDuplicate = files.some( + (f) => + f.name === file.name && + f.size === file.size && + f.lastModified === file.lastModified + ) + if (isDuplicate) { + myDropzone!.removeFile(file) + return + } + files = files.concat([file]) percentages = percentages.concat([0]) done = done.concat([false])