import { NextResponse, type NextRequest } from 'next/server'; import { and, eq, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { documentSendOpens, documentSends } from '@/lib/db/schema/brochures'; import { logger } from '@/lib/logger'; import { trackEvent } from '@/lib/services/umami.service'; /** * GET /api/public/email-pixel/[sendId] * * Returns a 1×1 transparent GIF and records an open event in * `document_send_opens` + bumps the cached aggregates on `document_sends`. * * Lookups are gated by `track_opens=true` on the send row, so a leaked * sendId for an untracked email is a no-op (the pixel still returns * 200/GIF so email clients don't surface a broken-image icon). * * Privacy: we deliberately don't store IP addresses or any data beyond * user-agent + referer. Apple Mail privacy proxy pre-fetches images, so * opens from iOS users are over-counted; image-blocking clients * (Outlook with images disabled) under-count. Standard email-tracking * caveats apply. */ // 1×1 transparent GIF, base64-encoded. Generated once at module-load so // every request returns the same buffer without re-allocating. const TRANSPARENT_GIF = Buffer.from( 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64', ); function gifResponse(): NextResponse { return new NextResponse(TRANSPARENT_GIF as unknown as BodyInit, { status: 200, headers: { 'Content-Type': 'image/gif', 'Content-Length': String(TRANSPARENT_GIF.length), // Tell every upstream cache to keep its hands off - we count opens // on the FETCH itself, so any cached response is a missed open. 'Cache-Control': 'no-store, no-cache, must-revalidate, private', Pragma: 'no-cache', Expires: '0', }, }); } export async function GET( req: NextRequest, ctx: { params: Promise<{ sendId: string }> }, ): Promise { const { sendId } = await ctx.params; try { // Look up the send row; ignore unknown / un-tracked sends silently. const sendRow = await db.query.documentSends.findFirst({ where: and(eq(documentSends.id, sendId), eq(documentSends.trackOpens, true)), columns: { id: true, portId: true, recipientEmail: true, documentKind: true }, }); if (!sendRow) return gifResponse(); const userAgent = req.headers.get('user-agent'); const referer = req.headers.get('referer'); // Best-effort write - never block the pixel response on a slow DB. // The pixel must return promptly so email clients render normally. db.insert(documentSendOpens) .values({ portId: sendRow.portId, sendId: sendRow.id, userAgent: userAgent ?? null, referer: referer ?? null, }) .then(() => db .update(documentSends) .set({ openCount: sql`${documentSends.openCount} + 1`, firstOpenedAt: sql`COALESCE(${documentSends.firstOpenedAt}, NOW())`, }) .where(eq(documentSends.id, sendRow.id)), ) .catch((err) => { logger.warn({ err, sendId: sendRow.id }, 'email-pixel: failed to record open'); }); // Cross-post to Umami so the marketing funnel includes opens. Don't // await - fire-and-forget so the pixel response stays fast. trackEvent( sendRow.portId, 'email-opened', { sendId: sendRow.id, documentKind: sendRow.documentKind, }, 'email://pixel', ).catch((err) => { logger.debug({ err, sendId: sendRow.id }, 'email-pixel: umami cross-post failed'); }); return gifResponse(); } catch (err) { logger.warn({ err, sendId }, 'email-pixel: unexpected error'); return gifResponse(); } }