// Sends transactional email through the Gmail API using an OAuth2 refresh // token (no third-party email service needed on a self-hosted box). // // Setup (one-time): // 1. Google Cloud console -> enable Gmail API, create an OAuth client. // 2. Generate a refresh token with scope // https://www.googleapis.com/auth/gmail.send // 3. Set GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN, GMAIL_FROM. const TOKEN_URL = "https://oauth2.googleapis.com/token"; const SEND_URL = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"; let cachedAccessToken: { token: string; expiresAt: number } | null = null; const getAccessToken = async (): Promise => { const clientId = process.env.GMAIL_CLIENT_ID; const clientSecret = process.env.GMAIL_CLIENT_SECRET; const refreshToken = process.env.GMAIL_REFRESH_TOKEN; if (!clientId || !clientSecret || !refreshToken) return null; if (cachedAccessToken && cachedAccessToken.expiresAt > Date.now() + 60_000) { return cachedAccessToken.token; } const response = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, grant_type: "refresh_token", }), }); if (!response.ok) { throw new Error(`Gmail token exchange failed: ${response.status}`); } const data = (await response.json()) as { access_token: string; expires_in: number; }; cachedAccessToken = { token: data.access_token, expiresAt: Date.now() + data.expires_in * 1000, }; return cachedAccessToken.token; }; export const sendEmail = async ( to: string, subject: string, text: string, ): Promise => { const accessToken = await getAccessToken(); if (!accessToken) { // Not configured: fall back to the server log so development still works. console.log(`[MAIL to=${to}] ${subject}\n${text}`); return; } const from = process.env.GMAIL_FROM; if (!from) throw new Error("Missing GMAIL_FROM."); const mime = [ `From: ${from}`, `To: ${to}`, `Subject: ${subject}`, "Content-Type: text/plain; charset=UTF-8", "", text, ].join("\r\n"); const response = await fetch(SEND_URL, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ raw: Buffer.from(mime) .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""), }), }); if (!response.ok) { throw new Error(`Gmail send failed: ${response.status}`); } };