mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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,
|
||||
|
||||
-9
@@ -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, '<').replace(/>/g, '>')
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user