mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
try to commit
This commit is contained in:
+6
-6
@@ -63,12 +63,12 @@ PAYPAL_CLIENT_ID=CHANGE_ME
|
|||||||
PAYPAL_CLIENT_SECRET=CHANGE_ME
|
PAYPAL_CLIENT_SECRET=CHANGE_ME
|
||||||
PAYPAL_ENV=sandbox
|
PAYPAL_ENV=sandbox
|
||||||
|
|
||||||
# ── Email (SMTP) ──────────────────────────────────────────────────────────────
|
# ── Email (Gmail API — OAuth2, over HTTPS) ────────────────────────────────────
|
||||||
# Gmail: enable 2FA → generate an App Password at myaccount.google.com/apppasswords
|
# Reuses the same OAuth client as GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET above —
|
||||||
SMTP_HOST=smtp.gmail.com
|
# enable the Gmail API on that project, then run:
|
||||||
SMTP_PORT=587
|
# node scripts/get_gmail_refresh_token.js
|
||||||
SMTP_USER=CHANGE_ME@gmail.com
|
# and paste the printed value below.
|
||||||
SMTP_PASS=CHANGE_ME_APP_PASSWORD
|
GMAIL_REFRESH_TOKEN=CHANGE_ME
|
||||||
EMAIL_FROM=CHANGE_ME@gmail.com
|
EMAIL_FROM=CHANGE_ME@gmail.com
|
||||||
|
|
||||||
OTP_EXPIRY_MINUTES=10
|
OTP_EXPIRY_MINUTES=10
|
||||||
|
|||||||
+8
-7
@@ -83,13 +83,14 @@ PAYPAL_CLIENT_ID=CHANGE_ME
|
|||||||
PAYPAL_CLIENT_SECRET=CHANGE_ME
|
PAYPAL_CLIENT_SECRET=CHANGE_ME
|
||||||
PAYPAL_ENV=live
|
PAYPAL_ENV=live
|
||||||
|
|
||||||
# ── Email (SMTP) ──────────────────────────────────────────────────────────────
|
# ── Email (Gmail API — OAuth2, over HTTPS) ────────────────────────────────────
|
||||||
# Gmail: enable 2FA → generate an App Password at myaccount.google.com/apppasswords
|
# Raw SMTP (port 25/465/587) is blocked outbound on Render and several other
|
||||||
# For higher volume use a transactional provider (Mailgun, Resend, SendGrid, etc.)
|
# PaaS hosts, so sending goes through the Gmail REST API over HTTPS instead.
|
||||||
SMTP_HOST=smtp.gmail.com
|
# Reuses the same OAuth client as GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET above —
|
||||||
SMTP_PORT=587
|
# just enable the Gmail API on that project, then run:
|
||||||
SMTP_USER=CHANGE_ME@gmail.com
|
# node scripts/get_gmail_refresh_token.js
|
||||||
SMTP_PASS=CHANGE_ME_APP_PASSWORD
|
# and paste the printed value below.
|
||||||
|
GMAIL_REFRESH_TOKEN=CHANGE_ME
|
||||||
EMAIL_FROM=CHANGE_ME@gmail.com
|
EMAIL_FROM=CHANGE_ME@gmail.com
|
||||||
|
|
||||||
OTP_EXPIRY_MINUTES=10
|
OTP_EXPIRY_MINUTES=10
|
||||||
|
|||||||
@@ -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);
|
||||||
+60
-34
@@ -1,35 +1,66 @@
|
|||||||
/***********************************************************************************************************************************************************************
|
/***********************************************************************************************************************************************************************
|
||||||
* File Name: email.service.js
|
* File Name: email.service.js
|
||||||
* Type of Program: Service
|
* 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 —
|
* 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,
|
* there is no admin UI or DB table. To add or change an email,
|
||||||
* edit that file directly and redeploy.
|
* edit that file directly and redeploy.
|
||||||
* Author: rgrgogu, Kenneth Obsequio (@lash0000)
|
* Author: rgrgogu, Kenneth Obsequio (@lash0000)
|
||||||
* Date Created: Oct. 6, 2025
|
* 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:
|
* HOW TO USE:
|
||||||
* const emailService = require('../services/email.service');
|
* const emailService = require('../services/email.service');
|
||||||
* await emailService.sendEmail({ to: user.email, type: 'OTP', data: { otp } });
|
* 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 { 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({
|
// Lazily initialised so the module can be required before env is loaded.
|
||||||
host: process.env.SMTP_HOST,
|
let _oauth2Client;
|
||||||
port,
|
const getOAuth2Client = () => {
|
||||||
secure: port === 465,
|
if (!_oauth2Client) {
|
||||||
requireTLS: port === 587,
|
_oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET);
|
||||||
auth: {
|
_oauth2Client.setCredentials({ refresh_token: process.env.GMAIL_REFRESH_TOKEN });
|
||||||
user: process.env.SMTP_USER,
|
}
|
||||||
pass: process.env.SMTP_PASS,
|
return _oauth2Client;
|
||||||
},
|
};
|
||||||
tls: {
|
|
||||||
rejectUnauthorized: false,
|
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 = {} }) => {
|
const sendEmail = async ({ to, type, data = {} }) => {
|
||||||
try {
|
try {
|
||||||
@@ -40,29 +71,24 @@ const sendEmail = async ({ to, type, data = {} }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { subject, html } = templateFn(data);
|
const { subject, html } = templateFn(data);
|
||||||
|
const accessToken = await getAccessToken();
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
const { data: result } = await axios.post(
|
||||||
transporter.sendMail(
|
GMAIL_SEND_URL,
|
||||||
{
|
{ raw: buildRawMessage({ to, subject, html }) },
|
||||||
from: {
|
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||||
name: "STARR System",
|
|
||||||
address: process.env.EMAIL_FROM,
|
|
||||||
},
|
|
||||||
to,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
},
|
|
||||||
(err, info) => {
|
|
||||||
if (err) return reject(err);
|
|
||||||
resolve(info);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
return result;
|
||||||
} catch (err) {
|
} 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 };
|
module.exports = { sendEmail, ping };
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
* ✓ database — PostgreSQL via Sequelize (CRITICAL)
|
* ✓ database — PostgreSQL via Sequelize (CRITICAL)
|
||||||
* ✓ cache — Redis PING; skipped in memory mode (optional)
|
* ✓ cache — Redis PING; skipped in memory mode (optional)
|
||||||
* ✓ storage — S3/Garage HeadBucket (optional)
|
* ✓ storage — S3/Garage HeadBucket (optional)
|
||||||
* ✓ smtp — Nodemailer connection verify (optional)
|
* ✓ email — Gmail API profile fetch (OAuth2) (optional)
|
||||||
*
|
*
|
||||||
* Checks intentionally excluded:
|
* Checks intentionally excluded:
|
||||||
* ✗ chibisafe — third-party CDN; not owned infrastructure
|
* ✗ chibisafe — third-party CDN; not owned infrastructure
|
||||||
@@ -36,7 +36,7 @@ const os = require('os');
|
|||||||
const sequelize = require('../config/db.config');
|
const sequelize = require('../config/db.config');
|
||||||
const redis = require('../config/redis.config');
|
const redis = require('../config/redis.config');
|
||||||
const { ping: pingS3 } = require('./s3.service');
|
const { ping: pingS3 } = require('./s3.service');
|
||||||
const { ping: pingSmtp } = require('./email.service');
|
const { ping: pingEmail } = require('./email.service');
|
||||||
|
|
||||||
const PKG = require('../package.json');
|
const PKG = require('../package.json');
|
||||||
const TIMEOUT_MS = 3000;
|
const TIMEOUT_MS = 3000;
|
||||||
@@ -85,12 +85,12 @@ const CHECKS = [
|
|||||||
run: pingS3,
|
run: pingS3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'smtp',
|
name: 'email',
|
||||||
critical: false,
|
critical: false,
|
||||||
enabled: !!process.env.SMTP_HOST,
|
enabled: !!(process.env.GOOGLE_CLIENT_ID && process.env.GMAIL_REFRESH_TOKEN),
|
||||||
skipNote: 'SMTP_HOST is not configured',
|
skipNote: 'GMAIL_REFRESH_TOKEN is not configured',
|
||||||
meta: { host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT) || 587 },
|
meta: { provider: 'gmail-api', account: process.env.EMAIL_FROM },
|
||||||
run: pingSmtp,
|
run: pingEmail,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ process.env.DB_PORT = '5432';
|
|||||||
process.env.S3_ENDPOINT = 'http://test-s3:3900';
|
process.env.S3_ENDPOINT = 'http://test-s3:3900';
|
||||||
process.env.S3_PUBLIC_URL = 'https://cdn.example.com';
|
process.env.S3_PUBLIC_URL = 'https://cdn.example.com';
|
||||||
process.env.S3_BUCKET = 'test-bucket';
|
process.env.S3_BUCKET = 'test-bucket';
|
||||||
process.env.SMTP_HOST = 'smtp.test.com';
|
process.env.GOOGLE_CLIENT_ID = 'test-client-id';
|
||||||
process.env.SMTP_PORT = '587';
|
process.env.GMAIL_REFRESH_TOKEN = 'test-refresh-token';
|
||||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379';
|
process.env.REDIS_URL = 'redis://127.0.0.1:6379';
|
||||||
|
|
||||||
// ── Mock all infrastructure before the service module loads ───────────────────
|
// ── 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 db = require('../../config/db.config');
|
||||||
const cache = require('../../config/redis.config');
|
const cache = require('../../config/redis.config');
|
||||||
const { ping: s3 } = require('../../services/s3.service');
|
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');
|
const { runDashboard, runReadiness } = require('../../services/health.service');
|
||||||
|
|
||||||
// ── Reset mocks between tests ─────────────────────────────────────────────────
|
// ── Reset mocks between tests ─────────────────────────────────────────────────
|
||||||
@@ -184,10 +184,10 @@ describe('runReadiness()', () => {
|
|||||||
expect(body).toHaveProperty('memory');
|
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();
|
allHealthy();
|
||||||
const { body } = await runReadiness();
|
const { body } = await runReadiness();
|
||||||
['database', 'cache', 'storage', 'smtp'].forEach((key) => {
|
['database', 'cache', 'storage', 'email'].forEach((key) => {
|
||||||
expect(body.checks).toHaveProperty(key);
|
expect(body.checks).toHaveProperty(key);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user