feat: send welcome emails through smtp
This commit is contained in:
@@ -22,9 +22,12 @@ PASSWORD_RESET_TTL_HOURS=24
|
|||||||
AUDIT_LOG_PAGE_SIZE=50
|
AUDIT_LOG_PAGE_SIZE=50
|
||||||
|
|
||||||
# Optional welcome e-mails sent when a student creates their own account.
|
# Optional welcome e-mails sent when a student creates their own account.
|
||||||
# Create an API key and verify this sender in Resend before setting both values.
|
# Configure all five values together. Port 465 uses SSL; port 587 uses STARTTLS.
|
||||||
RESEND_API_KEY=
|
SMTP_HOST=
|
||||||
EMAIL_FROM=Compor HUB <noreply@your-domain.com>
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
MAIL_FROM=Compor HUB <noreply@your-domain.com>
|
||||||
SUPERADMIN_NAME=Compor HUB Superadmin
|
SUPERADMIN_NAME=Compor HUB Superadmin
|
||||||
|
|
||||||
# Bunny Stream. Set all three in Portainer to enable instructor uploads and
|
# Bunny Stream. Set all three in Portainer to enable instructor uploads and
|
||||||
|
|||||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"fastify": "^5.3.2",
|
"fastify": "^5.3.2",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"lucide-react": "0.344.0",
|
"lucide-react": "0.344.0",
|
||||||
|
"nodemailer": "^10.0.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/pg": "^8.15.4",
|
"@types/pg": "^8.15.4",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
@@ -1475,6 +1477,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/pg": {
|
"node_modules/@types/pg": {
|
||||||
"version": "8.23.1",
|
"version": "8.23.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||||
@@ -2231,6 +2243,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "10.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.1.tgz",
|
||||||
|
"integrity": "sha512-c+gU9cL9HLDax3vjxL88kW+6NOgdtEUWaZ+AUtxdJR6LLhf0kGdCLExof7yiKW7zdO9EfXCSIgmhGyFmUM0mYQ==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/obliterator": {
|
"node_modules/obliterator": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"fastify": "^5.3.2",
|
"fastify": "^5.3.2",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"lucide-react": "0.344.0",
|
"lucide-react": "0.344.0",
|
||||||
|
"nodemailer": "^10.0.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/pg": "^8.15.4",
|
"@types/pg": "^8.15.4",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ const environmentSchema = z.object({
|
|||||||
INVITATION_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 90).default(24 * 7),
|
INVITATION_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 90).default(24 * 7),
|
||||||
PASSWORD_RESET_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 30).default(24),
|
PASSWORD_RESET_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 30).default(24),
|
||||||
AUDIT_LOG_PAGE_SIZE: z.coerce.number().int().min(10).max(500).default(50),
|
AUDIT_LOG_PAGE_SIZE: z.coerce.number().int().min(10).max(500).default(50),
|
||||||
RESEND_API_KEY: optionalEnvironmentValue(z.string().min(20)),
|
SMTP_HOST: optionalEnvironmentValue(z.string().trim().min(1).max(255)),
|
||||||
EMAIL_FROM: optionalEnvironmentValue(z.string().trim().min(3).max(320)),
|
SMTP_PORT: optionalEnvironmentValue(z.coerce.number().int().min(1).max(65_535)),
|
||||||
|
SMTP_USER: optionalEnvironmentValue(z.string().trim().min(1).max(320)),
|
||||||
|
SMTP_PASS: optionalEnvironmentValue(z.string().min(1).max(1_000)),
|
||||||
|
MAIL_FROM: optionalEnvironmentValue(z.string().trim().min(3).max(320)),
|
||||||
BUNNY_STREAM_LIBRARY_ID: optionalEnvironmentValue(z.coerce.number().int().positive()),
|
BUNNY_STREAM_LIBRARY_ID: optionalEnvironmentValue(z.coerce.number().int().positive()),
|
||||||
BUNNY_STREAM_API_KEY: optionalEnvironmentValue(z.string().min(20)),
|
BUNNY_STREAM_API_KEY: optionalEnvironmentValue(z.string().min(20)),
|
||||||
BUNNY_EMBED_TOKEN_KEY: optionalEnvironmentValue(z.string().min(20)),
|
BUNNY_EMBED_TOKEN_KEY: optionalEnvironmentValue(z.string().min(20)),
|
||||||
@@ -61,6 +64,7 @@ if (bunnyStorageConfigurationValues.some(Boolean) && !bunnyStorageConfigurationV
|
|||||||
throw new Error('BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, BUNNY_STORAGE_ENDPOINT, and BUNNY_STORAGE_CDN_HOST must be configured together.');
|
throw new Error('BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, BUNNY_STORAGE_ENDPOINT, and BUNNY_STORAGE_CDN_HOST must be configured together.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Boolean(config.RESEND_API_KEY) !== Boolean(config.EMAIL_FROM)) {
|
const smtpConfigurationValues = [config.SMTP_HOST, config.SMTP_PORT, config.SMTP_USER, config.SMTP_PASS, config.MAIL_FROM];
|
||||||
throw new Error('RESEND_API_KEY and EMAIL_FROM must be configured together.');
|
if (smtpConfigurationValues.some(Boolean) && !smtpConfigurationValues.every(Boolean)) {
|
||||||
|
throw new Error('SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, and MAIL_FROM must be configured together.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const token = await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL });
|
const token = await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL });
|
||||||
try {
|
try {
|
||||||
const sent = await sendWelcomeEmail({ name: user.name, email: user.email });
|
const sent = await sendWelcomeEmail({ name: user.name, email: user.email });
|
||||||
if (!sent && config.RESEND_API_KEY) request.log.warn({ email: user.email }, 'Welcome email was rejected by the provider');
|
if (!sent && config.SMTP_HOST) request.log.warn({ email: user.email }, 'Welcome email was rejected by the SMTP server');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
request.log.error(error, 'Welcome email could not be sent');
|
request.log.error(error, 'Welcome email could not be sent');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
type WelcomeRecipient = { name: string; email: string };
|
type WelcomeRecipient = { name: string; email: string };
|
||||||
|
|
||||||
export async function sendWelcomeEmail(recipient: WelcomeRecipient) {
|
export async function sendWelcomeEmail(recipient: WelcomeRecipient) {
|
||||||
if (!config.RESEND_API_KEY || !config.EMAIL_FROM) return false;
|
if (!config.SMTP_HOST || !config.SMTP_PORT || !config.SMTP_USER || !config.SMTP_PASS || !config.MAIL_FROM) return false;
|
||||||
|
|
||||||
const escapedName = recipient.name.replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character] || character));
|
const escapedName = recipient.name.replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character] || character));
|
||||||
const response = await fetch('https://api.resend.com/emails', {
|
const transporter = nodemailer.createTransport({
|
||||||
method: 'POST',
|
host: config.SMTP_HOST,
|
||||||
headers: { Authorization: `Bearer ${config.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
|
port: config.SMTP_PORT,
|
||||||
body: JSON.stringify({
|
secure: config.SMTP_PORT === 465,
|
||||||
from: config.EMAIL_FROM,
|
auth: { user: config.SMTP_USER, pass: config.SMTP_PASS },
|
||||||
to: [recipient.email],
|
});
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: config.MAIL_FROM,
|
||||||
|
to: recipient.email,
|
||||||
subject: 'Bem-vindo(a) ao Compor HUB',
|
subject: 'Bem-vindo(a) ao Compor HUB',
|
||||||
html: `<main style="font-family:Arial,sans-serif;color:#18181b;line-height:1.55"><h1>Bem-vindo(a), ${escapedName}!</h1><p>Sua conta no <strong>Compor HUB</strong> já está pronta.</p><p>Agora você pode acessar cursos, acompanhar suas aulas e baixar materiais complementares.</p><p><a href="${config.FRONTEND_ORIGIN}" style="display:inline-block;background:#f97316;color:#fff;padding:12px 18px;border-radius:8px;text-decoration:none;font-weight:700">Acessar Compor HUB</a></p></main>`,
|
html: `<main style="font-family:Arial,sans-serif;color:#18181b;line-height:1.55"><h1>Bem-vindo(a), ${escapedName}!</h1><p>Sua conta no <strong>Compor HUB</strong> já está pronta.</p><p>Agora você pode acessar cursos, acompanhar suas aulas e baixar materiais complementares.</p><p><a href="${config.FRONTEND_ORIGIN}" style="display:inline-block;background:#f97316;color:#fff;padding:12px 18px;border-radius:8px;text-decoration:none;font-weight:700">Acessar Compor HUB</a></p></main>`,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
return response.ok;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user