Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion apps/server/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { config } from "#config";

import { prisma } from "#utils/prisma";
import { getRedis } from "#utils/redis";
import { sendAuthOtpEmail } from "#auth/mailer";
import { sendAuthOtpEmail, sendWelcomeEmail, sendLoginAlertEmail } from "#auth/mailer";

import { createAuthMiddleware } from "better-auth/api";
import { invalidateSessionCache, invalidateCacheByToken } from "#utils/authCache";
Expand Down Expand Up @@ -137,6 +137,16 @@ export const auth = betterAuth({

databaseHooks: {
user: {
create: {
after: async (user) => {
try {
await sendWelcomeEmail(user.email, user.name || "there");
} catch (error) {
console.error("Failed to send welcome email for user:", user.email, error);
}
},
},

delete: {
before: async (user) => {
try {
Expand All @@ -154,6 +164,26 @@ export const auth = betterAuth({
},

session: {
create: {
after: async (session) => {
try {
const user = await prisma.user.findUnique({
where: { id: session.userId },
select: { email: true },
});

if (user?.email)
await sendLoginAlertEmail(user.email, {
ip: session.ipAddress || "Unknown",
device: session.userAgent || "Unknown",
timestamp: session.createdAt.toISOString(),
});
} catch (error) {
console.error("Failed to send login alert email for session:", session.id, error);
}
},
},

delete: {
after: async (session) => {
try {
Expand Down
174 changes: 77 additions & 97 deletions apps/server/src/auth/mailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import nodemailer from "nodemailer";

import { config, isDevelopment } from "#config";

import {
type LoginAlertMeta,
renderOtpEmail,
renderWelcomeEmail,
renderLoginAlertEmail,
} from "#mail/index";

import { logger } from "#utils/logger";

interface AuthOtpEmailPayload {
Expand All @@ -28,112 +35,85 @@ function getOtpEmailSubject(type: AuthOtpEmailPayload["type"]) {
}
}

function getOtpEmailHeadline(type: AuthOtpEmailPayload["type"]) {
switch (type) {
case "email-verification":
return "Verify your email";
case "forget-password":
return "Password reset code";
case "change-email":
return "Confirm email change";
case "sign-in":
default:
return "Use this code to sign in";
}
}
async function sendMail({
to,
subject,
text,
html,
}: {
to: string;
subject: string;
text: string;
html: string;
}) {
if (config.auth.emailProvider === "smtp") {
if (!validateSmtpConfig()) {
throw new Error("SMTP provider selected but SMTP environment values are incomplete");
}

function buildOtpEmailHtml(payload: AuthOtpEmailPayload) {
const headline = getOtpEmailHeadline(payload.type);

return `
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${headline}</title>
</head>
<body style="margin:0;padding:0;background:#f4f6fb;font-family:'Segoe UI',Arial,sans-serif;color:#111827;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="padding:28px 14px;background:#f4f6fb;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:620px;background:#ffffff;border:1px solid #e5e7eb;border-radius:18px;overflow:hidden;">
<tr>
<td style="padding:26px 30px;background:linear-gradient(135deg,#0f172a,#1e293b);color:#ffffff;">
<div style="font-size:12px;letter-spacing:0.12em;text-transform:uppercase;opacity:0.82;">VeriWorkly</div>
<h1 style="margin:10px 0 0 0;font-size:24px;line-height:1.25;font-weight:700;">${headline}</h1>
</td>
</tr>
<tr>
<td style="padding:30px;">
<p style="margin:0 0 14px 0;font-size:15px;line-height:1.7;color:#374151;">Hi,</p>
<p style="margin:0 0 20px 0;font-size:15px;line-height:1.7;color:#374151;">
Use the one-time verification code below to continue in VeriWorkly.
</p>
<div style="margin:20px 0 22px 0;padding:16px 18px;border:1px dashed #c7d2fe;border-radius:12px;background:#eef2ff;text-align:center;">
<div style="font-size:34px;letter-spacing:0.35em;font-weight:700;color:#1d4ed8;">${payload.otp}</div>
</div>
<p style="margin:0 0 14px 0;font-size:14px;line-height:1.7;color:#4b5563;">
This code expires in a few minutes and can only be used once.
</p>
<p style="margin:0;font-size:13px;line-height:1.7;color:#6b7280;">
If you did not request this code, you can safely ignore this email.
</p>
</td>
</tr>
<tr>
<td style="padding:0 30px 26px 30px;">
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0 0 16px 0;" />
<p style="margin:0;font-size:12px;color:#6b7280;line-height:1.7;">
VeriWorkly Security Team
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
const transporter = nodemailer.createTransport({
host: config.auth.smtpHost,
port: config.auth.smtpPort,
secure: config.auth.smtpSecure,
auth: {
user: config.auth.smtpUser,
pass: config.auth.smtpPass,
},
});

async function sendViaSmtp(payload: AuthOtpEmailPayload) {
if (!validateSmtpConfig()) {
throw new Error("SMTP provider selected but SMTP environment values are incomplete");
await transporter.sendMail({
from: config.auth.emailFrom,
to,
subject,
text,
html,
});
return;
}

const transporter = nodemailer.createTransport({
host: config.auth.smtpHost,
port: config.auth.smtpPort,
secure: config.auth.smtpSecure,
auth: {
user: config.auth.smtpUser,
pass: config.auth.smtpPass,
},
});
if (!isDevelopment) {
throw new Error("Console email provider is only available in development");
}

await transporter.sendMail({
from: config.auth.emailFrom,
to: payload.email,
subject: getOtpEmailSubject(payload.type),
text: `Your VeriWorkly verification code is: ${payload.otp}`,
html: buildOtpEmailHtml(payload),
logger.info("Email sent to console (dev mode)", {
to,
subject,
text,
});
}

/**
* Send authentication OTP email using premium HTML template
*/
export async function sendAuthOtpEmail(payload: AuthOtpEmailPayload): Promise<void> {
if (config.auth.emailProvider === "smtp") {
await sendViaSmtp(payload);
return;
}
const subject = getOtpEmailSubject(payload.type);
const text = `Your VeriWorkly verification code is: ${payload.otp}`;
const html = renderOtpEmail(payload.otp, payload.type);

if (!isDevelopment) {
throw new Error("Console OTP provider is only available in development");
}
await sendMail({ to: payload.email, subject, text, html });
}

logger.info("OTP generated (dev mode)", {
email: payload.email,
otp: payload.otp,
type: payload.type,
});
/**
* Send welcome email to a new user
*/
export async function sendWelcomeEmail(email: string, name: string): Promise<void> {
const subject = "Welcome to VeriWorkly!";
const text = `Welcome to VeriWorkly, ${name}! Start building your professional documents and portfolio today.`;

// Resolve dashboard URL
const dashboardUrl = `${config.auth.baseUrl.replace(/\/api\/v1\/auth\/?$/, "")}/dashboard`;
const html = renderWelcomeEmail(name, dashboardUrl);

await sendMail({ to: email, subject, text, html });
}

/**
* Send security login alert email
*/
export async function sendLoginAlertEmail(email: string, meta: LoginAlertMeta): Promise<void> {
const subject = "Security Alert: New Sign-in Detected";
const text = `A new login was detected on your VeriWorkly account. IP: ${meta.ip}, Device: ${meta.device}, Time: ${meta.timestamp}`;
const html = renderLoginAlertEmail(email, meta);

await sendMail({ to: email, subject, text, html });
}
3 changes: 3 additions & 0 deletions apps/server/src/mail/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { renderOtpEmail } from "./otp";
export { renderWelcomeEmail } from "./welcome";
export { renderLoginAlertEmail, type LoginAlertMeta } from "./loginAlert";
111 changes: 111 additions & 0 deletions apps/server/src/mail/layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
export interface MailLayoutOptions {
title: string;
preheader: string;
bodyHtml: string;
}

export function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

export function getBaseLayout({ title, preheader, bodyHtml }: MailLayoutOptions): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!--<![endif]-->
<title>${title}</title>
<style>
body {
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
@media only screen and (max-width: 600px) {
.email-container {
width: 100% !important;
border-radius: 16px !important;
margin: 10px 0 !important;
}
.content-cell {
padding: 36px 24px !important;
}
}
</style>
</head>
<body style="margin:0;padding:0;background-color:#f5f4ef;background-image:radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 35%), radial-gradient(circle at top right, rgba(96, 165, 250, 0.05), transparent 25%);color:#171717;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;width:100% !important;height:100% !important;">
<!-- Preheader text to optimize inbox previews -->
<div style="display:none;font-size:1px;color:#f5f4ef;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
${preheader}
</div>

<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color:transparent;width:100%;">
<tr>
<td align="center" style="padding:48px 14px 48px 14px;">
<!-- Master Container Card (Floating Editorial design with 32px rounded-4xl) -->
<table class="email-container" role="presentation" width="560" cellspacing="0" cellpadding="0" style="max-width:560px;background-color:#ffffff;border:1px solid rgba(23, 23, 23, 0.08);border-radius:32px;box-shadow:0 30px 90px -50px rgba(23, 23, 23, 0.25);overflow:hidden;">
<!-- Header Brand Branding -->
<tr>
<td align="center" style="padding:40px 48px 0 48px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-bottom:1px solid rgba(23, 23, 23, 0.06);padding-bottom:24px;">
<tr>
<td align="left">
<div style="font-size:12px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#2563eb;">
VeriWorkly <span style="color:#5f5c54;font-weight:500;">/ Securing Careers</span>
</div>
</td>
<td align="right" style="font-size:11px;color:#8f8c85;font-weight:500;letter-spacing:0.05em;text-transform:uppercase;">
System Msg
</td>
</tr>
</table>
</td>
</tr>

<!-- Email Body Content -->
<tr>
<td class="content-cell" style="padding:40px 48px;vertical-align:top;">
${bodyHtml}
</td>
</tr>

<!-- Footer -->
<tr>
<td align="center" style="padding:0 48px 40px 48px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-top:1px solid rgba(23, 23, 23, 0.06);padding-top:28px;">
<tr>
<td align="center" style="font-size:12px;color:#5f5c54;line-height:1.8;text-align:center;">
<p style="margin:0 0 8px 0;">
This email was sent by VeriWorkly.
</p>
<p style="margin:0 0 16px 0;color:#8f8c85;">
Secure resume, portfolio, and career-building infrastructure.
</p>
<p style="margin:0;font-weight:600;">
<a href="https://veriworkly.com" target="_blank" style="color:#2563eb;text-decoration:none;margin:0 8px;">Website</a>
<span style="color:rgba(23, 23, 23, 0.15);">•</span>
<a href="https://veriworkly.com/docs" target="_blank" style="color:#2563eb;text-decoration:none;margin:0 8px;">Docs</a>
<span style="color:rgba(23, 23, 23, 0.15);">•</span>
<a href="mailto:support@veriworkly.com" style="color:#2563eb;text-decoration:none;margin:0 8px;">Support</a>
</p>
<p style="margin:24px 0 0 0;font-size:11px;color:#a3a098;">
&copy; ${new Date().getFullYear()} VeriWorkly. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
Loading
Loading