Skip to content

Commit d52ee5b

Browse files
authored
feat(emailPreview): i18n fix, open links in new tab, HTML/text switcher (#246)
* chore: bump @e4a/pg-js to ^2.0.1 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"`. * fix(i18n): move emailPreview keys out of encryptPanel 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. * fix(emailPreview): open in-body links in a new tab via injected <base> 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 `<base target="_blank" rel="noopener">` into the rendered email's `<head>` (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. * feat(emailPreview): HTML / plain-text body switcher Cryptify already returns both MIME alternatives on `/staging/preview/<uuid>` (`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 `<pre>` (no iframe, no `<base>` 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). * chore(dev-stack): dedupe FileInput; bump cryptify + pkg submodules - `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.
1 parent c0d571b commit d52ee5b

7 files changed

Lines changed: 153 additions & 38 deletions

File tree

docker-compose.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ services:
1818
- ./cryptify/conf/config.dev.toml:/app/config.toml:ro
1919
- ./cryptify/src:/app/src
2020
- ./cryptify/templates:/app/templates
21+
# build.rs feeds `PG_CORE_VERSION` (used by the X-PostGuard
22+
# mail header) from Cargo.lock at compile time. Both files
23+
# need to be visible inside the container, or cargo bails
24+
# on `env!("PG_CORE_VERSION")`.
25+
- ./cryptify/build.rs:/app/build.rs:ro
26+
- ./cryptify/Cargo.lock:/app/Cargo.lock:ro
2127
- cryptify-target:/app/target
2228
environment:
2329
- RUST_LOG=info

src/lib/components/filesharing/EmailPreviewModal.svelte

Lines changed: 100 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@
4444
let data = $state<PreviewResponse | null>(null)
4545
/** Index into the flat list of [...recipients, confirmation?] */
4646
let activeIdx = $state(0)
47+
/** Which MIME alternative to show in the body. Mail clients pick
48+
* HTML when available; the plain-text branch is what text-only
49+
* clients (or accessibility tools) actually render. */
50+
let bodyView: 'html' | 'text' = $state('html')
4751
4852
let allEmails = $derived.by<RenderedEmail[]>(() => {
4953
if (!data) return []
@@ -83,6 +87,24 @@
8387
function handleKey(e: KeyboardEvent) {
8488
if (e.key === 'Escape') onClose()
8589
}
90+
91+
/** Inject `<base target="_blank">` into the rendered email HTML so
92+
* anchor clicks open in a new top-level tab instead of trying to
93+
* navigate the sandboxed iframe (which has no `allow-top-navigation`
94+
* and would otherwise blank the body). The `allow-popups` /
95+
* `allow-popups-to-escape-sandbox` flags already permit the new
96+
* tab. We try to splice into an existing `<head>`; if cryptify ever
97+
* changes the template shape, the prepended fallback still works
98+
* because browsers tolerate a `<base>` before `<html>`. */
99+
function withBaseTarget(html: string): string {
100+
const tag = '<base target="_blank" rel="noopener">'
101+
const headIdx = html.search(/<head[^>]*>/i)
102+
if (headIdx >= 0) {
103+
const end = html.indexOf('>', headIdx) + 1
104+
return html.slice(0, end) + tag + html.slice(end)
105+
}
106+
return tag + html
107+
}
86108
</script>
87109

88110
<svelte:window onkeydown={handleKey} />
@@ -182,12 +204,37 @@
182204
</div>
183205
</section>
184206

185-
<iframe
186-
class="email-frame"
187-
title={$_('filesharing.emailPreview.iframeTitle')}
188-
srcdoc={active.html}
189-
sandbox="allow-popups allow-popups-to-escape-sandbox"
190-
></iframe>
207+
<div class="body-tabs" role="tablist">
208+
<button
209+
type="button"
210+
role="tab"
211+
aria-selected={bodyView === 'html'}
212+
class="body-tab"
213+
class:active={bodyView === 'html'}
214+
onclick={() => (bodyView = 'html')}
215+
>{$_('filesharing.emailPreview.viewHtml')}</button
216+
>
217+
<button
218+
type="button"
219+
role="tab"
220+
aria-selected={bodyView === 'text'}
221+
class="body-tab"
222+
class:active={bodyView === 'text'}
223+
onclick={() => (bodyView = 'text')}
224+
>{$_('filesharing.emailPreview.viewText')}</button
225+
>
226+
</div>
227+
228+
{#if bodyView === 'html'}
229+
<iframe
230+
class="email-frame"
231+
title={$_('filesharing.emailPreview.iframeTitle')}
232+
srcdoc={withBaseTarget(active.html)}
233+
sandbox="allow-popups allow-popups-to-escape-sandbox"
234+
></iframe>
235+
{:else}
236+
<pre class="email-text">{active.text}</pre>
237+
{/if}
191238
{/if}
192239
</div>
193240
</div>
@@ -313,13 +360,60 @@
313360
font-size: var(--pg-font-size-sm);
314361
}
315362
363+
.body-tabs {
364+
display: flex;
365+
gap: 0.25rem;
366+
padding: 0.5rem 1.5rem 0;
367+
background: var(--pg-soft-background);
368+
border-bottom: 1px solid var(--pg-strong-background);
369+
}
370+
371+
.body-tab {
372+
background: transparent;
373+
border: 0;
374+
border-bottom: 2px solid transparent;
375+
padding: 0.4rem 0.8rem;
376+
font-family: var(--pg-font-family);
377+
font-size: var(--pg-font-size-sm);
378+
color: var(--pg-text-secondary);
379+
cursor: pointer;
380+
}
381+
382+
.body-tab:hover {
383+
color: var(--pg-text);
384+
}
385+
386+
.body-tab.active {
387+
color: var(--pg-text);
388+
border-bottom-color: var(--pg-primary-contrast);
389+
}
390+
391+
.body-tab:focus-visible {
392+
outline: 2px solid var(--pg-text);
393+
outline-offset: 2px;
394+
}
395+
316396
.email-frame {
317397
width: 100%;
318398
height: min(70vh, 720px);
319399
border: 0;
320400
background: var(--pg-soft-background);
321401
}
322402
403+
.email-text {
404+
margin: 0;
405+
padding: 1rem 1.5rem;
406+
max-height: min(70vh, 720px);
407+
overflow: auto;
408+
background: var(--pg-soft-background);
409+
color: var(--pg-text);
410+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
411+
font-size: var(--pg-font-size-sm);
412+
line-height: 1.5;
413+
white-space: pre-wrap;
414+
word-break: break-word;
415+
}
416+
323417
.state {
324418
padding: 2rem 1.5rem;
325419
text-align: center;

src/lib/components/filesharing/inputs/FileInput.svelte

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,17 @@
8787
})
8888
8989
myDropzone.on('addedfile', (file) => {
90+
const isDuplicate = files.some(
91+
(f) =>
92+
f.name === file.name &&
93+
f.size === file.size &&
94+
f.lastModified === file.lastModified
95+
)
96+
if (isDuplicate) {
97+
myDropzone!.removeFile(file)
98+
return
99+
}
100+
90101
files = files.concat([file])
91102
percentages = percentages.concat([0])
92103
done = done.concat([false])

src/lib/locales/en.json

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -229,21 +229,6 @@
229229
"addRecipient": "recipient",
230230
"addRecipientButton": "Add another recipient",
231231
"emailSenderConfirm": "Send me a confirmation",
232-
"emailPreview": {
233-
"title": "Notification email preview",
234-
"subtitle": "Staging only — cryptify does not actually send notification emails on staging. This is what each recipient would have received.",
235-
"loading": "Loading preview…",
236-
"error": "Could not load preview.",
237-
"empty": "No recipients to preview.",
238-
"close": "Close",
239-
"from": "From",
240-
"replyTo": "Reply to",
241-
"subject": "Subject",
242-
"to": "To",
243-
"confirmationTag": "sender copy",
244-
"iframeTitle": "Notification email body",
245-
"reopen": "Show email preview"
246-
},
247232
"timeremaining": {
248233
"estimate": "Estimating...",
249234
"unknown": "More then one day left.",
@@ -271,6 +256,23 @@
271256
"serverBlocked": "The server rejected this upload because you have used {used} GB of your {limit} GB 2-week limit. Your limit resets on {resets}."
272257
}
273258
},
259+
"emailPreview": {
260+
"title": "Notification email preview",
261+
"subtitle": "Staging only — cryptify does not actually send notification emails on staging. This is what each recipient would have received.",
262+
"loading": "Loading preview…",
263+
"error": "Could not load preview.",
264+
"empty": "No recipients to preview.",
265+
"close": "Close",
266+
"from": "From",
267+
"replyTo": "Reply to",
268+
"subject": "Subject",
269+
"to": "To",
270+
"confirmationTag": "sender copy",
271+
"iframeTitle": "Notification email body",
272+
"reopen": "Show email preview",
273+
"viewHtml": "HTML",
274+
"viewText": "Plain text"
275+
},
274276
"attributes": {
275277
"pbdf.sidn-pbdf.mobilenumber.mobilenumber": "Mobile phone number",
276278
"pbdf.sidn-pbdf.mobilenumber.mobilenumber.placeholder": "612345678",

src/lib/locales/nl.json

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -228,21 +228,6 @@
228228
"addRecipient": "ontvanger",
229229
"addRecipientButton": "Voeg nog een ontvanger toe",
230230
"emailSenderConfirm": "Stuur mij een bevestiging",
231-
"emailPreview": {
232-
"title": "Voorbeeld notificatie-e-mail",
233-
"subtitle": "Alleen staging — cryptify verstuurt op staging geen e-mails. Dit is wat elke ontvanger zou hebben gezien.",
234-
"loading": "Voorbeeld laden…",
235-
"error": "Voorbeeld kon niet worden geladen.",
236-
"empty": "Geen ontvangers om weer te geven.",
237-
"close": "Sluiten",
238-
"from": "Van",
239-
"replyTo": "Antwoord aan",
240-
"subject": "Onderwerp",
241-
"to": "Aan",
242-
"confirmationTag": "bevestigingskopie",
243-
"iframeTitle": "Inhoud van de notificatie-e-mail",
244-
"reopen": "Toon e-mailvoorbeeld"
245-
},
246231
"timeremaining": {
247232
"estimate": "Estimating...",
248233
"unknown": "Nog meer dan een dag",
@@ -270,6 +255,23 @@
270255
"serverBlocked": "De server weigerde deze upload omdat je {used} GB van je {limit} GB-limiet (2 weken) hebt gebruikt. De limiet reset op {resets}."
271256
}
272257
},
258+
"emailPreview": {
259+
"title": "Voorbeeld notificatie-e-mail",
260+
"subtitle": "Alleen staging — cryptify verstuurt op staging geen e-mails. Dit is wat elke ontvanger zou hebben gezien.",
261+
"loading": "Voorbeeld laden…",
262+
"error": "Voorbeeld kon niet worden geladen.",
263+
"empty": "Geen ontvangers om weer te geven.",
264+
"close": "Sluiten",
265+
"from": "Van",
266+
"replyTo": "Antwoord aan",
267+
"subject": "Onderwerp",
268+
"to": "Aan",
269+
"confirmationTag": "bevestigingskopie",
270+
"iframeTitle": "Inhoud van de notificatie-e-mail",
271+
"reopen": "Toon e-mailvoorbeeld",
272+
"viewHtml": "HTML",
273+
"viewText": "Platte tekst"
274+
},
273275
"attributes": {
274276
"pbdf.sidn-pbdf.mobilenumber.mobilenumber": "Mobiel telefoonnummer",
275277
"pbdf.sidn-pbdf.mobilenumber.mobilenumber.placeholder": "612345678",

0 commit comments

Comments
 (0)