feat(stats): count emails forwarded to the catch-all fallback

Adds an emails_forwarded counter (a subset of emails_rejected) bumped on a
successful FALLBACK_FORWARD_ADDRESS forward. Dropped = rejected − forwarded.
Surfaced in the /api/v1/stats response and the public status page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-24 17:19:12 +02:00
parent 1583e95875
commit 81e46c9026
7 changed files with 64 additions and 0 deletions
@@ -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);
});
});
});
+4
View File
@@ -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,
@@ -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 });