feat(admin): paperclip indicator for emails with attachments

Show an inline paperclip icon before the subject in the admin email
list when an email has attachments, with the count in a tooltip. Uses
the attachmentIds already stored in metadata, so no extra fetch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-23 15:10:24 +02:00
parent f4e751e40b
commit 7226e718f7
3 changed files with 107 additions and 11 deletions
+52
View File
@@ -676,6 +676,58 @@ describe("Admin Routes", () => {
} | null;
expect(metadataAfter?.emails.length).toBe(0);
});
it("should show a paperclip indicator only for emails with attachments", async () => {
const authCookie = await loginAndGetCookie();
const formData = new FormData();
formData.append("title", "Email Feed");
const createRes = await request("/admin/feeds/create", {
method: "POST",
headers: {
Cookie: authCookie,
Origin: "https://test.getmynews.app",
},
body: formData,
});
expect(createRes.status).toBe(302);
const feedList = (await mockEnv.EMAIL_STORAGE.get(
"feeds:list",
"json",
)) as { feeds: Array<{ id: string; title: string }> } | null;
const feedId = feedList?.feeds[0].id as string;
await mockEnv.EMAIL_STORAGE.put(
`feed:${feedId}:metadata`,
JSON.stringify({
emails: [
{
key: `feed:${feedId}:1`,
subject: "With attachments",
receivedAt: 2,
attachmentIds: ["att-1", "att-2"],
},
{
key: `feed:${feedId}:2`,
subject: "No attachments",
receivedAt: 1,
},
],
}),
);
const res = await request(`/admin/feeds/${feedId}/emails`, {
headers: { Cookie: authCookie },
});
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain("2 attachments");
const indicatorCount = (body.match(/attachment-indicator/g) || [])
.length;
expect(indicatorCount).toBe(1);
});
});
});
+37 -11
View File
@@ -349,6 +349,10 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
{feedMetadata.emails.map((email: EmailMetadata) => {
const subjectDisplay = clampText(email.subject, 180);
const subjectHover = clampText(email.subject, 1000);
const attachmentCount = email.attachmentIds?.length ?? 0;
const attachmentLabel = `${attachmentCount} attachment${
attachmentCount > 1 ? "s" : ""
}`;
const sortSubject = subjectHover.toLowerCase();
const sortReceivedAt = String(email.receivedAt);
const searchHaystack = clampText(
@@ -373,9 +377,32 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
/>
</td>
<td>
<span class="truncate" title={subjectHover}>
{subjectDisplay}
</span>
<div class="subject-cell">
{attachmentCount > 0 ? (
<span
class="attachment-indicator"
title={attachmentLabel}
aria-label={attachmentLabel}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
</span>
) : null}
<span class="truncate" title={subjectHover}>
{subjectDisplay}
</span>
</div>
</td>
<td>{new Date(email.receivedAt).toLocaleString()}</td>
<td>
@@ -415,7 +442,11 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
</div>
{/* Config bootstrap — injects dynamic server-side data before the static compiled script */}
<script dangerouslySetInnerHTML={{ __html: `window.__APP_CONFIG__=${JSON.stringify({ feedId })}` }} />
<script
dangerouslySetInnerHTML={{
__html: `window.__APP_CONFIG__=${JSON.stringify({ feedId })}`,
}}
/>
{/* Emails page logic compiled from src/scripts/client/emails-page.ts */}
<script dangerouslySetInnerHTML={{ __html: emailsPageScript }} />
</Layout>,
@@ -445,9 +476,7 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
return btoa(String.fromCharCode(...new Uint8Array(bytes)));
})();
const rawHtml = emailData.content
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const rawHtml = emailData.content.replace(/</g, "&lt;").replace(/>/g, "&gt;");
const viewScript = `
function showRendered() {
@@ -546,10 +575,7 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
value={new Date(emailData.receivedAt).toLocaleString()}
/>
<SenderField from={emailData.from} feedId={feedId} />
<CopyField
label="To:"
value={feedEmailAddress(feedId, env)}
/>
<CopyField label="To:" value={feedEmailAddress(feedId, env)} />
</div>
</div>
+18
View File
@@ -769,6 +769,24 @@ table.table code {
max-width: 100%;
}
.subject-cell {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.subject-cell .truncate {
min-width: 0;
}
.attachment-indicator {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
color: var(--color-text-secondary);
}
/* Compact copy-to-clipboard for table cells */
.copyable.copyable-inline {
margin-bottom: 0;