mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(admin): surface confirmation link, badge, banner + dismiss
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user