Merge branch 'email-cleanup'
This commit is contained in:
@@ -12,13 +12,13 @@
|
||||
"test": "bun test src/ --no-parallel"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-durationformat": "^0.9.2",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@orpc/experimental-pino": "^1.13.2",
|
||||
"@orpc/server": "^1.13.2",
|
||||
"@reviq/api-contract": "workspace:*",
|
||||
"@reviq/db": "workspace:*",
|
||||
"@reviq/db-schema": "workspace:*",
|
||||
"@reviq/emails": "workspace:*",
|
||||
"@reviq/server-utils": "workspace:*",
|
||||
"@scure/base": "^2.0.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
|
||||
@@ -36,10 +36,7 @@ export const EMAIL_FROM = Bun.env.EMAIL_FROM ?? "noreply@reviq.io";
|
||||
/** Base URL for generating email links */
|
||||
export const BASE_URL = Bun.env.BASE_URL ?? "http://localhost:6827";
|
||||
|
||||
/** Dev mode: log emails instead of sending (default: true) */
|
||||
export const EMAIL_DEV_MODE = Bun.env.EMAIL_DEV_MODE !== "false";
|
||||
|
||||
/** Postmark API key (required when EMAIL_DEV_MODE is false) */
|
||||
/** Postmark API key (optional - uses logging client if not set) */
|
||||
export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY;
|
||||
|
||||
// ===== Token Expiration Times =====
|
||||
|
||||
@@ -3,8 +3,18 @@
|
||||
*/
|
||||
|
||||
import type { Database } from "@reviq/db-schema";
|
||||
import type { EmailClient } from "@reviq/emails";
|
||||
import type { Kysely } from "kysely";
|
||||
|
||||
/**
|
||||
* Email configuration for the API
|
||||
*/
|
||||
export interface EmailConfig {
|
||||
client: EmailClient;
|
||||
fromAddress: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base API context available to all handlers
|
||||
*/
|
||||
@@ -23,6 +33,8 @@ export interface APIContext {
|
||||
resHeaders: Headers;
|
||||
/** Client IP address from direct connection (fallback when no proxy headers) */
|
||||
clientIP?: string | null;
|
||||
/** Email client and configuration */
|
||||
email: EmailConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,10 +2,17 @@ import type { APIContext } from "./context.js";
|
||||
import { LoggingHandlerPlugin } from "@orpc/experimental-pino";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import { createDb } from "@reviq/db";
|
||||
import {
|
||||
createLoggingEmailClient,
|
||||
createPostmarkClient,
|
||||
} from "@reviq/emails";
|
||||
import pino from "pino";
|
||||
import {
|
||||
BASE_URL,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_RP_NAME,
|
||||
EMAIL_FROM,
|
||||
POSTMARK_API_KEY,
|
||||
getAllowedOrigins,
|
||||
} from "./constants.js";
|
||||
import { router } from "./router.js";
|
||||
@@ -24,6 +31,16 @@ if (!databaseUrl) {
|
||||
throw new Error("DATABASE_URL environment variable is required");
|
||||
}
|
||||
const db = createDb(databaseUrl);
|
||||
|
||||
// Create email client - use Postmark if API key is set, otherwise log to console
|
||||
const emailClient = POSTMARK_API_KEY
|
||||
? createPostmarkClient(POSTMARK_API_KEY)
|
||||
: createLoggingEmailClient();
|
||||
|
||||
if (!POSTMARK_API_KEY) {
|
||||
logger.info("POSTMARK_API_KEY not set - emails will be logged to console");
|
||||
}
|
||||
|
||||
const handler = new RPCHandler(router, {
|
||||
plugins: [
|
||||
new LoggingHandlerPlugin({
|
||||
@@ -62,6 +79,11 @@ Bun.serve({
|
||||
reqHeaders: request.headers,
|
||||
resHeaders,
|
||||
clientIP,
|
||||
email: {
|
||||
client: emailClient,
|
||||
fromAddress: EMAIL_FROM,
|
||||
baseUrl: BASE_URL,
|
||||
},
|
||||
};
|
||||
|
||||
const { response } = await handler.handle(request, {
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
* This prevents attackers from determining which emails are registered
|
||||
*/
|
||||
|
||||
import { sendPasswordResetEmail } from "@reviq/emails";
|
||||
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
||||
import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendPasswordResetEmail } from "../../utils/email.js";
|
||||
import { os } from "../base.js";
|
||||
|
||||
export const forgotPassword = os.auth.forgotPassword.handler(
|
||||
@@ -51,8 +51,15 @@ export const forgotPassword = os.auth.forgotPassword.handler(
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Send password reset email (stubbed)
|
||||
await sendPasswordResetEmail(user.email, token);
|
||||
// Send password reset email
|
||||
await sendPasswordResetEmail({
|
||||
client: context.email.client,
|
||||
fromAddress: context.email.fromAddress,
|
||||
baseUrl: context.email.baseUrl,
|
||||
email: user.email,
|
||||
token,
|
||||
expiryHours: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Always return success (anti-enumeration)
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { sendLoginConfirmationEmail } from "@reviq/emails";
|
||||
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
|
||||
import { sendLoginConfirmationEmail } from "../../utils/email.js";
|
||||
import { verifyPassword } from "../../utils/password.js";
|
||||
import { isDeviceTrusted } from "../../utils/session.js";
|
||||
import { os } from "../base.js";
|
||||
@@ -108,7 +108,14 @@ export const loginPassword = os.auth.loginPassword.handler(
|
||||
} else {
|
||||
// Device is untrusted - send confirmation email with existing token
|
||||
// The same base58 token is used for both cookie lookup and email confirmation
|
||||
await sendLoginConfirmationEmail(result.email, result.token);
|
||||
await sendLoginConfirmationEmail({
|
||||
client: context.email.client,
|
||||
fromAddress: context.email.fromAddress,
|
||||
baseUrl: context.email.baseUrl,
|
||||
email: result.email,
|
||||
token: result.token,
|
||||
expiryMinutes: 15,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
* 5. Send verification email (stubbed)
|
||||
*/
|
||||
|
||||
import { sendVerificationEmail } from "@reviq/emails";
|
||||
import { TOKEN_DURATIONS } from "../../utils/cookies.js";
|
||||
import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendVerificationEmail } from "../../utils/email.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
|
||||
export const resendVerificationEmail = os.auth.resendVerificationEmail
|
||||
@@ -47,8 +47,15 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Send verification email (stubbed)
|
||||
await sendVerificationEmail(context.user.email, token);
|
||||
// Send verification email
|
||||
await sendVerificationEmail({
|
||||
client: context.email.client,
|
||||
fromAddress: context.email.fromAddress,
|
||||
baseUrl: context.email.baseUrl,
|
||||
email: context.user.email,
|
||||
token,
|
||||
expiryHours: 24,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import type { Kysely } from "kysely";
|
||||
import type { RPInfo } from "../../utils/webauthn.js";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { sendVerificationEmail } from "@reviq/emails";
|
||||
import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
||||
import {
|
||||
COOKIE_NAMES,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendVerificationEmail } from "../../utils/email.js";
|
||||
import { getGeoInfo, getUserAgent } from "../../utils/geo.js";
|
||||
import { hashPassword, validatePassword } from "../../utils/password.js";
|
||||
import { createSession } from "../../utils/session.js";
|
||||
@@ -300,8 +300,15 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Send verification email (stubbed)
|
||||
await sendVerificationEmail(email, verificationToken);
|
||||
// Send verification email
|
||||
await sendVerificationEmail({
|
||||
client: context.email.client,
|
||||
fromAddress: context.email.fromAddress,
|
||||
baseUrl: context.email.baseUrl,
|
||||
email,
|
||||
token: verificationToken,
|
||||
expiryHours: 24,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { sendOrgInviteEmail } from "@reviq/emails";
|
||||
import { ORG_INVITE_EXPIRY_DAYS } from "../../constants.js";
|
||||
import {
|
||||
generateExpiry,
|
||||
generateSecureBase58Token,
|
||||
} from "../../utils/crypto.js";
|
||||
import { sendOrgInviteEmail } from "../../utils/email.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
import { getMembership, lookupOrgBySlug, requireRole } from "./helpers.js";
|
||||
|
||||
@@ -122,7 +122,17 @@ export const invitesCreate = os.orgs.invites.create
|
||||
|
||||
// Send invitation email
|
||||
const inviterName = context.user.displayName ?? context.user.email;
|
||||
await sendOrgInviteEmail(email, token, org.displayName, inviterName, role);
|
||||
await sendOrgInviteEmail({
|
||||
client: context.email.client,
|
||||
fromAddress: context.email.fromAddress,
|
||||
baseUrl: context.email.baseUrl,
|
||||
email,
|
||||
token,
|
||||
orgName: org.displayName,
|
||||
inviterName,
|
||||
role,
|
||||
expiryDays: ORG_INVITE_EXPIRY_DAYS,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
/**
|
||||
* Email sending utilities using Postmark
|
||||
* Implements Workstream G: Email Service (Backend)
|
||||
*/
|
||||
|
||||
import type { OrgRole } from "@reviq/db-schema";
|
||||
import { DurationFormat } from "@formatjs/intl-durationformat";
|
||||
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 =====
|
||||
|
||||
/**
|
||||
* Email send result
|
||||
*/
|
||||
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 durationFormatter = new DurationFormat("en", { style: "long" });
|
||||
|
||||
const formatExpiryHours = (hours: number): string =>
|
||||
durationFormatter.format({ hours });
|
||||
|
||||
const formatExpiryMinutes = (minutes: number): string =>
|
||||
durationFormatter.format({ minutes });
|
||||
|
||||
const formatExpiryDays = (days: number): string =>
|
||||
durationFormatter.format({ days });
|
||||
|
||||
const roleLabels: Record<OrgRole, string> = {
|
||||
owner: "Owner",
|
||||
admin: "Admin",
|
||||
member: "Member",
|
||||
};
|
||||
|
||||
const formatRoleDisplay = (role: OrgRole): string => roleLabels[role];
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export async function sendVerificationEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
): 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),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send login confirmation email (for untrusted device flow)
|
||||
*/
|
||||
export async function sendLoginConfirmationEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
): 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),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
token: string,
|
||||
): 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,
|
||||
),
|
||||
});
|
||||
}
|
||||
28
bun.lock
28
bun.lock
@@ -15,13 +15,13 @@
|
||||
"name": "@reviq/api-server",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@formatjs/intl-durationformat": "^0.9.2",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@orpc/experimental-pino": "^1.13.2",
|
||||
"@orpc/server": "^1.13.2",
|
||||
"@reviq/api-contract": "workspace:*",
|
||||
"@reviq/db": "workspace:*",
|
||||
"@reviq/db-schema": "workspace:*",
|
||||
"@reviq/emails": "workspace:*",
|
||||
"@reviq/server-utils": "workspace:*",
|
||||
"@scure/base": "^2.0.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
@@ -181,6 +181,22 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/emails": {
|
||||
"name": "@reviq/emails",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@formatjs/intl-durationformat": "^0.7.0",
|
||||
"@reviq/db-schema": "workspace:*",
|
||||
"postmark": "^4.0.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@macalinao/eslint-config": "catalog:",
|
||||
"@macalinao/tsconfig": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/frontend-utils": {
|
||||
"name": "@reviq/frontend-utils",
|
||||
"version": "0.0.1",
|
||||
@@ -349,13 +365,13 @@
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@3.0.8", "", { "dependencies": { "@formatjs/fast-memoize": "3.0.3", "@formatjs/intl-localematcher": "0.7.5", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-NRiqvxAvhbARZRFSRFPjN0y8txxmVutv2vMYvW2HSdCVf58w9l4osLj6Ujif643vImwZBcbKqhiKE0IOhY+DvA=="],
|
||||
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="],
|
||||
|
||||
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.0.3", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-CArYtQKGLAOruCMeq5/RxCg6vUXFx3OuKBdTm30Wn/+gCefehmZ8Y2xSMxMrO2iel7hRyE3HKfV56t3vAU6D4Q=="],
|
||||
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="],
|
||||
|
||||
"@formatjs/intl-durationformat": ["@formatjs/intl-durationformat@0.9.2", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.0.8", "@formatjs/intl-localematcher": "0.7.5", "tslib": "^2.8.0" } }, "sha512-/QOJeY96qGj1j9saz32VANfgDYhChbbTRyjWLzjf7dc4OHIEWqGBIO4rQzUKDBVzqtRLJQMh4QKp37Uxkk0d8g=="],
|
||||
"@formatjs/intl-durationformat": ["@formatjs/intl-durationformat@0.7.6", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/intl-localematcher": "0.6.2", "tslib": "^2.8.0" } }, "sha512-jatAN3E84X6aP2UOGK1jTrwD1a7BiG3qWUSEDAhtyNd1BgYeS5wQPtXlnuGF1QRx0DjnwwNOIssyd7oQoRlQeg=="],
|
||||
|
||||
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.7.5", "", { "dependencies": { "@formatjs/fast-memoize": "3.0.3", "tslib": "^2.8.0" } }, "sha512-7/nd90cn5CT7SVF71/ybUKAcnvBlr9nZlJJp8O8xIZHXFgYOC4SXExZlSdgHv2l6utjw1byidL06QzChvQMHwA=="],
|
||||
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="],
|
||||
|
||||
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
|
||||
|
||||
@@ -455,6 +471,8 @@
|
||||
|
||||
"@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"],
|
||||
|
||||
"@reviq/emails": ["@reviq/emails@workspace:packages/emails"],
|
||||
|
||||
"@reviq/frontend-utils": ["@reviq/frontend-utils@workspace:packages/frontend-utils"],
|
||||
|
||||
"@reviq/server-utils": ["@reviq/server-utils@workspace:packages/server-utils"],
|
||||
|
||||
12
packages/emails/eslint.config.js
Normal file
12
packages/emails/eslint.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { configs } from "@macalinao/eslint-config";
|
||||
|
||||
export default [
|
||||
...configs.fast,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
33
packages/emails/package.json
Normal file
33
packages/emails/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@reviq/emails",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||
"lint": "eslint . --cache",
|
||||
"test": "bun test src/",
|
||||
"test:cov": "bun test --coverage src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-durationformat": "^0.7.0",
|
||||
"@reviq/db-schema": "workspace:*",
|
||||
"postmark": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@macalinao/eslint-config": "catalog:",
|
||||
"@macalinao/tsconfig": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
46
packages/emails/src/client.test.ts
Normal file
46
packages/emails/src/client.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, mock } from "bun:test";
|
||||
import { createPostmarkClient } from "./client.js";
|
||||
|
||||
const mockSendEmail = mock(() =>
|
||||
Promise.resolve({ MessageID: "test-message-id-123" }),
|
||||
);
|
||||
|
||||
void mock.module("postmark", () => ({
|
||||
ServerClient: class MockServerClient {
|
||||
sendEmail = mockSendEmail;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("createPostmarkClient", () => {
|
||||
it("should create an EmailClient with sendEmail method", () => {
|
||||
const client = createPostmarkClient("test-api-key");
|
||||
expect(typeof client.sendEmail).toBe("function");
|
||||
});
|
||||
|
||||
it("should throw an error if API key is empty", () => {
|
||||
expect(() => createPostmarkClient("")).toThrow(
|
||||
"Postmark API key is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("should convert our interface to Postmark format and return converted result", async () => {
|
||||
const client = createPostmarkClient("test-api-key");
|
||||
|
||||
const result = await client.sendEmail({
|
||||
from: "sender@example.com",
|
||||
to: "recipient@example.com",
|
||||
subject: "Test Subject",
|
||||
htmlBody: "<p>HTML</p>",
|
||||
textBody: "Text",
|
||||
});
|
||||
|
||||
expect(mockSendEmail).toHaveBeenCalledWith({
|
||||
From: "sender@example.com",
|
||||
To: "recipient@example.com",
|
||||
Subject: "Test Subject",
|
||||
HtmlBody: "<p>HTML</p>",
|
||||
TextBody: "Text",
|
||||
});
|
||||
expect(result.messageId).toBe("test-message-id-123");
|
||||
});
|
||||
});
|
||||
27
packages/emails/src/client.ts
Normal file
27
packages/emails/src/client.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ServerClient } from "postmark";
|
||||
import type {
|
||||
ClientSendParams,
|
||||
ClientSendResult,
|
||||
EmailClient,
|
||||
} from "./types.js";
|
||||
|
||||
export function createPostmarkClient(apiKey: string): EmailClient {
|
||||
if (!apiKey) {
|
||||
throw new Error("Postmark API key is required");
|
||||
}
|
||||
|
||||
const serverClient = new ServerClient(apiKey);
|
||||
|
||||
return {
|
||||
sendEmail: async (params: ClientSendParams): Promise<ClientSendResult> => {
|
||||
const result = await serverClient.sendEmail({
|
||||
From: params.from,
|
||||
To: params.to,
|
||||
Subject: params.subject,
|
||||
HtmlBody: params.htmlBody,
|
||||
TextBody: params.textBody,
|
||||
});
|
||||
return { messageId: result.MessageID };
|
||||
},
|
||||
};
|
||||
}
|
||||
39
packages/emails/src/emails/index.ts
Normal file
39
packages/emails/src/emails/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export {
|
||||
buildLoginConfirmationEmailHtml,
|
||||
buildLoginConfirmationEmailText,
|
||||
sendLoginConfirmationEmail,
|
||||
} from "./login-confirmation.js";
|
||||
export type {
|
||||
LoginConfirmationEmailParams,
|
||||
SendLoginConfirmationEmailParams,
|
||||
} from "./login-confirmation.js";
|
||||
|
||||
export {
|
||||
buildOrgInviteEmailHtml,
|
||||
buildOrgInviteEmailText,
|
||||
sendOrgInviteEmail,
|
||||
} from "./org-invite.js";
|
||||
export type {
|
||||
OrgInviteEmailParams,
|
||||
SendOrgInviteEmailParams,
|
||||
} from "./org-invite.js";
|
||||
|
||||
export {
|
||||
buildPasswordResetEmailHtml,
|
||||
buildPasswordResetEmailText,
|
||||
sendPasswordResetEmail,
|
||||
} from "./password-reset.js";
|
||||
export type {
|
||||
PasswordResetEmailParams,
|
||||
SendPasswordResetEmailParams,
|
||||
} from "./password-reset.js";
|
||||
|
||||
export {
|
||||
buildVerificationEmailHtml,
|
||||
buildVerificationEmailText,
|
||||
sendVerificationEmail,
|
||||
} from "./verification.js";
|
||||
export type {
|
||||
SendVerificationEmailParams,
|
||||
VerificationEmailParams,
|
||||
} from "./verification.js";
|
||||
108
packages/emails/src/emails/login-confirmation.test.ts
Normal file
108
packages/emails/src/emails/login-confirmation.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||
import type { EmailClient } from "../types.js";
|
||||
import {
|
||||
buildLoginConfirmationEmailHtml,
|
||||
buildLoginConfirmationEmailText,
|
||||
sendLoginConfirmationEmail,
|
||||
} from "./login-confirmation.js";
|
||||
|
||||
describe("buildLoginConfirmationEmailHtml", () => {
|
||||
const params = {
|
||||
confirmUrl: "https://example.com/auth/confirm?token=def456",
|
||||
expiresIn: "15 minutes",
|
||||
};
|
||||
|
||||
it("should include the confirm URL", () => {
|
||||
const html = buildLoginConfirmationEmailHtml(params);
|
||||
expect(html).toContain(
|
||||
'href="https://example.com/auth/confirm?token=def456"',
|
||||
);
|
||||
});
|
||||
|
||||
it("should include the expiry time", () => {
|
||||
const html = buildLoginConfirmationEmailHtml(params);
|
||||
expect(html).toContain("This link expires in 15 minutes.");
|
||||
});
|
||||
|
||||
it("should include the heading", () => {
|
||||
const html = buildLoginConfirmationEmailHtml(params);
|
||||
expect(html).toContain("Confirm your login");
|
||||
});
|
||||
|
||||
it("should include confirm button text", () => {
|
||||
const html = buildLoginConfirmationEmailHtml(params);
|
||||
expect(html).toContain(">Confirm Login</a>");
|
||||
});
|
||||
|
||||
it("should include warning about unauthorized access", () => {
|
||||
const html = buildLoginConfirmationEmailHtml(params);
|
||||
expect(html).toContain("Someone is trying to sign in to your account.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLoginConfirmationEmailText", () => {
|
||||
const params = {
|
||||
confirmUrl: "https://example.com/auth/confirm?token=def456",
|
||||
expiresIn: "15 minutes",
|
||||
};
|
||||
|
||||
it("should include the confirm URL", () => {
|
||||
const text = buildLoginConfirmationEmailText(params);
|
||||
expect(text).toContain("https://example.com/auth/confirm?token=def456");
|
||||
});
|
||||
|
||||
it("should include the expiry time", () => {
|
||||
const text = buildLoginConfirmationEmailText(params);
|
||||
expect(text).toContain("This link expires in 15 minutes.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendLoginConfirmationEmail", () => {
|
||||
const createMockClient = () => {
|
||||
const sendEmailMock = mock(() =>
|
||||
Promise.resolve({ messageId: "test-message-id" }),
|
||||
);
|
||||
return {
|
||||
sendEmail: sendEmailMock,
|
||||
} as EmailClient & { sendEmail: ReturnType<typeof mock> };
|
||||
};
|
||||
|
||||
let mockClient: ReturnType<typeof createMockClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = createMockClient();
|
||||
});
|
||||
|
||||
it("should send login confirmation email with correct URL and expiry", async () => {
|
||||
const result = await sendLoginConfirmationEmail({
|
||||
client: mockClient,
|
||||
fromAddress: "noreply@example.com",
|
||||
baseUrl: "https://app.example.com",
|
||||
email: "user@example.com",
|
||||
token: "confirm-token-456",
|
||||
expiryMinutes: 15,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockClient.sendEmail).toHaveBeenCalledTimes(1);
|
||||
|
||||
const call = mockClient.sendEmail.mock.calls[0];
|
||||
const params = call?.[0] as {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
};
|
||||
|
||||
expect(params.to).toBe("user@example.com");
|
||||
expect(params.subject).toBe("Confirm your login");
|
||||
expect(params.htmlBody).toContain(
|
||||
"https://app.example.com/auth/confirm?token=confirm-token-456",
|
||||
);
|
||||
expect(params.htmlBody).toContain("15 minutes");
|
||||
expect(params.textBody).toContain(
|
||||
"https://app.example.com/auth/confirm?token=confirm-token-456",
|
||||
);
|
||||
});
|
||||
});
|
||||
84
packages/emails/src/emails/login-confirmation.ts
Normal file
84
packages/emails/src/emails/login-confirmation.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { buildUrl, formatExpiryMinutes } from "../helpers.js";
|
||||
import { sendEmail } from "../send.js";
|
||||
import {
|
||||
buttonStyles,
|
||||
containerStyles,
|
||||
emailStyles,
|
||||
footerStyles,
|
||||
headingStyles,
|
||||
paragraphStyles,
|
||||
} from "../styles.js";
|
||||
import type { EmailClient, EmailResult } from "../types.js";
|
||||
|
||||
export interface LoginConfirmationEmailParams {
|
||||
confirmUrl: string;
|
||||
expiresIn: string;
|
||||
}
|
||||
|
||||
export function buildLoginConfirmationEmailHtml({
|
||||
confirmUrl,
|
||||
expiresIn,
|
||||
}: LoginConfirmationEmailParams): string {
|
||||
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}">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>
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildLoginConfirmationEmailText({
|
||||
confirmUrl,
|
||||
expiresIn,
|
||||
}: LoginConfirmationEmailParams): string {
|
||||
return `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.
|
||||
`;
|
||||
}
|
||||
|
||||
export interface SendLoginConfirmationEmailParams {
|
||||
client: EmailClient;
|
||||
fromAddress: string;
|
||||
baseUrl: string;
|
||||
email: string;
|
||||
token: string;
|
||||
expiryMinutes: number;
|
||||
}
|
||||
|
||||
export async function sendLoginConfirmationEmail({
|
||||
client,
|
||||
fromAddress,
|
||||
baseUrl,
|
||||
email,
|
||||
token,
|
||||
expiryMinutes,
|
||||
}: SendLoginConfirmationEmailParams): Promise<EmailResult> {
|
||||
const confirmUrl = buildUrl(baseUrl, "/auth/confirm", { token });
|
||||
const expiresIn = formatExpiryMinutes(expiryMinutes);
|
||||
|
||||
return sendEmail(client, fromAddress, {
|
||||
to: email,
|
||||
subject: "Confirm your login",
|
||||
htmlBody: buildLoginConfirmationEmailHtml({ confirmUrl, expiresIn }),
|
||||
textBody: buildLoginConfirmationEmailText({ confirmUrl, expiresIn }),
|
||||
});
|
||||
}
|
||||
195
packages/emails/src/emails/org-invite.test.ts
Normal file
195
packages/emails/src/emails/org-invite.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||
import type { EmailClient } from "../types.js";
|
||||
import {
|
||||
buildOrgInviteEmailHtml,
|
||||
buildOrgInviteEmailText,
|
||||
sendOrgInviteEmail,
|
||||
} from "./org-invite.js";
|
||||
|
||||
describe("buildOrgInviteEmailHtml", () => {
|
||||
const params = {
|
||||
email: "user@example.com",
|
||||
orgName: "Acme Corp",
|
||||
inviterName: "John Doe",
|
||||
role: "admin" as const,
|
||||
inviteUrl: "https://example.com/invite/accept?token=invite123",
|
||||
expiresIn: "7 days",
|
||||
};
|
||||
|
||||
it("should include the invite URL", () => {
|
||||
const html = buildOrgInviteEmailHtml(params);
|
||||
expect(html).toContain(
|
||||
'href="https://example.com/invite/accept?token=invite123"',
|
||||
);
|
||||
});
|
||||
|
||||
it("should include the organization name", () => {
|
||||
const html = buildOrgInviteEmailHtml(params);
|
||||
expect(html).toContain("Acme Corp");
|
||||
});
|
||||
|
||||
it("should include the inviter name", () => {
|
||||
const html = buildOrgInviteEmailHtml(params);
|
||||
expect(html).toContain("John Doe");
|
||||
});
|
||||
|
||||
it("should include the role with correct article", () => {
|
||||
const html = buildOrgInviteEmailHtml(params);
|
||||
expect(html).toContain("an <strong>Admin</strong>");
|
||||
});
|
||||
|
||||
it("should include the expiry time", () => {
|
||||
const html = buildOrgInviteEmailHtml(params);
|
||||
expect(html).toContain("This invitation expires in 7 days.");
|
||||
});
|
||||
|
||||
it("should include the recipient email", () => {
|
||||
const html = buildOrgInviteEmailHtml(params);
|
||||
expect(html).toContain("user@example.com");
|
||||
});
|
||||
|
||||
it("should escape HTML in organization name", () => {
|
||||
const paramsWithXss = {
|
||||
...params,
|
||||
orgName: '<script>alert("xss")</script>',
|
||||
};
|
||||
const html = buildOrgInviteEmailHtml(paramsWithXss);
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
});
|
||||
|
||||
it("should escape HTML in inviter name", () => {
|
||||
const paramsWithXss = {
|
||||
...params,
|
||||
inviterName: '<img src="x" onerror="alert(1)">',
|
||||
};
|
||||
const html = buildOrgInviteEmailHtml(paramsWithXss);
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain("<img");
|
||||
});
|
||||
|
||||
it("should use 'a' article for member role", () => {
|
||||
const memberParams = { ...params, role: "member" as const };
|
||||
const html = buildOrgInviteEmailHtml(memberParams);
|
||||
expect(html).toContain("a <strong>Member</strong>");
|
||||
});
|
||||
|
||||
it("should use 'an' article for owner role", () => {
|
||||
const ownerParams = { ...params, role: "owner" as const };
|
||||
const html = buildOrgInviteEmailHtml(ownerParams);
|
||||
expect(html).toContain("an <strong>Owner</strong>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOrgInviteEmailText", () => {
|
||||
const params = {
|
||||
email: "user@example.com",
|
||||
orgName: "Acme Corp",
|
||||
inviterName: "John Doe",
|
||||
role: "admin" as const,
|
||||
inviteUrl: "https://example.com/invite/accept?token=invite123",
|
||||
expiresIn: "7 days",
|
||||
};
|
||||
|
||||
it("should include the invite URL", () => {
|
||||
const text = buildOrgInviteEmailText(params);
|
||||
expect(text).toContain(
|
||||
"https://example.com/invite/accept?token=invite123",
|
||||
);
|
||||
});
|
||||
|
||||
it("should include the organization name", () => {
|
||||
const text = buildOrgInviteEmailText(params);
|
||||
expect(text).toContain("Acme Corp");
|
||||
});
|
||||
|
||||
it("should include the inviter name", () => {
|
||||
const text = buildOrgInviteEmailText(params);
|
||||
expect(text).toContain("John Doe");
|
||||
});
|
||||
|
||||
it("should include the role with correct article", () => {
|
||||
const text = buildOrgInviteEmailText(params);
|
||||
expect(text).toContain("an Admin");
|
||||
});
|
||||
|
||||
it("should include the expiry time", () => {
|
||||
const text = buildOrgInviteEmailText(params);
|
||||
expect(text).toContain("This invitation expires in 7 days.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendOrgInviteEmail", () => {
|
||||
const createMockClient = () => {
|
||||
const sendEmailMock = mock(() =>
|
||||
Promise.resolve({ messageId: "test-message-id" }),
|
||||
);
|
||||
return {
|
||||
sendEmail: sendEmailMock,
|
||||
} as EmailClient & { sendEmail: ReturnType<typeof mock> };
|
||||
};
|
||||
|
||||
let mockClient: ReturnType<typeof createMockClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = createMockClient();
|
||||
});
|
||||
|
||||
it("should send org invite email with correct details", async () => {
|
||||
const result = await sendOrgInviteEmail({
|
||||
client: mockClient,
|
||||
fromAddress: "noreply@example.com",
|
||||
baseUrl: "https://app.example.com",
|
||||
email: "newuser@example.com",
|
||||
token: "invite-token-abc",
|
||||
orgName: "Acme Corp",
|
||||
inviterName: "Jane Doe",
|
||||
role: "admin",
|
||||
expiryDays: 7,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockClient.sendEmail).toHaveBeenCalledTimes(1);
|
||||
|
||||
const call = mockClient.sendEmail.mock.calls[0];
|
||||
const params = call?.[0] as {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
};
|
||||
|
||||
expect(params.to).toBe("newuser@example.com");
|
||||
expect(params.subject).toBe("You've been invited to join Acme Corp");
|
||||
expect(params.htmlBody).toContain(
|
||||
"https://app.example.com/invite/accept?token=invite-token-abc",
|
||||
);
|
||||
expect(params.htmlBody).toContain("Acme Corp");
|
||||
expect(params.htmlBody).toContain("Jane Doe");
|
||||
expect(params.htmlBody).toContain("Admin");
|
||||
expect(params.htmlBody).toContain("7 days");
|
||||
expect(params.textBody).toContain("Acme Corp");
|
||||
expect(params.textBody).toContain("Jane Doe");
|
||||
expect(params.textBody).toContain("an Admin");
|
||||
});
|
||||
|
||||
it("should use correct article for member role", async () => {
|
||||
await sendOrgInviteEmail({
|
||||
client: mockClient,
|
||||
fromAddress: "noreply@example.com",
|
||||
baseUrl: "https://app.example.com",
|
||||
email: "newuser@example.com",
|
||||
token: "invite-token-abc",
|
||||
orgName: "Acme Corp",
|
||||
inviterName: "Jane Doe",
|
||||
role: "member",
|
||||
expiryDays: 7,
|
||||
});
|
||||
|
||||
const call = mockClient.sendEmail.mock.calls[0];
|
||||
const params = call?.[0] as { textBody: string };
|
||||
|
||||
expect(params.textBody).toContain("a Member");
|
||||
});
|
||||
});
|
||||
134
packages/emails/src/emails/org-invite.ts
Normal file
134
packages/emails/src/emails/org-invite.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { OrgRole } from "@reviq/db-schema";
|
||||
import {
|
||||
buildUrl,
|
||||
escapeHtml,
|
||||
formatExpiryDays,
|
||||
formatRoleDisplay,
|
||||
getArticleForRole,
|
||||
} from "../helpers.js";
|
||||
import { sendEmail } from "../send.js";
|
||||
import {
|
||||
buttonStyles,
|
||||
containerStyles,
|
||||
emailStyles,
|
||||
footerStyles,
|
||||
headingStyles,
|
||||
paragraphStyles,
|
||||
} from "../styles.js";
|
||||
import type { EmailClient, EmailResult } from "../types.js";
|
||||
|
||||
export interface OrgInviteEmailParams {
|
||||
email: string;
|
||||
orgName: string;
|
||||
inviterName: string;
|
||||
role: OrgRole;
|
||||
inviteUrl: string;
|
||||
expiresIn: string;
|
||||
}
|
||||
|
||||
export function buildOrgInviteEmailHtml({
|
||||
email,
|
||||
orgName,
|
||||
inviterName,
|
||||
role,
|
||||
inviteUrl,
|
||||
expiresIn,
|
||||
}: OrgInviteEmailParams): 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>
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildOrgInviteEmailText({
|
||||
email,
|
||||
orgName,
|
||||
inviterName,
|
||||
role,
|
||||
inviteUrl,
|
||||
expiresIn,
|
||||
}: OrgInviteEmailParams): 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.
|
||||
`;
|
||||
}
|
||||
|
||||
export interface SendOrgInviteEmailParams {
|
||||
client: EmailClient;
|
||||
fromAddress: string;
|
||||
baseUrl: string;
|
||||
email: string;
|
||||
token: string;
|
||||
orgName: string;
|
||||
inviterName: string;
|
||||
role: OrgRole;
|
||||
expiryDays: number;
|
||||
}
|
||||
|
||||
export async function sendOrgInviteEmail({
|
||||
client,
|
||||
fromAddress,
|
||||
baseUrl,
|
||||
email,
|
||||
token,
|
||||
orgName,
|
||||
inviterName,
|
||||
role,
|
||||
expiryDays,
|
||||
}: SendOrgInviteEmailParams): Promise<EmailResult> {
|
||||
const inviteUrl = buildUrl(baseUrl, "/invite/accept", { token });
|
||||
const expiresIn = formatExpiryDays(expiryDays);
|
||||
|
||||
return sendEmail(client, fromAddress, {
|
||||
to: email,
|
||||
subject: `You've been invited to join ${orgName}`,
|
||||
htmlBody: buildOrgInviteEmailHtml({
|
||||
email,
|
||||
orgName,
|
||||
inviterName,
|
||||
role,
|
||||
inviteUrl,
|
||||
expiresIn,
|
||||
}),
|
||||
textBody: buildOrgInviteEmailText({
|
||||
email,
|
||||
orgName,
|
||||
inviterName,
|
||||
role,
|
||||
inviteUrl,
|
||||
expiresIn,
|
||||
}),
|
||||
});
|
||||
}
|
||||
112
packages/emails/src/emails/password-reset.test.ts
Normal file
112
packages/emails/src/emails/password-reset.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||
import type { EmailClient } from "../types.js";
|
||||
import {
|
||||
buildPasswordResetEmailHtml,
|
||||
buildPasswordResetEmailText,
|
||||
sendPasswordResetEmail,
|
||||
} from "./password-reset.js";
|
||||
|
||||
describe("buildPasswordResetEmailHtml", () => {
|
||||
const params = {
|
||||
resetUrl: "https://example.com/auth/reset-password?token=xyz789",
|
||||
expiresIn: "1 hour",
|
||||
};
|
||||
|
||||
it("should include the reset URL", () => {
|
||||
const html = buildPasswordResetEmailHtml(params);
|
||||
expect(html).toContain(
|
||||
'href="https://example.com/auth/reset-password?token=xyz789"',
|
||||
);
|
||||
});
|
||||
|
||||
it("should include the expiry time", () => {
|
||||
const html = buildPasswordResetEmailHtml(params);
|
||||
expect(html).toContain("This link expires in 1 hour.");
|
||||
});
|
||||
|
||||
it("should include the heading", () => {
|
||||
const html = buildPasswordResetEmailHtml(params);
|
||||
expect(html).toContain("Reset your password");
|
||||
});
|
||||
|
||||
it("should include reset button text", () => {
|
||||
const html = buildPasswordResetEmailHtml(params);
|
||||
expect(html).toContain(">Reset Password</a>");
|
||||
});
|
||||
|
||||
it("should include ignore message", () => {
|
||||
const html = buildPasswordResetEmailHtml(params);
|
||||
expect(html).toContain(
|
||||
"If you didn't request a password reset, you can safely ignore this email.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPasswordResetEmailText", () => {
|
||||
const params = {
|
||||
resetUrl: "https://example.com/auth/reset-password?token=xyz789",
|
||||
expiresIn: "1 hour",
|
||||
};
|
||||
|
||||
it("should include the reset URL", () => {
|
||||
const text = buildPasswordResetEmailText(params);
|
||||
expect(text).toContain(
|
||||
"https://example.com/auth/reset-password?token=xyz789",
|
||||
);
|
||||
});
|
||||
|
||||
it("should include the expiry time", () => {
|
||||
const text = buildPasswordResetEmailText(params);
|
||||
expect(text).toContain("This link expires in 1 hour.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendPasswordResetEmail", () => {
|
||||
const createMockClient = () => {
|
||||
const sendEmailMock = mock(() =>
|
||||
Promise.resolve({ messageId: "test-message-id" }),
|
||||
);
|
||||
return {
|
||||
sendEmail: sendEmailMock,
|
||||
} as EmailClient & { sendEmail: ReturnType<typeof mock> };
|
||||
};
|
||||
|
||||
let mockClient: ReturnType<typeof createMockClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = createMockClient();
|
||||
});
|
||||
|
||||
it("should send password reset email with correct URL and expiry", async () => {
|
||||
const result = await sendPasswordResetEmail({
|
||||
client: mockClient,
|
||||
fromAddress: "noreply@example.com",
|
||||
baseUrl: "https://app.example.com",
|
||||
email: "user@example.com",
|
||||
token: "reset-token-789",
|
||||
expiryHours: 1,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockClient.sendEmail).toHaveBeenCalledTimes(1);
|
||||
|
||||
const call = mockClient.sendEmail.mock.calls[0];
|
||||
const params = call?.[0] as {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
};
|
||||
|
||||
expect(params.to).toBe("user@example.com");
|
||||
expect(params.subject).toBe("Reset your password");
|
||||
expect(params.htmlBody).toContain(
|
||||
"https://app.example.com/auth/reset-password?token=reset-token-789",
|
||||
);
|
||||
expect(params.htmlBody).toContain("1 hour");
|
||||
expect(params.textBody).toContain(
|
||||
"https://app.example.com/auth/reset-password?token=reset-token-789",
|
||||
);
|
||||
});
|
||||
});
|
||||
84
packages/emails/src/emails/password-reset.ts
Normal file
84
packages/emails/src/emails/password-reset.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { buildUrl, formatExpiryHours } from "../helpers.js";
|
||||
import { sendEmail } from "../send.js";
|
||||
import {
|
||||
buttonStyles,
|
||||
containerStyles,
|
||||
emailStyles,
|
||||
footerStyles,
|
||||
headingStyles,
|
||||
paragraphStyles,
|
||||
} from "../styles.js";
|
||||
import type { EmailClient, EmailResult } from "../types.js";
|
||||
|
||||
export interface PasswordResetEmailParams {
|
||||
resetUrl: string;
|
||||
expiresIn: string;
|
||||
}
|
||||
|
||||
export function buildPasswordResetEmailHtml({
|
||||
resetUrl,
|
||||
expiresIn,
|
||||
}: PasswordResetEmailParams): string {
|
||||
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}">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>
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildPasswordResetEmailText({
|
||||
resetUrl,
|
||||
expiresIn,
|
||||
}: PasswordResetEmailParams): string {
|
||||
return `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.
|
||||
`;
|
||||
}
|
||||
|
||||
export interface SendPasswordResetEmailParams {
|
||||
client: EmailClient;
|
||||
fromAddress: string;
|
||||
baseUrl: string;
|
||||
email: string;
|
||||
token: string;
|
||||
expiryHours: number;
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail({
|
||||
client,
|
||||
fromAddress,
|
||||
baseUrl,
|
||||
email,
|
||||
token,
|
||||
expiryHours,
|
||||
}: SendPasswordResetEmailParams): Promise<EmailResult> {
|
||||
const resetUrl = buildUrl(baseUrl, "/auth/reset-password", { token });
|
||||
const expiresIn = formatExpiryHours(expiryHours);
|
||||
|
||||
return sendEmail(client, fromAddress, {
|
||||
to: email,
|
||||
subject: "Reset your password",
|
||||
htmlBody: buildPasswordResetEmailHtml({ resetUrl, expiresIn }),
|
||||
textBody: buildPasswordResetEmailText({ resetUrl, expiresIn }),
|
||||
});
|
||||
}
|
||||
115
packages/emails/src/emails/verification.test.ts
Normal file
115
packages/emails/src/emails/verification.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||
import type { EmailClient } from "../types.js";
|
||||
import {
|
||||
buildVerificationEmailHtml,
|
||||
buildVerificationEmailText,
|
||||
sendVerificationEmail,
|
||||
} from "./verification.js";
|
||||
|
||||
describe("buildVerificationEmailHtml", () => {
|
||||
const params = {
|
||||
verifyUrl: "https://example.com/auth/verify?token=abc123",
|
||||
expiresIn: "24 hours",
|
||||
};
|
||||
|
||||
it("should include the verify URL", () => {
|
||||
const html = buildVerificationEmailHtml(params);
|
||||
expect(html).toContain('href="https://example.com/auth/verify?token=abc123"');
|
||||
});
|
||||
|
||||
it("should include the expiry time", () => {
|
||||
const html = buildVerificationEmailHtml(params);
|
||||
expect(html).toContain("This link expires in 24 hours.");
|
||||
});
|
||||
|
||||
it("should include the heading", () => {
|
||||
const html = buildVerificationEmailHtml(params);
|
||||
expect(html).toContain("Verify your email");
|
||||
});
|
||||
|
||||
it("should include verify button text", () => {
|
||||
const html = buildVerificationEmailHtml(params);
|
||||
expect(html).toContain(">Verify Email</a>");
|
||||
});
|
||||
|
||||
it("should include ignore message", () => {
|
||||
const html = buildVerificationEmailHtml(params);
|
||||
expect(html).toContain(
|
||||
"If you didn't create an account, you can safely ignore this email.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVerificationEmailText", () => {
|
||||
const params = {
|
||||
verifyUrl: "https://example.com/auth/verify?token=abc123",
|
||||
expiresIn: "24 hours",
|
||||
};
|
||||
|
||||
it("should include the verify URL", () => {
|
||||
const text = buildVerificationEmailText(params);
|
||||
expect(text).toContain("https://example.com/auth/verify?token=abc123");
|
||||
});
|
||||
|
||||
it("should include the expiry time", () => {
|
||||
const text = buildVerificationEmailText(params);
|
||||
expect(text).toContain("This link expires in 24 hours.");
|
||||
});
|
||||
|
||||
it("should include the heading", () => {
|
||||
const text = buildVerificationEmailText(params);
|
||||
expect(text).toContain("Verify your email");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendVerificationEmail", () => {
|
||||
const createMockClient = () => {
|
||||
const sendEmailMock = mock(() =>
|
||||
Promise.resolve({ messageId: "test-message-id" }),
|
||||
);
|
||||
return {
|
||||
sendEmail: sendEmailMock,
|
||||
} as EmailClient & { sendEmail: ReturnType<typeof mock> };
|
||||
};
|
||||
|
||||
let mockClient: ReturnType<typeof createMockClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = createMockClient();
|
||||
});
|
||||
|
||||
it("should send verification email with correct URL and expiry", async () => {
|
||||
const result = await sendVerificationEmail({
|
||||
client: mockClient,
|
||||
fromAddress: "noreply@example.com",
|
||||
baseUrl: "https://app.example.com",
|
||||
email: "user@example.com",
|
||||
token: "verify-token-123",
|
||||
expiryHours: 24,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockClient.sendEmail).toHaveBeenCalledTimes(1);
|
||||
|
||||
const call = mockClient.sendEmail.mock.calls[0];
|
||||
const params = call?.[0] as {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
};
|
||||
|
||||
expect(params.to).toBe("user@example.com");
|
||||
expect(params.from).toBe("noreply@example.com");
|
||||
expect(params.subject).toBe("Verify your email address");
|
||||
expect(params.htmlBody).toContain(
|
||||
"https://app.example.com/auth/verify?token=verify-token-123",
|
||||
);
|
||||
expect(params.htmlBody).toContain("24 hours");
|
||||
expect(params.textBody).toContain(
|
||||
"https://app.example.com/auth/verify?token=verify-token-123",
|
||||
);
|
||||
expect(params.textBody).toContain("24 hours");
|
||||
});
|
||||
});
|
||||
84
packages/emails/src/emails/verification.ts
Normal file
84
packages/emails/src/emails/verification.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { buildUrl, formatExpiryHours } from "../helpers.js";
|
||||
import { sendEmail } from "../send.js";
|
||||
import {
|
||||
buttonStyles,
|
||||
containerStyles,
|
||||
emailStyles,
|
||||
footerStyles,
|
||||
headingStyles,
|
||||
paragraphStyles,
|
||||
} from "../styles.js";
|
||||
import type { EmailClient, EmailResult } from "../types.js";
|
||||
|
||||
export interface VerificationEmailParams {
|
||||
verifyUrl: string;
|
||||
expiresIn: string;
|
||||
}
|
||||
|
||||
export function buildVerificationEmailHtml({
|
||||
verifyUrl,
|
||||
expiresIn,
|
||||
}: VerificationEmailParams): string {
|
||||
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}">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>
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildVerificationEmailText({
|
||||
verifyUrl,
|
||||
expiresIn,
|
||||
}: VerificationEmailParams): string {
|
||||
return `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.
|
||||
`;
|
||||
}
|
||||
|
||||
export interface SendVerificationEmailParams {
|
||||
client: EmailClient;
|
||||
fromAddress: string;
|
||||
baseUrl: string;
|
||||
email: string;
|
||||
token: string;
|
||||
expiryHours: number;
|
||||
}
|
||||
|
||||
export async function sendVerificationEmail({
|
||||
client,
|
||||
fromAddress,
|
||||
baseUrl,
|
||||
email,
|
||||
token,
|
||||
expiryHours,
|
||||
}: SendVerificationEmailParams): Promise<EmailResult> {
|
||||
const verifyUrl = buildUrl(baseUrl, "/auth/verify", { token });
|
||||
const expiresIn = formatExpiryHours(expiryHours);
|
||||
|
||||
return sendEmail(client, fromAddress, {
|
||||
to: email,
|
||||
subject: "Verify your email address",
|
||||
htmlBody: buildVerificationEmailHtml({ verifyUrl, expiresIn }),
|
||||
textBody: buildVerificationEmailText({ verifyUrl, expiresIn }),
|
||||
});
|
||||
}
|
||||
140
packages/emails/src/helpers.test.ts
Normal file
140
packages/emails/src/helpers.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
buildUrl,
|
||||
escapeHtml,
|
||||
formatExpiryDays,
|
||||
formatExpiryHours,
|
||||
formatExpiryMinutes,
|
||||
formatRoleDisplay,
|
||||
getArticleForRole,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("buildUrl", () => {
|
||||
it("should build a URL with query parameters", () => {
|
||||
const result = buildUrl("https://example.com", "/auth/verify", {
|
||||
token: "abc123",
|
||||
});
|
||||
expect(result).toBe("https://example.com/auth/verify?token=abc123");
|
||||
});
|
||||
|
||||
it("should handle multiple query parameters", () => {
|
||||
const result = buildUrl("https://example.com", "/page", {
|
||||
foo: "bar",
|
||||
baz: "qux",
|
||||
});
|
||||
expect(result).toBe("https://example.com/page?foo=bar&baz=qux");
|
||||
});
|
||||
|
||||
it("should encode special characters in query parameters", () => {
|
||||
const result = buildUrl("https://example.com", "/search", {
|
||||
q: "hello world",
|
||||
});
|
||||
expect(result).toBe("https://example.com/search?q=hello+world");
|
||||
});
|
||||
|
||||
it("should handle empty params", () => {
|
||||
const result = buildUrl("https://example.com", "/page", {});
|
||||
expect(result).toBe("https://example.com/page");
|
||||
});
|
||||
|
||||
it("should handle base URL with trailing slash", () => {
|
||||
const result = buildUrl("https://example.com/", "/auth/verify", {
|
||||
token: "abc",
|
||||
});
|
||||
expect(result).toBe("https://example.com/auth/verify?token=abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeHtml", () => {
|
||||
it("should escape ampersands", () => {
|
||||
expect(escapeHtml("foo & bar")).toBe("foo & bar");
|
||||
});
|
||||
|
||||
it("should escape less than signs", () => {
|
||||
expect(escapeHtml("<script>")).toBe("<script>");
|
||||
});
|
||||
|
||||
it("should escape greater than signs", () => {
|
||||
expect(escapeHtml("a > b")).toBe("a > b");
|
||||
});
|
||||
|
||||
it("should escape double quotes", () => {
|
||||
expect(escapeHtml('say "hello"')).toBe("say "hello"");
|
||||
});
|
||||
|
||||
it("should escape single quotes", () => {
|
||||
expect(escapeHtml("it's")).toBe("it's");
|
||||
});
|
||||
|
||||
it("should escape all characters in combination", () => {
|
||||
expect(escapeHtml('<a href="test">O\'Reilly & Co</a>')).toBe(
|
||||
"<a href="test">O'Reilly & Co</a>",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return empty string unchanged", () => {
|
||||
expect(escapeHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("should return safe strings unchanged", () => {
|
||||
expect(escapeHtml("hello world")).toBe("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatExpiryHours", () => {
|
||||
it("should format 1 hour", () => {
|
||||
expect(formatExpiryHours(1)).toBe("1 hour");
|
||||
});
|
||||
|
||||
it("should format multiple hours", () => {
|
||||
expect(formatExpiryHours(24)).toBe("24 hours");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatExpiryMinutes", () => {
|
||||
it("should format 1 minute", () => {
|
||||
expect(formatExpiryMinutes(1)).toBe("1 minute");
|
||||
});
|
||||
|
||||
it("should format multiple minutes", () => {
|
||||
expect(formatExpiryMinutes(15)).toBe("15 minutes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatExpiryDays", () => {
|
||||
it("should format 1 day", () => {
|
||||
expect(formatExpiryDays(1)).toBe("1 day");
|
||||
});
|
||||
|
||||
it("should format multiple days", () => {
|
||||
expect(formatExpiryDays(7)).toBe("7 days");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRoleDisplay", () => {
|
||||
it("should format owner role", () => {
|
||||
expect(formatRoleDisplay("owner")).toBe("Owner");
|
||||
});
|
||||
|
||||
it("should format admin role", () => {
|
||||
expect(formatRoleDisplay("admin")).toBe("Admin");
|
||||
});
|
||||
|
||||
it("should format member role", () => {
|
||||
expect(formatRoleDisplay("member")).toBe("Member");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getArticleForRole", () => {
|
||||
it("should return 'an' for owner", () => {
|
||||
expect(getArticleForRole("owner")).toBe("an");
|
||||
});
|
||||
|
||||
it("should return 'an' for admin", () => {
|
||||
expect(getArticleForRole("admin")).toBe("an");
|
||||
});
|
||||
|
||||
it("should return 'a' for member", () => {
|
||||
expect(getArticleForRole("member")).toBe("a");
|
||||
});
|
||||
});
|
||||
54
packages/emails/src/helpers.ts
Normal file
54
packages/emails/src/helpers.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { DurationFormat } from "@formatjs/intl-durationformat";
|
||||
import type { OrgRole } from "@reviq/db-schema";
|
||||
|
||||
export function buildUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
params: Record<string, string>,
|
||||
): string {
|
||||
const url = new URL(path, baseUrl);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function escapeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const durationFormatter = new DurationFormat("en", { style: "long" });
|
||||
|
||||
export function formatExpiryHours(hours: number): string {
|
||||
return durationFormatter.format({ hours });
|
||||
}
|
||||
|
||||
export function formatExpiryMinutes(minutes: number): string {
|
||||
return durationFormatter.format({ minutes });
|
||||
}
|
||||
|
||||
export function formatExpiryDays(days: number): string {
|
||||
return durationFormatter.format({ days });
|
||||
}
|
||||
|
||||
const roleLabels: Record<OrgRole, string> = {
|
||||
owner: "Owner",
|
||||
admin: "Admin",
|
||||
member: "Member",
|
||||
};
|
||||
|
||||
export function formatRoleDisplay(role: OrgRole): string {
|
||||
return roleLabels[role];
|
||||
}
|
||||
|
||||
export function getArticleForRole(role: OrgRole): string {
|
||||
if (role === "owner" || role === "admin") {
|
||||
return "an";
|
||||
}
|
||||
return "a";
|
||||
}
|
||||
26
packages/emails/src/index.ts
Normal file
26
packages/emails/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Client factories
|
||||
export { createPostmarkClient } from "./client.js";
|
||||
export { createLoggingEmailClient } from "./logging-client.js";
|
||||
|
||||
// Core types
|
||||
export type { EmailClient, EmailResult } from "./types.js";
|
||||
|
||||
// Base send function
|
||||
export { sendEmail } from "./send.js";
|
||||
export type { SendEmailParams } from "./send.js";
|
||||
|
||||
// Email-specific send functions
|
||||
export {
|
||||
sendLoginConfirmationEmail,
|
||||
sendOrgInviteEmail,
|
||||
sendPasswordResetEmail,
|
||||
sendVerificationEmail,
|
||||
} from "./emails/index.js";
|
||||
|
||||
// Email param types
|
||||
export type {
|
||||
SendLoginConfirmationEmailParams,
|
||||
SendOrgInviteEmailParams,
|
||||
SendPasswordResetEmailParams,
|
||||
SendVerificationEmailParams,
|
||||
} from "./emails/index.js";
|
||||
61
packages/emails/src/logging-client.test.ts
Normal file
61
packages/emails/src/logging-client.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, mock, beforeEach, afterEach } from "bun:test";
|
||||
import { createLoggingEmailClient } from "./logging-client.js";
|
||||
|
||||
describe("createLoggingEmailClient", () => {
|
||||
let originalLog: typeof console.log;
|
||||
let logOutput: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
logOutput = [];
|
||||
originalLog = console.log;
|
||||
console.log = mock((...args: unknown[]) => {
|
||||
logOutput.push(args.join(" "));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = originalLog;
|
||||
});
|
||||
|
||||
it("should create a client that logs emails to console", async () => {
|
||||
const client = createLoggingEmailClient();
|
||||
|
||||
const result = await client.sendEmail({
|
||||
from: "noreply@example.com",
|
||||
to: "user@example.com",
|
||||
subject: "Test Subject",
|
||||
htmlBody: "<p>Hello</p>",
|
||||
textBody: "Hello",
|
||||
});
|
||||
|
||||
expect(result.messageId).toMatch(/^dev-mode-\d+$/);
|
||||
expect(logOutput).toContain("=== DEV MODE EMAIL ===");
|
||||
expect(logOutput.some((line) => line.includes("From: noreply@example.com"))).toBe(true);
|
||||
expect(logOutput.some((line) => line.includes("To: user@example.com"))).toBe(true);
|
||||
expect(logOutput.some((line) => line.includes("Subject: Test Subject"))).toBe(true);
|
||||
expect(logOutput.some((line) => line.includes("Hello"))).toBe(true);
|
||||
expect(logOutput).toContain("======================");
|
||||
});
|
||||
|
||||
it("should generate unique message IDs", async () => {
|
||||
const client = createLoggingEmailClient();
|
||||
|
||||
const result1 = await client.sendEmail({
|
||||
from: "a@example.com",
|
||||
to: "b@example.com",
|
||||
subject: "Test 1",
|
||||
htmlBody: "",
|
||||
textBody: "",
|
||||
});
|
||||
|
||||
const result2 = await client.sendEmail({
|
||||
from: "a@example.com",
|
||||
to: "b@example.com",
|
||||
subject: "Test 2",
|
||||
htmlBody: "",
|
||||
textBody: "",
|
||||
});
|
||||
|
||||
expect(result1.messageId).not.toBe(result2.messageId);
|
||||
});
|
||||
});
|
||||
23
packages/emails/src/logging-client.ts
Normal file
23
packages/emails/src/logging-client.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type {
|
||||
ClientSendParams,
|
||||
ClientSendResult,
|
||||
EmailClient,
|
||||
} from "./types.js";
|
||||
|
||||
let messageIdCounter = 0;
|
||||
|
||||
export function createLoggingEmailClient(): EmailClient {
|
||||
return {
|
||||
sendEmail: (params: ClientSendParams): Promise<ClientSendResult> => {
|
||||
console.log("=== DEV MODE EMAIL ===");
|
||||
console.log(`From: ${params.from}`);
|
||||
console.log(`To: ${params.to}`);
|
||||
console.log(`Subject: ${params.subject}`);
|
||||
console.log(`Text Body:\n${params.textBody}`);
|
||||
console.log("======================");
|
||||
|
||||
messageIdCounter++;
|
||||
return Promise.resolve({ messageId: `dev-mode-${messageIdCounter.toString()}` });
|
||||
},
|
||||
};
|
||||
}
|
||||
69
packages/emails/src/send.test.ts
Normal file
69
packages/emails/src/send.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, mock } from "bun:test";
|
||||
import type { EmailClient } from "./types.js";
|
||||
import { sendEmail } from "./send.js";
|
||||
|
||||
describe("sendEmail", () => {
|
||||
const createMockClient = () => {
|
||||
const sendEmailMock = mock(() =>
|
||||
Promise.resolve({ messageId: "test-message-id" }),
|
||||
);
|
||||
return {
|
||||
sendEmail: sendEmailMock,
|
||||
} as EmailClient & { sendEmail: ReturnType<typeof mock> };
|
||||
};
|
||||
|
||||
it("should send email successfully", async () => {
|
||||
const mockClient = createMockClient();
|
||||
|
||||
const result = await sendEmail(mockClient, "noreply@example.com", {
|
||||
to: "user@example.com",
|
||||
subject: "Test Subject",
|
||||
htmlBody: "<p>Hello</p>",
|
||||
textBody: "Hello",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBe("test-message-id");
|
||||
expect(mockClient.sendEmail).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.sendEmail).toHaveBeenCalledWith({
|
||||
from: "noreply@example.com",
|
||||
to: "user@example.com",
|
||||
subject: "Test Subject",
|
||||
htmlBody: "<p>Hello</p>",
|
||||
textBody: "Hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
const mockClient = {
|
||||
sendEmail: mock(() => Promise.reject(new Error("API Error"))),
|
||||
} as EmailClient;
|
||||
|
||||
const result = await sendEmail(mockClient, "noreply@example.com", {
|
||||
to: "user@example.com",
|
||||
subject: "Test",
|
||||
htmlBody: "<p>Test</p>",
|
||||
textBody: "Test",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("API Error");
|
||||
});
|
||||
|
||||
it("should handle non-Error exceptions", async () => {
|
||||
const mockClient = {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
sendEmail: mock(() => Promise.reject("String error")),
|
||||
} as unknown as EmailClient;
|
||||
|
||||
const result = await sendEmail(mockClient, "noreply@example.com", {
|
||||
to: "user@example.com",
|
||||
subject: "Test",
|
||||
htmlBody: "<p>Test</p>",
|
||||
textBody: "Test",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("Unknown error");
|
||||
});
|
||||
});
|
||||
31
packages/emails/src/send.ts
Normal file
31
packages/emails/src/send.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { EmailClient, EmailResult } from "./types.js";
|
||||
|
||||
export interface SendEmailParams {
|
||||
to: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
}
|
||||
|
||||
export async function sendEmail(
|
||||
client: EmailClient,
|
||||
fromAddress: string,
|
||||
params: SendEmailParams,
|
||||
): Promise<EmailResult> {
|
||||
const { to, subject, htmlBody, textBody } = params;
|
||||
|
||||
try {
|
||||
const result = await client.sendEmail({
|
||||
from: fromAddress,
|
||||
to,
|
||||
subject,
|
||||
htmlBody,
|
||||
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 };
|
||||
}
|
||||
}
|
||||
12
packages/emails/src/styles.ts
Normal file
12
packages/emails/src/styles.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const emailStyles =
|
||||
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5;";
|
||||
export const containerStyles =
|
||||
"max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 40px;";
|
||||
export const headingStyles =
|
||||
"margin: 0 0 24px; font-size: 24px; color: #1a1a1a;";
|
||||
export const paragraphStyles =
|
||||
"margin: 0 0 24px; font-size: 16px; color: #4a4a4a; line-height: 1.5;";
|
||||
export const buttonStyles =
|
||||
"display: inline-block; background-color: #0066cc; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;";
|
||||
export const footerStyles =
|
||||
"margin: 24px 0 0; font-size: 14px; color: #6a6a6a;";
|
||||
21
packages/emails/src/types.ts
Normal file
21
packages/emails/src/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface EmailResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ClientSendParams {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
}
|
||||
|
||||
export interface ClientSendResult {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export interface EmailClient {
|
||||
sendEmail(params: ClientSendParams): Promise<ClientSendResult>;
|
||||
}
|
||||
6
packages/emails/tsconfig.json
Normal file
6
packages/emails/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user