From ed008d4466ac2dd06c3ae81038a0d18e7e107948 Mon Sep 17 00:00:00 2001 From: Matt Huntington Date: Wed, 30 Sep 2026 19:13:00 -0400 Subject: [PATCH] all the ham --- .gitignore | 2 + package.json | 10 +++++ server.js | 104 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..713d500 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/package.json b/package.json new file mode 100644 index 0000000..e1a02e4 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "pebble-transcription-mailer", + "private": true, + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=20.12" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..3884529 --- /dev/null +++ b/server.js @@ -0,0 +1,104 @@ +const crypto = require('node:crypto'); +const http = require('node:http'); +const { Readable } = require('node:stream'); + +try { + process.loadEnvFile(); +} catch { + // No .env file: env vars come from the host. +} + +const { PORT = 3000, API_KEY, RESEND_API_KEY, MAIL_FROM, MAIL_TO } = process.env; + +const missing = ['API_KEY', 'RESEND_API_KEY', 'MAIL_FROM', 'MAIL_TO'].filter((name) => !process.env[name]); +if (missing.length) { + console.error(`Missing required env vars: ${missing.join(', ')}`); + process.exit(1); +} + +const recipients = MAIL_TO.split(',').map((address) => address.trim()).filter(Boolean); + +const hash = (value) => crypto.createHash('sha256').update(value).digest(); + +function isAuthorized(header = '') { + const [scheme, token] = header.split(' '); + return scheme === 'Bearer' && Boolean(token) && crypto.timingSafeEqual(hash(token), hash(API_KEY)); +} + +async function sendEmail(text, recordedAt) { + const response = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { Authorization: `Bearer ${RESEND_API_KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + from: MAIL_FROM, + to: recipients, + subject: 'New Pebble voice memo', + text: `${text}\n\nRecorded ${recordedAt.toLocaleString()}`, + }), + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) throw new Error(`Resend ${response.status}: ${await response.text()}`); +} + +function respond(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }).end(JSON.stringify(body)); +} + +// Pebble sends multipart/form-data with fields: transcription, recordedAt (ms epoch), client, audio (optional). +async function handleWebhook(req, res) { + if (!isAuthorized(req.headers.authorization)) { + console.warn(' Rejected: missing or invalid Authorization header'); + return respond(res, 401, { error: 'Unauthorized' }); + } + + let form; + try { + form = await new Response(Readable.toWeb(req), { + headers: { 'Content-Type': req.headers['content-type'] ?? '' }, + }).formData(); + } catch { + console.warn(` Rejected: expected multipart/form-data, got ${req.headers['content-type'] ?? 'none'}`); + return respond(res, 400, { error: 'Expected multipart/form-data' }); + } + + const transcription = form.get('transcription'); + const text = typeof transcription === 'string' ? transcription.trim() : ''; + const recordedAt = new Date(Number(form.get('recordedAt')) || Date.now()); + console.log( + ` Received: client=${form.get('client')} recordedAt=${recordedAt.toISOString()} ` + + `chars=${text.length} audio=${form.has('audio')}` + ); + + if (!text) { + console.warn(' Rejected: no transcription'); + return respond(res, 400, { error: 'No transcription' }); + } + + try { + await sendEmail(text, recordedAt); + } catch (err) { + console.error(' Email failed:', err.message); + return respond(res, 502, { error: 'Failed to send email' }); + } + + console.log(' Email sent'); + respond(res, 200, { ok: true }); +} + +http + .createServer((req, res) => { + const { pathname } = new URL(req.url, 'http://localhost'); + console.log(`[${new Date().toISOString()}] ${req.method} ${pathname}`); + + if (req.method === 'GET' && pathname === '/health') { + return respond(res, 200, { ok: true }); + } + if (req.method === 'POST' && pathname === '/transcription') { + return handleWebhook(req, res).catch((err) => { + console.error(err); + respond(res, 500, { error: 'Internal server error' }); + }); + } + respond(res, 404, { error: 'Not found' }); + }) + .listen(PORT, () => console.log(`Listening on port ${PORT}`));