Skip to content

Commit f39795a

Browse files
authored
[Website] Add captured mail viewer (#4000)
## Summary Adds an Email panel to the Playground Site Manager for viewing messages captured from the active site, including message and attachment previews. ## Motivation for the change, related issues Builds on #4103 and #3996 to make captured `sendmail` messages available in the Site Manager UI. Related issue: #3994. ## Implementation details - Reads parsed messages from the active site's `clientInfo.emails` array and selects the newest message by default. - Displays message metadata and HTML or plain-text content. HTML and attachment-bearing messages render in a no-referrer, sandboxed iframe; links open outside the preview, while scripts and forms remain blocked. - Resolves `cid:` images and attachments to temporary Blob URLs, with image, video, and audio previews and downloads. URLs are revoked when no longer needed. - Uses `@wordpress/components` for the message list, empty state, and metadata. Attachment cards use isolated styles in a declarative shadow root. - Keeps messages in memory for the site client's lifetime. Aggregate retention is unbounded, there is no clear-mailbox action, and messages are discarded when the client restarts or is removed, or the page reloads. - Adds Playwright coverage that sends mail through PHP and verifies the captured subject and body in the Email panel. ## Testing Instructions 1. Start the website with `npm run dev`. 2. Open the [local Playground website with this Blueprint](http://127.0.0.1:5400/website-server/?blueprint-url=https%3A%2F%2Fgist.githubusercontent.com%2Fbgrgicak%2Ff4d2cc7242fceef00bd0109fb8b81137%2Fraw%2Femail-test-blueprint.json). 3. Open **Email** in the Site Manager and confirm the empty state is shown. 4. Select **Send test email** in the site's admin bar. 5. Confirm the message metadata, HTML, special characters, images, and external links render correctly. 6. Confirm supported attachments can be previewed and all attachments can be downloaded. <img width="1839" height="1324" alt="Email panel showing a captured message" src="https://github.com/user-attachments/assets/9c560378-dac4-4fd3-8895-e87c7d1d76ba" /> --------- Co-authored-by: mho22 <yannick@capsules.codes>
1 parent 3c2cb1d commit f39795a

13 files changed

Lines changed: 901 additions & 4 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { test, expect } from '../playground-fixtures';
2+
3+
test('shows email captured from PHP mail()', async ({ website }) => {
4+
await website.goto('./?storage=temp');
5+
6+
const responseText = await website.page.evaluate(async () => {
7+
const sitesApi = (window as any).playgroundSites;
8+
await sitesApi.isReady();
9+
const client = sitesApi.getClient();
10+
await client.writeFile(
11+
'/wordpress/sendmail-capture.php',
12+
`<?php
13+
$result = mail(
14+
'recipient@test.com',
15+
'Captured email subject',
16+
'Captured email body.',
17+
'From: sender@test.com'
18+
);
19+
echo $result ? 'SENT' : 'FAILED';
20+
`
21+
);
22+
const response = await client.request({
23+
url: '/sendmail-capture.php',
24+
});
25+
return response.text;
26+
});
27+
28+
expect(responseText).toBe('SENT');
29+
await website.openDockPane('Email', 'Email pane');
30+
const emailPane = website.page.getByRole('dialog', {
31+
name: 'Email pane',
32+
});
33+
await expect(
34+
emailPane.getByText('Captured email subject').first()
35+
).toBeVisible();
36+
await expect(emailPane).toContainText('Captured email body.');
37+
});

‎packages/playground/website/src/components/dock/dock.tsx‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { CSSTransition } from 'react-transition-group';
1616
import { Icon } from '@wordpress/components';
1717
import {
1818
close,
19+
envelope,
1920
external,
2021
grid,
2122
list,
@@ -129,6 +130,12 @@ const DOCK_ITEMS: DockItem[] = [
129130
ariaLabel: 'Logs',
130131
icon: <Icon icon={list} size={24} />,
131132
},
133+
{
134+
section: 'mail',
135+
label: 'Email',
136+
ariaLabel: 'Email',
137+
icon: <Icon icon={envelope} size={24} />,
138+
},
132139
{
133140
section: 'share',
134141
label: 'Export',
@@ -172,6 +179,10 @@ const PANE_COPY: Record<
172179
title: 'PHP error log',
173180
description: 'Errors, warnings, and notices from your site.',
174181
},
182+
mail: {
183+
title: 'Email',
184+
description: 'Preview messages sent by this Playground.',
185+
},
175186
share: {
176187
title: 'Export',
177188
description: '',
@@ -208,11 +219,14 @@ export function Dock({
208219
const paneCopy = PANE_COPY[section];
209220
const paneTitle = paneCopy.title;
210221
const isMobile = useIsMobileDock();
211-
const isEditorSection = section === 'blueprint' || section === 'files';
222+
const isEditorSection =
223+
section === 'blueprint' || section === 'files' || section === 'mail';
212224
// Logs hold long monospace records, so they get a wider pane.
213225
const isWideSection = section === 'logs';
214226
const isFixedHeightSection =
215-
section === 'new' || (section === 'share' && shareExportOpen);
227+
section === 'new' ||
228+
section === 'mail' ||
229+
(section === 'share' && shareExportOpen);
216230
const showSharedHeader = !isEditorSection;
217231
const siteSettingsVisible = dockPaneIsOpen && section === 'settings';
218232
const playgroundTitle =

‎packages/playground/website/src/components/site-manager/index.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ export const SiteManager = forwardRef<HTMLDivElement, SiteManagerProps>(
4545
activeSection === 'files' ||
4646
activeSection === 'blueprint' ||
4747
activeSection === 'database' ||
48-
activeSection === 'logs'
48+
activeSection === 'logs' ||
49+
activeSection === 'mail'
4950
? activeSection
5051
: null;
5152
const activeSiteTab = isVisible ? selectedSiteTab : null;

‎packages/playground/website/src/components/site-manager/site-info-panel/site-tool-panels.tsx‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { OfflineNotice } from '../../offline-notice';
99
import { PaneLoading } from '../../pane-loading';
1010
import { useDockPaneEditorHeaderSlot } from '../../dock/dock-pane';
1111
import { SiteDatabasePanel } from '../site-database-panel';
12+
import { SiteMailPanel } from '../site-mail-panel';
1213
import { ActiveSiteSettingsForm } from '../site-settings-form/active-site-settings-form';
1314
import css from './style.module.css';
1415

@@ -27,7 +28,8 @@ export type SiteInfoTabName =
2728
| 'files'
2829
| 'blueprint'
2930
| 'database'
30-
| 'logs';
31+
| 'logs'
32+
| 'mail';
3133

3234
/** Renders the tool surfaces selected by the site information tabs. */
3335
export function SiteToolPanels({
@@ -59,6 +61,8 @@ export function SiteToolPanels({
5961
activeTabName === 'database' || mountedTabNames.includes('database');
6062
const logsMounted =
6163
activeTabName === 'logs' || mountedTabNames.includes('logs');
64+
const mailMounted =
65+
activeTabName === 'mail' || mountedTabNames.includes('mail');
6266

6367
// Mount each tool lazily, then retain its draft, selection, scroll position,
6468
// and subscriptions while another Dock destination is visible.
@@ -193,6 +197,16 @@ export function SiteToolPanels({
193197
</div>
194198
</div>
195199
)}
200+
{mailMounted && (
201+
<div
202+
className={classNames(css.tabContents, css.mailTab, {
203+
[css.tabHidden]: activeTabName !== 'mail',
204+
})}
205+
hidden={activeTabName !== 'mail'}
206+
>
207+
<SiteMailPanel />
208+
</div>
209+
)}
196210
</>
197211
);
198212
}

‎packages/playground/website/src/components/site-manager/site-info-panel/style.module.css‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@
4848
overflow: hidden;
4949
}
5050

51+
.mail-tab {
52+
container-type: inline-size;
53+
overflow: hidden;
54+
}
55+
5156
/**
5257
* A workaround for scrolling overflowing content within
5358
* a flex container. TODO: find a way to avoid using
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import {
2+
Notice,
3+
__experimentalHStack as HStack,
4+
__experimentalHeading as Heading,
5+
__experimentalItem as Item,
6+
__experimentalItemGroup as ItemGroup,
7+
__experimentalText as Text,
8+
__experimentalVStack as VStack,
9+
} from '@wordpress/components';
10+
import type { Email } from 'postal-mime';
11+
import { useState } from 'react';
12+
import {
13+
getActiveClientInfo,
14+
useAppSelector,
15+
} from '../../../lib/state/redux/store';
16+
import { formatAddress, getMailSubject } from './mail-display';
17+
import { MailPreview } from './mail-preview';
18+
import css from './style.module.css';
19+
20+
export function SiteMailPanel() {
21+
const emails = useAppSelector(getActiveClientInfo)?.emails ?? [];
22+
const [selectedEmail, setSelectedEmail] = useState<Email>();
23+
const selectedMail =
24+
selectedEmail && emails.includes(selectedEmail)
25+
? selectedEmail
26+
: emails[0];
27+
28+
if (!selectedMail) {
29+
return (
30+
<section className={css.mailEmptyState} aria-label="Email">
31+
<Notice
32+
className={css.mailEmptyStateNotice}
33+
status="info"
34+
isDismissible={false}
35+
>
36+
<VStack spacing={2}>
37+
<Heading level={2}>No emails yet</Heading>
38+
<Text>
39+
Emails sent by this Playground will appear here.
40+
</Text>
41+
</VStack>
42+
</Notice>
43+
</section>
44+
);
45+
}
46+
47+
return (
48+
<section className={css.mailPanel} aria-label="Email">
49+
<aside className={css.mailPanelList} aria-label="Sent emails">
50+
<HStack className={css.mailPanelListHeader}>
51+
<Text as="h2" weight={600}>
52+
Sent
53+
</Text>
54+
<Text variant="muted">{emails.length}</Text>
55+
</HStack>
56+
<ItemGroup isSeparated isRounded={false} size="large">
57+
{emails.map((message, index) => {
58+
const isSelected = message === selectedMail;
59+
const subject = getMailSubject(message);
60+
61+
return (
62+
<Item
63+
key={message.messageId || `${subject}-${index}`}
64+
onClick={() => setSelectedEmail(message)}
65+
aria-pressed={isSelected}
66+
className={css.mailPanelListItem}
67+
>
68+
<VStack spacing={1}>
69+
<Text weight={600} truncate>
70+
{subject}
71+
</Text>
72+
<HStack spacing={2}>
73+
<Text variant="muted" truncate>
74+
{message.from
75+
? formatAddress(message.from)
76+
: 'Unknown sender'}
77+
</Text>
78+
{message.date && (
79+
<Text
80+
as="time"
81+
dateTime={message.date}
82+
variant="muted"
83+
>
84+
{formatSentTime(message.date)}
85+
</Text>
86+
)}
87+
</HStack>
88+
</VStack>
89+
</Item>
90+
);
91+
})}
92+
</ItemGroup>
93+
</aside>
94+
<MailPreview mail={selectedMail} />
95+
</section>
96+
);
97+
}
98+
99+
function formatSentTime(date: string): string {
100+
const parsedDate = new Date(date);
101+
if (Number.isNaN(parsedDate.getTime())) {
102+
return date;
103+
}
104+
return new Intl.DateTimeFormat(undefined, {
105+
hour: 'numeric',
106+
minute: '2-digit',
107+
}).format(parsedDate);
108+
}

0 commit comments

Comments
 (0)