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
5 changes: 0 additions & 5 deletions apps/api/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,3 @@ TWILIO_ACCOUNT_SID=CHANGE_ME
TWILIO_AUTH_TOKEN=CHANGE_ME
TWILIO_PHONE_NUMBER=CHANGE_ME

AWS_ACCESS_KEY_ID=CHANGE_ME
AWS_SECRET_ACCESS_KEY=CHANGE_ME
AWS_REGION=CHANGE_ME

SES_DEFAULT_FROM=CHANGE_ME
5 changes: 3 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,11 @@
"drizzle-orm": "0.44.5",
"exifreader": "^4.36.0",
"geotiff": "^2.1.3",
"hono": "^4.12.2",
"hono": "^4.12.7",
"jose": "^6.1.3",
"jsonpath-plus": "^10.3.0",
"mailparser": "^3.9.1",
"mailparser": "^3.9.3",
"mimetext": "^3.0.28",
"openai": "^6.16.0",
"stripe": "^20.1.2",
"three": "^0.182.0",
Expand Down
5 changes: 1 addition & 4 deletions apps/api/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface Bindings {
AI: Ai;
AI_OPTIONS: AiOptions;
LOADER?: any; // worker_loaders binding for Code Mode sandbox
SEND_EMAIL?: SendEmail;
BROWSER?: Fetcher;
EXECUTIONS: AnalyticsEngineDataset;
WEB_HOST: string;
Expand Down Expand Up @@ -53,10 +54,6 @@ export interface Bindings {
TWILIO_ACCOUNT_SID?: string;
TWILIO_AUTH_TOKEN?: string;
TWILIO_PHONE_NUMBER?: string;
AWS_ACCESS_KEY_ID?: string;
AWS_SECRET_ACCESS_KEY?: string;
AWS_REGION?: string;
SES_DEFAULT_FROM?: string;
HUGGINGFACE_API_KEY?: string;
REPLICATE_API_TOKEN?: string;
R2_ACCESS_KEY_ID?: string;
Expand Down
9 changes: 2 additions & 7 deletions apps/api/src/runtime/cloudflare-node-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,12 +453,7 @@ export class CloudflareNodeRegistry extends BaseNodeRegistry<Bindings> {
this.env.TWILIO_AUTH_TOKEN &&
this.env.TWILIO_PHONE_NUMBER
);
const hasSESEmail = !!(
this.env.AWS_ACCESS_KEY_ID &&
this.env.AWS_SECRET_ACCESS_KEY &&
this.env.AWS_REGION &&
this.env.SES_DEFAULT_FROM
);
const hasSendEmail = !!this.env.SEND_EMAIL;
const hasGoogleMail = !!(
this.env.INTEGRATION_GOOGLE_MAIL_CLIENT_ID &&
this.env.INTEGRATION_GOOGLE_MAIL_CLIENT_SECRET
Expand Down Expand Up @@ -717,7 +712,7 @@ export class CloudflareNodeRegistry extends BaseNodeRegistry<Bindings> {
this.registerImplementation(TwilioSmsNode);
}

if (hasSESEmail) {
if (hasSendEmail) {
this.registerImplementation(SendEmailNode);
}

Expand Down
125 changes: 41 additions & 84 deletions apps/api/src/services/email-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AwsClient } from "aws4fetch";
import { EmailMessage } from "cloudflare:email";
import { createMimeMessage } from "mimetext";

import type { Bindings } from "../context";

Expand All @@ -7,58 +8,40 @@ export interface EmailOptions {
subject: string;
html?: string;
text?: string;
cc?: string | string[];
replyTo?: string | string[];
}

export interface EmailResult {
success: boolean;
messageId?: string;
error?: string;
}

/**
* Application-level email service for sending transactional emails
* Uses Amazon SES with the configured noreply address
* Uses Cloudflare Email Routing with the configured noreply address
*/
export class EmailService {
private client: AwsClient;
private sendEmail: SendEmail;
private fromAddress: string;
private region: string;

constructor(env: Bindings) {
if (
!env.AWS_ACCESS_KEY_ID ||
!env.AWS_SECRET_ACCESS_KEY ||
!env.AWS_REGION
) {
throw new Error(
"AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION) are not configured"
);
}

if (!env.SES_DEFAULT_FROM) {
throw new Error("SES_DEFAULT_FROM is not configured");
if (!env.SEND_EMAIL) {
throw new Error("SEND_EMAIL binding is not configured");
}

this.client = new AwsClient({
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
});
this.region = env.AWS_REGION;
this.fromAddress = env.SES_DEFAULT_FROM;
this.sendEmail = env.SEND_EMAIL;
this.fromAddress = `noreply@${env.EMAIL_DOMAIN}`;
}

/**
* Send an email using Amazon SES
* Send an email using Cloudflare Email Routing
*/
async send(options: EmailOptions): Promise<EmailResult> {
const { to, subject, html, text, replyTo } = options;
const { to, subject, html, text, cc, replyTo } = options;

if (!to || !subject) {
return {
success: false,
error: "'to' and 'subject' are required",
};
return { success: false, error: "'to' and 'subject' are required" };
}

if (!html && !text) {
Expand All @@ -70,72 +53,46 @@ export class EmailService {

try {
const toArray = typeof to === "string" ? [to] : to;
const replyToArray = replyTo
? typeof replyTo === "string"
? [replyTo]
: replyTo
: [];

const params = new URLSearchParams();
params.set("Action", "SendEmail");
params.set("Source", this.fromAddress);

for (let i = 0; i < toArray.length; i++) {
params.set(`Destination.ToAddresses.member.${i + 1}`, toArray[i]);
}
for (let i = 0; i < replyToArray.length; i++) {
params.set(`ReplyToAddresses.member.${i + 1}`, replyToArray[i]);

// Build shared MIME parts once — only the recipient varies per send
const baseMsg = createMimeMessage();
baseMsg.setSender({ addr: this.fromAddress, name: "Dafthunk" });
baseMsg.setSubject(subject);

if (cc) {
const ccArray = typeof cc === "string" ? [cc] : cc;
for (const addr of ccArray) {
baseMsg.setCc(addr);
}
}

params.set("Message.Subject.Data", subject);
params.set("Message.Subject.Charset", "UTF-8");
if (replyTo) {
const replyToArray = typeof replyTo === "string" ? [replyTo] : replyTo;
baseMsg.setHeader("Reply-To", replyToArray.join(", "));
}

if (html) {
params.set("Message.Body.Html.Data", html);
params.set("Message.Body.Html.Charset", "UTF-8");
baseMsg.addMessage({ contentType: "text/html", data: html });
}
if (text) {
params.set("Message.Body.Text.Data", text);
params.set("Message.Body.Text.Charset", "UTF-8");
baseMsg.addMessage({ contentType: "text/plain", data: text });
}

const response = await this.client.fetch(
`https://email.${this.region}.amazonaws.com/`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
}
);

const responseText = await response.text();

if (!response.ok) {
const errorMatch = responseText.match(/<Message>(.*?)<\/Message>/);
const errorMessage = errorMatch?.[1] ?? responseText;
console.error("Email Service SES Error:", errorMessage);
return {
success: false,
error: `AWS SES Error: ${errorMessage}`,
};
}

const messageIdMatch = responseText.match(
/<MessageId>(.*?)<\/MessageId>/
await Promise.all(
toArray.map((recipient) => {
baseMsg.setRecipient(recipient);
const message = new EmailMessage(
this.fromAddress,
recipient,
baseMsg.asRaw()
);
return this.sendEmail.send(message);
})
);
if (messageIdMatch?.[1]) {
return {
success: true,
messageId: messageIdMatch[1],
};
}

return {
success: false,
error: "Failed to send email via Amazon SES. No MessageId returned.",
};
return { success: true };
} catch (error) {
console.error("Email Service SES Error:", error);
console.error("Email Service Error:", error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
Expand Down
10 changes: 10 additions & 0 deletions apps/api/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
"ai": {
"binding": "AI"
},
"send_email": [
{
"name": "SEND_EMAIL"
}
],
"containers": [
{
"class_name": "FFmpegContainer",
Expand Down Expand Up @@ -233,6 +238,11 @@
"ai": {
"binding": "AI"
},
"send_email": [
{
"name": "SEND_EMAIL"
}
],
"containers": [
{
"class_name": "FFmpegContainer",
Expand Down
3 changes: 2 additions & 1 deletion packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
"geotiff": "^2.1.3",
"iconv-lite": "^0.7.2",
"jsonpath-plus": "^10.3.0",
"mailparser": "^3.9.1",
"mailparser": "^3.9.3",
"mimetext": "^3.0.28",
"openai": "^6.16.0",
"three": "^0.182.0",
"three-bvh-csg": "^0.0.17",
Expand Down
5 changes: 1 addition & 4 deletions packages/runtime/src/node-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,7 @@ export interface NodeEnv {
TWILIO_ACCOUNT_SID?: string;
TWILIO_AUTH_TOKEN?: string;
TWILIO_PHONE_NUMBER?: string;
AWS_ACCESS_KEY_ID?: string;
AWS_SECRET_ACCESS_KEY?: string;
AWS_REGION?: string;
SES_DEFAULT_FROM?: string;
SEND_EMAIL?: SendEmail;
HUGGINGFACE_API_KEY?: string;
REPLICATE_API_TOKEN?: string;
}
Expand Down
Loading
Loading