Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <noreply@solarsim.tech>
# 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=
Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
5 changes: 4 additions & 1 deletion backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <noreply@solarsim.tech>'),
// 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'),
Expand Down
70 changes: 70 additions & 0 deletions backend/src/emails/__tests__/emails.test.ts
Original file line number Diff line number Diff line change
@@ -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"<finish>'
const escapedUrl = 'https://app.example/auth?next=dashboard&amp;mode=&quot;new&quot;&lt;finish&gt;'

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</span>')
})

it.each(renderers)('does not leave template tokens behind', ({ render }) => {
const email = render(testUrl)

expect(email.html).not.toMatch(new RegExp('\\{\\{\\s*.+?\\s*\\}\\}'))
})
})
48 changes: 48 additions & 0 deletions backend/src/emails/emailChange.ts
Original file line number Diff line number Diff line change
@@ -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 '&amp;'
case '<':
return '&lt;'
case '>':
return '&gt;'
case '"':
return '&quot;'
case "'":
return '&#39;'
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: `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Confirm email change</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=Work+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<style>
body { margin: 0; padding: 0; background: #fdf9f4; font-family: 'Work Sans', 'Segoe UI', system-ui, -apple-system, sans-serif; color: #1c1917; }
table { border-collapse: collapse; }.wrapper { background: #fdf9f4; padding: 40px 16px; }.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; 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; }
</style>
</head>
<body>
<div class="preheader">Confirm your new SolarSim email address.</div>
<div class="wrapper"><div class="card"><div class="header"><div class="brand"><span class="brand-cell"><span class="brand-mark"><img src="${logoUrl}" width="42" height="42" alt="" style="display:block;border:0;" /></span></span><span class="brand-cell brand-gap"></span><span class="brand-cell brand-name">SolarSim</span></div></div><div class="hero"><p class="eyebrow">Email change</p><h1>Confirm your new email address</h1></div><div class="body"><p class="lead">Please confirm your new email address by clicking the button below.</p><div class="cta"><a class="btn" href="${escapedUrl}">Confirm Email Change</a></div><div class="notice">If you did not request this change, please contact support immediately.</div><p class="fallback">If the button does not work, copy and paste this link into your browser:<br /><a href="${escapedUrl}">${escapedUrl}</a></p></div></div><div class="footer">You received this email because an email address change was requested for your SolarSim account.</div></div>
</body>
</html>`
}
}
5 changes: 5 additions & 0 deletions backend/src/emails/index.ts
Original file line number Diff line number Diff line change
@@ -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'
48 changes: 48 additions & 0 deletions backend/src/emails/invite.ts
Original file line number Diff line number Diff line change
@@ -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 '&amp;'
case '<':
return '&lt;'
case '>':
return '&gt;'
case '"':
return '&quot;'
case "'":
return '&#39;'
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: `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>You have been invited</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=Work+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<style>
body { margin: 0; padding: 0; background: #fdf9f4; font-family: 'Work Sans', 'Segoe UI', system-ui, -apple-system, sans-serif; color: #1c1917; }
table { border-collapse: collapse; }.wrapper { background: #fdf9f4; padding: 40px 16px; }.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; 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; }
</style>
</head>
<body>
<div class="preheader">You have been invited to join SolarSim.</div>
<div class="wrapper"><div class="card"><div class="header"><div class="brand"><span class="brand-cell"><span class="brand-mark"><img src="${logoUrl}" width="42" height="42" alt="" style="display:block;border:0;" /></span></span><span class="brand-cell brand-gap"></span><span class="brand-cell brand-name">SolarSim</span></div></div><div class="hero"><p class="eyebrow">Invitation</p><h1>You have been invited</h1></div><div class="body"><p class="lead">You have been invited to join Solar Layout Generator. Click the button below to accept the invitation and set up your account.</p><div class="cta"><a class="btn" href="${escapedUrl}">Accept Invitation</a></div><div class="notice">If you were not expecting this invitation, you can safely ignore this email.</div><p class="fallback">If the button does not work, copy and paste this link into your browser:<br /><a href="${escapedUrl}">${escapedUrl}</a></p></div></div><div class="footer">You received this email because you were invited to SolarSim.</div></div>
</body>
</html>`
}
}
Loading
Loading