-
Notifications
You must be signed in to change notification settings - Fork 7
(proxy) create email service [DA-392] #319
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,6 +51,7 @@ | |
| "drizzle-orm": "^0.45.1", | ||
| "hono": "^4.10.6", | ||
| "hono-openapi": "^1.2.0", | ||
| "resend": "^6.9.2", | ||
| "zod": "^4.3.5" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { execSync } from 'node:child_process' | ||
|
|
||
| const LOCAL_SECRET_STORE_ID = '4a9ca50edba84431879f34b0b67f9998' | ||
|
|
||
| const LOCAL_SECRETS = [ | ||
| { | ||
| name: 'DANMAKU_GEMINI_API_KEY_STG', | ||
| scope: 'workers', | ||
| value: 'gemini-api', | ||
| }, | ||
| { | ||
| name: 'DA_AI_GATEWAY_ID_STG', | ||
| scope: 'workers', | ||
| value: 'da-ai-gateway-id', | ||
| }, | ||
| { | ||
| name: 'DA_AI_GATEWAY_NAME_STG', | ||
| scope: 'workers', | ||
| value: 'da-ai-gateway-name', | ||
| }, | ||
| { | ||
| name: 'BETTER_AUTH_SECRET_STG', | ||
| scope: 'workers', | ||
| value: 'better-auth-secret', | ||
| }, | ||
| { | ||
| name: 'GOOGLE_CLIENT_SECRET_STG', | ||
| scope: 'workers', | ||
| value: 'google-client-secret', | ||
| }, | ||
| { | ||
| name: 'RESEND_API_KEY_STG', | ||
| scope: 'workers', | ||
| value: 'resend-api-key', | ||
| }, | ||
| ] | ||
|
|
||
| async function setupSecretsStore() { | ||
| for (const secret of LOCAL_SECRETS) { | ||
| console.log(`Creating secret ${secret.name}...`) | ||
| execSync( | ||
| `pnpm wrangler secrets-store secret create ${LOCAL_SECRET_STORE_ID} --name ${secret.name} --scopes ${secret.scope} --value ${secret.value}` | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| void setupSecretsStore() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { ResendEmailService } from './resend' | ||
| import type { EmailService } from './types' | ||
|
|
||
| export type { EmailService, SendEmailParams } from './types' | ||
|
|
||
| let emailService: EmailService | null = null | ||
|
|
||
| export async function getOrCreateEmailService(env: Env): Promise<EmailService> { | ||
| if (emailService) { | ||
| return emailService | ||
| } | ||
|
|
||
| const apiKey = await env.RESEND_API_KEY.get() | ||
| const from = env.EMAIL_FROM.trim() | ||
|
|
||
| emailService = new ResendEmailService(apiKey, from) | ||
|
Comment on lines
+14
to
+16
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The const from = env.EMAIL_FROM.trim()
if (!from) {
throw new Error('EMAIL_FROM environment variable is not configured.')
}
emailService = new ResendEmailService(apiKey, from) |
||
| return emailService | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,43 @@ | ||||||||||||||||||||||||||||||||||||||
| import { Resend } from 'resend' | ||||||||||||||||||||||||||||||||||||||
| import type { EmailService, SendEmailParams } from './types' | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||
| * Resend implementation of the email service abstraction. | ||||||||||||||||||||||||||||||||||||||
| * Only this file should import from 'resend' so the rest of the app stays provider-agnostic. | ||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||
| export class ResendEmailService implements EmailService { | ||||||||||||||||||||||||||||||||||||||
| private readonly resend: Resend | ||||||||||||||||||||||||||||||||||||||
| private readonly from: string | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| constructor(apiKey: string, from: string) { | ||||||||||||||||||||||||||||||||||||||
| this.resend = new Resend(apiKey) | ||||||||||||||||||||||||||||||||||||||
| this.from = from | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| async send(params: SendEmailParams): Promise<void> { | ||||||||||||||||||||||||||||||||||||||
| const to = Array.isArray(params.to) ? params.to : [params.to] | ||||||||||||||||||||||||||||||||||||||
| const html = | ||||||||||||||||||||||||||||||||||||||
| params.html ?? | ||||||||||||||||||||||||||||||||||||||
| (params.text | ||||||||||||||||||||||||||||||||||||||
| ? `<p>${params.text.replaceAll('\n', '<br>')}</p>` | ||||||||||||||||||||||||||||||||||||||
| : '<p></p>') | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+19
to
+23
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When converting a plain text body to HTML, the text content is not escaped. This could lead to a Cross-Site Scripting (XSS) vulnerability if the
Suggested change
|
||||||||||||||||||||||||||||||||||||||
| const text = params.text | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const payload: Parameters<Resend['emails']['send']>[0] = { | ||||||||||||||||||||||||||||||||||||||
| from: this.from, | ||||||||||||||||||||||||||||||||||||||
| to, | ||||||||||||||||||||||||||||||||||||||
| subject: params.subject, | ||||||||||||||||||||||||||||||||||||||
| html, | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| if (text) payload.text = text | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| console.log('Sending email to', to) | ||||||||||||||||||||||||||||||||||||||
| const { error, data } = await this.resend.emails.send(payload) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if (error) { | ||||||||||||||||||||||||||||||||||||||
| throw new Error(`Resend send failed: ${JSON.stringify(error)}`) | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| console.log('Email sent successfully', data) | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| /** | ||
| * Abstraction for sending transactional emails. | ||
| * Keeps the app decoupled from a specific provider (e.g. Resend). | ||
| */ | ||
|
|
||
| export interface SendEmailParams { | ||
| /** Recipient address(es) */ | ||
| to: string | string[] | ||
| /** Email subject */ | ||
| subject: string | ||
| /** Plain text body (optional if html is set) */ | ||
| text?: string | ||
| /** HTML body (optional if text is set) */ | ||
| html?: string | ||
| } | ||
|
|
||
| export interface EmailService { | ||
| send(params: SendEmailParams): Promise<void> | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.