- Replace Clerk with self-hosted JWT auth (register/login/verify, bcrypt passwords, Gmail OTP with console fallback) - Add lib/db.ts pg pool + transaction helpers; seed script migrates legacy Clerk-era schema (drop clerk_id, enforce UUID ids and unique email) - Add owner-gated admin API: stats, users, drivers CRUD, rides - Add dashboard/ Vite React owner dashboard (login, overview, users, fleet, rides) with dev-server proxy to avoid Expo CORS middleware - Add scripts/set-owner.mjs for role management
97 lines
2.7 KiB
TypeScript
97 lines
2.7 KiB
TypeScript
// 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<string | null> => {
|
|
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<void> => {
|
|
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}`);
|
|
}
|
|
};
|