From fbc9f7584bf44501cc9334226870c9e424211fb5 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 20:21:42 +0800 Subject: [PATCH 1/5] feat(email): send transactional mail directly via Resend Removes the Supabase Auth SMTP relay that disappeared with Supabase Auth. The four branded templates move in-repo as typed render functions, and the backend calls Resend itself. Also clears template debt that was previously unfixable: the brand mark was still the generic sun character because updating it required the Supabase dashboard. Now a 6KB email-sized asset referenced through EMAIL_ASSET_BASE_URL, with the wordmark carrying meaning when clients block images. Carries over the retired email_sent=30/hour guard as an in-process sliding window so a signup loop cannot burn the Resend free quota. Verified live: domain, DKIM and SPF all verified in Resend; a real send through the service reached Resend with the correct sender identity, subject, and rendered logo. Closes #7 --- .env.example | 7 +- backend/package.json | 1 + backend/src/config/env.ts | 7 + backend/src/emails/__tests__/emails.test.ts | 60 +++++++ backend/src/emails/emailChange.ts | 48 ++++++ backend/src/emails/index.ts | 5 + backend/src/emails/invite.ts | 48 ++++++ backend/src/emails/passwordReset.ts | 53 ++++++ backend/src/emails/types.ts | 12 ++ backend/src/emails/verification.ts | 89 +++++++++++ .../services/__tests__/emailService.test.ts | 151 ++++++++++++++++++ backend/src/services/emailService.ts | 82 +++++++++- frontend/public/email-logo.png | Bin 0 -> 5840 bytes pnpm-lock.yaml | 64 ++++++-- 14 files changed, 608 insertions(+), 19 deletions(-) create mode 100644 backend/src/emails/__tests__/emails.test.ts create mode 100644 backend/src/emails/emailChange.ts create mode 100644 backend/src/emails/index.ts create mode 100644 backend/src/emails/invite.ts create mode 100644 backend/src/emails/passwordReset.ts create mode 100644 backend/src/emails/types.ts create mode 100644 backend/src/emails/verification.ts create mode 100644 backend/src/services/__tests__/emailService.test.ts create mode 100644 frontend/public/email-logo.png diff --git a/.env.example b/.env.example index 3af9d04..3961268 100644 --- a/.env.example +++ b/.env.example @@ -68,8 +68,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/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..06987d2 --- /dev/null +++ b/backend/src/emails/__tests__/emails.test.ts @@ -0,0 +1,60 @@ +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"') + expect(email.html).toContain('alt="SolarSim logo"') + }) + + 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..9542e15 --- /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 logoSolarSim

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..3dc5bac --- /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 logoSolarSim

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..4b3e08f --- /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 logoSolarSim

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..33dbdf9 --- /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 logo + + 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 0000000000000000000000000000000000000000..6aa6440aec303571aedcb27edd4d3e7636795df0 GIT binary patch literal 5840 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 literal 0 HcmV?d00001 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 From 2362f02e8e7e1a3352020c1a2b40084e514891c4 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 21:10:20 +0800 Subject: [PATCH 2/5] chore(db): keep pool_timeout above connect_timeout so the latter can take effect --- .env.example | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 3961268..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 From 2b938641d9dc8a993067a0f0e91168201acb0f70 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 21:14:31 +0800 Subject: [PATCH 3/5] fix(email): drop the legacy brand chip and make the logo decorative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 42px logo sat inside the orange gradient square that used to frame the old sun character. When a client blocks images — most do by default — the alt text rendered clipped inside that box and read as broken. Removes the chip, matching what the app itself did when it adopted the new mark, and sets alt="" so a blocked load collapses cleanly and screen readers announce the adjacent wordmark once instead of twice. --- backend/src/emails/__tests__/emails.test.ts | 12 +++++++++++- backend/src/emails/emailChange.ts | 4 ++-- backend/src/emails/invite.ts | 4 ++-- backend/src/emails/passwordReset.ts | 4 ++-- backend/src/emails/verification.ts | 4 ++-- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/backend/src/emails/__tests__/emails.test.ts b/backend/src/emails/__tests__/emails.test.ts index 06987d2..74b6276 100644 --- a/backend/src/emails/__tests__/emails.test.ts +++ b/backend/src/emails/__tests__/emails.test.ts @@ -49,7 +49,17 @@ describe('email renderers', () => { const email = render(testUrl) expect(email.html).toContain('src="https://assets.example/email-logo.png"') - expect(email.html).toContain('alt="SolarSim logo"') + }) + + // 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 }) => { diff --git a/backend/src/emails/emailChange.ts b/backend/src/emails/emailChange.ts index 9542e15..fbacaeb 100644 --- a/backend/src/emails/emailChange.ts +++ b/backend/src/emails/emailChange.ts @@ -36,12 +36,12 @@ export const renderEmailChangeEmail = (url: string): RenderedEmail => {
Confirm your new SolarSim email address.
-
SolarSim logoSolarSim

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}

+
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/invite.ts b/backend/src/emails/invite.ts index 3dc5bac..1b50a95 100644 --- a/backend/src/emails/invite.ts +++ b/backend/src/emails/invite.ts @@ -36,12 +36,12 @@ export const renderInviteEmail = (url: string): RenderedEmail => {
You have been invited to join SolarSim.
-
SolarSim logoSolarSim

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}

+
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 index 4b3e08f..c7404e5 100644 --- a/backend/src/emails/passwordReset.ts +++ b/backend/src/emails/passwordReset.ts @@ -41,12 +41,12 @@ export const renderPasswordResetEmail = (url: string): RenderedEmail => { .card { max-width: 560px; margin: 0 auto; background: #ffffff; border: 1px solid #efe5d9; border-radius: 18px; overflow: hidden; box-shadow: 0 18px 48px rgba(154, 52, 18, 0.1); } .header { position: relative; background: radial-gradient(circle at 18% 28%, rgba(251, 146, 60, 0.32) 0, rgba(251, 146, 60, 0) 34%), linear-gradient(135deg, #fff7ed 0%, #ffedd5 48%, #ffffff 100%); border-bottom: 1px solid #fed7aa; padding: 30px 32px; } .header::after { content: ''; position: absolute; left: 0; right: 0; bottom: -1px; height: 3px; background: linear-gradient(90deg, #ea580c 0%, #f59e0b 48%, #16a34a 100%); } - .brand { display: table; text-decoration: none; }.brand-cell { display: table-cell; vertical-align: middle; }.brand-gap { width: 12px; }.brand-mark { display: inline-block; width: 42px; height: 42px; border-radius: 12px; background: linear-gradient(135deg, #ea580c, #f59e0b); box-shadow: 0 10px 24px rgba(234, 88, 12, 0.26); }.brand-name { color: #1c1917; font-family: 'Outfit', 'Segoe UI', system-ui, sans-serif; font-size: 22px; font-weight: 800; letter-spacing: 0; }.eyebrow { margin: 0; color: #9a3412; font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }.hero { position: relative; padding: 34px 32px 18px; }h1 { margin: 10px 0 0; color: #1c1917; font-family: 'Outfit', 'Segoe UI', system-ui, sans-serif; font-size: 30px; font-weight: 800; line-height: 1.18; letter-spacing: 0; }.body { padding: 0 32px 34px; color: #57534e; font-size: 15px; line-height: 1.65; }.body p { margin: 0 0 16px; }.lead { color: #44403c; font-size: 16px; }.btn { display: inline-block; background: #ea580c; color: #ffffff !important; text-decoration: none; padding: 13px 28px; border-radius: 8px; font-weight: 800; font-size: 15px; box-shadow: 0 12px 24px rgba(234, 88, 12, 0.22); }.btn:hover { background: #c2410c; }.cta { text-align: center; margin: 28px 0; }.notice { margin-top: 26px; padding: 16px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 12px; color: #78716c; font-size: 13px; }.fallback { margin-top: 20px; color: #78716c; font-size: 12px; line-height: 1.6; }.fallback a { color: #c2410c; word-break: break-all; }.footer { max-width: 560px; margin: 18px auto 0; text-align: center; color: #a8a29e; font-size: 12px; line-height: 1.6; }.preheader { display: none; max-height: 0; overflow: hidden; opacity: 0; color: transparent; } + .brand { display: table; text-decoration: none; }.brand-cell { display: table-cell; vertical-align: middle; }.brand-gap { width: 12px; }.brand-mark { display: inline-block; width: 42px; height: 42px; line-height: 0; }.brand-name { color: #1c1917; font-family: 'Outfit', 'Segoe UI', system-ui, sans-serif; font-size: 22px; font-weight: 800; letter-spacing: 0; }.eyebrow { margin: 0; color: #9a3412; font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }.hero { position: relative; padding: 34px 32px 18px; }h1 { margin: 10px 0 0; color: #1c1917; font-family: 'Outfit', 'Segoe UI', system-ui, sans-serif; font-size: 30px; font-weight: 800; line-height: 1.18; letter-spacing: 0; }.body { padding: 0 32px 34px; color: #57534e; font-size: 15px; line-height: 1.65; }.body p { margin: 0 0 16px; }.lead { color: #44403c; font-size: 16px; }.btn { display: inline-block; background: #ea580c; color: #ffffff !important; text-decoration: none; padding: 13px 28px; border-radius: 8px; font-weight: 800; font-size: 15px; box-shadow: 0 12px 24px rgba(234, 88, 12, 0.22); }.btn:hover { background: #c2410c; }.cta { text-align: center; margin: 28px 0; }.notice { margin-top: 26px; padding: 16px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 12px; color: #78716c; font-size: 13px; }.fallback { margin-top: 20px; color: #78716c; font-size: 12px; line-height: 1.6; }.fallback a { color: #c2410c; word-break: break-all; }.footer { max-width: 560px; margin: 18px auto 0; text-align: center; color: #a8a29e; font-size: 12px; line-height: 1.6; }.preheader { display: none; max-height: 0; overflow: hidden; opacity: 0; color: transparent; }
Reset your SolarSim password securely.
-
SolarSim logoSolarSim

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}

+
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/verification.ts b/backend/src/emails/verification.ts index 33dbdf9..1202ba8 100644 --- a/backend/src/emails/verification.ts +++ b/backend/src/emails/verification.ts @@ -44,7 +44,7 @@ export const renderVerificationEmail = (url: string): RenderedEmail => { .brand { display: table; text-decoration: none; } .brand-cell { display: table-cell; vertical-align: middle; } .brand-gap { width: 12px; } - .brand-mark { display: inline-block; width: 42px; height: 42px; border-radius: 12px; background: linear-gradient(135deg, #ea580c, #f59e0b); box-shadow: 0 10px 24px rgba(234, 88, 12, 0.26); } + .brand-mark { display: inline-block; width: 42px; height: 42px; line-height: 0; } .brand-name { color: #1c1917; font-family: 'Outfit', 'Segoe UI', system-ui, sans-serif; font-size: 22px; font-weight: 800; letter-spacing: 0; } .eyebrow { margin: 0; color: #9a3412; font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } .hero { position: relative; padding: 34px 32px 18px; } @@ -68,7 +68,7 @@ export const renderVerificationEmail = (url: string): RenderedEmail => {
- SolarSim logo + SolarSim
From 87db43c6a31e3ded62e9fcc967bfc1768901ac6c Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 22:04:10 +0800 Subject: [PATCH 4/5] fix(email,auth): stop email overflow and send OAuth back to the app origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The email wrapper declared width:100% alongside 32px of horizontal padding. Gmail strips the `*` box-sizing reset, so the wrapper rendered wider than the viewport and produced a horizontal scrollbar. A block div is already full width, so the declaration is simply removed. signInWithGoogle passed a relative callbackURL, which Better Auth resolves against its own baseURL — the API origin. In dev that is the backend on :3001, which does not serve the SPA, so a successful sign-in landed on a 404. Restores the absolute window.location.origin form the Supabase implementation used. --- backend/src/emails/emailChange.ts | 2 +- backend/src/emails/invite.ts | 2 +- backend/src/emails/passwordReset.ts | 2 +- backend/src/emails/verification.ts | 2 +- frontend/src/hooks/__tests__/useAuth.test.tsx | 7 ++++++- frontend/src/hooks/useAuth.tsx | 8 +++++++- 6 files changed, 17 insertions(+), 6 deletions(-) diff --git a/backend/src/emails/emailChange.ts b/backend/src/emails/emailChange.ts index fbacaeb..657c171 100644 --- a/backend/src/emails/emailChange.ts +++ b/backend/src/emails/emailChange.ts @@ -36,7 +36,7 @@ export const renderEmailChangeEmail = (url: string): RenderedEmail => { diff --git a/backend/src/emails/invite.ts b/backend/src/emails/invite.ts index 1b50a95..eb46109 100644 --- a/backend/src/emails/invite.ts +++ b/backend/src/emails/invite.ts @@ -36,7 +36,7 @@ export const renderInviteEmail = (url: string): RenderedEmail => { diff --git a/backend/src/emails/passwordReset.ts b/backend/src/emails/passwordReset.ts index c7404e5..be4cbd4 100644 --- a/backend/src/emails/passwordReset.ts +++ b/backend/src/emails/passwordReset.ts @@ -37,7 +37,7 @@ export const renderPasswordResetEmail = (url: string): RenderedEmail => {