From 4f4ec133220e848b0c8ac4413a9ab2eaaaed02cf Mon Sep 17 00:00:00 2001 From: Chris Jayden Date: Wed, 3 Jun 2026 08:42:31 +0200 Subject: [PATCH] Migrate transactional email from Bento to AutoSend Replace @bentonow/bento-node-sdk with autosendjs. Swap the email service to AutoSend's structured from-address and res.success checks, switch to single-key (AUTOSEND_API_KEY) init, and delete the Bento wrapper. No event tracking was on Bento (analytics run via @stacksee/analytics), so nothing was dropped. --- .env.example | 10 ++ package.json | 2 +- pnpm-lock.yaml | 14 +- .../features/email/server/email.service.ts | 69 ++++---- src/lib/features/email/server/index.ts | 43 +++-- src/lib/server/autosend.ts | 107 +++++++++++++ src/lib/server/bento.ts | 149 ------------------ src/routes/api/footprint/ingest/+server.ts | 2 +- .../email-preview/[...email]/+page.server.ts | 5 +- 9 files changed, 192 insertions(+), 209 deletions(-) create mode 100644 src/lib/server/autosend.ts delete mode 100644 src/lib/server/bento.ts diff --git a/.env.example b/.env.example index 2eba909..c505faa 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,16 @@ DATABASE_URL="postgres://root:mysecretpassword@localhost:5433/local" UPSTASH_REDIS_REST_URL="https://your-region.upstash.io" UPSTASH_REDIS_REST_TOKEN="your-token-here" +# ----------------------------------------------------------------------------- +# Email (Autosend) +# ----------------------------------------------------------------------------- +# Required for transactional email (auth verification, OTP, password reset). +# Get an API key from your Autosend dashboard. +AUTOSEND_API_KEY="" +# Optional overrides. Accepts "Name " or a bare email address. +AUTOSEND_DEFAULT_FROM="EmitKit " +AUTOSEND_REPLY_TO="support@emitkit.com" + # ----------------------------------------------------------------------------- # Authentication (Better Auth) # ----------------------------------------------------------------------------- diff --git a/package.json b/package.json index 7476d79..cbf7caa 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,6 @@ }, "dependencies": { "@ai-sdk/openai": "^2.0.77", - "@bentonow/bento-node-sdk": "^0.2.1", "@better-auth/core": "1.4.22", "@floating-ui/dom": "^1.7.4", "@fontsource/inter": "^5.2.8", @@ -128,6 +127,7 @@ "@upstash/workflow": "^0.2.23", "@vercel/functions": "^3.5.0", "ai": "^5.0.107", + "autosendjs": "^1.0.3", "better-auth": "1.4.22", "better-auth-ui-svelte": "^0.12.2", "better-svelte-email": "^1.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 790fd5c..119d811 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@ai-sdk/openai': specifier: ^2.0.77 version: 2.0.77(zod@4.1.13) - '@bentonow/bento-node-sdk': - specifier: ^0.2.1 - version: 0.2.1 '@better-auth/core': specifier: 1.4.22 version: 1.4.22(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.1.13))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0) @@ -71,6 +68,9 @@ importers: ai: specifier: ^5.0.107 version: 5.0.107(zod@4.1.13) + autosendjs: + specifier: ^1.0.3 + version: 1.0.3 better-auth: specifier: 1.4.22 version: 1.4.22(@sveltejs/kit@2.59.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.55.5(@typescript-eslint/types@8.48.1))(vite@7.3.2(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.5(@typescript-eslint/types@8.48.1))(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2)))(drizzle-kit@0.31.8)(drizzle-orm@0.45.2(@neondatabase/serverless@1.0.2)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(@upstash/redis@1.35.7)(kysely@0.28.17)(postgres@3.4.7))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(svelte@5.55.5(@typescript-eslint/types@8.48.1))(vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@20.0.10)(vite@7.3.2(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.2))) @@ -2885,6 +2885,10 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + autosendjs@1.0.3: + resolution: {integrity: sha512-UFbM6YZqhvG1IN9gGta6QatpAJ6gsVWdpwkIK8s/9NvBq3o/XaRPJdcAavhpatrn2XAopVMoVx8i8jUnfjmdYg==} + engines: {node: '>=18'} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -7129,6 +7133,7 @@ snapshots: cross-fetch: 4.1.0 transitivePeerDependencies: - encoding + optional: true '@better-auth/core@1.4.22(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.1.13))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.2.0)': dependencies: @@ -9268,6 +9273,8 @@ snapshots: at-least-node@1.0.0: {} + autosendjs@1.0.3: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -9573,6 +9580,7 @@ snapshots: node-fetch: 2.7.0 transitivePeerDependencies: - encoding + optional: true cross-spawn@7.0.6: dependencies: diff --git a/src/lib/features/email/server/email.service.ts b/src/lib/features/email/server/email.service.ts index 46fa194..767a6ce 100644 --- a/src/lib/features/email/server/email.service.ts +++ b/src/lib/features/email/server/email.service.ts @@ -1,7 +1,8 @@ import type { Component } from 'svelte'; import { Renderer } from 'better-svelte-email'; import { dev } from '$app/environment'; -import type { BentoClient, BentoTransactionalEmail } from '$lib/server/bento'; +import type { EmailAddress } from 'autosendjs'; +import type { AutosendClient, TransactionalEmail } from '$lib/server/autosend'; import { createContextLogger } from '$lib/server/logger'; import { emailConfig } from '../components/email.config'; @@ -10,28 +11,28 @@ type SingleEmailOptions = Record; props: Props; - from?: string; - replyTo?: string; + from?: EmailAddress; + replyTo?: EmailAddress; }; type BatchEmailOptions = Record> = SingleEmailOptions[]; type EmailServiceConfig = { - bentoClient: BentoClient; - defaultFrom: string; - defaultReplyTo?: string; + autosendClient: AutosendClient; + defaultFrom: EmailAddress; + defaultReplyTo?: EmailAddress; }; export class EmailService { - private bentoClient: BentoClient; - private defaultFrom: string; - private defaultReplyTo?: string; + private autosendClient: AutosendClient; + private defaultFrom: EmailAddress; + private defaultReplyTo?: EmailAddress; private logger = createContextLogger('email-service'); private renderer: Renderer; constructor(config: EmailServiceConfig) { - this.bentoClient = config.bentoClient; + this.autosendClient = config.autosendClient; this.defaultFrom = config.defaultFrom; this.defaultReplyTo = config.defaultReplyTo; @@ -61,8 +62,8 @@ export class EmailService { to: options.to }); - if (!this.bentoClient.isEnabled()) { - this.logger.warn('Bento disabled; skipping sendEmail', { + if (!this.autosendClient.isEnabled()) { + this.logger.warn('Autosend disabled; skipping sendEmail', { subject: options.subject, to: options.to }); @@ -70,12 +71,6 @@ export class EmailService { return { skipped: true }; } - if (options.replyTo || this.defaultReplyTo) { - this.logger.warn( - 'replyTo is set but Bento transactional API does not currently accept reply_to; value will be ignored' - ); - } - try { operation.step('Rendering email template'); const html = await this.toHtml(options.component, options.props); @@ -88,19 +83,20 @@ export class EmailService { }); } - const email: BentoTransactionalEmail = { - to: options.to, + const replyTo = options.replyTo ?? this.defaultReplyTo; + const email: TransactionalEmail = { + to: { email: options.to }, from: options.from ?? this.defaultFrom, subject: options.subject, - html_body: html, - transactional: true + html, + ...(replyTo ? { replyTo } : {}) }; - operation.step('Sending via Bento'); - const sentCount = await this.bentoClient.sendTransactionalEmail(email); + operation.step('Sending via Autosend'); + const response = await this.autosendClient.sendTransactionalEmail(email); - operation.end({ count: sentCount }); - return { sent: true, count: sentCount }; + operation.end({ emailId: response.data?.emailId }); + return { sent: true, emailId: response.data?.emailId }; } catch (error) { operation.error('Failed to send email', error, { subject: options.subject, @@ -117,8 +113,8 @@ export class EmailService { batchSize: batch.length }); - if (!this.bentoClient.isEnabled()) { - this.logger.warn('Bento disabled; skipping sendBatch', { + if (!this.autosendClient.isEnabled()) { + this.logger.warn('Autosend disabled; skipping sendBatch', { batchSize: batch.length }); operation.end({ skipped: true }); @@ -127,7 +123,7 @@ export class EmailService { try { operation.step('Rendering email templates', { count: batch.length }); - const emails: BentoTransactionalEmail[] = await Promise.all( + const emails: TransactionalEmail[] = await Promise.all( batch.map(async (options) => { const html = await this.toHtml(options.component, options.props); if (dev) { @@ -138,21 +134,22 @@ export class EmailService { }); } + const replyTo = options.replyTo ?? this.defaultReplyTo; return { - to: options.to, + to: { email: options.to }, from: options.from ?? this.defaultFrom, subject: options.subject, - html_body: html, - transactional: true + html, + ...(replyTo ? { replyTo } : {}) }; }) ); - operation.step('Sending batch via Bento', { emailCount: emails.length }); - const sentCount = await this.bentoClient.sendTransactionalEmailBatch(emails); + operation.step('Sending batch via Autosend', { emailCount: emails.length }); + const responses = await this.autosendClient.sendTransactionalEmailBatch(emails); - operation.end({ count: sentCount }); - return { sent: true, count: sentCount }; + operation.end({ count: responses.length }); + return { sent: true, count: responses.length }; } catch (error) { operation.error('Failed to send batch', error, { batchSize: batch.length diff --git a/src/lib/features/email/server/index.ts b/src/lib/features/email/server/index.ts index 9cc7893..3ea0642 100644 --- a/src/lib/features/email/server/index.ts +++ b/src/lib/features/email/server/index.ts @@ -1,29 +1,40 @@ -import { - BENTO_DEFAULT_FROM, - BENTO_REPLY_TO, - BENTO_PUBLISHABLE_KEY, - BENTO_SECRET_KEY, - BENTO_SITE_UUID -} from '$env/static/private'; -import { BentoClient } from '$lib/server/bento.js'; +import { AUTOSEND_API_KEY, AUTOSEND_DEFAULT_FROM, AUTOSEND_REPLY_TO } from '$env/static/private'; +import type { EmailAddress } from 'autosendjs'; +import { AutosendClient } from '$lib/server/autosend.js'; import { EmailService } from '$lib/features/email/server/email.service.js'; -const bentoClient = new BentoClient({ - publishableKey: BENTO_PUBLISHABLE_KEY, - secretKey: BENTO_SECRET_KEY, - siteUuid: BENTO_SITE_UUID +const autosendClient = new AutosendClient({ + apiKey: AUTOSEND_API_KEY }); -const defaultFrom = BENTO_DEFAULT_FROM || 'EmitKit '; -const defaultReplyTo = BENTO_REPLY_TO || 'support@emitkit.com'; +/** + * Parse an "Name " or bare "email" string into a structured EmailAddress. + * Autosend requires a structured object for from/replyTo; passing an RFC-5322 + * string causes silent send failures. + */ +function parseEmailAddress(value: string | undefined, fallback: EmailAddress): EmailAddress { + if (!value) return fallback; + const match = value.match(/^\s*(.*?)\s*<\s*([^>]+)\s*>\s*$/); + if (match) { + const [, name, email] = match; + return name ? { email, name } : { email }; + } + return { email: value.trim() }; +} + +const defaultFrom = parseEmailAddress(AUTOSEND_DEFAULT_FROM, { + email: 'noreply@emitkit.com', + name: 'EmitKit' +}); +const defaultReplyTo = parseEmailAddress(AUTOSEND_REPLY_TO, { email: 'support@emitkit.com' }); export const emailService = new EmailService({ - bentoClient, + autosendClient, defaultFrom, defaultReplyTo }); -export { bentoClient }; +export { autosendClient }; export { default as OTPEmail } from '../components/otp-email.svelte'; export { default as WelcomeEmail } from '../components/welcome-email.svelte'; diff --git a/src/lib/server/autosend.ts b/src/lib/server/autosend.ts new file mode 100644 index 0000000..7e394bd --- /dev/null +++ b/src/lib/server/autosend.ts @@ -0,0 +1,107 @@ +import { Autosend, type EmailAddress } from 'autosendjs'; +import { dev } from '$app/environment'; +import { createContextLogger } from '$lib/server/logger'; + +type AutosendClientConfig = { + apiKey?: string; +}; + +type TransactionalEmail = { + to: EmailAddress; + from: EmailAddress; + subject: string; + html: string; + replyTo?: EmailAddress; +}; + +export class AutosendClient { + private client: Autosend | null = null; + private enabled = false; + private logger = createContextLogger('autosend-client'); + + constructor(config: AutosendClientConfig) { + if (dev) { + this.logger.info('Initializing with config', { + hasApiKey: !!config.apiKey + }); + } + + if (config.apiKey) { + this.enabled = true; + this.client = new Autosend(config.apiKey, { debug: dev }); + if (dev) this.logger.success('Client initialized successfully'); + } else { + this.logger.warn('Missing config - client disabled'); + } + } + + public isEnabled() { + return this.enabled; + } + + public async sendTransactionalEmail(email: TransactionalEmail) { + const operation = this.logger.start('Send transactional email', { + to: email.to.email, + from: email.from.email, + subject: email.subject, + enabled: this.enabled + }); + + if (!this.enabled || !this.client) { + const error = new Error('Autosend client is disabled. Provide AUTOSEND_API_KEY.'); + operation.error('Client disabled', error); + throw error; + } + + try { + const response = await this.client.emails.send({ + to: email.to, + from: email.from, + subject: email.subject, + html: email.html, + ...(email.replyTo ? { replyTo: email.replyTo } : {}) + }); + + if (!response.success) { + const error = new Error(`Autosend send failed: ${response.error ?? 'unknown error'}`); + operation.error('Send returned unsuccessful response', error, { + response: JSON.stringify(response) + }); + throw error; + } + + operation.end({ emailId: response.data?.emailId }); + return response; + } catch (error) { + operation.error('Failed to send email', error); + throw error; + } + } + + public async sendTransactionalEmailBatch(emails: TransactionalEmail[]) { + const operation = this.logger.start('Send transactional email batch', { + emailCount: emails.length, + enabled: this.enabled + }); + + if (!this.enabled || !this.client) { + const error = new Error('Autosend client is disabled. Provide AUTOSEND_API_KEY.'); + operation.error('Client disabled', error); + throw error; + } + + try { + // Autosend has no multi-template batch endpoint; send each individually. + const responses = await Promise.all( + emails.map((email) => this.sendTransactionalEmail(email)) + ); + operation.end({ count: responses.length }); + return responses; + } catch (error) { + operation.error('Failed to send batch', error); + throw error; + } + } +} + +export type { TransactionalEmail }; diff --git a/src/lib/server/bento.ts b/src/lib/server/bento.ts deleted file mode 100644 index 88ac3b3..0000000 --- a/src/lib/server/bento.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Analytics } from '@bentonow/bento-node-sdk'; -import { dev } from '$app/environment'; -import { createContextLogger } from '$lib/server/logger'; - -type BentoConfig = { - publishableKey?: string; - secretKey?: string; - siteUuid?: string; -}; - -type BentoTransactionalEmail = { - to: string; - from: string; - subject: string; - html_body: string; - transactional: true; -}; - -type BentoSubscriberUpsert = { - email: string; - tags?: string[]; - removeTags?: string[]; -}; - -export class BentoClient { - private client: Analytics | null = null; - private enabled = false; - private logger = createContextLogger('bento-client'); - - constructor(config: BentoConfig) { - if (dev) { - this.logger.info('Initializing with config', { - hasPublishableKey: !!config.publishableKey, - hasSecretKey: !!config.secretKey, - hasSiteUuid: !!config.siteUuid - }); - } - - if (config.publishableKey && config.secretKey && config.siteUuid) { - this.enabled = true; - this.client = new Analytics({ - authentication: { - publishableKey: config.publishableKey, - secretKey: config.secretKey - }, - siteUuid: config.siteUuid, - logErrors: true - }); - if (dev) this.logger.success('Client initialized successfully'); - } else { - this.logger.warn('Missing config - client disabled'); - } - } - - public isEnabled() { - return this.enabled; - } - - public async sendTransactionalEmail(email: BentoTransactionalEmail) { - const operation = this.logger.start('Send transactional email', { - to: email.to, - from: email.from, - subject: email.subject, - enabled: this.enabled - }); - - if (!this.enabled || !this.client) { - const error = new Error( - 'Bento client is disabled. Provide publishable/secret keys and site UUID.' - ); - operation.error('Client disabled', error); - throw error; - } - - try { - const response = await this.client.V1.Batch.sendTransactionalEmails({ - emails: [email] - }); - operation.end({ count: response }); - return response; - } catch (error) { - operation.error('Failed to send email', error); - throw error; - } - } - - public async sendTransactionalEmailBatch(emails: BentoTransactionalEmail[]) { - const operation = this.logger.start('Send transactional email batch', { - emailCount: emails.length, - enabled: this.enabled - }); - - if (!this.enabled || !this.client) { - const error = new Error( - 'Bento client is disabled. Provide publishable/secret keys and site UUID.' - ); - operation.error('Client disabled', error); - throw error; - } - - try { - const response = await this.client.V1.Batch.sendTransactionalEmails({ - emails - }); - operation.end({ count: response }); - return response; - } catch (error) { - operation.error('Failed to send batch', error); - throw error; - } - } - - public async upsertSubscribers(subscribers: BentoSubscriberUpsert[]) { - const operation = this.logger.start('Upsert subscribers', { - subscriberCount: subscribers.length, - enabled: this.enabled - }); - - if (!this.enabled || !this.client) { - const error = new Error( - 'Bento client is disabled. Provide publishable/secret keys and site UUID.' - ); - operation.error('Client disabled', error); - throw error; - } - - try { - // Map to API fields; tags are comma-separated array per Bento docs. - const payload = subscribers.map((s) => ({ - email: s.email, - ...(s.tags ? { tags: s.tags } : {}), - ...(s.removeTags ? { remove_tags: s.removeTags } : {}) - })); - - const result = await this.client.V1.Batch.importSubscribers({ - // allow remove_tags passthrough - subscribers: payload as unknown as { email: string; [key: string]: unknown }[] - }); - - operation.end({ result }); - return result; - } catch (error) { - operation.error('Failed to upsert subscribers', error); - throw error; - } - } -} - -export type { BentoTransactionalEmail, BentoSubscriberUpsert }; diff --git a/src/routes/api/footprint/ingest/+server.ts b/src/routes/api/footprint/ingest/+server.ts index 1088060..a5ff31d 100644 --- a/src/routes/api/footprint/ingest/+server.ts +++ b/src/routes/api/footprint/ingest/+server.ts @@ -2,7 +2,7 @@ * Analytics Proxy Endpoint * * This endpoint receives analytics events from the client-side ProxyProvider - * and forwards them to server-side analytics providers (Bento). + * and forwards them to server-side analytics providers. * * Benefits: * - Bypasses ad-blockers diff --git a/src/routes/email-preview/[...email]/+page.server.ts b/src/routes/email-preview/[...email]/+page.server.ts index 3c9ef60..8dbe8eb 100644 --- a/src/routes/email-preview/[...email]/+page.server.ts +++ b/src/routes/email-preview/[...email]/+page.server.ts @@ -58,7 +58,7 @@ export function load() { export const actions: Actions = { ...createEmail({ renderer }), - // Custom send email action using Bento + // Custom send email action using Autosend 'send-email': async (event) => { const data = await event.request.formData(); const file = (data.get('file') as string | null) || ''; @@ -87,8 +87,7 @@ export const actions: Actions = { if (result.skipped) { return { success: false, - error: - 'Email skipped: Bento is not configured. Check BENTO_PUBLISHABLE_KEY, BENTO_SECRET_KEY, and BENTO_SITE_UUID env vars.' + error: 'Email skipped: Autosend is not configured. Check the AUTOSEND_API_KEY env var.' }; }