From 716a86b634c7c4bf8bc77c1737588be4adc1ec7b Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 14 Jan 2026 17:50:34 -0800 Subject: [PATCH 1/2] fix(notifications): consolidate notification utils, update email styling --- apps/sim/app/api/emails/preview/route.ts | 52 ++++++ .../notifications/[notificationId]/route.ts | 4 - .../[notificationId]/test/route.ts | 78 +++++--- .../workspaces/[id]/notifications/route.ts | 4 - .../notifications/notifications.tsx | 9 - .../workspace-notification-delivery.ts | 175 ++++-------------- apps/sim/components/emails/_styles/base.ts | 11 ++ apps/sim/components/emails/index.ts | 2 + .../components/emails/notifications/index.ts | 1 + .../workflow-notification-email.tsx | 156 ++++++++++++++++ apps/sim/components/emails/render.ts | 48 +++++ apps/sim/hooks/queries/notifications.ts | 2 - apps/sim/lib/logs/events.ts | 5 - 13 files changed, 358 insertions(+), 189 deletions(-) create mode 100644 apps/sim/components/emails/notifications/index.ts create mode 100644 apps/sim/components/emails/notifications/workflow-notification-email.tsx diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index 5047a3eaa6..80c298bdad 100644 --- a/apps/sim/app/api/emails/preview/route.ts +++ b/apps/sim/app/api/emails/preview/route.ts @@ -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) diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts index 0fff019545..5637ad1f4c 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts @@ -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, diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts index 3e95e22205..c6e0902014 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts @@ -5,8 +5,10 @@ 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 { 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' @@ -25,6 +27,25 @@ interface SlackConfig { accountId: string } +interface RateLimitStatus { + requestsPerMinute: number + remaining: number + maxBurst?: number + resetAt?: string +} + +interface RateLimitsData { + sync?: RateLimitStatus + async?: RateLimitStatus +} + +interface UsageDataProps { + currentPeriodCost: number + limit: number + percentUsed: number + isExceeded?: boolean +} + function generateSignature(secret: string, timestamp: number, body: string): string { const signatureBase = `${timestamp}.${body}` const hmac = createHmac('sha256', secret) @@ -67,29 +88,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 +172,26 @@ async function testEmail(subscription: typeof workspaceNotificationSubscription. const { payload } = buildTestPayload(subscription) const data = (payload as Record).data as Record + 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)?.total as number) || 0).toFixed(4)}`, + logUrl, + finalOutput: data.finalOutput, + rateLimits: data.rateLimits as RateLimitsData | undefined, + usageData: data.usage as UsageDataProps | 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: ` -
-

Test Notification

-

This is a test notification from Sim Studio.

- - - - -
Workflow${data.workflowName}
Status${data.status}
Duration${data.totalDurationMs}ms
-

This notification is configured for workspace notifications.

-
- `, + html, + text: `This is a test notification from Sim Studio.\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', }) diff --git a/apps/sim/app/api/workspaces/[id]/notifications/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/route.ts index b5852a0182..ef630045cd 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/route.ts @@ -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, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx index 1d78fe2db6..684b0394f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx @@ -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({ { const labels: Record = { 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[] diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts index e0356f91d8..a796e8ad59 100644 --- a/apps/sim/background/workspace-notification-delivery.ts +++ b/apps/sim/background/workspace-notification-delivery.ts @@ -10,12 +10,13 @@ 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 { 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' @@ -29,6 +30,25 @@ function getRetryDelayWithJitter(baseDelay: number): number { return Math.floor(baseDelay + jitter) } +interface RateLimitStatus { + requestsPerMinute: number + remaining: number + maxBurst?: number + resetAt?: string +} + +interface RateLimitsData { + sync?: RateLimitStatus + async?: RateLimitStatus +} + +interface UsageDataPayload { + currentPeriodCost: number + limit: number + percentUsed: number + isExceeded?: boolean +} + interface NotificationPayload { id: string type: 'workflow.execution.completed' @@ -45,9 +65,8 @@ interface NotificationPayload { totalDurationMs: number cost?: Record finalOutput?: unknown - traceSpans?: unknown[] - rateLimits?: Record - usage?: Record + rateLimits?: RateLimitsData + usage?: UsageDataPayload } } @@ -94,10 +113,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 +266,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, '>') - return ` -
-

${label}

-
${escapedJson}
-
- ` -} - async function deliverEmail( subscription: typeof workspaceNotificationSubscription.$inferSelect, payload: NotificationPayload, @@ -275,8 +278,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 +287,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: ` - - - - - - - -
- -
- Sim Studio -
- - -
-
-
-
-
- - -
-

- ${alertReason ? 'Alert Triggered' : isError ? 'Workflow Execution Failed' : 'Workflow Execution Completed'} -

- ${alertReason ? `

Reason: ${alertReason}

` : ''} - - - - - - - - - - - - - - - - - - - - - - -
Workflow${payload.data.workflowName}
Status${statusText}
Trigger${payload.data.trigger}
Duration${formatDuration(payload.data.totalDurationMs)}
Cost${formatCost(payload.data.cost)}
- - - View Execution Log → - - - ${includedDataHtml} - -

- Best regards,
- The Sim Team -

-
-
- - -
-

- © ${new Date().getFullYear()} Sim Studio, All Rights Reserved -

-

- Privacy Policy • - Terms of Service -

-
- - - `, + 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 +404,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({ diff --git a/apps/sim/components/emails/_styles/base.ts b/apps/sim/components/emails/_styles/base.ts index 844ac1c55f..4b9e2f9e6b 100644 --- a/apps/sim/components/emails/_styles/base.ts +++ b/apps/sim/components/emails/_styles/base.ts @@ -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, diff --git a/apps/sim/components/emails/index.ts b/apps/sim/components/emails/index.ts index 6cd1fea0db..419b250b95 100644 --- a/apps/sim/components/emails/index.ts +++ b/apps/sim/components/emails/index.ts @@ -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' diff --git a/apps/sim/components/emails/notifications/index.ts b/apps/sim/components/emails/notifications/index.ts new file mode 100644 index 0000000000..c24652b753 --- /dev/null +++ b/apps/sim/components/emails/notifications/index.ts @@ -0,0 +1 @@ +export { WorkflowNotificationEmail } from './workflow-notification-email' diff --git a/apps/sim/components/emails/notifications/workflow-notification-email.tsx b/apps/sim/components/emails/notifications/workflow-notification-email.tsx new file mode 100644 index 0000000000..67e492ad87 --- /dev/null +++ b/apps/sim/components/emails/notifications/workflow-notification-email.tsx @@ -0,0 +1,156 @@ +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' + +interface RateLimitStatus { + requestsPerMinute: number + remaining: number + maxBurst?: number + resetAt?: string +} + +interface RateLimitsData { + sync?: RateLimitStatus + async?: RateLimitStatus +} + +interface UsageDataProps { + currentPeriodCost: number + limit: number + percentUsed: number + isExceeded?: boolean +} + +interface WorkflowNotificationEmailProps { + workflowName: string + status: 'success' | 'error' + trigger: string + duration: string + cost: string + logUrl: string + alertReason?: string + finalOutput?: unknown + rateLimits?: RateLimitsData + usageData?: UsageDataProps +} + +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 ( + + Hello, + {message} + +
+ {alertReason && ( + + Reason: {alertReason} + + )} + + Workflow: {workflowName} + + + Status: {statusText} + + + Trigger: {trigger} + + + Duration: {duration} + + + Cost: {cost} + +
+ + + View Execution Log + + + {rateLimits && (rateLimits.sync || rateLimits.async) ? ( + <> +
+
+ Rate Limits + {rateLimits.sync && ( + + Sync: {rateLimits.sync.remaining} of {rateLimits.sync.requestsPerMinute} remaining + + )} + {rateLimits.async && ( + + Async: {rateLimits.async.remaining} of {rateLimits.async.requestsPerMinute}{' '} + remaining + + )} +
+ + ) : null} + + {usageData ? ( + <> +
+
+ Usage + + ${usageData.currentPeriodCost.toFixed(2)} of ${usageData.limit.toFixed(2)} used ( + {usageData.percentUsed.toFixed(1)}%) + +
+ + ) : null} + + {finalOutput ? ( + <> +
+
+ Final Output + + {formatJsonForEmail(finalOutput)} + +
+ + ) : null} + +
+ + + You're receiving this because you subscribed to workflow notifications. + + + ) +} + +export default WorkflowNotificationEmail diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index 90522246aa..61f8a7eb9f 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -15,6 +15,7 @@ import { PollingGroupInvitationEmail, WorkspaceInvitationEmail, } from '@/components/emails/invitations' +import { WorkflowNotificationEmail } from '@/components/emails/notifications' import { HelpConfirmationEmail } from '@/components/emails/support' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -258,3 +259,50 @@ export async function renderCareersSubmissionEmail(params: { }) ) } + +interface RateLimitStatus { + requestsPerMinute: number + remaining: number + maxBurst?: number + resetAt?: string +} + +interface RateLimitsData { + sync?: RateLimitStatus + async?: RateLimitStatus +} + +interface UsageDataProps { + currentPeriodCost: number + limit: number + percentUsed: number + isExceeded?: boolean +} + +export async function renderWorkflowNotificationEmail(params: { + workflowName: string + status: 'success' | 'error' + trigger: string + duration: string + cost: string + logUrl: string + alertReason?: string + finalOutput?: unknown + rateLimits?: RateLimitsData + usageData?: UsageDataProps +}): Promise { + return await render( + WorkflowNotificationEmail({ + workflowName: params.workflowName, + status: params.status, + trigger: params.trigger, + duration: params.duration, + cost: params.cost, + logUrl: params.logUrl, + alertReason: params.alertReason, + finalOutput: params.finalOutput, + rateLimits: params.rateLimits, + usageData: params.usageData, + }) + ) +} diff --git a/apps/sim/hooks/queries/notifications.ts b/apps/sim/hooks/queries/notifications.ts index 49af2ed8d5..b92d77b1e9 100644 --- a/apps/sim/hooks/queries/notifications.ts +++ b/apps/sim/hooks/queries/notifications.ts @@ -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 diff --git a/apps/sim/lib/logs/events.ts b/apps/sim/lib/logs/events.ts index 767c4bd8a0..7ad832ad65 100644 --- a/apps/sim/lib/logs/events.ts +++ b/apps/sim/lib/logs/events.ts @@ -25,7 +25,6 @@ function prepareLogData( log: WorkflowExecutionLog, subscription: { includeFinalOutput: boolean - includeTraceSpans: boolean } ) { const preparedLog = { ...log, executionData: {} as Record } @@ -38,10 +37,6 @@ function prepareLogData( webhookData.finalOutput = data.finalOutput } - if (subscription.includeTraceSpans && data.traceSpans) { - webhookData.traceSpans = data.traceSpans - } - preparedLog.executionData = webhookData } From 172b9a5a17d1933ad3d6145619dc3954ef3ba8b2 Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 14 Jan 2026 18:13:46 -0800 Subject: [PATCH 2/2] fixed duplicate types --- .../[notificationId]/test/route.ts | 33 ++++-------- .../workspace-notification-delivery.ts | 29 +++------- .../components/emails/notifications/index.ts | 6 +++ .../workflow-notification-email.tsx | 21 +++++--- apps/sim/components/emails/render.ts | 54 +++---------------- 5 files changed, 43 insertions(+), 100 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts index c6e0902014..ade0689ae0 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts @@ -5,7 +5,11 @@ 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 { renderWorkflowNotificationEmail } from '@/components/emails' +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' @@ -27,25 +31,6 @@ interface SlackConfig { accountId: string } -interface RateLimitStatus { - requestsPerMinute: number - remaining: number - maxBurst?: number - resetAt?: string -} - -interface RateLimitsData { - sync?: RateLimitStatus - async?: RateLimitStatus -} - -interface UsageDataProps { - currentPeriodCost: number - limit: number - percentUsed: number - isExceeded?: boolean -} - function generateSignature(secret: string, timestamp: number, body: string): string { const signatureBase = `${timestamp}.${body}` const hmac = createHmac('sha256', secret) @@ -183,15 +168,15 @@ async function testEmail(subscription: typeof workspaceNotificationSubscription. cost: `$${(((data.cost as Record)?.total as number) || 0).toFixed(4)}`, logUrl, finalOutput: data.finalOutput, - rateLimits: data.rateLimits as RateLimitsData | undefined, - usageData: data.usage as UsageDataProps | undefined, + 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}`, html, - text: `This is a test notification from Sim Studio.\n\nWorkflow: ${data.workflowName}\nStatus: ${data.status}\nDuration: ${data.totalDurationMs}ms\n\nView Log: ${logUrl}\n\nThis notification is configured for workspace notifications.`, + 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', }) @@ -245,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.', }, ], }, diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts index a796e8ad59..8c0c350731 100644 --- a/apps/sim/background/workspace-notification-delivery.ts +++ b/apps/sim/background/workspace-notification-delivery.ts @@ -10,7 +10,11 @@ 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 { renderWorkflowNotificationEmail } from '@/components/emails' +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' @@ -30,25 +34,6 @@ function getRetryDelayWithJitter(baseDelay: number): number { return Math.floor(baseDelay + jitter) } -interface RateLimitStatus { - requestsPerMinute: number - remaining: number - maxBurst?: number - resetAt?: string -} - -interface RateLimitsData { - sync?: RateLimitStatus - async?: RateLimitStatus -} - -interface UsageDataPayload { - currentPeriodCost: number - limit: number - percentUsed: number - isExceeded?: boolean -} - interface NotificationPayload { id: string type: 'workflow.execution.completed' @@ -65,8 +50,8 @@ interface NotificationPayload { totalDurationMs: number cost?: Record finalOutput?: unknown - rateLimits?: RateLimitsData - usage?: UsageDataPayload + rateLimits?: EmailRateLimitsData + usage?: EmailUsageData } } diff --git a/apps/sim/components/emails/notifications/index.ts b/apps/sim/components/emails/notifications/index.ts index c24652b753..52de16601f 100644 --- a/apps/sim/components/emails/notifications/index.ts +++ b/apps/sim/components/emails/notifications/index.ts @@ -1 +1,7 @@ +export type { + EmailRateLimitStatus, + EmailRateLimitsData, + EmailUsageData, + WorkflowNotificationEmailProps, +} from './workflow-notification-email' export { WorkflowNotificationEmail } from './workflow-notification-email' diff --git a/apps/sim/components/emails/notifications/workflow-notification-email.tsx b/apps/sim/components/emails/notifications/workflow-notification-email.tsx index 67e492ad87..88ad6fba66 100644 --- a/apps/sim/components/emails/notifications/workflow-notification-email.tsx +++ b/apps/sim/components/emails/notifications/workflow-notification-email.tsx @@ -3,26 +3,31 @@ import { baseStyles } from '@/components/emails/_styles' import { EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/lib/branding/branding' -interface RateLimitStatus { +/** + * 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 } -interface RateLimitsData { - sync?: RateLimitStatus - async?: RateLimitStatus +export interface EmailRateLimitsData { + sync?: EmailRateLimitStatus + async?: EmailRateLimitStatus } -interface UsageDataProps { +export interface EmailUsageData { currentPeriodCost: number limit: number percentUsed: number isExceeded?: boolean } -interface WorkflowNotificationEmailProps { +export interface WorkflowNotificationEmailProps { workflowName: string status: 'success' | 'error' trigger: string @@ -31,8 +36,8 @@ interface WorkflowNotificationEmailProps { logUrl: string alertReason?: string finalOutput?: unknown - rateLimits?: RateLimitsData - usageData?: UsageDataProps + rateLimits?: EmailRateLimitsData + usageData?: EmailUsageData } function formatJsonForEmail(data: unknown): string { diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index 61f8a7eb9f..3c1395d051 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -15,7 +15,10 @@ import { PollingGroupInvitationEmail, WorkspaceInvitationEmail, } from '@/components/emails/invitations' -import { WorkflowNotificationEmail } from '@/components/emails/notifications' +import { + WorkflowNotificationEmail, + type WorkflowNotificationEmailProps, +} from '@/components/emails/notifications' import { HelpConfirmationEmail } from '@/components/emails/support' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -260,49 +263,8 @@ export async function renderCareersSubmissionEmail(params: { ) } -interface RateLimitStatus { - requestsPerMinute: number - remaining: number - maxBurst?: number - resetAt?: string -} - -interface RateLimitsData { - sync?: RateLimitStatus - async?: RateLimitStatus -} - -interface UsageDataProps { - currentPeriodCost: number - limit: number - percentUsed: number - isExceeded?: boolean -} - -export async function renderWorkflowNotificationEmail(params: { - workflowName: string - status: 'success' | 'error' - trigger: string - duration: string - cost: string - logUrl: string - alertReason?: string - finalOutput?: unknown - rateLimits?: RateLimitsData - usageData?: UsageDataProps -}): Promise { - return await render( - WorkflowNotificationEmail({ - workflowName: params.workflowName, - status: params.status, - trigger: params.trigger, - duration: params.duration, - cost: params.cost, - logUrl: params.logUrl, - alertReason: params.alertReason, - finalOutput: params.finalOutput, - rateLimits: params.rateLimits, - usageData: params.usageData, - }) - ) +export async function renderWorkflowNotificationEmail( + params: WorkflowNotificationEmailProps +): Promise { + return await render(WorkflowNotificationEmail(params)) }