fix(notifications): consolidate notification utils, update email styling (#2822)

* fix(notifications): consolidate notification utils, update email styling

* fixed duplicate types
This commit is contained in:
Waleed
2026-01-14 18:35:51 -08:00
committed by GitHub
parent 2cee30ff15
commit 4899c28421
13 changed files with 302 additions and 190 deletions
+52
View File
@@ -15,6 +15,7 @@ import {
renderPlanWelcomeEmail,
renderUsageThresholdEmail,
renderWelcomeEmail,
renderWorkflowNotificationEmail,
renderWorkspaceInvitationEmail,
} from '@/components/emails'
@@ -108,6 +109,51 @@ const emailTemplates = {
message:
'I have 10 years of experience building scalable distributed systems. Most recently, I led a team at a Series B startup where we scaled from 100K to 10M users.',
}),
// Notification emails
'workflow-notification-success': () =>
renderWorkflowNotificationEmail({
workflowName: 'Customer Onboarding Flow',
status: 'success',
trigger: 'api',
duration: '2.3s',
cost: '$0.0042',
logUrl: 'https://sim.ai/workspace/ws_123/logs?search=exec_abc123',
}),
'workflow-notification-error': () =>
renderWorkflowNotificationEmail({
workflowName: 'Customer Onboarding Flow',
status: 'error',
trigger: 'webhook',
duration: '1.1s',
cost: '$0.0021',
logUrl: 'https://sim.ai/workspace/ws_123/logs?search=exec_abc123',
}),
'workflow-notification-alert': () =>
renderWorkflowNotificationEmail({
workflowName: 'Customer Onboarding Flow',
status: 'error',
trigger: 'schedule',
duration: '45.2s',
cost: '$0.0156',
logUrl: 'https://sim.ai/workspace/ws_123/logs?search=exec_abc123',
alertReason: '3 consecutive failures detected',
}),
'workflow-notification-full': () =>
renderWorkflowNotificationEmail({
workflowName: 'Data Processing Pipeline',
status: 'success',
trigger: 'api',
duration: '12.5s',
cost: '$0.0234',
logUrl: 'https://sim.ai/workspace/ws_123/logs?search=exec_abc123',
finalOutput: { processed: 150, skipped: 3, status: 'completed' },
rateLimits: {
sync: { requestsPerMinute: 60, remaining: 45 },
async: { requestsPerMinute: 120, remaining: 98 },
},
usageData: { currentPeriodCost: 12.45, limit: 50, percentUsed: 24.9 },
}),
} as const
type EmailTemplate = keyof typeof emailTemplates
@@ -131,6 +177,12 @@ export async function GET(request: NextRequest) {
'payment-failed',
],
Careers: ['careers-confirmation', 'careers-submission'],
Notifications: [
'workflow-notification-success',
'workflow-notification-error',
'workflow-notification-alert',
'workflow-notification-full',
],
}
const categoryHtml = Object.entries(categories)
@@ -80,7 +80,6 @@ const updateNotificationSchema = z
levelFilter: levelFilterSchema.optional(),
triggerFilter: triggerFilterSchema.optional(),
includeFinalOutput: z.boolean().optional(),
includeTraceSpans: z.boolean().optional(),
includeRateLimits: z.boolean().optional(),
includeUsageData: z.boolean().optional(),
alertConfig: alertConfigSchema.optional(),
@@ -147,7 +146,6 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
levelFilter: subscription.levelFilter,
triggerFilter: subscription.triggerFilter,
includeFinalOutput: subscription.includeFinalOutput,
includeTraceSpans: subscription.includeTraceSpans,
includeRateLimits: subscription.includeRateLimits,
includeUsageData: subscription.includeUsageData,
webhookConfig: subscription.webhookConfig,
@@ -222,7 +220,6 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
if (data.triggerFilter !== undefined) updateData.triggerFilter = data.triggerFilter
if (data.includeFinalOutput !== undefined)
updateData.includeFinalOutput = data.includeFinalOutput
if (data.includeTraceSpans !== undefined) updateData.includeTraceSpans = data.includeTraceSpans
if (data.includeRateLimits !== undefined) updateData.includeRateLimits = data.includeRateLimits
if (data.includeUsageData !== undefined) updateData.includeUsageData = data.includeUsageData
if (data.alertConfig !== undefined) updateData.alertConfig = data.alertConfig
@@ -260,7 +257,6 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
levelFilter: subscription.levelFilter,
triggerFilter: subscription.triggerFilter,
includeFinalOutput: subscription.includeFinalOutput,
includeTraceSpans: subscription.includeTraceSpans,
includeRateLimits: subscription.includeRateLimits,
includeUsageData: subscription.includeUsageData,
webhookConfig: subscription.webhookConfig,
@@ -5,8 +5,14 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import {
type EmailRateLimitsData,
type EmailUsageData,
renderWorkflowNotificationEmail,
} from '@/components/emails'
import { getSession } from '@/lib/auth'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -67,29 +73,23 @@ function buildTestPayload(subscription: typeof workspaceNotificationSubscription
data.finalOutput = { message: 'This is a test notification', test: true }
}
if (subscription.includeTraceSpans) {
data.traceSpans = [
{
id: 'span_test_1',
name: 'Test Block',
type: 'block',
status: 'success',
startTime: new Date(timestamp - 5000).toISOString(),
endTime: new Date(timestamp).toISOString(),
duration: 5000,
},
]
}
if (subscription.includeRateLimits) {
data.rateLimits = {
sync: { limit: 150, remaining: 45, resetAt: new Date(timestamp + 60000).toISOString() },
async: { limit: 1000, remaining: 50, resetAt: new Date(timestamp + 60000).toISOString() },
sync: {
requestsPerMinute: 150,
remaining: 45,
resetAt: new Date(timestamp + 60000).toISOString(),
},
async: {
requestsPerMinute: 1000,
remaining: 50,
resetAt: new Date(timestamp + 60000).toISOString(),
},
}
}
if (subscription.includeUsageData) {
data.usage = { currentPeriodCost: 2.45, limit: 20, plan: 'pro', isExceeded: false }
data.usage = { currentPeriodCost: 2.45, limit: 20, percentUsed: 12.25, isExceeded: false }
}
return { payload, timestamp }
@@ -157,23 +157,26 @@ async function testEmail(subscription: typeof workspaceNotificationSubscription.
const { payload } = buildTestPayload(subscription)
const data = (payload as Record<string, unknown>).data as Record<string, unknown>
const baseUrl = getBaseUrl()
const logUrl = `${baseUrl}/workspace/${subscription.workspaceId}/logs`
const html = await renderWorkflowNotificationEmail({
workflowName: data.workflowName as string,
status: data.status as 'success' | 'error',
trigger: data.trigger as string,
duration: `${data.totalDurationMs}ms`,
cost: `$${(((data.cost as Record<string, unknown>)?.total as number) || 0).toFixed(4)}`,
logUrl,
finalOutput: data.finalOutput,
rateLimits: data.rateLimits as EmailRateLimitsData | undefined,
usageData: data.usage as EmailUsageData | undefined,
})
const result = await sendEmail({
to: subscription.emailRecipients,
subject: `[Test] Workflow Execution: ${data.workflowName}`,
text: `This is a test notification from Sim Studio.\n\nWorkflow: ${data.workflowName}\nStatus: ${data.status}\nDuration: ${data.totalDurationMs}ms\n\nThis notification is configured for workspace notifications.`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #7F2FFF;">Test Notification</h2>
<p>This is a test notification from Sim Studio.</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr><td style="padding: 8px; border: 1px solid #eee;"><strong>Workflow</strong></td><td style="padding: 8px; border: 1px solid #eee;">${data.workflowName}</td></tr>
<tr><td style="padding: 8px; border: 1px solid #eee;"><strong>Status</strong></td><td style="padding: 8px; border: 1px solid #eee;">${data.status}</td></tr>
<tr><td style="padding: 8px; border: 1px solid #eee;"><strong>Duration</strong></td><td style="padding: 8px; border: 1px solid #eee;">${data.totalDurationMs}ms</td></tr>
</table>
<p style="color: #666; font-size: 12px;">This notification is configured for workspace notifications.</p>
</div>
`,
html,
text: `This is a test notification from Sim.\n\nWorkflow: ${data.workflowName}\nStatus: ${data.status}\nDuration: ${data.totalDurationMs}ms\n\nView Log: ${logUrl}\n\nThis notification is configured for workspace notifications.`,
emailType: 'notifications',
})
@@ -227,7 +230,7 @@ async function testSlack(
elements: [
{
type: 'mrkdwn',
text: 'This is a test notification from Sim Studio workspace notifications.',
text: 'This is a test notification from Sim workspace notifications.',
},
],
},
@@ -83,7 +83,6 @@ const createNotificationSchema = z
levelFilter: levelFilterSchema.default(['info', 'error']),
triggerFilter: triggerFilterSchema.default([...CORE_TRIGGER_TYPES]),
includeFinalOutput: z.boolean().default(false),
includeTraceSpans: z.boolean().default(false),
includeRateLimits: z.boolean().default(false),
includeUsageData: z.boolean().default(false),
alertConfig: alertConfigSchema.optional(),
@@ -138,7 +137,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
levelFilter: workspaceNotificationSubscription.levelFilter,
triggerFilter: workspaceNotificationSubscription.triggerFilter,
includeFinalOutput: workspaceNotificationSubscription.includeFinalOutput,
includeTraceSpans: workspaceNotificationSubscription.includeTraceSpans,
includeRateLimits: workspaceNotificationSubscription.includeRateLimits,
includeUsageData: workspaceNotificationSubscription.includeUsageData,
webhookConfig: workspaceNotificationSubscription.webhookConfig,
@@ -240,7 +238,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
levelFilter: data.levelFilter,
triggerFilter: data.triggerFilter,
includeFinalOutput: data.includeFinalOutput,
includeTraceSpans: data.includeTraceSpans,
includeRateLimits: data.includeRateLimits,
includeUsageData: data.includeUsageData,
alertConfig: data.alertConfig || null,
@@ -266,7 +263,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
levelFilter: subscription.levelFilter,
triggerFilter: subscription.triggerFilter,
includeFinalOutput: subscription.includeFinalOutput,
includeTraceSpans: subscription.includeTraceSpans,
includeRateLimits: subscription.includeRateLimits,
includeUsageData: subscription.includeUsageData,
webhookConfig: subscription.webhookConfig,
@@ -136,7 +136,6 @@ export function NotificationSettings({
levelFilter: ['info', 'error'] as LogLevel[],
triggerFilter: [...CORE_TRIGGER_TYPES] as CoreTriggerType[],
includeFinalOutput: false,
includeTraceSpans: false,
includeRateLimits: false,
includeUsageData: false,
webhookUrl: '',
@@ -203,7 +202,6 @@ export function NotificationSettings({
levelFilter: ['info', 'error'],
triggerFilter: [...CORE_TRIGGER_TYPES],
includeFinalOutput: false,
includeTraceSpans: false,
includeRateLimits: false,
includeUsageData: false,
webhookUrl: '',
@@ -422,7 +420,6 @@ export function NotificationSettings({
levelFilter: formData.levelFilter,
triggerFilter: formData.triggerFilter,
includeFinalOutput: formData.includeFinalOutput,
includeTraceSpans: formData.includeTraceSpans,
includeRateLimits: formData.includeRateLimits,
includeUsageData: formData.includeUsageData,
alertConfig,
@@ -474,7 +471,6 @@ export function NotificationSettings({
levelFilter: subscription.levelFilter as LogLevel[],
triggerFilter: subscription.triggerFilter as CoreTriggerType[],
includeFinalOutput: subscription.includeFinalOutput,
includeTraceSpans: subscription.includeTraceSpans,
includeRateLimits: subscription.includeRateLimits,
includeUsageData: subscription.includeUsageData,
webhookUrl: subscription.webhookConfig?.url || '',
@@ -830,7 +826,6 @@ export function NotificationSettings({
<Combobox
options={[
{ label: 'Final Output', value: 'includeFinalOutput' },
{ label: 'Trace Spans', value: 'includeTraceSpans' },
{ label: 'Rate Limits', value: 'includeRateLimits' },
{ label: 'Usage Data', value: 'includeUsageData' },
]}
@@ -838,7 +833,6 @@ export function NotificationSettings({
multiSelectValues={
[
formData.includeFinalOutput && 'includeFinalOutput',
formData.includeTraceSpans && 'includeTraceSpans',
formData.includeRateLimits && 'includeRateLimits',
formData.includeUsageData && 'includeUsageData',
].filter(Boolean) as string[]
@@ -847,7 +841,6 @@ export function NotificationSettings({
setFormData({
...formData,
includeFinalOutput: values.includes('includeFinalOutput'),
includeTraceSpans: values.includes('includeTraceSpans'),
includeRateLimits: values.includes('includeRateLimits'),
includeUsageData: values.includes('includeUsageData'),
})
@@ -856,13 +849,11 @@ export function NotificationSettings({
overlayContent={(() => {
const labels: Record<string, string> = {
includeFinalOutput: 'Final Output',
includeTraceSpans: 'Trace Spans',
includeRateLimits: 'Rate Limits',
includeUsageData: 'Usage Data',
}
const selected = [
formData.includeFinalOutput && 'includeFinalOutput',
formData.includeTraceSpans && 'includeTraceSpans',
formData.includeRateLimits && 'includeRateLimits',
formData.includeUsageData && 'includeUsageData',
].filter(Boolean) as string[]
@@ -10,12 +10,17 @@ import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
import { and, eq, isNull, lte, or, sql } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import {
type EmailRateLimitsData,
type EmailUsageData,
renderWorkflowNotificationEmail,
} from '@/components/emails'
import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
import type { WorkflowExecutionLog } from '@/lib/logs/types'
import { sendEmail } from '@/lib/messaging/email/mailer'
import type { AlertConfig } from '@/lib/notifications/alert-rules'
@@ -45,9 +50,8 @@ interface NotificationPayload {
totalDurationMs: number
cost?: Record<string, unknown>
finalOutput?: unknown
traceSpans?: unknown[]
rateLimits?: Record<string, unknown>
usage?: Record<string, unknown>
rateLimits?: EmailRateLimitsData
usage?: EmailUsageData
}
}
@@ -94,10 +98,6 @@ async function buildPayload(
payload.data.finalOutput = executionData.finalOutput
}
if (subscription.includeTraceSpans && executionData.traceSpans) {
payload.data.traceSpans = executionData.traceSpans as unknown[]
}
if (subscription.includeRateLimits && userId) {
try {
const userSubscription = await getHighestPrioritySubscription(userId)
@@ -251,18 +251,6 @@ function formatAlertReason(alertConfig: AlertConfig): string {
}
}
function formatJsonForEmail(data: unknown, label: string): string {
if (!data) return ''
const json = JSON.stringify(data, null, 2)
const escapedJson = json.replace(/</g, '&lt;').replace(/>/g, '&gt;')
return `
<div style="margin-top: 20px;">
<h3 style="color: #1a1a1a; font-size: 14px; margin-bottom: 8px;">${label}</h3>
<pre style="background: #f5f5f5; padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 12px; color: #333; white-space: pre-wrap; word-wrap: break-word;">${escapedJson}</pre>
</div>
`
}
async function deliverEmail(
subscription: typeof workspaceNotificationSubscription.$inferSelect,
payload: NotificationPayload,
@@ -275,8 +263,7 @@ async function deliverEmail(
const isError = payload.data.status !== 'success'
const statusText = isError ? 'Error' : 'Success'
const logUrl = buildLogUrl(subscription.workspaceId, payload.data.executionId)
const baseUrl = getBaseUrl()
const alertReason = alertConfig ? formatAlertReason(alertConfig) : null
const alertReason = alertConfig ? formatAlertReason(alertConfig) : undefined
// Build subject line
const subject = alertReason
@@ -285,113 +272,36 @@ async function deliverEmail(
? `Error Alert: ${payload.data.workflowName}`
: `Workflow Completed: ${payload.data.workflowName}`
let includedDataHtml = ''
// Build plain text for fallback
let includedDataText = ''
if (payload.data.finalOutput) {
includedDataHtml += formatJsonForEmail(payload.data.finalOutput, 'Final Output')
includedDataText += `\n\nFinal Output:\n${JSON.stringify(payload.data.finalOutput, null, 2)}`
}
if (
payload.data.traceSpans &&
Array.isArray(payload.data.traceSpans) &&
payload.data.traceSpans.length > 0
) {
includedDataHtml += formatJsonForEmail(payload.data.traceSpans, 'Trace Spans')
includedDataText += `\n\nTrace Spans:\n${JSON.stringify(payload.data.traceSpans, null, 2)}`
}
if (payload.data.rateLimits) {
includedDataHtml += formatJsonForEmail(payload.data.rateLimits, 'Rate Limits')
includedDataText += `\n\nRate Limits:\n${JSON.stringify(payload.data.rateLimits, null, 2)}`
}
if (payload.data.usage) {
includedDataHtml += formatJsonForEmail(payload.data.usage, 'Usage Data')
includedDataText += `\n\nUsage Data:\n${JSON.stringify(payload.data.usage, null, 2)}`
}
// Render the email using the shared template
const html = await renderWorkflowNotificationEmail({
workflowName: payload.data.workflowName || 'Unknown Workflow',
status: payload.data.status,
trigger: payload.data.trigger,
duration: formatDuration(payload.data.totalDurationMs),
cost: formatCost(payload.data.cost),
logUrl,
alertReason,
finalOutput: payload.data.finalOutput,
rateLimits: payload.data.rateLimits,
usageData: payload.data.usage,
})
const result = await sendEmail({
to: subscription.emailRecipients,
subject,
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="background-color: #f5f5f7; font-family: HelveticaNeue, Helvetica, Arial, sans-serif; margin: 0; padding: 0;">
<div style="max-width: 580px; margin: 30px auto; background-color: #ffffff; border-radius: 5px; overflow: hidden;">
<!-- Header with Logo -->
<div style="padding: 30px 0; text-align: center;">
<img src="${baseUrl}/logo/reverse/text/medium.png" width="114" alt="Sim Studio" style="margin: 0 auto;" />
</div>
<!-- Section Border -->
<div style="display: flex; width: 100%;">
<div style="border-bottom: 1px solid #eeeeee; width: 249px;"></div>
<div style="border-bottom: 1px solid #6F3DFA; width: 102px;"></div>
<div style="border-bottom: 1px solid #eeeeee; width: 249px;"></div>
</div>
<!-- Content -->
<div style="padding: 5px 30px 20px 30px;">
<h2 style="font-size: 20px; color: #333333; margin: 20px 0;">
${alertReason ? 'Alert Triggered' : isError ? 'Workflow Execution Failed' : 'Workflow Execution Completed'}
</h2>
${alertReason ? `<p style="color: #d97706; background: #fef3c7; padding: 12px; border-radius: 6px; margin-bottom: 20px; font-size: 14px;"><strong>Reason:</strong> ${alertReason}</p>` : ''}
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 12px 0; color: #666; width: 140px;">Workflow</td>
<td style="padding: 12px 0; color: #333; font-weight: 500;">${payload.data.workflowName}</td>
</tr>
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 12px 0; color: #666;">Status</td>
<td style="padding: 12px 0; color: ${isError ? '#ef4444' : '#22c55e'}; font-weight: 500;">${statusText}</td>
</tr>
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 12px 0; color: #666;">Trigger</td>
<td style="padding: 12px 0; color: #333;">${payload.data.trigger}</td>
</tr>
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 12px 0; color: #666;">Duration</td>
<td style="padding: 12px 0; color: #333;">${formatDuration(payload.data.totalDurationMs)}</td>
</tr>
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 12px 0; color: #666;">Cost</td>
<td style="padding: 12px 0; color: #333;">${formatCost(payload.data.cost)}</td>
</tr>
</table>
<a href="${logUrl}" style="display: inline-block; background-color: #6F3DFA; color: #ffffff; font-weight: bold; font-size: 16px; padding: 12px 30px; border-radius: 5px; text-decoration: none; text-align: center; margin: 20px 0;">
View Execution Log →
</a>
${includedDataHtml}
<p style="font-size: 16px; line-height: 1.5; color: #333333; margin-top: 30px;">
Best regards,<br />
The Sim Team
</p>
</div>
</div>
<!-- Footer -->
<div style="max-width: 580px; margin: 0 auto; padding: 20px 0; text-align: center;">
<p style="font-size: 12px; color: #706a7b; margin: 8px 0 0 0;">
© ${new Date().getFullYear()} Sim Studio, All Rights Reserved
</p>
<p style="font-size: 12px; color: #706a7b; margin: 8px 0 0 0;">
<a href="${baseUrl}/privacy" style="color: #706a7b; text-decoration: underline;">Privacy Policy</a> •
<a href="${baseUrl}/terms" style="color: #706a7b; text-decoration: underline;">Terms of Service</a>
</p>
</div>
</body>
</html>
`,
html,
text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs)}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
emailType: 'notifications',
})
@@ -479,26 +389,6 @@ async function deliverSlack(
})
}
if (
payload.data.traceSpans &&
Array.isArray(payload.data.traceSpans) &&
payload.data.traceSpans.length > 0
) {
const spansSummary = (payload.data.traceSpans as TraceSpan[])
.map((span) => {
const status = span.status === 'success' ? '✓' : '✗'
return `${status} ${span.name || 'Unknown'} (${formatDuration(span.duration || 0)})`
})
.join('\n')
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `*Trace Spans:*\n\`\`\`${spansSummary}\`\`\``,
},
})
}
if (payload.data.rateLimits) {
const limitsStr = JSON.stringify(payload.data.rateLimits, null, 2)
blocks.push({
@@ -173,6 +173,17 @@ export const baseStyles = {
margin: 0,
},
/** Code block text (for JSON/code display) */
codeBlock: {
fontSize: typography.fontSize.caption,
lineHeight: typography.lineHeight.caption,
color: colors.textSecondary,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap' as const,
wordWrap: 'break-word' as const,
margin: 0,
},
/** Highlighted info box (e.g., "What you get with Pro") */
infoBox: {
backgroundColor: colors.bgOuter,
+2
View File
@@ -10,6 +10,8 @@ export * from './careers'
export * from './components'
// Invitation emails
export * from './invitations'
// Notification emails
export * from './notifications'
// Render functions and subjects
export * from './render'
export * from './subjects'
@@ -0,0 +1,7 @@
export type {
EmailRateLimitStatus,
EmailRateLimitsData,
EmailUsageData,
WorkflowNotificationEmailProps,
} from './workflow-notification-email'
export { WorkflowNotificationEmail } from './workflow-notification-email'
@@ -0,0 +1,161 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
/**
* Serialized rate limit status for email payloads.
* Note: This differs from the canonical RateLimitStatus in @/lib/core/rate-limiter
* which uses Date for resetAt. This version uses string for JSON serialization.
*/
export interface EmailRateLimitStatus {
requestsPerMinute: number
remaining: number
maxBurst?: number
resetAt?: string
}
export interface EmailRateLimitsData {
sync?: EmailRateLimitStatus
async?: EmailRateLimitStatus
}
export interface EmailUsageData {
currentPeriodCost: number
limit: number
percentUsed: number
isExceeded?: boolean
}
export interface WorkflowNotificationEmailProps {
workflowName: string
status: 'success' | 'error'
trigger: string
duration: string
cost: string
logUrl: string
alertReason?: string
finalOutput?: unknown
rateLimits?: EmailRateLimitsData
usageData?: EmailUsageData
}
function formatJsonForEmail(data: unknown): string {
return JSON.stringify(data, null, 2)
}
export function WorkflowNotificationEmail({
workflowName,
status,
trigger,
duration,
cost,
logUrl,
alertReason,
finalOutput,
rateLimits,
usageData,
}: WorkflowNotificationEmailProps) {
const brand = getBrandConfig()
const isError = status === 'error'
const statusText = isError ? 'Error' : 'Success'
const previewText = alertReason
? `${brand.name}: Alert - ${workflowName}`
: isError
? `${brand.name}: Workflow Failed - ${workflowName}`
: `${brand.name}: Workflow Completed - ${workflowName}`
const message = alertReason
? 'An alert was triggered for your workflow.'
: isError
? 'Your workflow execution failed.'
: 'Your workflow completed successfully.'
return (
<EmailLayout preview={previewText}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>Hello,</Text>
<Text style={baseStyles.paragraph}>{message}</Text>
<Section style={baseStyles.infoBox}>
{alertReason && (
<Text style={baseStyles.infoBoxList}>
<strong>Reason:</strong> {alertReason}
</Text>
)}
<Text style={{ ...baseStyles.infoBoxList, marginTop: alertReason ? '4px' : 0 }}>
<strong>Workflow:</strong> {workflowName}
</Text>
<Text style={{ ...baseStyles.infoBoxList, marginTop: '4px' }}>
<strong>Status:</strong> {statusText}
</Text>
<Text style={{ ...baseStyles.infoBoxList, marginTop: '4px' }}>
<strong>Trigger:</strong> {trigger}
</Text>
<Text style={{ ...baseStyles.infoBoxList, marginTop: '4px' }}>
<strong>Duration:</strong> {duration}
</Text>
<Text style={{ ...baseStyles.infoBoxList, marginTop: '4px' }}>
<strong>Cost:</strong> {cost}
</Text>
</Section>
<Link href={logUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>View Execution Log</Text>
</Link>
{rateLimits && (rateLimits.sync || rateLimits.async) ? (
<>
<div style={baseStyles.divider} />
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Rate Limits</Text>
{rateLimits.sync && (
<Text style={baseStyles.infoBoxList}>
Sync: {rateLimits.sync.remaining} of {rateLimits.sync.requestsPerMinute} remaining
</Text>
)}
{rateLimits.async && (
<Text style={{ ...baseStyles.infoBoxList, marginTop: rateLimits.sync ? '4px' : 0 }}>
Async: {rateLimits.async.remaining} of {rateLimits.async.requestsPerMinute}{' '}
remaining
</Text>
)}
</Section>
</>
) : null}
{usageData ? (
<>
<div style={baseStyles.divider} />
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Usage</Text>
<Text style={baseStyles.infoBoxList}>
${usageData.currentPeriodCost.toFixed(2)} of ${usageData.limit.toFixed(2)} used (
{usageData.percentUsed.toFixed(1)}%)
</Text>
</Section>
</>
) : null}
{finalOutput ? (
<>
<div style={baseStyles.divider} />
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Final Output</Text>
<Text style={{ ...baseStyles.codeBlock, marginTop: '8px' }}>
{formatJsonForEmail(finalOutput)}
</Text>
</Section>
</>
) : null}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
You're receiving this because you subscribed to workflow notifications.
</Text>
</EmailLayout>
)
}
export default WorkflowNotificationEmail
+10
View File
@@ -15,6 +15,10 @@ import {
PollingGroupInvitationEmail,
WorkspaceInvitationEmail,
} from '@/components/emails/invitations'
import {
WorkflowNotificationEmail,
type WorkflowNotificationEmailProps,
} from '@/components/emails/notifications'
import { HelpConfirmationEmail } from '@/components/emails/support'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -258,3 +262,9 @@ export async function renderCareersSubmissionEmail(params: {
})
)
}
export async function renderWorkflowNotificationEmail(
params: WorkflowNotificationEmailProps
): Promise<string> {
return await render(WorkflowNotificationEmail(params))
}
-2
View File
@@ -61,7 +61,6 @@ export interface NotificationSubscription {
levelFilter: LogLevel[]
triggerFilter: TriggerType[]
includeFinalOutput: boolean
includeTraceSpans: boolean
includeRateLimits: boolean
includeUsageData: boolean
webhookConfig?: WebhookConfig | null
@@ -106,7 +105,6 @@ interface CreateNotificationParams {
levelFilter: LogLevel[]
triggerFilter: TriggerType[]
includeFinalOutput: boolean
includeTraceSpans: boolean
includeRateLimits: boolean
includeUsageData: boolean
alertConfig?: AlertConfig | null
-5
View File
@@ -25,7 +25,6 @@ function prepareLogData(
log: WorkflowExecutionLog,
subscription: {
includeFinalOutput: boolean
includeTraceSpans: boolean
}
) {
const preparedLog = { ...log, executionData: {} as Record<string, unknown> }
@@ -38,10 +37,6 @@ function prepareLogData(
webhookData.finalOutput = data.finalOutput
}
if (subscription.includeTraceSpans && data.traceSpans) {
webhookData.traceSpans = data.traceSpans
}
preparedLog.executionData = webhookData
}