Skip to content

Commit acc5258

Browse files
committed
feat(staging): in-page preview of the email cryptify would have sent
On staging, cryptify runs with `staging_mode = true` and logs notification emails instead of dispatching via SMTP — handy for safety, painful for testing because the only way to get the /download link was to scrape container logs. This wires up an automatic modal on the "files sent" screen that pulls the rendered email(s) from cryptify's new `GET /staging/preview/<uuid>` endpoint and embeds the HTML body in a sandboxed iframe. A header bar shows From / Reply-To / Subject / To with a switcher when there are multiple recipients (including the sender's confirmation copy). The email body itself is rendered by cryptify, not re-mocked here, so the two cannot drift. The runtime gate is a new `STAGING` flag on `window.APP_CONFIG`, injected by the postguard-ops ConfigMap from the existing `cryptify_staging_mode` Terraform variable. Production sees no UI change. Requires encryption4all/cryptify#171.
1 parent e4e66f7 commit acc5258

8 files changed

Lines changed: 411 additions & 0 deletions

File tree

src/lib/components/filesharing/Done.svelte

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,23 @@
44
import HelpToggle from '$lib/components/HelpToggle.svelte'
55
import Chip from '$lib/components/Chip.svelte'
66
import FileList from '$lib/components/filesharing/FileList.svelte'
7+
import EmailPreviewModal from '$lib/components/filesharing/EmailPreviewModal.svelte'
78
import airplane from '$lib/assets/images/airplane.svg'
9+
import { STAGING } from '$lib/env'
810
911
interface props {
1012
encryptState: EncryptState
1113
createDefaultEncryptState: () => EncryptState
1214
}
1315
let { encryptState = $bindable(), createDefaultEncryptState }: props =
1416
$props()
17+
18+
// On staging, cryptify renders the notification email but does not
19+
// dispatch via SMTP. Auto-open the preview modal so developers don't
20+
// have to scrape cryptify logs for the /download link. The modal
21+
// fetches the actual rendered HTML from cryptify, so this branch
22+
// never runs in production (STAGING is false there).
23+
let previewOpen = $state(STAGING && !!encryptState.uploadUuid)
1524
</script>
1625

1726
<div class="container">
@@ -59,12 +68,28 @@
5968
variant="dark"
6069
/>
6170

71+
{#if STAGING && encryptState.uploadUuid}
72+
<Chip
73+
text={$_('filesharing.emailPreview.reopen')}
74+
onclick={() => (previewOpen = true)}
75+
size="lg"
76+
variant="default"
77+
/>
78+
{/if}
79+
6280
<div class="spacer"></div>
6381

6482
<!-- Airplane decoration -->
6583
<img src={airplane} alt="" aria-hidden="true" class="airplane-decoration" />
6684
</div>
6785

86+
{#if STAGING && previewOpen && encryptState.uploadUuid}
87+
<EmailPreviewModal
88+
uuid={encryptState.uploadUuid}
89+
onClose={() => (previewOpen = false)}
90+
/>
91+
{/if}
92+
6893
<style>
6994
.container {
7095
display: flex;
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
<!--
2+
Staging-only preview of the notification email cryptify *would* send
3+
to a recipient. On staging, cryptify runs with `staging_mode = true`
4+
(see cryptify/src/email.rs::send_email) and logs the email instead of
5+
dispatching via SMTP — which makes it tedious to grab a download link
6+
for testing.
7+
8+
Source of truth for the rendering lives in cryptify, exposed via
9+
`GET /staging/preview/<uuid>`. This component fetches that JSON and
10+
drops the per-recipient HTML body into a sandboxed iframe via
11+
`srcdoc`, so the email's own inline styles render in isolation. The
12+
header bar (from / to / subject / reply-to + recipient switcher) is
13+
the only locally-rendered chrome.
14+
-->
15+
<script lang="ts">
16+
import { _ } from 'svelte-i18n'
17+
import { onMount } from 'svelte'
18+
import { FILEHOST_URL } from '$lib/env'
19+
20+
interface RenderedEmail {
21+
recipient: string
22+
subject: string
23+
from: string
24+
reply_to: string | null
25+
html: string
26+
text: string
27+
}
28+
29+
interface PreviewResponse {
30+
recipients: RenderedEmail[]
31+
confirmation: RenderedEmail | null
32+
}
33+
34+
interface Props {
35+
uuid: string
36+
onClose: () => void
37+
}
38+
39+
let { uuid, onClose }: Props = $props()
40+
41+
type Status = 'loading' | 'ready' | 'error'
42+
let status: Status = $state('loading')
43+
let errorMessage = $state('')
44+
let data = $state<PreviewResponse | null>(null)
45+
/** Index into the flat list of [...recipients, confirmation?] */
46+
let activeIdx = $state(0)
47+
48+
let allEmails = $derived.by<RenderedEmail[]>(() => {
49+
if (!data) return []
50+
return data.confirmation
51+
? [...data.recipients, data.confirmation]
52+
: data.recipients
53+
})
54+
let active = $derived(allEmails[activeIdx])
55+
let isConfirmation = $derived(
56+
!!data?.confirmation && activeIdx === allEmails.length - 1
57+
)
58+
59+
onMount(async () => {
60+
try {
61+
const url = `${FILEHOST_URL.replace(
62+
/\/$/,
63+
''
64+
)}/staging/preview/${encodeURIComponent(uuid)}`
65+
const res = await fetch(url, { credentials: 'omit' })
66+
if (!res.ok) {
67+
status = 'error'
68+
errorMessage = `${res.status} ${res.statusText}`
69+
return
70+
}
71+
data = (await res.json()) as PreviewResponse
72+
if (allEmails.length === 0) {
73+
status = 'error'
74+
errorMessage = $_('filesharing.emailPreview.empty')
75+
return
76+
}
77+
status = 'ready'
78+
} catch (e) {
79+
status = 'error'
80+
errorMessage = e instanceof Error ? e.message : String(e)
81+
}
82+
})
83+
84+
function handleKey(e: KeyboardEvent) {
85+
if (e.key === 'Escape') onClose()
86+
}
87+
</script>
88+
89+
<svelte:window onkeydown={handleKey} />
90+
91+
<div
92+
class="backdrop"
93+
role="dialog"
94+
aria-modal="true"
95+
aria-labelledby="email-preview-title"
96+
>
97+
<button
98+
class="backdrop-close"
99+
type="button"
100+
aria-label={$_('filesharing.emailPreview.close')}
101+
onclick={onClose}
102+
></button>
103+
104+
<div class="modal">
105+
<header class="modal-header">
106+
<div>
107+
<h2 id="email-preview-title">
108+
{$_('filesharing.emailPreview.title')}
109+
</h2>
110+
<p class="modal-subtitle">
111+
{$_('filesharing.emailPreview.subtitle')}
112+
</p>
113+
</div>
114+
<button
115+
class="close-btn"
116+
type="button"
117+
aria-label={$_('filesharing.emailPreview.close')}
118+
onclick={onClose}>×</button
119+
>
120+
</header>
121+
122+
{#if status === 'loading'}
123+
<div class="state">{$_('filesharing.emailPreview.loading')}</div>
124+
{:else if status === 'error'}
125+
<div class="state state-error">
126+
<p>{$_('filesharing.emailPreview.error')}</p>
127+
<p class="error-detail">{errorMessage}</p>
128+
</div>
129+
{:else if active}
130+
<section class="meta">
131+
<div class="meta-row">
132+
<span class="meta-label"
133+
>{$_('filesharing.emailPreview.from')}</span
134+
>
135+
<span class="meta-value">{active.from}</span>
136+
</div>
137+
{#if active.reply_to}
138+
<div class="meta-row">
139+
<span class="meta-label"
140+
>{$_('filesharing.emailPreview.replyTo')}</span
141+
>
142+
<span class="meta-value">{active.reply_to}</span>
143+
</div>
144+
{/if}
145+
<div class="meta-row">
146+
<span class="meta-label"
147+
>{$_('filesharing.emailPreview.subject')}</span
148+
>
149+
<span class="meta-value">{active.subject}</span>
150+
</div>
151+
<div class="meta-row meta-row-recipient">
152+
<label class="meta-label" for="recipient-switcher"
153+
>{$_('filesharing.emailPreview.to')}</label
154+
>
155+
{#if allEmails.length > 1}
156+
<select
157+
id="recipient-switcher"
158+
class="recipient-select"
159+
bind:value={activeIdx}
160+
>
161+
{#each allEmails as r, i (i)}
162+
<option value={i}
163+
>{r.recipient}{data?.confirmation &&
164+
i === allEmails.length - 1
165+
? ` (${$_(
166+
'filesharing.emailPreview.confirmationTag'
167+
)})`
168+
: ''}</option
169+
>
170+
{/each}
171+
</select>
172+
{:else}
173+
<span class="meta-value"
174+
>{active.recipient}{isConfirmation
175+
? ` (${$_(
176+
'filesharing.emailPreview.confirmationTag'
177+
)})`
178+
: ''}</span
179+
>
180+
{/if}
181+
</div>
182+
</section>
183+
184+
<iframe
185+
class="email-frame"
186+
title={$_('filesharing.emailPreview.iframeTitle')}
187+
srcdoc={active.html}
188+
sandbox="allow-popups allow-popups-to-escape-sandbox"
189+
></iframe>
190+
{/if}
191+
</div>
192+
</div>
193+
194+
<style lang="scss">
195+
.backdrop {
196+
position: fixed;
197+
inset: 0;
198+
background: rgba(3, 14, 23, 0.55);
199+
display: flex;
200+
align-items: flex-start;
201+
justify-content: center;
202+
z-index: 1000;
203+
padding: 2rem 1rem;
204+
overflow-y: auto;
205+
}
206+
207+
.backdrop-close {
208+
position: absolute;
209+
inset: 0;
210+
background: transparent;
211+
border: 0;
212+
padding: 0;
213+
cursor: pointer;
214+
}
215+
216+
.modal {
217+
position: relative;
218+
background: var(--pg-general-background);
219+
border-radius: var(--pg-border-radius-lg);
220+
max-width: 720px;
221+
width: 100%;
222+
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3);
223+
z-index: 1;
224+
overflow: hidden;
225+
display: flex;
226+
flex-direction: column;
227+
}
228+
229+
.modal-header {
230+
display: flex;
231+
align-items: flex-start;
232+
justify-content: space-between;
233+
gap: 1rem;
234+
padding: 1.25rem 1.5rem;
235+
background: var(--pg-soft-background);
236+
border-bottom: 1px solid var(--pg-strong-background);
237+
}
238+
239+
.modal-header h2 {
240+
margin: 0;
241+
font-size: var(--pg-font-size-lg);
242+
font-weight: var(--pg-font-weight-bold);
243+
color: var(--pg-text);
244+
}
245+
246+
.modal-subtitle {
247+
margin: 0.25rem 0 0;
248+
font-size: var(--pg-font-size-sm);
249+
color: var(--pg-text-secondary);
250+
font-family: var(--pg-font-family);
251+
}
252+
253+
.close-btn {
254+
background: transparent;
255+
border: 0;
256+
font-size: 1.75rem;
257+
line-height: 1;
258+
cursor: pointer;
259+
color: var(--pg-text-secondary);
260+
padding: 0 0.25rem;
261+
}
262+
263+
.close-btn:hover {
264+
color: var(--pg-text);
265+
}
266+
267+
.meta {
268+
display: flex;
269+
flex-direction: column;
270+
gap: 0.4rem;
271+
padding: 1rem 1.5rem;
272+
border-bottom: 1px solid var(--pg-strong-background);
273+
font-family: var(--pg-font-family);
274+
font-size: var(--pg-font-size-sm);
275+
color: var(--pg-text);
276+
}
277+
278+
.meta-row {
279+
display: flex;
280+
gap: 0.75rem;
281+
align-items: baseline;
282+
}
283+
284+
.meta-row-recipient {
285+
align-items: center;
286+
}
287+
288+
.meta-label {
289+
flex: 0 0 5.5rem;
290+
color: var(--pg-text-secondary);
291+
font-weight: var(--pg-font-weight-bold);
292+
}
293+
294+
.meta-value {
295+
word-break: break-word;
296+
}
297+
298+
.recipient-select {
299+
flex: 1;
300+
padding: 0.35rem 0.6rem;
301+
border: 1px solid var(--pg-strong-background);
302+
border-radius: var(--pg-border-radius-sm);
303+
background: var(--pg-general-background);
304+
color: var(--pg-text);
305+
font-family: var(--pg-font-family);
306+
font-size: var(--pg-font-size-sm);
307+
}
308+
309+
.email-frame {
310+
width: 100%;
311+
height: min(70vh, 720px);
312+
border: 0;
313+
background: var(--pg-soft-background);
314+
}
315+
316+
.state {
317+
padding: 2rem 1.5rem;
318+
text-align: center;
319+
font-family: var(--pg-font-family);
320+
font-size: var(--pg-font-size-md);
321+
color: var(--pg-text-secondary);
322+
}
323+
324+
.state-error {
325+
color: var(--pg-text);
326+
}
327+
328+
.error-detail {
329+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
330+
font-size: var(--pg-font-size-sm);
331+
color: var(--pg-text-secondary);
332+
margin: 0.5rem 0 0;
333+
word-break: break-all;
334+
}
335+
</style>

0 commit comments

Comments
 (0)