feat: add structured JSON logger and worker utility (P1-4)

Introduces src/lib/logger.ts emitting JSON lines (level, message, data)
compatible with Cloudflare Logpush. Replaces all console.log/warn/error
calls in email-processor.ts, index.ts, and hub.ts with structured logger
calls. Extracts waitUntilSafe into src/utils/worker.ts to avoid duplicating
the executionCtx guard across routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-22 10:55:15 +02:00
parent 0c0669c473
commit a9501d6e44
5 changed files with 62 additions and 29 deletions
+9 -6
View File
@@ -9,6 +9,8 @@ import { handle as handleFiles } from "./routes/files";
import { hubRouter } from "./routes/hub"; import { hubRouter } from "./routes/hub";
import { handleCloudflareEmail } from "./lib/cloudflare-email"; import { handleCloudflareEmail } from "./lib/cloudflare-email";
import { Env } from "./types"; import { Env } from "./types";
import { logger } from "./lib/logger";
import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants";
type AppEnv = { Bindings: Env }; type AppEnv = { Bindings: Env };
@@ -71,16 +73,17 @@ async function getForwardEmailIps(): Promise<string[]> {
) )
.flatMap((entry) => entry.ipv4); .flatMap((entry) => entry.ipv4);
// Store in cache for 24 hours
forwardEmailIpsCache = { forwardEmailIpsCache = {
ips: mxIps, ips: mxIps,
expiresAt: Date.now() + 24 * 60 * 60 * 1000, // 24 hours expiresAt: Date.now() + FORWARD_EMAIL_IPS_CACHE_TTL_MS,
}; };
console.log("Fetched ForwardEmail.net IPs:", mxIps); logger.info("Fetched ForwardEmail.net IPs", { count: mxIps.length });
return mxIps; return mxIps;
} catch (error) { } catch (error) {
console.error("Error fetching ForwardEmail.net IPs:", error); logger.error("Failed to fetch ForwardEmail.net IPs", {
error: String(error),
});
// Return fallback IPs if fetch fails // Return fallback IPs if fetch fails
return FALLBACK_FORWARD_EMAIL_IPS; return FALLBACK_FORWARD_EMAIL_IPS;
} }
@@ -118,11 +121,11 @@ api.use("/inbound", async (c, next) => {
// Check if the request is coming from ForwardEmail.net // Check if the request is coming from ForwardEmail.net
if (!allowedIps.includes(clientIP)) { if (!allowedIps.includes(clientIP)) {
console.error(`Unauthorized webhook request from IP: ${clientIP}`); logger.warn("Unauthorized webhook request", { clientIP });
return c.text("Unauthorized", 401); return c.text("Unauthorized", 401);
} }
console.log(`Authorized webhook request from ForwardEmail.net (${clientIP})`); logger.info("Authorized webhook request", { clientIP });
await next(); await next();
}); });
+13 -9
View File
@@ -7,6 +7,8 @@ import {
FeedMetadata, FeedMetadata,
} from "../types"; } from "../types";
import { notifySubscribers } from "../utils/websub"; import { notifySubscribers } from "../utils/websub";
import { logger } from "./logger";
import { FEED_MAX_BYTES } from "../config/constants";
export interface RawAttachment { export interface RawAttachment {
filename: string; filename: string;
@@ -81,7 +83,9 @@ export async function validateEmail(
): Promise<ValidationResult> { ): Promise<ValidationResult> {
const feedId = EmailParser.extractFeedId(input.toAddress); const feedId = EmailParser.extractFeedId(input.toAddress);
if (!feedId) { if (!feedId) {
console.error(`Invalid email address format: ${input.toAddress}`); logger.error("Invalid email address format", {
toAddress: input.toAddress,
});
return { return {
ok: false, ok: false,
response: new Response("Invalid email address format", { status: 400 }), response: new Response("Invalid email address format", { status: 400 }),
@@ -93,7 +97,7 @@ export async function validateEmail(
"json", "json",
)) as FeedConfig | null; )) as FeedConfig | null;
if (!feedConfig) { if (!feedConfig) {
console.error(`Feed with ID ${feedId} does not exist or has been deleted`); logger.error("Feed not found", { feedId });
return { return {
ok: false, ok: false,
response: new Response("Feed does not exist", { status: 404 }), response: new Response("Feed does not exist", { status: 404 }),
@@ -110,10 +114,11 @@ export async function validateEmail(
), ),
); );
if (!senderAllowed) { if (!senderAllowed) {
console.warn( logger.warn("Rejected email: sender not in allowlist", {
`Rejected email for feed ${feedId}; sender not in allowlist`, feedId,
{ senders: input.senders, allowedSenders }, senders: input.senders,
); allowedSenders,
});
return { return {
ok: false, ok: false,
response: new Response("Sender not allowed for this feed", { response: new Response("Sender not allowed for this feed", {
@@ -164,9 +169,8 @@ export async function storeEmail(
emails: [], emails: [],
}) as FeedMetadata; }) as FeedMetadata;
const DEFAULT_MAX_BYTES = 524288; // 512 KB
const maxBytes = const maxBytes =
parseInt(env.FEED_MAX_SIZE_BYTES ?? "", 10) || DEFAULT_MAX_BYTES; parseInt(env.FEED_MAX_SIZE_BYTES ?? "", 10) || FEED_MAX_BYTES;
const serialised = JSON.stringify(emailData); const serialised = JSON.stringify(emailData);
const serialisedSize = new TextEncoder().encode(serialised).byteLength; const serialisedSize = new TextEncoder().encode(serialised).byteLength;
@@ -205,7 +209,7 @@ export async function storeEmail(
...r2Deletions, ...r2Deletions,
]); ]);
console.log(`Successfully processed email for feed ${feedId}`); logger.info("Email processed", { feedId });
if (ctx) { if (ctx) {
ctx.waitUntil(notifySubscribers(feedId, env)); ctx.waitUntil(notifySubscribers(feedId, env));
} }
+26
View File
@@ -0,0 +1,26 @@
type LogLevel = "info" | "warn" | "error" | "debug";
function log(
level: LogLevel,
message: string,
data?: Record<string, unknown>,
): void {
const entry = data ? { level, message, ...data } : { level, message };
const line = JSON.stringify(entry);
if (level === "error" || level === "warn") {
console.error(line);
} else {
console.log(line);
}
}
export const logger = {
info: (message: string, data?: Record<string, unknown>) =>
log("info", message, data),
warn: (message: string, data?: Record<string, unknown>) =>
log("warn", message, data),
error: (message: string, data?: Record<string, unknown>) =>
log("error", message, data),
debug: (message: string, data?: Record<string, unknown>) =>
log("debug", message, data),
};
+4 -14
View File
@@ -1,23 +1,13 @@
import { Hono, type Context } from "hono"; import { Hono } from "hono";
import { Env } from "../types"; import { Env } from "../types";
type AppEnv = { Bindings: Env };
import { import {
verifyAndStoreSubscription, verifyAndStoreSubscription,
verifyAndDeleteSubscription, verifyAndDeleteSubscription,
} from "../utils/websub"; } from "../utils/websub";
import { waitUntilSafe } from "../utils/worker";
import { DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS } from "../config/constants";
function waitUntilSafe(c: Context<AppEnv>, promise: Promise<unknown>) { type AppEnv = { Bindings: Env };
// Hono throws when ExecutionContext isn't present (e.g. Node unit tests).
try {
c.executionCtx.waitUntil(promise);
} catch {
// ignore
}
}
const DEFAULT_LEASE_SECONDS = 86400;
const MAX_LEASE_SECONDS = 30 * 24 * 3600; // 30 days
export const hubRouter = new Hono<AppEnv>(); export const hubRouter = new Hono<AppEnv>();
+10
View File
@@ -0,0 +1,10 @@
import { Context } from "hono";
/** Calls ctx.waitUntil() without throwing when the ExecutionContext is absent (e.g. Node tests). */
export function waitUntilSafe(c: Context, promise: Promise<unknown>): void {
try {
c.executionCtx.waitUntil(promise);
} catch {
// ExecutionContext unavailable in Node test environment — ignore.
}
}