diff --git a/.env.example b/.env.example index 3af9d04..ac6528e 100644 --- a/.env.example +++ b/.env.example @@ -26,8 +26,11 @@ GOOGLE_OAUTH_SECRET=your_google_oauth_secret # `connect_timeout=15` is required, not cosmetic: Neon's free tier scales compute # to zero when idle and a cold start can exceed Prisma's 5 s default, which fails # the first request after a quiet period with "Can't reach database server". -DATABASE_URL=postgresql://user:password@ep-example-pooler.region.aws.neon.tech/neondb?sslmode=require&connect_timeout=15 -DIRECT_URL=postgresql://user:password@ep-example.region.aws.neon.tech/neondb?sslmode=require&connect_timeout=15 +# `pool_timeout=20` must stay greater than connect_timeout — Prisma's 10 s pool +# default would otherwise give up while the connection is still being established, +# making the larger connect_timeout ineffective. +DATABASE_URL=postgresql://user:password@ep-example-pooler.region.aws.neon.tech/neondb?sslmode=require&connect_timeout=15&pool_timeout=20 +DIRECT_URL=postgresql://user:password@ep-example.region.aws.neon.tech/neondb?sslmode=require&connect_timeout=15&pool_timeout=20 # Cloudflare R2 object storage (S3-compatible) # The API token needs Object Read & Write on this bucket only. The endpoint is derived as @@ -68,8 +71,13 @@ PDF_TOKEN_SECRET=your_token_secret PDF_EXPORT_URL=your_service_url VITE_PDF_EXPORT_URL=${PDF_EXPORT_URL} -# Resend Email Service +# Resend Email Service (called directly by the backend; no SMTP relay) +# The sender domain must stay DKIM/SPF-verified in Resend or deliverability drops. RESEND_API_KEY=your_resend_api_key +EMAIL_FROM=SolarSim +# Absolute origin for images embedded in email — mail clients cannot resolve +# relative paths, so this must be a reachable public URL. +EMAIL_ASSET_BASE_URL=https://solarsim.tech # Deployment (set by Heroku at runtime) HEROKU_PORT= diff --git a/backend/package.json b/backend/package.json index 20bc1d9..d444389 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "geotiff": "^2.1.3", "jsonwebtoken": "^9.0.3", "proj4": "^2.15.0", + "resend": "^6.18.1", "sharp": "^0.34.1", "zod": "^3.24.4" }, diff --git a/backend/src/app.ts b/backend/src/app.ts index b1af3d0..4a45f0c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -66,9 +66,12 @@ app.use( } }) ) +// Logger first so auth requests are visible too — the Better Auth handler must +// precede express.json() to receive a raw body, which previously put it ahead of +// the logger and left every sign-in and OAuth callback unlogged. +app.use(requestLogger) app.all('/api/auth/*splat', toNodeHandler(auth)) app.use(express.json()) -app.use(requestLogger) app.use('/api/health', healthRouter) app.use('/api/locations', locationsRouter) diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index c9f2ea1..82170c8 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -33,6 +33,13 @@ const envSchema = z R2_BUCKET: z.string().min(1), BETTER_AUTH_SECRET: z.string().min(32), BETTER_AUTH_URL: z.string().url(), + RESEND_API_KEY: z.string().min(1), + // Sender identity must stay on the DKIM-verified solarsim.tech domain, or + // deliverability and existing inbox reputation are lost. + EMAIL_FROM: z.string().min(1).default('SolarSim '), + // Absolute base for images referenced in email — clients cannot resolve + // relative paths. Falls back to the public site origin. + EMAIL_ASSET_BASE_URL: z.string().url().default('https://solarsim.tech'), GOOGLE_OAUTH_CLIENT_ID: z.string().min(1), GOOGLE_OAUTH_SECRET: z.string().min(1), FRONTEND_URL: z.string().url().optional().default('http://localhost:5173'), diff --git a/backend/src/emails/__tests__/emails.test.ts b/backend/src/emails/__tests__/emails.test.ts new file mode 100644 index 0000000..74b6276 --- /dev/null +++ b/backend/src/emails/__tests__/emails.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' + +const { emailAssetBaseUrl } = vi.hoisted(() => ({ + emailAssetBaseUrl: 'https://assets.example' +})) + +vi.mock('../../config/env.js', () => ({ + env: { EMAIL_ASSET_BASE_URL: emailAssetBaseUrl } +})) + +import { + renderEmailChangeEmail, + renderInviteEmail, + renderPasswordResetEmail, + renderVerificationEmail +} from '../index.js' + +const testUrl = 'https://app.example/auth?next=dashboard&mode="new"' +const escapedUrl = 'https://app.example/auth?next=dashboard&mode="new"<finish>' + +const renderers = [ + { + render: renderVerificationEmail, + subject: 'Confirm Your SolarSim Account' + }, + { + render: renderPasswordResetEmail, + subject: 'Reset your Solar Layout Generator password' + }, + { + render: renderEmailChangeEmail, + subject: 'Confirm your new email address' + }, + { + render: renderInviteEmail, + subject: "You've been invited to Solar Layout Generator" + } +] + +describe('email renderers', () => { + it.each(renderers)('returns the configured subject and escaped URL', ({ render, subject }) => { + const email = render(testUrl) + + expect(email.subject).toBe(subject) + expect(email.html).toContain(escapedUrl) + }) + + it.each(renderers)('uses the configured public logo asset', ({ render }) => { + const email = render(testUrl) + + expect(email.html).toContain('src="https://assets.example/email-logo.png"') + }) + + // Most clients block images by default, so the logo is decorative: an empty alt + // lets a blocked load collapse to nothing instead of rendering clipped alt text + // in a 42px box, and the adjacent wordmark still carries the brand. + it.each(renderers)('keeps the logo decorative with the wordmark carrying meaning', ({ render }) => { + const email = render(testUrl) + + expect(email.html).toContain('alt=""') + expect(email.html).not.toContain('alt="SolarSim logo"') + expect(email.html).toContain('SolarSim') + }) + + it.each(renderers)('does not leave template tokens behind', ({ render }) => { + const email = render(testUrl) + + expect(email.html).not.toMatch(new RegExp('\\{\\{\\s*.+?\\s*\\}\\}')) + }) +}) diff --git a/backend/src/emails/emailChange.ts b/backend/src/emails/emailChange.ts new file mode 100644 index 0000000..657c171 --- /dev/null +++ b/backend/src/emails/emailChange.ts @@ -0,0 +1,48 @@ +import { env } from '../config/env.js' +import type { RenderedEmail } from './types.js' + +const escapeHtml = (value: string) => + value.replace(/[&<>"']/g, (character) => { + switch (character) { + case '&': + return '&' + case '<': + return '<' + case '>': + return '>' + case '"': + return '"' + case "'": + return ''' + default: + return character + } + }) + +export const renderEmailChangeEmail = (url: string): RenderedEmail => { + const escapedUrl = escapeHtml(url) + const logoUrl = `${env.EMAIL_ASSET_BASE_URL}/email-logo.png` + + return { + subject: 'Confirm your new email address', + html: ` + + + + + Confirm email change + + + + + + +
Confirm your new SolarSim email address.
+
SolarSim

Email change

Confirm your new email address

Please confirm your new email address by clicking the button below.

If you did not request this change, please contact support immediately.

If the button does not work, copy and paste this link into your browser:
${escapedUrl}

+ +` + } +} diff --git a/backend/src/emails/index.ts b/backend/src/emails/index.ts new file mode 100644 index 0000000..4d2b90c --- /dev/null +++ b/backend/src/emails/index.ts @@ -0,0 +1,5 @@ +export { renderVerificationEmail } from './verification.js' +export { renderPasswordResetEmail } from './passwordReset.js' +export { renderEmailChangeEmail } from './emailChange.js' +export { renderInviteEmail } from './invite.js' +export type { RenderedEmail } from './types.js' diff --git a/backend/src/emails/invite.ts b/backend/src/emails/invite.ts new file mode 100644 index 0000000..eb46109 --- /dev/null +++ b/backend/src/emails/invite.ts @@ -0,0 +1,48 @@ +import { env } from '../config/env.js' +import type { RenderedEmail } from './types.js' + +const escapeHtml = (value: string) => + value.replace(/[&<>"']/g, (character) => { + switch (character) { + case '&': + return '&' + case '<': + return '<' + case '>': + return '>' + case '"': + return '"' + case "'": + return ''' + default: + return character + } + }) + +export const renderInviteEmail = (url: string): RenderedEmail => { + const escapedUrl = escapeHtml(url) + const logoUrl = `${env.EMAIL_ASSET_BASE_URL}/email-logo.png` + + return { + subject: "You've been invited to Solar Layout Generator", + html: ` + + + + + You have been invited + + + + + + +
You have been invited to join SolarSim.
+
SolarSim

Invitation

You have been invited

You have been invited to join Solar Layout Generator. Click the button below to accept the invitation and set up your account.

If you were not expecting this invitation, you can safely ignore this email.

If the button does not work, copy and paste this link into your browser:
${escapedUrl}

+ +` + } +} diff --git a/backend/src/emails/passwordReset.ts b/backend/src/emails/passwordReset.ts new file mode 100644 index 0000000..be4cbd4 --- /dev/null +++ b/backend/src/emails/passwordReset.ts @@ -0,0 +1,53 @@ +import { env } from '../config/env.js' +import type { RenderedEmail } from './types.js' + +const escapeHtml = (value: string) => + value.replace(/[&<>"']/g, (character) => { + switch (character) { + case '&': + return '&' + case '<': + return '<' + case '>': + return '>' + case '"': + return '"' + case "'": + return ''' + default: + return character + } + }) + +export const renderPasswordResetEmail = (url: string): RenderedEmail => { + const escapedUrl = escapeHtml(url) + const logoUrl = `${env.EMAIL_ASSET_BASE_URL}/email-logo.png` + + return { + subject: 'Reset your Solar Layout Generator password', + html: ` + + + + + Reset your password + + + + + + +
Reset your SolarSim password securely.
+
SolarSim

Password reset

Reset your password

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

If you did not request a password reset, you can safely ignore this email. Your password will remain unchanged.

If the button does not work, copy and paste this link into your browser:
${escapedUrl}

+ +` + } +} diff --git a/backend/src/emails/types.ts b/backend/src/emails/types.ts new file mode 100644 index 0000000..84952e8 --- /dev/null +++ b/backend/src/emails/types.ts @@ -0,0 +1,12 @@ +/** + * Contract between rendered email templates and the dispatch service. + * + * Templates own subject + HTML; `emailService` owns delivery. Keeping the two + * apart means a template change cannot break sending, and vice versa. + */ + +/** A fully rendered message, ready to hand to the mail provider. */ +export type RenderedEmail = { + subject: string + html: string +} diff --git a/backend/src/emails/verification.ts b/backend/src/emails/verification.ts new file mode 100644 index 0000000..6524c23 --- /dev/null +++ b/backend/src/emails/verification.ts @@ -0,0 +1,89 @@ +import { env } from '../config/env.js' +import type { RenderedEmail } from './types.js' + +const escapeHtml = (value: string) => + value.replace(/[&<>"']/g, (character) => { + switch (character) { + case '&': + return '&' + case '<': + return '<' + case '>': + return '>' + case '"': + return '"' + case "'": + return ''' + default: + return character + } + }) + +export const renderVerificationEmail = (url: string): RenderedEmail => { + const escapedUrl = escapeHtml(url) + const logoUrl = `${env.EMAIL_ASSET_BASE_URL}/email-logo.png` + + return { + subject: 'Confirm Your SolarSim Account', + html: ` + + + + + Confirm Your SolarSim Account + + + + + + +
Confirm your email address to finish setting up your SolarSim account.
+
+
+
+
+ + + SolarSim +
+
+

Account confirmation

Confirm Your SolarSim Account

+
+

Welcome to SolarSim. Please confirm your email address to finish setting up your account.

+ +
If you did not create a SolarSim account, you can safely ignore this email.
+

If the button does not work, copy and paste this link into your browser:
${escapedUrl}

+
+
+ +
+ +` + } +} diff --git a/backend/src/services/__tests__/emailService.test.ts b/backend/src/services/__tests__/emailService.test.ts new file mode 100644 index 0000000..702ce07 --- /dev/null +++ b/backend/src/services/__tests__/emailService.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + emailsSendMock, + renderVerificationEmailMock, + renderPasswordResetEmailMock, + renderEmailChangeEmailMock, + renderInviteEmailMock +} = vi.hoisted(() => ({ + emailsSendMock: vi.fn(), + renderVerificationEmailMock: vi.fn(), + renderPasswordResetEmailMock: vi.fn(), + renderEmailChangeEmailMock: vi.fn(), + renderInviteEmailMock: vi.fn() +})) + +vi.mock('../../config/env.js', () => ({ + env: { + RESEND_API_KEY: 're_test_key', + EMAIL_FROM: 'SolarSim ' + } +})) + +vi.mock('resend', () => ({ + Resend: class { + emails = { send: (...args: unknown[]) => emailsSendMock(...args) } + } +})) + +vi.mock('../../emails/index.js', () => ({ + renderVerificationEmail: (...args: unknown[]) => renderVerificationEmailMock(...args), + renderPasswordResetEmail: (...args: unknown[]) => renderPasswordResetEmailMock(...args), + renderEmailChangeEmail: (...args: unknown[]) => renderEmailChangeEmailMock(...args), + renderInviteEmail: (...args: unknown[]) => renderInviteEmailMock(...args) +})) + +import { + EMAIL_RATE_LIMIT, + sendEmail, + sendEmailChangeEmail, + sendInviteEmail, + sendPasswordResetEmail, + sendVerificationEmail +} from '../emailService.js' + +const EMAIL_FROM = 'SolarSim ' +const RECIPIENT = 'homeowner@example.com' +let testTime = new Date('2026-07-30T00:00:00.000Z') +let consoleErrorSpy: ReturnType + +describe('emailService', () => { + beforeEach(() => { + testTime = new Date(testTime.getTime() + 3_600_001) + vi.useFakeTimers() + vi.setSystemTime(testTime) + emailsSendMock.mockReset() + emailsSendMock.mockResolvedValue({ data: { id: 'email_123' }, error: null }) + renderVerificationEmailMock.mockReset() + renderPasswordResetEmailMock.mockReset() + renderEmailChangeEmailMock.mockReset() + renderInviteEmailMock.mockReset() + renderVerificationEmailMock.mockReturnValue({ subject: 'Verify your email', html: '

Verify

' }) + renderPasswordResetEmailMock.mockReturnValue({ subject: 'Reset your password', html: '

Reset

' }) + renderEmailChangeEmailMock.mockReturnValue({ subject: 'Confirm your email change', html: '

Change

' }) + renderInviteEmailMock.mockReturnValue({ subject: 'You are invited', html: '

Invite

' }) + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + consoleErrorSpy.mockRestore() + vi.useRealTimers() + }) + + it('renders and sends a verification email to the supplied recipient', async () => { + const url = 'https://solarsim.tech/verify?token=verification-token' + + await sendVerificationEmail(RECIPIENT, url) + + expect(renderVerificationEmailMock).toHaveBeenCalledWith(url) + expect(emailsSendMock).toHaveBeenCalledWith({ + from: EMAIL_FROM, + to: RECIPIENT, + subject: 'Verify your email', + html: '

Verify

' + }) + }) + + it('renders and sends a password-reset email to the supplied recipient', async () => { + const url = 'https://solarsim.tech/reset?token=reset-token' + + await sendPasswordResetEmail(RECIPIENT, url) + + expect(renderPasswordResetEmailMock).toHaveBeenCalledWith(url) + expect(emailsSendMock).toHaveBeenCalledWith({ + from: EMAIL_FROM, + to: RECIPIENT, + subject: 'Reset your password', + html: '

Reset

' + }) + }) + + it('renders and sends an email-change confirmation to the supplied recipient', async () => { + const url = 'https://solarsim.tech/email-change?token=change-token' + + await sendEmailChangeEmail(RECIPIENT, url) + + expect(renderEmailChangeEmailMock).toHaveBeenCalledWith(url) + expect(emailsSendMock).toHaveBeenCalledWith({ + from: EMAIL_FROM, + to: RECIPIENT, + subject: 'Confirm your email change', + html: '

Change

' + }) + }) + + it('renders and sends an invite email to the supplied recipient', async () => { + const url = 'https://solarsim.tech/invite?token=invite-token' + + await sendInviteEmail(RECIPIENT, url) + + expect(renderInviteEmailMock).toHaveBeenCalledWith(url) + expect(emailsSendMock).toHaveBeenCalledWith({ + from: EMAIL_FROM, + to: RECIPIENT, + subject: 'You are invited', + html: '

Invite

' + }) + }) + + it('throws a greppable error when Resend reports a failure', async () => { + emailsSendMock.mockResolvedValue({ data: null, error: { message: 'Sender domain is unavailable' } }) + + await expect(sendEmail({ to: RECIPIENT, subject: 'Subject', html: '

Body

' })).rejects.toThrow( + `Email send failed for ${RECIPIENT}: Sender domain is unavailable` + ) + }) + + it('allows sends below the hourly limit and blocks the next one', async () => { + for (let index = 0; index < EMAIL_RATE_LIMIT; index += 1) { + await sendEmail({ to: `homeowner-${index}@example.com`, subject: 'Subject', html: '

Body

' }) + } + + await expect(sendEmail({ to: RECIPIENT, subject: 'Subject', html: '

Body

' })).rejects.toThrow( + `Email rate limit exceeded for ${RECIPIENT}: maximum ${EMAIL_RATE_LIMIT} emails per hour` + ) + expect(emailsSendMock).toHaveBeenCalledTimes(EMAIL_RATE_LIMIT) + expect(consoleErrorSpy).toHaveBeenCalledWith( + `[Email] rate limit exceeded for ${RECIPIENT}: maximum ${EMAIL_RATE_LIMIT} emails per hour` + ) + }) +}) diff --git a/backend/src/services/emailService.ts b/backend/src/services/emailService.ts index edcd3e2..0b362b7 100644 --- a/backend/src/services/emailService.ts +++ b/backend/src/services/emailService.ts @@ -1,11 +1,57 @@ /** - * Transactional email dispatch. + * Transactional email dispatch through Resend. * - * Placeholder implementation: issue #7 replaces these bodies with direct Resend - * calls and the branded templates ported out of `supabase/templates/`. Until - * then the links are logged so local sign-up flows remain completable. + * Templates own message content; this service owns provider delivery and the + * retired auth service's hourly send guard. */ +import { Resend } from 'resend' +import { env } from '../config/env.js' +import { + renderEmailChangeEmail, + renderInviteEmail, + renderPasswordResetEmail, + renderVerificationEmail +} from '../emails/index.js' + +/** Maximum number of transactional emails accepted in one rolling hour. */ +export const EMAIL_RATE_LIMIT = 30 + +const EMAIL_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000 +const resend = new Resend(env.RESEND_API_KEY) +const sentEmailTimestamps: number[] = [] + +/** + * Sends a rendered transactional email through the DKIM-aligned Resend sender. + * + * @param message - Recipient and fully rendered message content + */ +export async function sendEmail({ to, subject, html }: { to: string; subject: string; html: string }): Promise { + const cutoff = Date.now() - EMAIL_RATE_LIMIT_WINDOW_MS + + while (sentEmailTimestamps[0] !== undefined && sentEmailTimestamps[0] <= cutoff) { + sentEmailTimestamps.shift() + } + + if (sentEmailTimestamps.length >= EMAIL_RATE_LIMIT) { + const message = `Email rate limit exceeded for ${to}: maximum ${EMAIL_RATE_LIMIT} emails per hour` + console.error(`[Email] rate limit exceeded for ${to}: maximum ${EMAIL_RATE_LIMIT} emails per hour`) + throw new Error(message) + } + + // Reserve before the provider call so concurrent requests cannot exceed the quota. + sentEmailTimestamps.push(Date.now()) + + try { + const { error } = await resend.emails.send({ from: env.EMAIL_FROM, to, subject, html }) + + if (error) throw new Error(error.message) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Email send failed for ${to}: ${message}`) + } +} + /** * Sends an address-verification link to a newly registered user. * @@ -13,7 +59,8 @@ * @param url - Better Auth verification link */ export async function sendVerificationEmail(email: string, url: string): Promise { - console.info(`[Email] verification for ${email}: ${url}`) + const { subject, html } = renderVerificationEmail(url) + await sendEmail({ to: email, subject, html }) } /** @@ -23,5 +70,28 @@ export async function sendVerificationEmail(email: string, url: string): Promise * @param url - Better Auth password-reset link */ export async function sendPasswordResetEmail(email: string, url: string): Promise { - console.info(`[Email] password reset for ${email}: ${url}`) + const { subject, html } = renderPasswordResetEmail(url) + await sendEmail({ to: email, subject, html }) +} + +/** + * Sends an email-change confirmation link. + * + * @param email - Recipient address + * @param url - Email-change confirmation link + */ +export async function sendEmailChangeEmail(email: string, url: string): Promise { + const { subject, html } = renderEmailChangeEmail(url) + await sendEmail({ to: email, subject, html }) +} + +/** + * Sends an invitation link. + * + * @param email - Recipient address + * @param url - Invitation link + */ +export async function sendInviteEmail(email: string, url: string): Promise { + const { subject, html } = renderInviteEmail(url) + await sendEmail({ to: email, subject, html }) } diff --git a/frontend/public/email-logo.png b/frontend/public/email-logo.png new file mode 100644 index 0000000..6aa6440 Binary files /dev/null and b/frontend/public/email-logo.png differ diff --git a/frontend/src/hooks/__tests__/useAuth.test.tsx b/frontend/src/hooks/__tests__/useAuth.test.tsx index 67ba1e1..42edf38 100644 --- a/frontend/src/hooks/__tests__/useAuth.test.tsx +++ b/frontend/src/hooks/__tests__/useAuth.test.tsx @@ -160,7 +160,12 @@ describe('AuthProvider', () => { name: 'member@example.com', password: 'password' }) - expect(signInSocialMock).toHaveBeenCalledWith({ provider: 'google', callbackURL: '/dashboard' }) + // Must be absolute: a relative callbackURL resolves against Better Auth's + // baseURL (the API origin), which in dev is the backend and 404s. + expect(signInSocialMock).toHaveBeenCalledWith({ + provider: 'google', + callbackURL: `${window.location.origin}/dashboard` + }) }) it('surfaces and removes OAuth callback errors', async () => { diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index c8f04ce..30ab6ab 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -94,7 +94,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []) const signInWithGoogle = useCallback(async () => { - const { error } = await authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' }) + // Absolute, not relative: Better Auth resolves a relative callbackURL against + // its own baseURL (the API origin), which in dev is the backend on :3001 and + // does not serve the SPA. Matches the origin-based redirect this replaced. + const { error } = await authClient.signIn.social({ + provider: 'google', + callbackURL: `${window.location.origin}/dashboard` + }) return { error: toAuthError(error) } }, []) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5878d38..5312297 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: proj4: specifier: ^2.15.0 version: 2.20.3 + resend: + specifier: ^6.18.1 + version: 6.18.1 sharp: specifier: ^0.34.1 version: 0.34.5 @@ -1834,6 +1837,9 @@ packages: resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} engines: {node: '>=18.0.0'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2901,6 +2907,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3697,6 +3706,9 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + postal-mime@2.7.5: + resolution: {integrity: sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==} + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -3936,6 +3948,15 @@ packages: reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resend@6.18.1: + resolution: {integrity: sha512-XN8XIaDdKF+ziSQ3K23ndUcyhP7U3ze2gky6SPgYkuAOq54mH4Wdhwm7QylEQ3zlz0NzdX7/l1AgmJUZbdPI/Q==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4049,6 +4070,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + stats-gl@2.4.2: resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==} peerDependencies: @@ -4862,29 +4886,29 @@ snapshots: nanostores: 1.4.2 zod: 4.4.3 - '@better-auth/drizzle-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': + '@better-auth/drizzle-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': dependencies: '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) '@better-auth/utils': 0.4.2 - '@better-auth/kysely-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(kysely@0.29.4)': + '@better-auth/kysely-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(kysely@0.29.4)': dependencies: '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.4 - '@better-auth/memory-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': dependencies: '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)': dependencies: '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.2(typescript@5.9.3))': + '@better-auth/prisma-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.2(typescript@5.9.3))': dependencies: '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) '@better-auth/utils': 0.4.2 @@ -4892,7 +4916,7 @@ snapshots: '@prisma/client': 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) prisma: 6.19.2(typescript@5.9.3) - '@better-auth/telemetry@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) '@better-auth/utils': 0.4.2 @@ -5919,6 +5943,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -6460,12 +6486,12 @@ snapshots: better-auth@1.6.25(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.2(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.13)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)): dependencies: '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) - '@better-auth/drizzle-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) - '@better-auth/kysely-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(kysely@0.29.4) - '@better-auth/memory-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.2(typescript@5.9.3)) - '@better-auth/telemetry': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/drizzle-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.2(typescript@5.9.3)) + '@better-auth/telemetry': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.2.0 @@ -7049,6 +7075,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -7932,6 +7960,8 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + postal-mime@2.7.5: {} + postcss-value-parser@4.2.0: {} postcss@8.5.8: @@ -8188,6 +8218,11 @@ snapshots: reselect@5.1.1: {} + resend@6.18.1: + dependencies: + postal-mime: 2.7.5 + standardwebhooks: 1.0.0 + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -8363,6 +8398,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + stats-gl@2.4.2(@types/three@0.183.1)(three@0.183.2): dependencies: '@types/three': 0.183.1