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 | |
| 17 | import { lookupIp } from '../../lib/geoip.js'; |
| 18 | import { nearestCityFromCoords } from './nearest-city.js'; |
| 19 | |
| 20 | /** Default timezone for Auth email timestamps (flndrn / EU ops). */ |
| 21 | export const AUTH_EMAIL_TIMEZONE = 'Europe/Brussels'; |
| 22 | |
| 23 | export 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 | */ |
| 36 | export 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 | */ |
| 89 | export 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 | |
| 113 | function 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 | |
| 123 | function 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 | |
| 131 | function 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 | */ |
| 147 | export 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 | */ |
| 186 | export 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 | */ |
| 229 | export 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 | */ |
| 259 | type LucideEmailIcon = 'monitor' | 'map-pin' | 'clock'; |
| 260 | |
| 261 | /** Exact Lucide path geometry (viewBox 0 0 24 24). */ |
| 262 | const 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 | |
| 274 | function 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. */ |
| 281 | export 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"> </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 | |
| 299 | export 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 | |
| 307 | function escapeHtml(s: string): string { |
| 308 | return s |
| 309 | .replace(/&/g, '&') |
| 310 | .replace(/</g, '<') |
| 311 | .replace(/>/g, '>') |
| 312 | .replace(/"/g, '"') |
| 313 | .replace(/'/g, '''); |
| 314 | } |