Files
lunarfront-app/packages/backend/src/services/email.service.ts
ryan 45fd6d34eb
All checks were successful
CI / ci (pull_request) Successful in 24s
CI / e2e (pull_request) Successful in 1m2s
feat: email receipts and repair estimates
Backend:
- Server-side HTML email templates (receipt + estimate) with inline CSS
- POST /v1/transactions/:id/email-receipt with per-transaction rate limiting
- POST /v1/repair-tickets/:id/email-estimate with per-ticket rate limiting
- customerEmail field added to receipt and ticket detail responses
- Test email provider for API tests (logs instead of sending)

Frontend:
- POS payment dialog Email button enabled with inline email input
- Pre-fills customer email from linked account
- Repair ticket detail page has Email Estimate button with dialog
- Pre-fills from account email

Tests:
- 12 unit tests for email template renderers
- 8 API tests for email receipt/estimate endpoints and validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 20:32:52 +00:00

128 lines
3.6 KiB
TypeScript

import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { SettingsService } from './settings.service.js'
interface SendOpts {
to: string
subject: string
html: string
text?: string
}
interface EmailProvider {
send(opts: SendOpts): Promise<void>
}
class ResendProvider implements EmailProvider {
constructor(private db: PostgresJsDatabase<any>) {}
async send(opts: SendOpts): Promise<void> {
const apiKey = await SettingsService.get(this.db, 'email.resend_api_key')
?? process.env.RESEND_API_KEY
const from = await SettingsService.get(this.db, 'email.from_address')
?? process.env.MAIL_FROM
if (!apiKey) throw new Error('Resend API key not configured')
if (!from) throw new Error('email.from_address not configured')
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from,
to: opts.to,
subject: opts.subject,
html: opts.html,
...(opts.text ? { text: opts.text } : {}),
}),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Resend API error ${res.status}: ${body}`)
}
}
}
class SendGridProvider implements EmailProvider {
constructor(private db: PostgresJsDatabase<any>) {}
async send(opts: SendOpts): Promise<void> {
const apiKey = await SettingsService.get(this.db, 'email.sendgrid_api_key')
const from = await SettingsService.get(this.db, 'email.from_address')
?? process.env.MAIL_FROM
if (!apiKey) throw new Error('SendGrid API key not configured')
if (!from) throw new Error('email.from_address not configured')
const res = await fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
personalizations: [{ to: [{ email: opts.to }] }],
from: { email: from },
subject: opts.subject,
content: [
{ type: 'text/html', value: opts.html },
...(opts.text ? [{ type: 'text/plain', value: opts.text }] : []),
],
}),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`SendGrid API error ${res.status}: ${body}`)
}
}
}
class SmtpProvider implements EmailProvider {
async send(_opts: SendOpts): Promise<void> {
throw new Error('SMTP email provider is not yet implemented')
}
}
class TestProvider implements EmailProvider {
static lastEmail: SendOpts | null = null
static emails: SendOpts[] = []
async send(opts: SendOpts): Promise<void> {
TestProvider.lastEmail = opts
TestProvider.emails.push(opts)
}
static reset() {
TestProvider.lastEmail = null
TestProvider.emails = []
}
}
export const EmailService = {
async send(db: PostgresJsDatabase<any>, opts: SendOpts): Promise<void> {
const provider = await SettingsService.get(db, 'email.provider')
?? process.env.MAIL_PROVIDER
switch (provider) {
case 'resend':
return new ResendProvider(db).send(opts)
case 'sendgrid':
return new SendGridProvider(db).send(opts)
case 'smtp':
return new SmtpProvider().send(opts)
case 'test':
return new TestProvider().send(opts)
default:
throw new Error('Email provider not configured. Set email.provider in app_settings.')
}
},
getTestEmails: () => TestProvider.emails,
getLastTestEmail: () => TestProvider.lastEmail,
resetTestEmails: () => TestProvider.reset(),
}