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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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(", ")}`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/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 || "",
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user