From 9456a98eaca5bd1a175606495a8103f5c3ce0180 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Fri, 9 Jan 2026 15:42:33 +0800 Subject: [PATCH] Implement Workstream G: Email Service with Postmark - Add postmark dependency and email configuration constants - Implement sendVerificationEmail, sendPasswordResetEmail, sendLoginConfirmationEmail, and sendOrgInviteEmail helpers - Add HTML + text email templates with inline CSS - Support dev mode (EMAIL_DEV_MODE=true) for console logging - Use URL constructor for proper URL building - Add XSS protection with HTML escaping in templates - Create .env file with email environment variables Co-Authored-By: Claude Opus 4.5 --- apps/api-server/package.json | 1 + apps/api-server/src/constants.ts | 28 ++ apps/api-server/src/procedures/auth/signup.ts | 2 +- apps/api-server/src/utils/email.ts | 419 +++++++++++++++++- bun.lock | 45 ++ docs/initial-app.md | 12 +- 6 files changed, 483 insertions(+), 24 deletions(-) diff --git a/apps/api-server/package.json b/apps/api-server/package.json index f6d256b..b073a64 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -18,6 +18,7 @@ "@simplewebauthn/server": "^13.2.2", "@simplewebauthn/types": "^12.0.0", "kysely": "^0.28.2", + "postmark": "^4.0.5", "zxcvbn": "^4.4.2" }, "devDependencies": { diff --git a/apps/api-server/src/constants.ts b/apps/api-server/src/constants.ts index 8db58e4..2ae3206 100644 --- a/apps/api-server/src/constants.ts +++ b/apps/api-server/src/constants.ts @@ -27,3 +27,31 @@ export const getAllowedOrigins = (): string[] => { "http://localhost:6828", ]; }; + +// ===== Email Configuration ===== + +/** Email sender address */ +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) */ +export const POSTMARK_API_KEY = Bun.env.POSTMARK_API_KEY; + +// ===== Token Expiration Times ===== + +/** Email verification token expiry in hours */ +export const EMAIL_VERIFICATION_EXPIRY_HOURS = 24; + +/** Password reset token expiry in hours */ +export const PASSWORD_RESET_EXPIRY_HOURS = 1; + +/** Login confirmation token expiry in minutes */ +export const LOGIN_CONFIRMATION_EXPIRY_MINUTES = 15; + +/** Org invite token expiry in days */ +export const ORG_INVITE_EXPIRY_DAYS = 7; diff --git a/apps/api-server/src/procedures/auth/signup.ts b/apps/api-server/src/procedures/auth/signup.ts index 4cf66aa..22b4a3e 100644 --- a/apps/api-server/src/procedures/auth/signup.ts +++ b/apps/api-server/src/procedures/auth/signup.ts @@ -2,12 +2,12 @@ * Signup procedure - creates a new user account with email + password or passkey */ +import type { DB } from "@reviq/db-schema"; import type { PublicKeyCredentialCreationOptionsJSON, RegistrationResponseJSON, } from "@simplewebauthn/types"; import type { Kysely } from "kysely"; -import type { DB } from "@reviq/db-schema"; import type { APIContext } from "../../context.js"; import type { RPInfo } from "../../utils/webauthn.js"; import { implement, ORPCError } from "@orpc/server"; diff --git a/apps/api-server/src/utils/email.ts b/apps/api-server/src/utils/email.ts index ae11537..4c16f18 100644 --- a/apps/api-server/src/utils/email.ts +++ b/apps/api-server/src/utils/email.ts @@ -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 => { + 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, "'"); + +// ===== 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 => { + 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 => ` + + + + + + + +
+

Verify your email

+

Please verify your email address by clicking the button below:

+ Verify Email +

This link expires in ${expiresIn}.

+

If you didn't create an account, you can safely ignore this email.

+
+ + +`; + +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 => ` + + + + + + + +
+

Reset your password

+

We received a request to reset your password. Click the button below to choose a new password:

+ Reset Password +

This link expires in ${expiresIn}.

+

If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.

+
+ + +`; + +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 => ` + + + + + + + +
+

Confirm your login

+

Someone is trying to sign in to your account. If this was you, click the button below to confirm:

+ Confirm Login +

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.

+
+ + +`; + +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 ` + + + + + + + +
+

You've been invited to join ${safeOrgName}

+

${safeInviterName} has invited you to join ${safeOrgName} as ${article} ${roleDisplay}.

+ Accept Invitation +

This invitation expires in ${expiresIn}.

+

This invitation was sent to ${safeEmail}. If you weren't expecting this invitation, you can safely ignore this email.

+
+ + +`; +}; + +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 { - const url = `${getBaseUrl()}/auth/verify?token=${token}`; - console.log(`[EMAIL STUB] Verification email to ${email}`); - console.log(`[EMAIL STUB] Verify link: ${url}`); +): Promise { + 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 { - const url = `${getBaseUrl()}/auth/confirm?token=${token}`; - console.log(`[EMAIL STUB] Login confirmation to ${email}`); - console.log(`[EMAIL STUB] Confirm link: ${url}`); +): Promise { + 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 { - const url = `${getBaseUrl()}/auth/reset-password?token=${token}`; - console.log(`[EMAIL STUB] Password reset to ${email}`); - console.log(`[EMAIL STUB] Reset link: ${url}`); +): Promise { + 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 { + 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, + ), + }); } diff --git a/bun.lock b/bun.lock index 8403687..f08c666 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "@simplewebauthn/server": "^13.2.2", "@simplewebauthn/types": "^12.0.0", "kysely": "^0.28.2", + "postmark": "^4.0.5", "zxcvbn": "^4.4.2", }, "devDependencies": { @@ -494,6 +495,10 @@ "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -506,6 +511,8 @@ "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -518,6 +525,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], @@ -532,6 +541,8 @@ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -544,12 +555,22 @@ "dotenv-expand": ["dotenv-expand@12.0.3", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -596,12 +617,20 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "git-diff": ["git-diff@2.0.6", "", { "dependencies": { "chalk": "^2.3.2", "diff": "^3.5.0", "loglevel": "^1.6.1", "shelljs": "^0.8.1", "shelljs.exec": "^1.1.7" } }, "sha512-/Iu4prUrydE3Pb3lCBMbcSNIf81tgGt0W1ZwknnyF62t3tHmtiJTRj0f+1ZIhp3+Rh0ktz1pJVoa7ZXUCskivA=="], "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -610,10 +639,16 @@ "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -708,8 +743,14 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -780,8 +821,12 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "postmark": ["postmark@4.0.5", "", { "dependencies": { "axios": "^1.7.4" } }, "sha512-nerZdd3TwOH4CgGboZnlUM/q7oZk0EqpZgJL+Y3Nup8kHeaukxouQ6JcFF3EJEijc4QbuNv1TefGhboAKtf/SQ=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], diff --git a/docs/initial-app.md b/docs/initial-app.md index e126777..9c00cda 100644 --- a/docs/initial-app.md +++ b/docs/initial-app.md @@ -2260,12 +2260,12 @@ _Depends on: D1 (auth middleware)_ _Depends on: C2_ _Can run parallel to D, E, F_ -- [ ] **G1**: Set up Postmark client with env config -- [ ] **G2**: Create email templates (verification, password reset, login confirmation, org invite) -- [ ] **G3**: Implement `sendVerificationEmail()` helper -- [ ] **G4**: Implement `sendPasswordResetEmail()` helper -- [ ] **G5**: Implement `sendLoginConfirmationEmail()` helper -- [ ] **G6**: Implement `sendOrgInviteEmail()` helper +- [x] **G1**: Set up Postmark client with env config +- [x] **G2**: Create email templates (verification, password reset, login confirmation, org invite) +- [x] **G3**: Implement `sendVerificationEmail()` helper +- [x] **G4**: Implement `sendPasswordResetEmail()` helper +- [x] **G5**: Implement `sendLoginConfirmationEmail()` helper +- [x] **G6**: Implement `sendOrgInviteEmail()` helper ---