auth-email-context.ts314 lines · main
1/**
2 * Request context on every Briven Auth customer email:
3 * Platform · Device location (city / region / country + IP) · Time (Europe/Brussels).
4 *
5 * SuperTokens-style security meta so users can spot unexpected logins.
6 * Geo: self-hosted MaxMind GeoLite2 + offline nearest-city fallback — no third-party API.
7 *
8 * Icons: Lucide SVG (same design system as lucide-animated.com). Email clients
9 * cannot run React/Motion animations, so we embed static Lucide strokes.
10 *
11 * Target display (flndrn 2026-07-28):
12 * Platform: Brave browser on macOS device
13 * Device location: Ghent, East Flanders, Belgium (109.128.54.152)
14 * Time: July 25, 2026 at 10:48:35 AM GMT+2
15 */
16
17import { lookupIp } from '../../lib/geoip.js';
18import { nearestCityFromCoords } from './nearest-city.js';
19
20/** Default timezone for Auth email timestamps (flndrn / EU ops). */
21export const AUTH_EMAIL_TIMEZONE = 'Europe/Brussels';
22
23export type AuthEmailRequestMeta = {
24 /** e.g. "Brave browser on macOS device" */
25 platform: string;
26 /** e.g. "Ghent, East Flanders, Belgium (109.128.54.152)" */
27 deviceLocation: string;
28 /** e.g. "July 25, 2026 at 10:48:35 AM GMT+2" */
29 time: string;
30};
31
32/**
33 * Parse browser from User-Agent and optional Sec-CH-UA client hints.
34 * Brave must be checked before Chrome (Brave UAs often include Chrome/).
35 */
36export function formatAuthEmailPlatform(
37 userAgent: string | null | undefined,
38 clientHintsUa?: string | null,
39): string {
40 const ua = userAgent ?? '';
41 const ch = clientHintsUa ?? '';
42 if (!ua.trim() && !ch.trim()) return 'Unknown browser on unknown device';
43
44 // Client Hints brand list: "Not A(Brand";v="99", "Brave";v="121", "Chromium";v="121"
45 const brandFromHints = (): string | null => {
46 if (!ch.trim()) return null;
47 // Quoted brands — match Brave before Chromium/Google Chrome
48 if (/"Brave"/i.test(ch) || /,\s*Brave;/i.test(ch)) return 'Brave';
49 if (/"Microsoft Edge"/i.test(ch) || /"Edge"/i.test(ch)) return 'Edge';
50 if (/"Opera"/i.test(ch) || /"Opera GX"/i.test(ch)) return 'Opera';
51 if (/"Firefox"/i.test(ch)) return 'Firefox';
52 if (/"Google Chrome"/i.test(ch) || /"Chrome"/i.test(ch)) return 'Chrome';
53 if (/"Chromium"/i.test(ch) && !/"Google Chrome"/i.test(ch)) return 'Chrome';
54 if (/"Safari"/i.test(ch)) return 'Safari';
55 return null;
56 };
57
58 let browser = brandFromHints() ?? 'Unknown browser';
59 if (browser === 'Unknown browser' && ua.trim()) {
60 // Order matters: Brave/Edge/Opera before Chrome; Samsung before Chrome.
61 if (/Brave\//i.test(ua) || /\bBrave\b/i.test(ua)) browser = 'Brave';
62 else if (/Edg\//i.test(ua)) browser = 'Edge';
63 else if (/OPR\/|Opera/i.test(ua)) browser = 'Opera';
64 else if (/Firefox\//i.test(ua) || /FxiOS\//i.test(ua)) browser = 'Firefox';
65 else if (/SamsungBrowser/i.test(ua)) browser = 'Samsung Internet';
66 else if (/CriOS\//i.test(ua)) browser = 'Chrome';
67 else if (/Chrome\//i.test(ua) && /Safari\//i.test(ua)) browser = 'Chrome';
68 else if (/Safari\//i.test(ua) && !/Chrome\//i.test(ua)) browser = 'Safari';
69 }
70
71 let device = 'unknown';
72 if (/iPhone/i.test(ua)) device = 'iPhone';
73 else if (/iPad/i.test(ua)) device = 'iPad';
74 else if (/Android/i.test(ua)) device = 'Android';
75 else if (/CrOS/i.test(ua)) device = 'ChromeOS';
76 else if (/Mac OS X|Macintosh/i.test(ua)) device = 'macOS';
77 else if (/Windows NT/i.test(ua)) device = 'Windows';
78 else if (/Linux/i.test(ua)) device = 'Linux';
79
80 // "Brave browser on macOS device"
81 return `${browser} browser on ${device} device`;
82}
83
84/**
85 * Extract client IP. Prefer explicit Briven header (set by first-party proxy),
86 * then CDN / reverse-proxy headers. Skips obvious private hop when a public
87 * address is also present in X-Forwarded-For.
88 */
89export function clientIpFromHeaders(
90 header: (name: string) => string | undefined | null,
91): string | null {
92 const briven = header('x-briven-client-ip')?.trim();
93 if (briven && isPlausibleIp(briven)) return stripIp(briven);
94
95 const cf = header('cf-connecting-ip')?.trim();
96 if (cf && isPlausibleIp(cf)) return stripIp(cf);
97
98 const real = header('x-real-ip')?.trim();
99 if (real && isPlausibleIp(real)) return stripIp(real);
100
101 const forwarded = header('x-forwarded-for') ?? '';
102 const parts = forwarded
103 .split(',')
104 .map((p) => stripIp(p.trim()))
105 .filter((p) => p && isPlausibleIp(p));
106 // Leftmost is the original client when proxies append.
107 const publicPart = parts.find((p) => p && !isPrivateIp(p));
108 if (publicPart) return publicPart;
109 if (parts[0]) return parts[0];
110 return null;
111}
112
113function stripIp(raw: string): string {
114 let s = raw.trim();
115 if (s.startsWith('[')) {
116 const end = s.indexOf(']');
117 if (end > 0) return s.slice(1, end);
118 }
119 if (/^\d+\.\d+\.\d+\.\d+:\d+$/.test(s)) return s.split(':')[0]!;
120 return s;
121}
122
123function isPlausibleIp(ip: string): boolean {
124 const s = stripIp(ip);
125 if (!s || s.length > 45) return false;
126 if (/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) return true;
127 if (s.includes(':') && /^[0-9a-fA-F:.]+$/.test(s)) return true;
128 return false;
129}
130
131function isPrivateIp(ip: string): boolean {
132 const s = stripIp(ip);
133 if (s === '127.0.0.1' || s === '::1' || s === 'localhost') return true;
134 if (s.startsWith('10.') || s.startsWith('192.168.')) return true;
135 if (s.startsWith('169.254.')) return true;
136 if (s.startsWith('172.')) {
137 const second = Number(s.split('.')[1]);
138 if (second >= 16 && second <= 31) return true;
139 }
140 return false;
141}
142
143/**
144 * Human send time fixed to Europe/Brussels.
145 * Example: "July 25, 2026 at 10:48:35 AM GMT+2"
146 */
147export function formatAuthEmailTime(
148 when: Date = new Date(),
149 timeZone: string = AUTH_EMAIL_TIMEZONE,
150): string {
151 try {
152 const parts = new Intl.DateTimeFormat('en-US', {
153 timeZone,
154 year: 'numeric',
155 month: 'long',
156 day: 'numeric',
157 hour: 'numeric',
158 minute: '2-digit',
159 second: '2-digit',
160 hour12: true,
161 timeZoneName: 'shortOffset',
162 }).formatToParts(when);
163 const get = (type: Intl.DateTimeFormatPartTypes): string =>
164 parts.find((p) => p.type === type)?.value ?? '';
165 const month = get('month');
166 const day = get('day');
167 const year = get('year');
168 const hour = get('hour');
169 const minute = get('minute');
170 const second = get('second');
171 const dayPeriod = get('dayPeriod'); // AM / PM
172 let tz = get('timeZoneName') || '';
173 // Normalize "GMT+2" / "UTC+2" → "GMT+2"
174 tz = tz.replace(/^UTC/, 'GMT').replace(/\s+/g, '');
175 // July 25, 2026 at 10:48:35 AM GMT+2
176 return `${month} ${day}, ${year} at ${hour}:${minute}:${second} ${dayPeriod}${tz ? ` ${tz}` : ''}`.trim();
177 } catch {
178 return when.toISOString();
179 }
180}
181
182/**
183 * Device location line: "City, Region, Country (IP)"
184 * Prefer MaxMind city; if missing, nearest offline city from lat/lon.
185 */
186export function formatAuthEmailDeviceLocation(
187 geo: {
188 city: string | null;
189 region: string | null;
190 country: string | null;
191 latitude?: number | null;
192 longitude?: number | null;
193 accuracyRadiusKm?: number | null;
194 } | null,
195 ip: string | null | undefined,
196): string {
197 let city = geo?.city?.trim() || null;
198 let region = geo?.region?.trim() || null;
199 const country = geo?.country?.trim() || null;
200
201 // MaxMind often returns country centroid with no city (e.g. BE ISP ranges →
202 // Brussels coords only). Fill nearest known city offline.
203 if (!city && geo?.latitude != null && geo?.longitude != null) {
204 const maxKm = Math.max(120, (geo.accuracyRadiusKm ?? 50) * 2);
205 const near = nearestCityFromCoords(geo.latitude, geo.longitude, maxKm);
206 if (near) {
207 city = near.name;
208 if (!region && near.region) region = near.region;
209 }
210 }
211
212 const placeParts: string[] = [];
213 if (city) placeParts.push(city);
214 if (region && region !== city) placeParts.push(region);
215 if (country && country !== city && country !== region) placeParts.push(country);
216 const place = placeParts.join(', ');
217 const ipPart = ip?.trim() || null;
218
219 if (place && ipPart) return `${place} (${ipPart})`;
220 if (place) return place;
221 if (ipPart) return `Location unavailable (${ipPart})`;
222 return 'Location unavailable';
223}
224
225/**
226 * Resolve full meta block for an outbound Auth email.
227 * `when` defaults to now = the actual send moment.
228 */
229export async function resolveAuthEmailRequestMeta(input: {
230 userAgent?: string | null;
231 /** Sec-CH-UA client hint — needed to distinguish Brave from Chrome. */
232 clientHintsUa?: string | null;
233 clientIp?: string | null;
234 when?: Date;
235 timeZone?: string;
236}): Promise<AuthEmailRequestMeta> {
237 const sentAt = input.when ?? new Date();
238 const ip = input.clientIp?.trim() || null;
239 const geo = ip ? await lookupIp(ip) : null;
240 return {
241 platform: formatAuthEmailPlatform(input.userAgent, input.clientHintsUa),
242 deviceLocation: formatAuthEmailDeviceLocation(geo, ip),
243 time: formatAuthEmailTime(sentAt, input.timeZone),
244 };
245}
246
247/**
248 * Official Lucide outline icons (static first frame of lucide-animated.com).
249 *
250 * Source of truth for shapes:
251 * https://lucide-animated.com/icons/monitor
252 * https://lucide-animated.com/icons/map-pin
253 * https://lucide-animated.com/icons/clock
254 * Path data matches lucide-static (ISC) — same geometry the animated set uses.
255 *
256 * Email cannot run Motion hover animations, so we embed the static Lucide SVG
257 * (no emoji — flndrn rule). Layout: icon column | label | value for clean align.
258 */
259type LucideEmailIcon = 'monitor' | 'map-pin' | 'clock';
260
261/** Exact Lucide path geometry (viewBox 0 0 24 24). */
262const LUCIDE_PATHS: Record<LucideEmailIcon, string> = {
263 // https://lucide-animated.com/icons/monitor + lucide-static monitor.svg
264 monitor:
265 '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
266 // https://lucide-animated.com/icons/map-pin + lucide-static map-pin.svg
267 'map-pin':
268 '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
269 // https://lucide-animated.com/icons/clock + lucide-static clock.svg
270 clock:
271 '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
272};
273
274function lucideIcon(kind: LucideEmailIcon): string {
275 // 18×18, 1.75 stroke — crisper in Gmail/Apple Mail than 16/2.
276 // Explicit hex stroke (not currentColor) so clients that ignore CSS still paint.
277 return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" role="img" aria-hidden="true" focusable="false" style="display:block;width:18px;height:18px;min-width:18px">${LUCIDE_PATHS[kind]}</svg>`;
278}
279
280/** HTML meta block with Lucide icons (no emoji). Labels match the product copy. */
281export function authEmailRequestMetaHtml(meta: AuthEmailRequestMeta): string {
282 const font =
283 "font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif";
284 const row = (label: string, value: string, icon: LucideEmailIcon) =>
285 `<tr>
286 <td style="padding:8px 10px 8px 0;vertical-align:middle;width:18px">${lucideIcon(icon)}</td>
287 <td style="padding:8px 12px 8px 0;vertical-align:middle;white-space:nowrap;color:#9ca3af;font-size:13px;line-height:18px;${font}">${escapeHtml(label)}</td>
288 <td style="padding:8px 0;vertical-align:middle;color:#e5e7eb;font-size:13px;line-height:18px;${font}">${escapeHtml(value)}</td>
289 </tr>`;
290 return `
291 <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:20px 0 0 0;width:100%;border-collapse:collapse;border-top:1px solid #1e2128">
292 <tr><td colspan="3" style="height:16px;line-height:16px;font-size:0">&nbsp;</td></tr>
293 ${row('Platform', meta.platform, 'monitor')}
294 ${row('Device location', meta.deviceLocation, 'map-pin')}
295 ${row('Time', meta.time, 'clock')}
296 </table>`;
297}
298
299export function authEmailRequestMetaText(meta: AuthEmailRequestMeta): string {
300 return [
301 `Platform: ${meta.platform}`,
302 `Device location: ${meta.deviceLocation}`,
303 `Time: ${meta.time}`,
304 ].join('\n');
305}
306
307function escapeHtml(s: string): string {
308 return s
309 .replace(/&/g, '&amp;')
310 .replace(/</g, '&lt;')
311 .replace(/>/g, '&gt;')
312 .replace(/"/g, '&quot;')
313 .replace(/'/g, '&#39;');
314}