From a7a72096a5a61099d8a248201b6c9e41958ad7bd Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Tue, 14 Jul 2026 12:34:19 +0800 Subject: [PATCH] try to commit --- .env-development | 12 ++-- .env-production | 15 ++--- scripts/get_gmail_refresh_token.js | 79 ++++++++++++++++++++++++ services/email.service.js | 96 +++++++++++++++++++----------- services/health.service.js | 16 ++--- tests/services/health.test.js | 10 ++-- 6 files changed, 167 insertions(+), 61 deletions(-) create mode 100644 scripts/get_gmail_refresh_token.js diff --git a/.env-development b/.env-development index 14a5232..31a60e9 100644 --- a/.env-development +++ b/.env-development @@ -63,12 +63,12 @@ PAYPAL_CLIENT_ID=CHANGE_ME PAYPAL_CLIENT_SECRET=CHANGE_ME PAYPAL_ENV=sandbox -# ── Email (SMTP) ────────────────────────────────────────────────────────────── -# Gmail: enable 2FA → generate an App Password at myaccount.google.com/apppasswords -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USER=CHANGE_ME@gmail.com -SMTP_PASS=CHANGE_ME_APP_PASSWORD +# ── Email (Gmail API — OAuth2, over HTTPS) ──────────────────────────────────── +# Reuses the same OAuth client as GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET above — +# enable the Gmail API on that project, then run: +# node scripts/get_gmail_refresh_token.js +# and paste the printed value below. +GMAIL_REFRESH_TOKEN=CHANGE_ME EMAIL_FROM=CHANGE_ME@gmail.com OTP_EXPIRY_MINUTES=10 diff --git a/.env-production b/.env-production index aa1b7d2..d49e7d4 100644 --- a/.env-production +++ b/.env-production @@ -83,13 +83,14 @@ PAYPAL_CLIENT_ID=CHANGE_ME PAYPAL_CLIENT_SECRET=CHANGE_ME PAYPAL_ENV=live -# ── Email (SMTP) ────────────────────────────────────────────────────────────── -# Gmail: enable 2FA → generate an App Password at myaccount.google.com/apppasswords -# For higher volume use a transactional provider (Mailgun, Resend, SendGrid, etc.) -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USER=CHANGE_ME@gmail.com -SMTP_PASS=CHANGE_ME_APP_PASSWORD +# ── Email (Gmail API — OAuth2, over HTTPS) ──────────────────────────────────── +# Raw SMTP (port 25/465/587) is blocked outbound on Render and several other +# PaaS hosts, so sending goes through the Gmail REST API over HTTPS instead. +# Reuses the same OAuth client as GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET above — +# just enable the Gmail API on that project, then run: +# node scripts/get_gmail_refresh_token.js +# and paste the printed value below. +GMAIL_REFRESH_TOKEN=CHANGE_ME EMAIL_FROM=CHANGE_ME@gmail.com OTP_EXPIRY_MINUTES=10 diff --git a/scripts/get_gmail_refresh_token.js b/scripts/get_gmail_refresh_token.js new file mode 100644 index 0000000..5f03fae --- /dev/null +++ b/scripts/get_gmail_refresh_token.js @@ -0,0 +1,79 @@ +/*********************************************************************************************************************************************************************** + * File Name: get_gmail_refresh_token.js + * Type of Program: One-time setup script (run locally, not deployed) + * Description: Mints a long-lived Gmail API refresh token for the sending mailbox + * (EMAIL_FROM, e.g. services.philpro@gmail.com), so services/email.service.js + * can send mail over HTTPS via the Gmail API instead of SMTP. + * + * Prerequisites (one-time, in Google Cloud Console — same project as GOOGLE_CLIENT_ID): + * 1. APIs & Services → Library → enable "Gmail API". + * 2. APIs & Services → Credentials → open the OAuth client used for GOOGLE_CLIENT_ID + * → Authorized redirect URIs → add: http://localhost:5555/oauth2callback + * (You can remove this URI again after this script succeeds.) + * + * Usage: + * node scripts/get_gmail_refresh_token.js + * → prints an auth URL. Open it in a browser, sign in AS the EMAIL_FROM mailbox, + * approve the "Send email on your behalf" consent screen. + * → the script prints GMAIL_REFRESH_TOKEN — copy it into Render's env vars. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 14, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +require('dotenv').config(); +const http = require('http'); +const { OAuth2Client } = require('google-auth-library'); + +const REDIRECT_URI = 'http://localhost:5555/oauth2callback'; +const SCOPE = 'https://www.googleapis.com/auth/gmail.send'; + +const clientId = process.env.GOOGLE_CLIENT_ID; +const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + +if (!clientId || !clientSecret) { + console.error('Missing GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET in .env'); + process.exit(1); +} + +const client = new OAuth2Client(clientId, clientSecret, REDIRECT_URI); + +const authUrl = client.generateAuthUrl({ + access_type: 'offline', + prompt: 'consent', // forces a refresh_token even if this account consented before + scope: [SCOPE], +}); + +console.log('\nOpen this URL, sign in AS the EMAIL_FROM mailbox, and approve access:\n'); +console.log(authUrl, '\n'); +console.log(`Waiting for the redirect on ${REDIRECT_URI} ...\n`); + +const server = http.createServer(async (req, res) => { + if (!req.url.startsWith('/oauth2callback')) { + res.writeHead(404).end(); + return; + } + + const code = new URL(req.url, REDIRECT_URI).searchParams.get('code'); + + if (!code) { + res.writeHead(400).end('Missing ?code — check the URL Google redirected you to.'); + return; + } + + try { + const { tokens } = await client.getToken(code); + + res.writeHead(200, { 'Content-Type': 'text/plain' }).end('Done — check your terminal.'); + console.log('GMAIL_REFRESH_TOKEN=' + tokens.refresh_token, '\n'); + console.log('Copy the line above into Render\'s environment variables, then redeploy.'); + } catch (err) { + res.writeHead(500).end('Token exchange failed — see terminal.'); + console.error('Token exchange failed:', err.response?.data || err.message); + } finally { + server.close(); + } +}); + +server.listen(5555); diff --git a/services/email.service.js b/services/email.service.js index d3b6696..af651d6 100644 --- a/services/email.service.js +++ b/services/email.service.js @@ -1,35 +1,66 @@ /*********************************************************************************************************************************************************************** * File Name: email.service.js * Type of Program: Service - * Description: Nodemailer-based email service. + * Description: Gmail API-based email service (HTTPS, gmail.googleapis.com). + * Render blocks outbound raw SMTP (ports 25/465/587), so nodemailer's + * SMTP transport to smtp.gmail.com can never connect from this host — + * every send failed with nodemailer's own "Connection timeout" after + * its 2-minute connectionTimeout expired. Sending over the Gmail REST + * API instead rides on HTTPS/443, which isn't blocked. + * Auth is OAuth2 with a long-lived refresh token for services.philpro@gmail.com + * (see scripts/get_gmail_refresh_token.js to mint one), reusing the same + * GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET already registered for Google login. * Subject + body per type are hardcoded in data/email_body.data.js — * there is no admin UI or DB table. To add or change an email, * edit that file directly and redeploy. * Author: rgrgogu, Kenneth Obsequio (@lash0000) * Date Created: Oct. 6, 2025 + * Date Modified: Jul. 14, 2026 — Replaced nodemailer/SMTP with Gmail API over HTTPS; + * SMTP was silently black-holed by Render's network. (Kenneth Obsequio) *********************************************************************************************************************************************************************** * HOW TO USE: * const emailService = require('../services/email.service'); * await emailService.sendEmail({ to: user.email, type: 'OTP', data: { otp } }); ***********************************************************************************************************************************************************************/ -const nodemailer = require('nodemailer'); +const axios = require('axios'); +const { OAuth2Client } = require('google-auth-library'); const { emailTemplates } = require('../data/email_body.data'); -const port = Number(process.env.SMTP_PORT); +const GMAIL_SEND_URL = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send'; +const GMAIL_PROFILE_URL = 'https://gmail.googleapis.com/gmail/v1/users/me/profile'; -const transporter = nodemailer.createTransport({ - host: process.env.SMTP_HOST, - port, - secure: port === 465, - requireTLS: port === 587, - auth: { - user: process.env.SMTP_USER, - pass: process.env.SMTP_PASS, - }, - tls: { - rejectUnauthorized: false, - }, -}); +// Lazily initialised so the module can be required before env is loaded. +let _oauth2Client; +const getOAuth2Client = () => { + if (!_oauth2Client) { + _oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET); + _oauth2Client.setCredentials({ refresh_token: process.env.GMAIL_REFRESH_TOKEN }); + } + return _oauth2Client; +}; + +const getAccessToken = async () => { + const { token } = await getOAuth2Client().getAccessToken(); + if (!token) throw new Error('Failed to obtain Gmail access token'); + return token; +}; + +// Encodes a MIME header value so non-ASCII subjects survive transit (RFC 2047). +const encodeHeader = (value) => `=?UTF-8?B?${Buffer.from(value, 'utf-8').toString('base64')}?=`; + +const buildRawMessage = ({ to, subject, html }) => { + const message = [ + `From: "STARR System" <${process.env.EMAIL_FROM}>`, + `To: ${to}`, + `Subject: ${encodeHeader(subject)}`, + 'MIME-Version: 1.0', + 'Content-Type: text/html; charset=UTF-8', + '', + html, + ].join('\r\n'); + + return Buffer.from(message).toString('base64url'); +}; const sendEmail = async ({ to, type, data = {} }) => { try { @@ -40,29 +71,24 @@ const sendEmail = async ({ to, type, data = {} }) => { } const { subject, html } = templateFn(data); + const accessToken = await getAccessToken(); - return await new Promise((resolve, reject) => { - transporter.sendMail( - { - from: { - name: "STARR System", - address: process.env.EMAIL_FROM, - }, - to, - subject, - html, - }, - (err, info) => { - if (err) return reject(err); - resolve(info); - } - ); - }); + const { data: result } = await axios.post( + GMAIL_SEND_URL, + { raw: buildRawMessage({ to, subject, html }) }, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + + return result; } catch (err) { - throw new Error(err.message); + throw new Error(err.response?.data?.error?.message || err.message); } }; -const ping = () => transporter.verify(); +// Confirms the refresh token is valid and the Gmail API is reachable. +const ping = async () => { + const accessToken = await getAccessToken(); + await axios.get(GMAIL_PROFILE_URL, { headers: { Authorization: `Bearer ${accessToken}` } }); +}; module.exports = { sendEmail, ping }; diff --git a/services/health.service.js b/services/health.service.js index a5f34fc..f5b201c 100644 --- a/services/health.service.js +++ b/services/health.service.js @@ -20,7 +20,7 @@ * ✓ database — PostgreSQL via Sequelize (CRITICAL) * ✓ cache — Redis PING; skipped in memory mode (optional) * ✓ storage — S3/Garage HeadBucket (optional) - * ✓ smtp — Nodemailer connection verify (optional) + * ✓ email — Gmail API profile fetch (OAuth2) (optional) * * Checks intentionally excluded: * ✗ chibisafe — third-party CDN; not owned infrastructure @@ -35,8 +35,8 @@ const os = require('os'); const sequelize = require('../config/db.config'); const redis = require('../config/redis.config'); -const { ping: pingS3 } = require('./s3.service'); -const { ping: pingSmtp } = require('./email.service'); +const { ping: pingS3 } = require('./s3.service'); +const { ping: pingEmail } = require('./email.service'); const PKG = require('../package.json'); const TIMEOUT_MS = 3000; @@ -85,12 +85,12 @@ const CHECKS = [ run: pingS3, }, { - name: 'smtp', + name: 'email', critical: false, - enabled: !!process.env.SMTP_HOST, - skipNote: 'SMTP_HOST is not configured', - meta: { host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT) || 587 }, - run: pingSmtp, + enabled: !!(process.env.GOOGLE_CLIENT_ID && process.env.GMAIL_REFRESH_TOKEN), + skipNote: 'GMAIL_REFRESH_TOKEN is not configured', + meta: { provider: 'gmail-api', account: process.env.EMAIL_FROM }, + run: pingEmail, }, ]; diff --git a/tests/services/health.test.js b/tests/services/health.test.js index 850462e..2e084d8 100644 --- a/tests/services/health.test.js +++ b/tests/services/health.test.js @@ -6,8 +6,8 @@ process.env.DB_PORT = '5432'; process.env.S3_ENDPOINT = 'http://test-s3:3900'; process.env.S3_PUBLIC_URL = 'https://cdn.example.com'; process.env.S3_BUCKET = 'test-bucket'; -process.env.SMTP_HOST = 'smtp.test.com'; -process.env.SMTP_PORT = '587'; +process.env.GOOGLE_CLIENT_ID = 'test-client-id'; +process.env.GMAIL_REFRESH_TOKEN = 'test-refresh-token'; process.env.REDIS_URL = 'redis://127.0.0.1:6379'; // ── Mock all infrastructure before the service module loads ─────────────────── @@ -19,7 +19,7 @@ jest.mock('../../services/email.service', () => ({ ping: jest.fn() })); const db = require('../../config/db.config'); const cache = require('../../config/redis.config'); const { ping: s3 } = require('../../services/s3.service'); -const { ping: smtp } = require('../../services/email.service'); +const { ping: smtp } = require('../../services/email.service'); // alias kept for test-body brevity; JSON key is now "email" const { runDashboard, runReadiness } = require('../../services/health.service'); // ── Reset mocks between tests ───────────────────────────────────────────────── @@ -184,10 +184,10 @@ describe('runReadiness()', () => { expect(body).toHaveProperty('memory'); }); - test('checks object has database, cache, storage, smtp keys', async () => { + test('checks object has database, cache, storage, email keys', async () => { allHealthy(); const { body } = await runReadiness(); - ['database', 'cache', 'storage', 'smtp'].forEach((key) => { + ['database', 'cache', 'storage', 'email'].forEach((key) => { expect(body.checks).toHaveProperty(key); }); });