feat: add gmail-hook monorepo with watcher, api, and web

Ship the receipt pipeline (Livin/Grab/BRI), PocketBase spendings, PM2 ecosystem, and a proper .gitignore that excludes secrets, SQLite data, and media dumps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-13 22:58:47 +07:00
co-authored by Cursor
parent 8fa9f4350e
commit 46a2e2a7b0
67 changed files with 12153 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"name": "api",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js"
},
"dependencies": {
"@fastify/secure-session": "^8.2.0",
"@fastify/static": "^8.2.0",
"better-sqlite3": "^12.2.0",
"dotenv": "^17.2.2",
"fastify": "^5.6.0",
"googleapis": "^159.0.0"
}
}
+38
View File
@@ -0,0 +1,38 @@
import { config } from "dotenv";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
export const rootDir = resolve(__dirname, "../../..");
export const apiDir = resolve(__dirname, "..");
export const webDistDir = resolve(__dirname, "../../web/dist");
config({ path: resolve(rootDir, ".env"), quiet: true });
export const PORT = Number(process.env.PORT || 3000);
export const NODE_ENV = process.env.NODE_ENV || "development";
export const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || "";
export const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || "";
export const GOOGLE_REDIRECT_URI =
process.env.GOOGLE_REDIRECT_URI || "http://localhost:5173/callback";
export const SESSION_SECRET = process.env.SESSION_SECRET || "";
export const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly";
export const OAUTH_SCOPES = [
GMAIL_SCOPE,
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
];
export function assertConfig() {
const missing = [];
if (!GOOGLE_CLIENT_ID) missing.push("GOOGLE_CLIENT_ID");
if (!GOOGLE_CLIENT_SECRET) missing.push("GOOGLE_CLIENT_SECRET");
if (!SESSION_SECRET || SESSION_SECRET.length < 16) {
missing.push("SESSION_SECRET (16+ characters)");
}
if (missing.length) {
throw new Error(`Missing required env: ${missing.join(", ")}`);
}
}
+82
View File
@@ -0,0 +1,82 @@
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import Database from "better-sqlite3";
import { apiDir } from "./config.js";
const dataDir = resolve(apiDir, "data");
mkdirSync(dataDir, { recursive: true });
const db = new Database(resolve(dataDir, "app.db"));
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
google_sub TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
name TEXT,
picture TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS oauth_tokens (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
access_token TEXT NOT NULL,
refresh_token TEXT,
expiry INTEGER NOT NULL
);
`);
const upsertUserStmt = db.prepare(`
INSERT INTO users (google_sub, email, name, picture)
VALUES (@googleSub, @email, @name, @picture)
ON CONFLICT(google_sub) DO UPDATE SET
email = excluded.email,
name = excluded.name,
picture = excluded.picture
RETURNING *
`);
const saveTokensStmt = db.prepare(`
INSERT INTO oauth_tokens (user_id, access_token, refresh_token, expiry)
VALUES (@userId, @accessToken, @refreshToken, @expiry)
ON CONFLICT(user_id) DO UPDATE SET
access_token = excluded.access_token,
refresh_token = COALESCE(excluded.refresh_token, oauth_tokens.refresh_token),
expiry = excluded.expiry
`);
const getUserByIdStmt = db.prepare(`SELECT * FROM users WHERE id = ?`);
const getTokensStmt = db.prepare(`SELECT * FROM oauth_tokens WHERE user_id = ?`);
export function upsertUser({ googleSub, email, name, picture }) {
return upsertUserStmt.get({ googleSub, email, name, picture });
}
export function saveTokens(userId, { accessToken, refreshToken, expiry }) {
saveTokensStmt.run({
userId,
accessToken,
refreshToken: refreshToken || null,
expiry,
});
}
export function getUserById(id) {
return getUserByIdStmt.get(id) || null;
}
export function getTokens(userId) {
return getTokensStmt.get(userId) || null;
}
export function publicUser(row) {
if (!row) return null;
return {
id: row.id,
email: row.email,
name: row.name,
picture: row.picture,
};
}
+89
View File
@@ -0,0 +1,89 @@
import { google } from "googleapis";
import {
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI,
OAUTH_SCOPES,
} from "./config.js";
import { getTokens, saveTokens } from "./db.js";
import { HttpError } from "./http.js";
export function createOAuthClient() {
return new google.auth.OAuth2(
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI,
);
}
export function authUrl(state) {
return createOAuthClient().generateAuthUrl({
access_type: "offline",
prompt: "consent",
include_granted_scopes: true,
scope: OAUTH_SCOPES,
state,
});
}
export async function exchangeCode(code) {
const client = createOAuthClient();
const { tokens } = await client.getToken(code);
client.setCredentials(tokens);
const oauth2 = google.oauth2({ version: "v2", auth: client });
const { data: profile } = await oauth2.userinfo.get();
if (!profile.id || !profile.email) {
throw new HttpError(400, "Google profile did not include email");
}
return {
profile: {
googleSub: profile.id,
email: profile.email,
name: profile.name || profile.email,
picture: profile.picture || null,
},
tokens: {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || null,
expiry: tokens.expiry_date || Date.now() + 3600 * 1000,
},
};
}
function persistClientTokens(userId, client) {
client.on("tokens", (tokens) => {
if (!tokens.access_token) return;
saveTokens(userId, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || null,
expiry: tokens.expiry_date || Date.now() + 3600 * 1000,
});
});
}
export async function gmailClientForUser(userId) {
const stored = getTokens(userId);
if (!stored?.access_token && !stored?.refresh_token) {
throw new HttpError(401, "Google account is not connected");
}
const client = createOAuthClient();
client.setCredentials({
access_token: stored.access_token,
refresh_token: stored.refresh_token,
expiry_date: stored.expiry,
});
persistClientTokens(userId, client);
try {
await client.getAccessToken();
} catch {
throw new HttpError(
401,
"Gmail access expired or was revoked. Sign in again.",
);
}
return google.gmail({ version: "v1", auth: client });
}
+14
View File
@@ -0,0 +1,14 @@
export class HttpError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
}
}
export function requireUser(request) {
const userId = request.session.get("userId");
if (!userId) {
throw new HttpError(401, "Not authenticated");
}
return userId;
}
+84
View File
@@ -0,0 +1,84 @@
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import Fastify from "fastify";
import fastifySecureSession from "@fastify/secure-session";
import fastifyStatic from "@fastify/static";
import {
NODE_ENV,
PORT,
SESSION_SECRET,
assertConfig,
webDistDir,
} from "./config.js";
import { HttpError } from "./http.js";
import { registerAuthRoutes } from "./routes/auth.js";
import { registerMessageRoutes } from "./routes/messages.js";
assertConfig();
const app = Fastify({
logger: true,
trustProxy: true,
});
await app.register(fastifySecureSession, {
key: createHash("sha256").update(SESSION_SECRET).digest(),
cookieName: "session",
expiry: 60 * 60 * 24 * 7,
cookie: {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: NODE_ENV === "production",
},
});
app.get("/api/health", async () => ({ ok: true }));
await registerAuthRoutes(app);
await registerMessageRoutes(app);
app.setErrorHandler((err, request, reply) => {
const status = err.statusCode || 500;
if (status >= 500) {
request.log.error(err);
}
const message =
err instanceof HttpError || status < 500
? err.message
: "Internal server error";
reply.code(status).send({ error: message });
});
const serveFrontend = existsSync(webDistDir);
if (serveFrontend) {
await app.register(fastifyStatic, {
root: webDistDir,
wildcard: false,
});
app.setNotFoundHandler((request, reply) => {
if (
request.method !== "GET" ||
request.url.startsWith("/api") ||
request.url.startsWith("/auth") ||
request.url.startsWith("/callback")
) {
reply.code(404).send({ error: "Not found" });
return;
}
return reply.sendFile("index.html");
});
}
try {
await app.listen({ port: PORT, host: "0.0.0.0" });
app.log.info(
`API listening on http://localhost:${PORT}` +
(serveFrontend ? " (serving web dist)" : " (API only; use Vite in dev)"),
);
} catch (err) {
app.log.error(err);
process.exit(1);
}
+69
View File
@@ -0,0 +1,69 @@
function decodeBase64Url(data) {
if (!data) return "";
const pad = data.length % 4 === 0 ? "" : "=".repeat(4 - (data.length % 4));
return Buffer.from(
data.replace(/-/g, "+").replace(/_/g, "/") + pad,
"base64",
).toString("utf8");
}
function stripHtml(html) {
return html
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<\/div>/gi, "\n")
.replace(/<\/tr>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/gi, "'")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function walkParts(payload, acc = { text: "", html: "" }) {
if (!payload) return acc;
const mime = payload.mimeType || "";
const data = payload.body?.data;
if (data) {
if (mime === "text/plain") acc.text += decodeBase64Url(data);
else if (mime === "text/html") acc.html += decodeBase64Url(data);
}
for (const part of payload.parts || []) {
walkParts(part, acc);
}
return acc;
}
export function extractBody(payload) {
const { text, html } = walkParts(payload);
if (text.trim()) return text.trim();
if (html.trim()) return stripHtml(html);
return "";
}
export function header(payload, name) {
const match = payload?.headers?.find(
(h) => h.name?.toLowerCase() === name.toLowerCase(),
);
return match?.value || "";
}
export function summarizeMessage(message) {
const payload = message.payload || {};
return {
id: message.id,
threadId: message.threadId,
from: header(payload, "From"),
to: header(payload, "To"),
subject: header(payload, "Subject") || "(no subject)",
date: header(payload, "Date"),
snippet: message.snippet || "",
};
}
+53
View File
@@ -0,0 +1,53 @@
import { randomBytes } from "node:crypto";
import { getUserById, publicUser, saveTokens, upsertUser } from "../db.js";
import { HttpError, requireUser } from "../http.js";
import { authUrl, exchangeCode } from "../google.js";
export async function registerAuthRoutes(app) {
app.get("/auth/google", async (request, reply) => {
const state = randomBytes(16).toString("hex");
request.session.set("oauthState", state);
return reply.redirect(authUrl(state));
});
app.get("/callback", async (request, reply) => {
const { code, state, error } = request.query;
if (error) {
request.log.warn({ error }, "Google OAuth error");
return reply.redirect("/login?error=denied");
}
if (!code || typeof code !== "string") {
return reply.redirect("/login?error=missing_code");
}
const expected = request.session.get("oauthState");
if (!expected || state !== expected) {
return reply.redirect("/login?error=invalid_state");
}
request.session.set("oauthState", undefined);
try {
const { profile, tokens } = await exchangeCode(code);
const user = upsertUser(profile);
saveTokens(user.id, tokens);
request.session.set("userId", user.id);
return reply.redirect("/");
} catch (err) {
request.log.error(err);
return reply.redirect("/login?error=exchange_failed");
}
});
app.post("/auth/logout", async (request, reply) => {
request.session.delete();
return reply.send({ ok: true });
});
app.get("/api/me", async (request) => {
const userId = requireUser(request);
const user = getUserById(userId);
if (!user) {
throw new HttpError(401, "Not authenticated");
}
return publicUser(user);
});
}
+92
View File
@@ -0,0 +1,92 @@
import { gmailClientForUser } from "../google.js";
import { HttpError, requireUser } from "../http.js";
import { extractBody, summarizeMessage } from "../mime.js";
function fromGoogleError(err) {
const status = Number(err.code || err.response?.status || err.statusCode);
if (status === 401 || status === 403) {
return new HttpError(
401,
"Gmail access expired or was revoked. Sign in again.",
);
}
if (status === 404) {
return new HttpError(404, "Message not found");
}
return err;
}
export async function registerMessageRoutes(app) {
app.get("/api/messages", async (request) => {
const userId = requireUser(request);
const gmail = await gmailClientForUser(userId);
const q = typeof request.query.q === "string" ? request.query.q : "";
const pageToken =
typeof request.query.pageToken === "string"
? request.query.pageToken
: undefined;
const maxResults = Math.min(
50,
Math.max(1, Number(request.query.maxResults) || 20),
);
let list;
try {
list = await gmail.users.messages.list({
userId: "me",
q: q || undefined,
maxResults,
pageToken,
});
} catch (err) {
throw fromGoogleError(err);
}
const refs = list.data.messages || [];
const messages = [];
const settled = await Promise.allSettled(
refs.map((ref) =>
gmail.users.messages.get({
userId: "me",
id: ref.id,
format: "metadata",
metadataHeaders: ["From", "To", "Subject", "Date"],
}),
),
);
for (const result of settled) {
if (result.status === "fulfilled") {
messages.push(summarizeMessage(result.value.data));
}
}
return {
messages,
nextPageToken: list.data.nextPageToken || null,
resultSizeEstimate: list.data.resultSizeEstimate || 0,
};
});
app.get("/api/messages/:id", async (request) => {
const userId = requireUser(request);
const gmail = await gmailClientForUser(userId);
const { id } = request.params;
let data;
try {
({ data } = await gmail.users.messages.get({
userId: "me",
id,
format: "full",
}));
} catch (err) {
throw fromGoogleError(err);
}
return {
...summarizeMessage(data),
body: extractBody(data.payload),
};
});
}