107 lines
3.6 KiB
TypeScript
107 lines
3.6 KiB
TypeScript
|
|
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<NextResponse> {
|
|||
|
|
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();
|
|||
|
|
}
|
|||
|
|
}
|