|
|
|
|
@@ -1,13 +1,345 @@
|
|
|
|
|
/**
|
|
|
|
|
* Email sending utilities (stubbed for now)
|
|
|
|
|
* Will be implemented in Workstream G with actual Postmark integration
|
|
|
|
|
* Email sending utilities using Postmark
|
|
|
|
|
* Implements Workstream G: Email Service (Backend)
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { OrgRole } from "@reviq/db-schema";
|
|
|
|
|
import { ServerClient } from "postmark";
|
|
|
|
|
import {
|
|
|
|
|
BASE_URL,
|
|
|
|
|
EMAIL_DEV_MODE,
|
|
|
|
|
EMAIL_FROM,
|
|
|
|
|
EMAIL_VERIFICATION_EXPIRY_HOURS,
|
|
|
|
|
LOGIN_CONFIRMATION_EXPIRY_MINUTES,
|
|
|
|
|
ORG_INVITE_EXPIRY_DAYS,
|
|
|
|
|
PASSWORD_RESET_EXPIRY_HOURS,
|
|
|
|
|
POSTMARK_API_KEY,
|
|
|
|
|
} from "../constants.js";
|
|
|
|
|
|
|
|
|
|
// ===== Types =====
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the base URL for email links
|
|
|
|
|
* Read at function call time to allow environment variable changes
|
|
|
|
|
* Email send result
|
|
|
|
|
*/
|
|
|
|
|
const getBaseUrl = (): string => Bun.env.APP_URL ?? "http://localhost:6827";
|
|
|
|
|
export interface EmailResult {
|
|
|
|
|
success: boolean;
|
|
|
|
|
messageId?: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Postmark Client =====
|
|
|
|
|
|
|
|
|
|
let postmarkClient: ServerClient | null = null;
|
|
|
|
|
|
|
|
|
|
const getPostmarkClient = (): ServerClient => {
|
|
|
|
|
if (!postmarkClient) {
|
|
|
|
|
if (!POSTMARK_API_KEY) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
"POSTMARK_API_KEY is required when EMAIL_DEV_MODE is false",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
postmarkClient = new ServerClient(POSTMARK_API_KEY);
|
|
|
|
|
}
|
|
|
|
|
return postmarkClient;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ===== URL Helpers =====
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build a URL with query parameters using the URL constructor
|
|
|
|
|
*/
|
|
|
|
|
const buildUrl = (path: string, params: Record<string, string>): string => {
|
|
|
|
|
const url = new URL(path, BASE_URL);
|
|
|
|
|
for (const [key, value] of Object.entries(params)) {
|
|
|
|
|
url.searchParams.set(key, value);
|
|
|
|
|
}
|
|
|
|
|
return url.toString();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ===== HTML Escaping =====
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Escape HTML special characters to prevent XSS
|
|
|
|
|
*/
|
|
|
|
|
const escapeHtml = (unsafe: string): string =>
|
|
|
|
|
unsafe
|
|
|
|
|
.replace(/&/g, "&")
|
|
|
|
|
.replace(/</g, "<")
|
|
|
|
|
.replace(/>/g, ">")
|
|
|
|
|
.replace(/"/g, """)
|
|
|
|
|
.replace(/'/g, "'");
|
|
|
|
|
|
|
|
|
|
// ===== Core Email Function =====
|
|
|
|
|
|
|
|
|
|
interface SendEmailParams {
|
|
|
|
|
to: string;
|
|
|
|
|
subject: string;
|
|
|
|
|
htmlBody: string;
|
|
|
|
|
textBody: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Send an email via Postmark (or log in dev mode)
|
|
|
|
|
*/
|
|
|
|
|
const sendEmail = async (params: SendEmailParams): Promise<EmailResult> => {
|
|
|
|
|
const { to, subject, htmlBody, textBody } = params;
|
|
|
|
|
|
|
|
|
|
// Dev mode: log instead of sending
|
|
|
|
|
if (EMAIL_DEV_MODE) {
|
|
|
|
|
console.log("=== DEV MODE EMAIL ===");
|
|
|
|
|
console.log(`To: ${to}`);
|
|
|
|
|
console.log(`Subject: ${subject}`);
|
|
|
|
|
console.log(`Body:\n${textBody}`);
|
|
|
|
|
console.log("======================");
|
|
|
|
|
return { success: true, messageId: "dev-mode" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const client = getPostmarkClient();
|
|
|
|
|
const result = await client.sendEmail({
|
|
|
|
|
From: EMAIL_FROM,
|
|
|
|
|
To: to,
|
|
|
|
|
Subject: subject,
|
|
|
|
|
HtmlBody: htmlBody,
|
|
|
|
|
TextBody: textBody,
|
|
|
|
|
});
|
|
|
|
|
return { success: true, messageId: result.MessageID };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
|
|
|
console.error(`Failed to send email to ${to}:`, message);
|
|
|
|
|
return { success: false, error: message };
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ===== Template Helpers =====
|
|
|
|
|
|
|
|
|
|
const formatExpiryHours = (hours: number): string => {
|
|
|
|
|
if (hours === 1) {
|
|
|
|
|
return "1 hour";
|
|
|
|
|
}
|
|
|
|
|
return `${hours} hours`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatExpiryMinutes = (minutes: number): string => {
|
|
|
|
|
if (minutes === 1) {
|
|
|
|
|
return "1 minute";
|
|
|
|
|
}
|
|
|
|
|
return `${minutes} minutes`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatExpiryDays = (days: number): string => {
|
|
|
|
|
if (days === 1) {
|
|
|
|
|
return "1 day";
|
|
|
|
|
}
|
|
|
|
|
return `${days} days`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatRoleDisplay = (role: OrgRole): string => {
|
|
|
|
|
switch (role) {
|
|
|
|
|
case "owner":
|
|
|
|
|
return "Owner";
|
|
|
|
|
case "admin":
|
|
|
|
|
return "Admin";
|
|
|
|
|
case "member":
|
|
|
|
|
return "Member";
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the correct article (a/an) for a role
|
|
|
|
|
*/
|
|
|
|
|
const getArticleForRole = (role: OrgRole): string => {
|
|
|
|
|
return role === "owner" || role === "admin" ? "an" : "a";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ===== Email Templates =====
|
|
|
|
|
|
|
|
|
|
// Common styles
|
|
|
|
|
const emailStyles = `font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5;`;
|
|
|
|
|
const containerStyles =
|
|
|
|
|
"max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 40px;";
|
|
|
|
|
const headingStyles = "margin: 0 0 24px; font-size: 24px; color: #1a1a1a;";
|
|
|
|
|
const paragraphStyles =
|
|
|
|
|
"margin: 0 0 24px; font-size: 16px; color: #4a4a4a; line-height: 1.5;";
|
|
|
|
|
const buttonStyles =
|
|
|
|
|
"display: inline-block; background-color: #0066cc; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;";
|
|
|
|
|
const footerStyles = "margin: 24px 0 0; font-size: 14px; color: #6a6a6a;";
|
|
|
|
|
|
|
|
|
|
// Verification Email
|
|
|
|
|
const buildVerificationEmailHtml = (
|
|
|
|
|
verifyUrl: string,
|
|
|
|
|
expiresIn: string,
|
|
|
|
|
): string => `
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
</head>
|
|
|
|
|
<body style="${emailStyles}">
|
|
|
|
|
<div style="${containerStyles}">
|
|
|
|
|
<h1 style="${headingStyles}">Verify your email</h1>
|
|
|
|
|
<p style="${paragraphStyles}">Please verify your email address by clicking the button below:</p>
|
|
|
|
|
<a href="${verifyUrl}" style="${buttonStyles}">Verify Email</a>
|
|
|
|
|
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
|
|
|
|
|
<p style="${footerStyles}">If you didn't create an account, you can safely ignore this email.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const buildVerificationEmailText = (
|
|
|
|
|
verifyUrl: string,
|
|
|
|
|
expiresIn: string,
|
|
|
|
|
): string =>
|
|
|
|
|
`Verify your email
|
|
|
|
|
|
|
|
|
|
Please verify your email address by clicking the link below:
|
|
|
|
|
|
|
|
|
|
${verifyUrl}
|
|
|
|
|
|
|
|
|
|
This link expires in ${expiresIn}.
|
|
|
|
|
|
|
|
|
|
If you didn't create an account, you can safely ignore this email.
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
// Password Reset Email
|
|
|
|
|
const buildPasswordResetEmailHtml = (
|
|
|
|
|
resetUrl: string,
|
|
|
|
|
expiresIn: string,
|
|
|
|
|
): string => `
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
</head>
|
|
|
|
|
<body style="${emailStyles}">
|
|
|
|
|
<div style="${containerStyles}">
|
|
|
|
|
<h1 style="${headingStyles}">Reset your password</h1>
|
|
|
|
|
<p style="${paragraphStyles}">We received a request to reset your password. Click the button below to choose a new password:</p>
|
|
|
|
|
<a href="${resetUrl}" style="${buttonStyles}">Reset Password</a>
|
|
|
|
|
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
|
|
|
|
|
<p style="${footerStyles}">If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const buildPasswordResetEmailText = (
|
|
|
|
|
resetUrl: string,
|
|
|
|
|
expiresIn: string,
|
|
|
|
|
): string =>
|
|
|
|
|
`Reset your password
|
|
|
|
|
|
|
|
|
|
We received a request to reset your password. Click the link below to choose a new password:
|
|
|
|
|
|
|
|
|
|
${resetUrl}
|
|
|
|
|
|
|
|
|
|
This link expires in ${expiresIn}.
|
|
|
|
|
|
|
|
|
|
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
// Login Confirmation Email
|
|
|
|
|
const buildLoginConfirmationEmailHtml = (
|
|
|
|
|
confirmUrl: string,
|
|
|
|
|
expiresIn: string,
|
|
|
|
|
): string => `
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
</head>
|
|
|
|
|
<body style="${emailStyles}">
|
|
|
|
|
<div style="${containerStyles}">
|
|
|
|
|
<h1 style="${headingStyles}">Confirm your login</h1>
|
|
|
|
|
<p style="${paragraphStyles}">Someone is trying to sign in to your account. If this was you, click the button below to confirm:</p>
|
|
|
|
|
<a href="${confirmUrl}" style="${buttonStyles}">Confirm Login</a>
|
|
|
|
|
<p style="${footerStyles}">This link expires in ${expiresIn}.</p>
|
|
|
|
|
<p style="${footerStyles}">If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const buildLoginConfirmationEmailText = (
|
|
|
|
|
confirmUrl: string,
|
|
|
|
|
expiresIn: string,
|
|
|
|
|
): string =>
|
|
|
|
|
`Confirm your login
|
|
|
|
|
|
|
|
|
|
Someone is trying to sign in to your account. If this was you, click the link below to confirm:
|
|
|
|
|
|
|
|
|
|
${confirmUrl}
|
|
|
|
|
|
|
|
|
|
This link expires in ${expiresIn}.
|
|
|
|
|
|
|
|
|
|
If you didn't try to sign in, you can safely ignore this email. Someone may have entered your email address by mistake.
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
// Org Invite Email
|
|
|
|
|
const buildOrgInviteEmailHtml = (
|
|
|
|
|
email: string,
|
|
|
|
|
orgName: string,
|
|
|
|
|
inviterName: string,
|
|
|
|
|
role: OrgRole,
|
|
|
|
|
inviteUrl: string,
|
|
|
|
|
expiresIn: string,
|
|
|
|
|
): string => {
|
|
|
|
|
const safeOrgName = escapeHtml(orgName);
|
|
|
|
|
const safeInviterName = escapeHtml(inviterName);
|
|
|
|
|
const safeEmail = escapeHtml(email);
|
|
|
|
|
const roleDisplay = formatRoleDisplay(role);
|
|
|
|
|
const article = getArticleForRole(role);
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
</head>
|
|
|
|
|
<body style="${emailStyles}">
|
|
|
|
|
<div style="${containerStyles}">
|
|
|
|
|
<h1 style="${headingStyles}">You've been invited to join ${safeOrgName}</h1>
|
|
|
|
|
<p style="${paragraphStyles}">${safeInviterName} has invited you to join <strong>${safeOrgName}</strong> as ${article} <strong>${roleDisplay}</strong>.</p>
|
|
|
|
|
<a href="${inviteUrl}" style="${buttonStyles}">Accept Invitation</a>
|
|
|
|
|
<p style="${footerStyles}">This invitation expires in ${expiresIn}.</p>
|
|
|
|
|
<p style="${footerStyles}">This invitation was sent to ${safeEmail}. If you weren't expecting this invitation, you can safely ignore this email.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildOrgInviteEmailText = (
|
|
|
|
|
email: string,
|
|
|
|
|
orgName: string,
|
|
|
|
|
inviterName: string,
|
|
|
|
|
role: OrgRole,
|
|
|
|
|
inviteUrl: string,
|
|
|
|
|
expiresIn: string,
|
|
|
|
|
): string => {
|
|
|
|
|
const roleDisplay = formatRoleDisplay(role);
|
|
|
|
|
const article = getArticleForRole(role);
|
|
|
|
|
|
|
|
|
|
return `You've been invited to join ${orgName}
|
|
|
|
|
|
|
|
|
|
${inviterName} has invited you to join ${orgName} as ${article} ${roleDisplay}.
|
|
|
|
|
|
|
|
|
|
Click the link below to accept the invitation:
|
|
|
|
|
|
|
|
|
|
${inviteUrl}
|
|
|
|
|
|
|
|
|
|
This invitation expires in ${expiresIn}.
|
|
|
|
|
|
|
|
|
|
This invitation was sent to ${email}. If you weren't expecting this invitation, you can safely ignore this email.
|
|
|
|
|
`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ===== Email Helpers =====
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Send verification email to user
|
|
|
|
|
@@ -15,10 +347,16 @@ const getBaseUrl = (): string => Bun.env.APP_URL ?? "http://localhost:6827";
|
|
|
|
|
export async function sendVerificationEmail(
|
|
|
|
|
email: string,
|
|
|
|
|
token: string,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const url = `${getBaseUrl()}/auth/verify?token=${token}`;
|
|
|
|
|
console.log(`[EMAIL STUB] Verification email to ${email}`);
|
|
|
|
|
console.log(`[EMAIL STUB] Verify link: ${url}`);
|
|
|
|
|
): Promise<EmailResult> {
|
|
|
|
|
const url = buildUrl("/auth/verify", { token });
|
|
|
|
|
const expiresIn = formatExpiryHours(EMAIL_VERIFICATION_EXPIRY_HOURS);
|
|
|
|
|
|
|
|
|
|
return sendEmail({
|
|
|
|
|
to: email,
|
|
|
|
|
subject: "Verify your email address",
|
|
|
|
|
htmlBody: buildVerificationEmailHtml(url, expiresIn),
|
|
|
|
|
textBody: buildVerificationEmailText(url, expiresIn),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@@ -27,10 +365,16 @@ export async function sendVerificationEmail(
|
|
|
|
|
export async function sendLoginConfirmationEmail(
|
|
|
|
|
email: string,
|
|
|
|
|
token: string,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const url = `${getBaseUrl()}/auth/confirm?token=${token}`;
|
|
|
|
|
console.log(`[EMAIL STUB] Login confirmation to ${email}`);
|
|
|
|
|
console.log(`[EMAIL STUB] Confirm link: ${url}`);
|
|
|
|
|
): Promise<EmailResult> {
|
|
|
|
|
const url = buildUrl("/auth/confirm", { token });
|
|
|
|
|
const expiresIn = formatExpiryMinutes(LOGIN_CONFIRMATION_EXPIRY_MINUTES);
|
|
|
|
|
|
|
|
|
|
return sendEmail({
|
|
|
|
|
to: email,
|
|
|
|
|
subject: "Confirm your login",
|
|
|
|
|
htmlBody: buildLoginConfirmationEmailHtml(url, expiresIn),
|
|
|
|
|
textBody: buildLoginConfirmationEmailText(url, expiresIn),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@@ -39,8 +383,49 @@ export async function sendLoginConfirmationEmail(
|
|
|
|
|
export async function sendPasswordResetEmail(
|
|
|
|
|
email: string,
|
|
|
|
|
token: string,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const url = `${getBaseUrl()}/auth/reset-password?token=${token}`;
|
|
|
|
|
console.log(`[EMAIL STUB] Password reset to ${email}`);
|
|
|
|
|
console.log(`[EMAIL STUB] Reset link: ${url}`);
|
|
|
|
|
): Promise<EmailResult> {
|
|
|
|
|
const url = buildUrl("/auth/reset-password", { token });
|
|
|
|
|
const expiresIn = formatExpiryHours(PASSWORD_RESET_EXPIRY_HOURS);
|
|
|
|
|
|
|
|
|
|
return sendEmail({
|
|
|
|
|
to: email,
|
|
|
|
|
subject: "Reset your password",
|
|
|
|
|
htmlBody: buildPasswordResetEmailHtml(url, expiresIn),
|
|
|
|
|
textBody: buildPasswordResetEmailText(url, expiresIn),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Send org invite email
|
|
|
|
|
*/
|
|
|
|
|
export async function sendOrgInviteEmail(
|
|
|
|
|
email: string,
|
|
|
|
|
token: string,
|
|
|
|
|
orgName: string,
|
|
|
|
|
inviterName: string,
|
|
|
|
|
role: OrgRole,
|
|
|
|
|
): Promise<EmailResult> {
|
|
|
|
|
const url = buildUrl("/invite/accept", { token });
|
|
|
|
|
const expiresIn = formatExpiryDays(ORG_INVITE_EXPIRY_DAYS);
|
|
|
|
|
|
|
|
|
|
return sendEmail({
|
|
|
|
|
to: email,
|
|
|
|
|
subject: `You've been invited to join ${orgName}`,
|
|
|
|
|
htmlBody: buildOrgInviteEmailHtml(
|
|
|
|
|
email,
|
|
|
|
|
orgName,
|
|
|
|
|
inviterName,
|
|
|
|
|
role,
|
|
|
|
|
url,
|
|
|
|
|
expiresIn,
|
|
|
|
|
),
|
|
|
|
|
textBody: buildOrgInviteEmailText(
|
|
|
|
|
email,
|
|
|
|
|
orgName,
|
|
|
|
|
inviterName,
|
|
|
|
|
role,
|
|
|
|
|
url,
|
|
|
|
|
expiresIn,
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|