feat(admin): surface confirmation link, badge, banner + dismiss

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-25 09:08:30 +02:00
parent c4d591b962
commit 1525b36cab
3 changed files with 270 additions and 0 deletions
+167
View File
@@ -5,6 +5,10 @@ import app from "./admin";
import { createMockEnv, server } from "../test/setup"; import { createMockEnv, server } from "../test/setup";
import { getCounters } from "../application/stats"; import { getCounters } from "../application/stats";
import { Env } from "../types"; import { Env } from "../types";
import { FeedRepository } from "../infrastructure/feed-repository";
import { Feed } from "../domain/feed.aggregate";
import { FeedId } from "../domain/value-objects/feed-id";
import { MailboxId } from "../domain/value-objects/mailbox-id";
describe("Admin Routes", () => { describe("Admin Routes", () => {
let testApp: Hono; let testApp: Hono;
@@ -1126,4 +1130,167 @@ describe("Admin Routes", () => {
expect(cfg.allowed_senders).toContain("alice@example.com"); expect(cfg.allowed_senders).toContain("alice@example.com");
}); });
}); });
describe("Confirmation features", () => {
it("detail view shows confirmation-section with links when email has confirmation metadata", async () => {
const authCookie = await loginAndGetCookie();
const repo = FeedRepository.from(mockEnv as unknown as Env);
// Create feed aggregate
const feedId = FeedId.generate();
const mailboxId = MailboxId.unchecked("confirm.test.01");
const feed = Feed.create(
feedId,
{
title: "Confirm Test Feed",
language: "en",
allowedSenders: [],
blockedSenders: [],
},
{ mailboxId },
);
await repo.save(feed);
// Mint an email key and put the email body
const emailKey = repo.newEmailKey(feedId);
await repo.putEmail(emailKey, {
subject: "Please confirm your subscription",
from: "newsletter@example.com",
content: "<p>Click to confirm</p>",
receivedAt: Date.now(),
headers: {},
});
// Ingest the email into the aggregate with confirmation links
feed.ingest(
{
key: emailKey,
subject: "Please confirm your subscription",
receivedAt: Date.now(),
confirmation: { links: ["https://example.com/confirm?t=1"] },
},
{ maxBytes: 10_000_000 },
);
await repo.saveMetadata(feed);
const res = await request(`/admin/emails/${emailKey}`, {
headers: { Cookie: authCookie },
});
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain("confirmation-section");
expect(body).toContain("https://example.com/confirm?t=1");
});
it("email list shows confirmation-badge for emails with confirmation metadata", async () => {
const authCookie = await loginAndGetCookie();
const repo = FeedRepository.from(mockEnv as unknown as Env);
// Create feed aggregate
const feedId = FeedId.generate();
const mailboxId = MailboxId.unchecked("confirm.badge.02");
const feed = Feed.create(
feedId,
{
title: "Confirm Badge Feed",
language: "en",
allowedSenders: [],
blockedSenders: [],
},
{ mailboxId },
);
await repo.save(feed);
const emailKey = repo.newEmailKey(feedId);
await repo.putEmail(emailKey, {
subject: "Confirm subscription",
from: "newsletter@example.com",
content: "<p>Click to confirm</p>",
receivedAt: Date.now(),
headers: {},
});
feed.ingest(
{
key: emailKey,
subject: "Confirm subscription",
receivedAt: Date.now(),
confirmation: { links: ["https://example.com/confirm?t=1"] },
},
{ maxBytes: 10_000_000 },
);
await repo.saveMetadata(feed);
const res = await request(`/admin/feeds/${feedId.value}/emails`, {
headers: { Cookie: authCookie },
});
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain("confirmation-badge");
});
it("dismiss route clears pendingConfirmation flag", async () => {
const authCookie = await loginAndGetCookie();
const repo = FeedRepository.from(mockEnv as unknown as Env);
// Create feed aggregate with a confirmation email
const feedId = FeedId.generate();
const mailboxId = MailboxId.unchecked("confirm.dismiss.03");
const feed = Feed.create(
feedId,
{
title: "Dismiss Test Feed",
language: "en",
allowedSenders: [],
blockedSenders: [],
},
{ mailboxId },
);
await repo.save(feed);
const emailKey = repo.newEmailKey(feedId);
await repo.putEmail(emailKey, {
subject: "Confirm subscription",
from: "newsletter@example.com",
content: "<p>Click to confirm</p>",
receivedAt: Date.now(),
headers: {},
});
feed.ingest(
{
key: emailKey,
subject: "Confirm subscription",
receivedAt: Date.now(),
confirmation: { links: ["https://example.com/confirm?t=2"] },
},
{ maxBytes: 10_000_000 },
);
await repo.saveMetadata(feed);
// Verify flag is set
expect(feed.pendingConfirmation).toBe(true);
// Call dismiss route
const dismissRes = await request(
`/admin/feeds/${feedId.value}/confirmation/dismiss`,
{
method: "POST",
headers: {
Cookie: authCookie,
"Content-Type": "application/json",
Origin: `https://${mockEnv.DOMAIN}`,
},
},
);
expect(dismissRes.status).toBe(200);
const payload = (await dismissRes.json()) as any;
expect(payload.ok).toBe(true);
// Reload feed from repo and check flag is cleared
const reloaded = await repo.load(feedId);
expect(reloaded).not.toBeNull();
expect(reloaded!.pendingConfirmation).toBe(false);
});
});
}); });
+84
View File
@@ -221,6 +221,25 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
</div> </div>
</div> </div>
{feedMetadata.pendingConfirmation && (
<div
class="confirmation-banner"
id="confirmation-banner"
data-feed-id={feedId}
>
<span>A subscription-confirmation email was detected.</span>
<div class="confirmation-banner-actions">
<button
type="button"
class="button button-small"
id="confirmation-dismiss"
>
Mark as confirmed
</button>
</div>
</div>
)}
<h2> <h2>
Emails ( Emails (
<span id="email-total-count">{feedMetadata.emails.length}</span>) <span id="email-total-count">{feedMetadata.emails.length}</span>)
@@ -355,6 +374,7 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
const attachmentLabel = `${attachmentCount} attachment${ const attachmentLabel = `${attachmentCount} attachment${
attachmentCount > 1 ? "s" : "" attachmentCount > 1 ? "s" : ""
}`; }`;
const isConfirmation = !!email.confirmation;
const sortSubject = subjectHover.toLowerCase(); const sortSubject = subjectHover.toLowerCase();
const sortReceivedAt = String(email.receivedAt); const sortReceivedAt = String(email.receivedAt);
const searchHaystack = clampText( const searchHaystack = clampText(
@@ -401,6 +421,14 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
</svg> </svg>
</span> </span>
) : null} ) : null}
{isConfirmation ? (
<span
class="confirmation-badge"
title="Subscription confirmation"
>
Confirmation
</span>
) : null}
<span class="truncate" title={subjectHover}> <span class="truncate" title={subjectHover}>
{subjectDisplay} {subjectDisplay}
</span> </span>
@@ -469,6 +497,11 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
const feedId = repo.feedIdFromEmailKey(emailKey); const feedId = repo.feedIdFromEmailKey(emailKey);
const feedConfig = await repo.getConfig(FeedId.unchecked(feedId)); const feedConfig = await repo.getConfig(FeedId.unchecked(feedId));
if (!feedConfig) return c.text("Feed not found", 404); if (!feedConfig) return c.text("Feed not found", 404);
const feedMetadata = await repo.getMetadata(FeedId.unchecked(feedId));
const confirmationLinks =
feedMetadata?.emails.find((e) => e.key === emailKey)?.confirmation?.links ??
[];
// Inline images render in place; only downloadable attachments go in the list. // Inline images render in place; only downloadable attachments go in the list.
const attachments = (emailData.attachments ?? []).filter((a) => !a.inline); const attachments = (emailData.attachments ?? []).filter((a) => !a.inline);
@@ -594,6 +627,31 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
</div> </div>
</div> </div>
{confirmationLinks.length > 0 && (
<div class="confirmation-section">
<h2>Confirm your subscription</h2>
<p class="muted">
This looks like a subscription-confirmation email. Open the link
to confirm.
</p>
<a
class="button confirmation-primary"
href={confirmationLinks[0]}
target="_blank"
rel="noopener noreferrer"
>
Confirm subscription
</a>
<div class="confirmation-links">
{confirmationLinks.map((link) => (
<a href={link} target="_blank" rel="noopener noreferrer">
{link}
</a>
))}
</div>
</div>
)}
<div class="toggle-view"> <div class="toggle-view">
<button <button
id="rendered-button" id="rendered-button"
@@ -704,6 +762,32 @@ emailsRouter.post("/emails/:emailKey/delete", async (c) => {
} }
}); });
// ── Dismiss confirmation ──────────────────────────────────────────────────────
emailsRouter.post("/feeds/:feedId/confirmation/dismiss", async (c) => {
const env = c.env;
const repo = FeedRepository.from(env);
const feedId = c.req.param("feedId");
const wantsJson = (
c.req.header("Accept") ||
c.req.header("Content-Type") ||
""
).includes("application/json");
const feed = await repo.load(FeedId.unchecked(feedId));
if (!feed) {
return wantsJson
? c.json({ ok: false, error: "Feed not found" }, 404)
: c.text("Feed not found", 404);
}
feed.dismissConfirmation();
await repo.saveMetadata(feed);
return wantsJson
? c.json({ ok: true })
: c.redirect(`/admin/feeds/${feedId}/emails`);
});
// ── Bulk delete emails ──────────────────────────────────────────────────────── // ── Bulk delete emails ────────────────────────────────────────────────────────
emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => { emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
+19
View File
@@ -615,3 +615,22 @@ async function bulkDeleteSelectedEmails(): Promise<void> {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
initEmailUI(); initEmailUI();
}); });
// ── Confirmation banner dismiss ───────────────────────────────────────────────
const dismissBtn = document.getElementById("confirmation-dismiss");
const banner = document.getElementById("confirmation-banner");
if (dismissBtn && banner) {
dismissBtn.addEventListener("click", () => {
const feedId = banner.getAttribute("data-feed-id") ?? "";
fetch(`/admin/feeds/${feedId}/confirmation/dismiss`, {
method: "POST",
headers: { "Content-Type": "application/json" },
})
.then((r) => r.json())
.then((d) => {
if ((d as { ok?: boolean }).ok) banner.remove();
})
.catch(() => {});
});
}