Files
moeka-project/apps/server/src/services/email.ts
T

277 lines
9.5 KiB
TypeScript

import type { Logger } from '@guiiai/logg'
import { useLogger } from '@guiiai/logg'
import { errorMessageFrom } from '@moeru/std'
import { Resend } from 'resend'
import { ApiError } from '../utils/error'
/**
* Outbound email payload accepted by {@link EmailService.send}.
*
* Use when:
* - Building a higher-level transactional template (verification, reset, magic link, change-email).
*
* Expects:
* - Both `html` and `text` set so deliverability scoring stays high (text fallback
* is what spam filters score when HTML is hostile or stripped).
* - `to` is already validated by Better Auth (we trust caller for internal flows).
*/
export interface EmailPayload {
/** Recipient address. Single address — Better Auth callbacks always emit one. */
to: string
/** Subject line. Plain text. */
subject: string
/** HTML body. */
html: string
/** Plain-text body. Required for spam-filter parity and accessibility. */
text: string
}
/**
* Email service abstraction shared by all Better Auth callbacks.
*
* Use when:
* - Wiring `sendVerificationEmail` / `sendResetPassword` / `sendMagicLink` /
* `sendChangeEmailConfirmation` in `createAuth()`.
*
* Expects:
* - Service is constructed once per process by `injeca` and shared across requests.
*
* Returns:
* - A `send` method plus four high-level helpers that own subject/body composition.
*/
export interface EmailService {
send: (payload: EmailPayload) => Promise<void>
sendVerification: (params: { to: string, url: string }) => Promise<void>
sendPasswordReset: (params: { to: string, url: string }) => Promise<void>
sendMagicLink: (params: { to: string, url: string }) => Promise<void>
sendChangeEmailConfirmation: (params: { to: string, newEmail: string, url: string }) => Promise<void>
}
interface EmailConfig {
apiKey: string
fromEmail: string
fromName?: string
}
/**
* Format an RFC 5322 display-name + address pair for the `From` header.
*
* Before:
* - `{ fromEmail: 'noreply@a.io', fromName: 'AIRI' }`
*
* After:
* - `'AIRI <noreply@a.io>'`
*/
function formatFrom(config: EmailConfig): string {
if (config.fromName)
return `${config.fromName} <${config.fromEmail}>`
return config.fromEmail
}
/**
* Construct the email service.
*
* Use when:
* - DI assembly in `apps/server/src/app.ts`.
*
* Expects:
* - `RESEND_API_KEY` is set in env. When empty, `send` throws an `ApiError`
* instead of silently dropping mail — Better Auth surfaces it back to the
* caller so frontend can show a clear "email service not configured" error.
*/
export function createEmailService(config: EmailConfig, logger: Logger = useLogger('email')): EmailService {
// NOTICE:
// Construct Resend lazily so the server can boot in environments where the
// RESEND_API_KEY is intentionally empty (e.g. local dev that never exercises
// email flows). Calls to `send` will throw, which Better Auth surfaces.
// Root cause summary: Resend's constructor logs but does not throw on empty
// keys; explicit guard keeps the failure mode visible at the call site.
// Source: node_modules/.pnpm/resend@*/node_modules/resend/dist/index.cjs
// Removal condition: when we make RESEND_API_KEY required at env-parse time.
let client: Resend | null = null
function getClient(): Resend {
if (!client) {
if (!config.apiKey) {
throw new ApiError(
503,
'email/service_not_configured',
'Email service not configured (RESEND_API_KEY is missing).',
)
}
client = new Resend(config.apiKey)
}
return client
}
const from = formatFrom(config)
async function send(payload: EmailPayload): Promise<void> {
try {
const { error } = await getClient().emails.send({
from,
to: [payload.to],
subject: payload.subject,
html: payload.html,
text: payload.text,
})
if (error) {
logger.withFields({ to: payload.to, subject: payload.subject, errorName: error.name }).error(error.message)
throw new ApiError(502, 'email/send_failed', error.message, { providerError: error.name })
}
}
catch (error) {
if (error instanceof ApiError)
throw error
const message = errorMessageFrom(error) ?? 'Unknown email send error'
logger.withFields({ to: payload.to, subject: payload.subject }).error(message)
throw new ApiError(502, 'email/send_failed', message)
}
}
return {
send,
async sendVerification({ to, url }) {
await send({
to,
subject: 'Verify your email for Project AIRI',
html: renderVerificationHtml(url),
text: renderVerificationText(url),
})
},
async sendPasswordReset({ to, url }) {
await send({
to,
subject: 'Reset your Project AIRI password',
html: renderPasswordResetHtml(url),
text: renderPasswordResetText(url),
})
},
async sendMagicLink({ to, url }) {
await send({
to,
subject: 'Your Project AIRI sign-in link',
html: renderMagicLinkHtml(url),
text: renderMagicLinkText(url),
})
},
async sendChangeEmailConfirmation({ to, newEmail, url }) {
await send({
to,
subject: 'Confirm your new email address for Project AIRI',
html: renderChangeEmailHtml(url, newEmail),
text: renderChangeEmailText(url, newEmail),
})
},
}
}
// NOTICE:
// Templates are intentionally minimal inline HTML. Goal here is functional
// delivery + plaintext fallback. Visual design is deferred (see
// docs/ai/context/email-auth-resend.md "不做" section).
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function renderActionEmailHtml(args: { heading: string, body: string, ctaLabel: string, url: string, footer: string }): string {
const safeUrl = escapeHtml(args.url)
return `<!doctype html>
<html><body style="font-family: -apple-system, Segoe UI, sans-serif; color: #111; max-width: 480px; margin: 24px auto; padding: 0 16px;">
<h2 style="margin: 0 0 16px;">${escapeHtml(args.heading)}</h2>
<p style="margin: 0 0 16px;">${escapeHtml(args.body)}</p>
<p style="margin: 0 0 16px;"><a href="${safeUrl}" style="display: inline-block; padding: 10px 16px; background: #111; color: #fff; border-radius: 6px; text-decoration: none;">${escapeHtml(args.ctaLabel)}</a></p>
<p style="margin: 0 0 16px; font-size: 12px; color: #666;">If the button doesn't work, copy this URL into your browser:<br/><span style="word-break: break-all;">${safeUrl}</span></p>
<p style="margin: 24px 0 0; font-size: 12px; color: #888;">${escapeHtml(args.footer)}</p>
</body></html>`
}
function renderActionEmailText(args: { heading: string, body: string, url: string, footer: string }): string {
return `${args.heading}\n\n${args.body}\n\n${args.url}\n\n${args.footer}\n`
}
function renderVerificationHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Verify your email',
body: 'Welcome to Project AIRI. Click the button below to confirm this is your email address.',
ctaLabel: 'Verify email',
url,
footer: 'If you did not create an account, you can safely ignore this email.',
})
}
function renderVerificationText(url: string): string {
return renderActionEmailText({
heading: 'Verify your email',
body: 'Welcome to Project AIRI. Open this link to confirm your email address:',
url,
footer: 'If you did not create an account, you can safely ignore this email.',
})
}
function renderPasswordResetHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Reset your password',
body: 'We received a request to reset the password for your Project AIRI account.',
ctaLabel: 'Reset password',
url,
footer: 'If you did not request this, you can safely ignore this email — your password will not change.',
})
}
function renderPasswordResetText(url: string): string {
return renderActionEmailText({
heading: 'Reset your password',
body: 'Open this link to reset your Project AIRI password:',
url,
footer: 'If you did not request this, you can safely ignore this email — your password will not change.',
})
}
function renderMagicLinkHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Sign in to Project AIRI',
body: 'Click the button below to sign in. This link expires shortly and can be used once.',
ctaLabel: 'Sign in',
url,
footer: 'If you did not request this link, you can safely ignore this email.',
})
}
function renderMagicLinkText(url: string): string {
return renderActionEmailText({
heading: 'Sign in to Project AIRI',
body: 'Open this link to sign in (single-use, expires shortly):',
url,
footer: 'If you did not request this link, you can safely ignore this email.',
})
}
function renderChangeEmailHtml(url: string, newEmail: string): string {
return renderActionEmailHtml({
heading: 'Confirm your new email',
body: `Confirm that ${newEmail} should become your Project AIRI account email.`,
ctaLabel: 'Confirm new email',
url,
footer: 'If you did not request this change, contact support immediately.',
})
}
function renderChangeEmailText(url: string, newEmail: string): string {
return renderActionEmailText({
heading: 'Confirm your new email',
body: `Confirm that ${newEmail} should become your Project AIRI account email by opening this link:`,
url,
footer: 'If you did not request this change, contact support immediately.',
})
}