diff --git a/src/application/stats.ts b/src/application/stats.ts index 979c93d..57ee938 100644 --- a/src/application/stats.ts +++ b/src/application/stats.ts @@ -11,6 +11,7 @@ const EMPTY_COUNTERS: Counters = { feeds_deleted: 0, emails_received: 0, emails_rejected: 0, + emails_forwarded: 0, unsubscribes_sent: 0, }; @@ -41,6 +42,7 @@ export async function bumpCounters( current.feeds_deleted += changes.feeds_deleted ?? 0; current.emails_received += changes.emails_received ?? 0; current.emails_rejected += changes.emails_rejected ?? 0; + current.emails_forwarded += changes.emails_forwarded ?? 0; current.unsubscribes_sent += changes.unsubscribes_sent ?? 0; if (changes.last_email_at) current.last_email_at = changes.last_email_at; if (changes.last_feed_created_at) diff --git a/src/infrastructure/cloudflare-email.test.ts b/src/infrastructure/cloudflare-email.test.ts index 35b7571..72ee288 100644 --- a/src/infrastructure/cloudflare-email.test.ts +++ b/src/infrastructure/cloudflare-email.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import "../test/setup"; import { createMockEnv } from "../test/setup"; import { handleCloudflareEmail } from "./cloudflare-email"; +import { getCounters } from "../application/stats"; const VALID_FEED_ID = "apple.mountain.42"; const DOMAIN = "test.getmynews.app"; @@ -245,5 +246,53 @@ describe("handleCloudflareEmail", () => { ), ).resolves.toBeUndefined(); }); + + it("increments the emails_forwarded counter on a successful forward", async () => { + const { forward } = spyForward(); + env.FALLBACK_FORWARD_ADDRESS = FALLBACK; + + await handleCloudflareEmail( + makeMessage({ forward }), + env as any, + { waitUntil: () => {} } as any, + ); + + const counters = await getCounters(env.EMAIL_STORAGE as any); + expect(counters.emails_forwarded).toBe(1); + }); + + it("does not increment emails_forwarded when the forward fails", async () => { + env.FALLBACK_FORWARD_ADDRESS = FALLBACK; + const forward = async () => { + throw new Error("destination address not verified"); + }; + + await handleCloudflareEmail( + makeMessage({ forward }), + env as any, + { waitUntil: () => {} } as any, + ); + + const counters = await getCounters(env.EMAIL_STORAGE as any); + expect(counters.emails_forwarded).toBe(0); + }); + + it("does not increment emails_forwarded for dropped reasons", async () => { + const { forward } = spyForward(); + env.FALLBACK_FORWARD_ADDRESS = FALLBACK; + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({ expires_at: Date.now() - 1000 }), + ); + + await handleCloudflareEmail( + makeMessage({ forward }), + env as any, + { waitUntil: () => {} } as any, + ); + + const counters = await getCounters(env.EMAIL_STORAGE as any); + expect(counters.emails_forwarded).toBe(0); + }); }); }); diff --git a/src/infrastructure/cloudflare-email.ts b/src/infrastructure/cloudflare-email.ts index 57528c0..ce7680d 100644 --- a/src/infrastructure/cloudflare-email.ts +++ b/src/infrastructure/cloudflare-email.ts @@ -5,6 +5,7 @@ import { RawAttachment, IngestRejectionReason, } from "../application/email-processor"; +import { bumpCounters } from "../application/stats"; import { normalizeCid } from "../infrastructure/html-processor"; import { logger } from "./logger"; @@ -80,6 +81,9 @@ async function maybeForwardFallback( try { await message.forward(fallback); + // Counted as a subset of emails_rejected (already bumped in processEmail); + // the dropped count is derived as emails_rejected − emails_forwarded. + await bumpCounters(env.EMAIL_STORAGE, { emails_forwarded: 1 }); } catch (error) { logger.warn("Fallback forward failed", { to: message.to, diff --git a/src/infrastructure/counters-repository.test.ts b/src/infrastructure/counters-repository.test.ts index b06ba34..c00c77d 100644 --- a/src/infrastructure/counters-repository.test.ts +++ b/src/infrastructure/counters-repository.test.ts @@ -14,6 +14,7 @@ describe("CountersRepository", () => { feeds_deleted: 0, emails_received: 2, emails_rejected: 0, + emails_forwarded: 0, unsubscribes_sent: 0, }); expect(await repo.getRaw()).toMatchObject({ emails_received: 2 }); diff --git a/src/routes/api/schemas.ts b/src/routes/api/schemas.ts index 0e40876..e2f8210 100644 --- a/src/routes/api/schemas.ts +++ b/src/routes/api/schemas.ts @@ -138,6 +138,7 @@ export const StatsSchema = z feeds_deleted: z.number(), emails_received: z.number(), emails_rejected: z.number(), + emails_forwarded: z.number(), unsubscribes_sent: z.number(), active_feeds: z.number(), websub_subscriptions_active: z.number(), diff --git a/src/routes/home.tsx b/src/routes/home.tsx index c65702d..564149c 100644 --- a/src/routes/home.tsx +++ b/src/routes/home.tsx @@ -162,6 +162,10 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise { value={stats.emails_rejected} tone="danger" /> +