2026-05-20 15:53:41 +02:00
|
|
|
|
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),
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
|
// Tell every upstream cache to keep its hands off - we count opens
|
2026-05-20 15:53:41 +02:00
|
|
|
|
// 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');
|
|
|
|
|
|
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
|
// Best-effort write - never block the pixel response on a slow DB.
|
2026-05-20 15:53:41 +02:00
|
|
|
|
// 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
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
|
// await - fire-and-forget so the pixel response stays fast.
|
2026-05-20 15:53:41 +02:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|