Skip to content
Open
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <email>" or a bare email address.
AUTOSEND_DEFAULT_FROM="EmitKit <noreply@emitkit.com>"
AUTOSEND_REPLY_TO="support@emitkit.com"

# -----------------------------------------------------------------------------
# Authentication (Better Auth)
# -----------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
14 changes: 11 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 33 additions & 36 deletions src/lib/features/email/server/email.service.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -10,28 +11,28 @@ type SingleEmailOptions<Props extends Record<string, unknown> = Record<string, u
subject: string;
component: Component<Props>;
props: Props;
from?: string;
replyTo?: string;
from?: EmailAddress;
replyTo?: EmailAddress;
};

type BatchEmailOptions<Props extends Record<string, unknown> = Record<string, unknown>> =
SingleEmailOptions<Props>[];

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;

Expand Down Expand Up @@ -61,21 +62,15 @@ 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
});
operation.end({ skipped: true });
return { skipped: true };
Comment on lines +65 to 71

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't silently skip sends when Autosend is misconfigured outside local preview/dev flows.

These branches turn a required provider misconfiguration into { skipped: true }. src/lib/server/auth.ts:101-128 awaits emailService.sendEmail() but never checks skipped, so password-reset mail can be dropped while the auth operation still records success. Throw here in non-dev (or fail fast during startup) and keep the skip path only for explicitly local preview scenarios.

Also applies to: 116-122

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/features/email/server/email.service.ts` around lines 65 - 71, The
current sendEmail path silently returns { skipped: true } when
autosendClient.isEnabled() is false; change it to fail fast outside explicit
local-preview/dev flows by throwing an error (e.g., throw new Error('Autosend
provider not configured') ) instead of returning skipped when not in a local
preview mode; keep the existing skip behavior only when an explicit
local-preview flag or NODE_ENV==='development' (or an isLocalPreview config) is
present. Update the branch that checks autosendClient.isEnabled() (and the
analogous branch at lines ~116-122) to throw in production/non-local modes so
callers of sendEmail() (such as auth.ts awaiting sendEmail()) cannot silently
drop emails, and additionally add a startup validation that verifies
autosendClient is configured to fail-fast during init if the app is expected to
send real emails.

}

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);
Expand All @@ -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,
Expand All @@ -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 });
Expand All @@ -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) {
Expand All @@ -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
Expand Down
43 changes: 27 additions & 16 deletions src/lib/features/email/server/index.ts
Original file line number Diff line number Diff line change
@@ -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 <noreply@emitkit.com>';
const defaultReplyTo = BENTO_REPLY_TO || 'support@emitkit.com';
/**
* Parse an "Name <email>" 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() };
}
Comment on lines +15 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden env email parsing to avoid invalid defaults reaching Autosend

At Line 16 and Line 22, whitespace-only env values can produce { email: '' } instead of using fallback. This can break all sends when defaultFrom/defaultReplyTo are built from misconfigured env vars. Normalize first, trim captures, and fall back if parsed email is empty.

Suggested patch
 function parseEmailAddress(value: string | undefined, fallback: EmailAddress): EmailAddress {
-	if (!value) return fallback;
-	const match = value.match(/^\s*(.*?)\s*<\s*([^>]+)\s*>\s*$/);
+	const normalized = value?.trim();
+	if (!normalized) return fallback;
+	const match = normalized.match(/^\s*(.*?)\s*<\s*([^>]+)\s*>\s*$/);
 	if (match) {
-		const [, name, email] = match;
-		return name ? { email, name } : { email };
+		const [, rawName, rawEmail] = match;
+		const email = rawEmail.trim();
+		if (!email) return fallback;
+		const name = rawName.trim();
+		return name ? { email, name } : { email };
 	}
-	return { email: value.trim() };
+	return normalized ? { email: normalized } : fallback;
 }

Also applies to: 25-29

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/features/email/server/index.ts` around lines 15 - 23, The
parseEmailAddress function may return { email: '' } for whitespace-only inputs;
normalize and trim the incoming value first and also trim the regex capture
groups, then if the resulting email string is empty return the provided
fallback. Update parseEmailAddress (and any analogous parsing used for
defaultFrom/defaultReplyTo) to run value = value?.trim() || undefined before
matching, use trimmed captures for name and email, and if the parsed email after
trimming is falsy, return fallback instead of { email: '' }.


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';
Expand Down
Loading
Loading