Skip to content

Commit e320298

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 e320298

33 files changed

Lines changed: 996 additions & 276 deletions

apps/api/.dev.vars.example

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

4+
EMAIL_DOMAIN=dafthunk.com
45
EMAIL_DOMAIN=dafthunk.com
56

67
CLOUDFLARE_ENV=development
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)