Skip to content

Commit 05ede09

Browse files
bchapuisclaude
andcommitted
Add custom sender email support with SES verification
Allow organizations to configure verified sender email addresses for the Send Email node via AWS SES. Includes API routes for setting, verifying, and removing sender emails, a new EmailService abstraction in the runtime, database migration, and frontend management UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 712bcd9 commit 05ede09

49 files changed

Lines changed: 959 additions & 95 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/.dev.vars.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ WEB_HOST=http://localhost:3001
22
WEBSITE_URL=http://localhost:3002
33

44
EMAIL_DOMAIN=dafthunk.com
5+
WORKFLOW_EMAIL_DOMAIN=mail.dafthunk.com
56

67
CLOUDFLARE_ENV=development
78
CLOUDFLARE_ACCOUNT_ID=CHANGE_ME

apps/api/src/context.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export interface Bindings {
2828
EXECUTIONS: AnalyticsEngineDataset;
2929
WEB_HOST: string;
3030
WEBSITE_URL: string;
31-
EMAIL_DOMAIN: string;
31+
WORKFLOW_EMAIL_DOMAIN: string;
3232
JWT_SECRET: string;
3333
CLOUDFLARE_ENV: string;
3434
CLOUDFLARE_ACCOUNT_ID: string;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE `emails` ADD COLUMN `sender_email` text;
2+
ALTER TABLE `emails` ADD COLUMN `sender_email_status` text;

apps/api/src/db/queries.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
type SchemaInsert,
6060
type SchemaRow,
6161
type SecretInsert,
62+
type SenderEmailStatusType,
6263
scheduledTriggers,
6364
schemas,
6465
secrets,
@@ -863,6 +864,8 @@ export async function getEmails(
863864
.select({
864865
id: emails.id,
865866
name: emails.name,
867+
senderEmail: emails.senderEmail,
868+
senderEmailStatus: emails.senderEmailStatus,
866869
createdAt: emails.createdAt,
867870
updatedAt: emails.updatedAt,
868871
})
@@ -2169,6 +2172,75 @@ export async function getOrganizationBillingInfo(
21692172
return organization;
21702173
}
21712174

2175+
/**
2176+
* Get the sender email configuration for an email
2177+
*/
2178+
export async function getEmailSenderEmail(
2179+
db: ReturnType<typeof createDatabase>,
2180+
emailId: string,
2181+
organizationId: string
2182+
): Promise<
2183+
| {
2184+
senderEmail: string | null;
2185+
senderEmailStatus: SenderEmailStatusType | null;
2186+
}
2187+
| undefined
2188+
> {
2189+
const [email] = await db
2190+
.select({
2191+
senderEmail: emails.senderEmail,
2192+
senderEmailStatus: emails.senderEmailStatus,
2193+
})
2194+
.from(emails)
2195+
.where(
2196+
and(eq(emails.id, emailId), eq(emails.organizationId, organizationId))
2197+
)
2198+
.limit(1);
2199+
return email;
2200+
}
2201+
2202+
/**
2203+
* Update the sender email and status for an email
2204+
*/
2205+
export async function updateEmailSenderEmail(
2206+
db: ReturnType<typeof createDatabase>,
2207+
emailId: string,
2208+
organizationId: string,
2209+
senderEmailAddress: string,
2210+
status: SenderEmailStatusType
2211+
) {
2212+
await db
2213+
.update(emails)
2214+
.set({
2215+
senderEmail: senderEmailAddress,
2216+
senderEmailStatus: status,
2217+
updatedAt: new Date(),
2218+
})
2219+
.where(
2220+
and(eq(emails.id, emailId), eq(emails.organizationId, organizationId))
2221+
);
2222+
}
2223+
2224+
/**
2225+
* Clear the sender email configuration for an email
2226+
*/
2227+
export async function clearEmailSenderEmail(
2228+
db: ReturnType<typeof createDatabase>,
2229+
emailId: string,
2230+
organizationId: string
2231+
) {
2232+
await db
2233+
.update(emails)
2234+
.set({
2235+
senderEmail: null,
2236+
senderEmailStatus: null,
2237+
updatedAt: new Date(),
2238+
})
2239+
.where(
2240+
and(eq(emails.id, emailId), eq(emails.organizationId, organizationId))
2241+
);
2242+
}
2243+
21722244
/**
21732245
* Derive user plan from organization billing info.
21742246
* Pro if has active subscription OR canceled but still in billing period.
@@ -2851,6 +2923,31 @@ export async function isOrganizationOwner(
28512923
return !!membership;
28522924
}
28532925

2926+
/**
2927+
* Check if a user is an admin or owner of an organization
2928+
*/
2929+
export async function isOrganizationAdminOrOwner(
2930+
db: ReturnType<typeof createDatabase>,
2931+
organizationId: string,
2932+
userId: string
2933+
): Promise<boolean> {
2934+
const [membership] = await db
2935+
.select({ role: memberships.role })
2936+
.from(memberships)
2937+
.where(
2938+
and(
2939+
eq(memberships.userId, userId),
2940+
eq(memberships.organizationId, organizationId)
2941+
)
2942+
)
2943+
.limit(1);
2944+
2945+
return (
2946+
membership?.role === OrganizationRole.OWNER ||
2947+
membership?.role === OrganizationRole.ADMIN
2948+
);
2949+
}
2950+
28542951
/**
28552952
* Add or update a user's membership in an organization
28562953
*

apps/api/src/db/schema/index.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,16 @@ export const SubscriptionStatus = {
110110
export type SubscriptionStatusType =
111111
(typeof SubscriptionStatus)[keyof typeof SubscriptionStatus];
112112

113+
// Sender email verification status
114+
export const SenderEmailStatus = {
115+
PENDING: "pending",
116+
VERIFIED: "verified",
117+
FAILED: "failed",
118+
} as const;
119+
120+
export type SenderEmailStatusType =
121+
(typeof SenderEmailStatus)[keyof typeof SenderEmailStatus];
122+
113123
/**
114124
* REUSABLE COLUMNS
115125
*/
@@ -468,7 +478,7 @@ export const queueTriggers = sqliteTable(
468478
]
469479
);
470480

471-
// Emails - Email inboxes associated with organizations
481+
// Emails - Emails associated with organizations
472482
export const emails = sqliteTable(
473483
"emails",
474484
{
@@ -477,6 +487,10 @@ export const emails = sqliteTable(
477487
organizationId: text("organization_id")
478488
.notNull()
479489
.references(() => organizations.id, { onDelete: "cascade" }),
490+
senderEmail: text("sender_email"),
491+
senderEmailStatus: text(
492+
"sender_email_status"
493+
).$type<SenderEmailStatusType>(),
480494
createdAt: createCreatedAt(),
481495
updatedAt: createUpdatedAt(),
482496
},
@@ -487,7 +501,7 @@ export const emails = sqliteTable(
487501
]
488502
);
489503

490-
// Email Triggers - Email inbox triggers for workflows
504+
// Email Triggers - Email triggers for workflows
491505
export const emailTriggers = sqliteTable(
492506
"email_triggers",
493507
{

apps/api/src/email.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,16 @@ export async function handleIncomingEmail(
5151
return;
5252
}
5353

54-
console.log(`Processing email trigger for email inbox: ${emailId}`);
54+
console.log(`Processing email trigger for email: ${emailId}`);
5555

5656
const db = createDatabase(env.DB);
5757
const workflowStore = new WorkflowStore(env);
5858

59-
// Get email inbox (globally unique UUID, org derived from record)
59+
// Get email (globally unique UUID, org derived from record)
6060
const { getEmailById, getEmailTriggersByEmail } = await import("./db");
6161
const email = await getEmailById(db, emailId);
6262
if (!email) {
63-
console.error(`Email inbox '${emailId}' not found`);
63+
console.error(`Email '${emailId}' not found`);
6464
return;
6565
}
6666

@@ -74,12 +74,12 @@ export async function handleIncomingEmail(
7474
);
7575

7676
if (emailTriggersWithWorkflows.length === 0) {
77-
console.log(`No active workflows found for email inbox: ${emailId}`);
77+
console.log(`No active workflows found for email: ${emailId}`);
7878
return;
7979
}
8080

8181
console.log(
82-
`Found ${emailTriggersWithWorkflows.length} workflow(s) to trigger for email inbox ${emailId}`
82+
`Found ${emailTriggersWithWorkflows.length} workflow(s) to trigger for email ${emailId}`
8383
);
8484

8585
// Read raw email content once (stream can only be consumed once)

0 commit comments

Comments
 (0)