v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)

* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
This commit is contained in:
Waleed
2026-01-03 13:19:18 -08:00
committed by GitHub
157 changed files with 6519 additions and 3126 deletions

View File

@@ -30,6 +30,18 @@ import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/component
import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard'
```
## No Re-exports
Do not re-export from non-barrel files. Import directly from the source.
```typescript
// ✓ Good - import from where it's declared
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
// ✗ Bad - re-exporting in utils.ts then importing from there
import { CORE_TRIGGER_TYPES } from '@/app/workspace/.../utils'
```
## Import Order
1. React/core libraries

View File

@@ -9,7 +9,7 @@ globs: ["apps/sim/**/*.tsx", "apps/sim/**/*.css"]
1. **No inline styles** - Use Tailwind classes
2. **No duplicate dark classes** - Skip `dark:` when value matches light mode
3. **Exact values** - `text-[14px]`, `h-[25px]`
3. **Exact values** - `text-[14px]`, `h-[26px]`
4. **Transitions** - `transition-colors` for interactive states
## Conditional Classes

View File

@@ -52,7 +52,7 @@ import { useWorkflowStore } from '@/stores/workflows/store'
import { useWorkflowStore } from '../../../stores/workflows/store'
```
Use barrel exports (`index.ts`) when a folder has 3+ exports.
Use barrel exports (`index.ts`) when a folder has 3+ exports. Do not re-export from non-barrel files; import directly from the source.
### Import Order
1. React/core libraries

View File

@@ -187,7 +187,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Sim Studio, Inc.
Copyright 2026 Sim Studio, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

2
NOTICE
View File

@@ -1,4 +1,4 @@
Sim Studio
Copyright 2025 Sim Studio
Copyright 2026 Sim Studio
This product includes software developed for the Sim project.

File diff suppressed because one or more lines are too long

View File

@@ -58,6 +58,7 @@ import {
LinkupIcon,
MailchimpIcon,
MailgunIcon,
MailServerIcon,
Mem0Icon,
MicrosoftExcelIcon,
MicrosoftOneDriveIcon,
@@ -165,6 +166,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
huggingface: HuggingFaceIcon,
hunter: HunterIOIcon,
image_generator: ImageIcon,
imap: MailServerIcon,
incidentio: IncidentioIcon,
intercom: IntercomIcon,
jina: JinaAIIcon,

View File

@@ -0,0 +1,36 @@
---
title: IMAP-E-Mail
description: Workflows auslösen, wenn neue E-Mails über IMAP eintreffen
(funktioniert mit jedem E-Mail-Anbieter)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="imap"
color="#6366F1"
/>
{/* MANUAL-CONTENT-START:intro */}
Der IMAP-E-Mail-Trigger ermöglicht es Ihren Sim-Workflows, automatisch zu starten, sobald eine neue E-Mail in einem Postfach empfangen wird, das das IMAP-Protokoll unterstützt. Dies funktioniert mit Gmail, Outlook, Yahoo und den meisten anderen E-Mail-Anbietern.
Mit dem IMAP-Trigger können Sie:
- **E-Mail-Verarbeitung automatisieren**: Starten Sie Workflows in Echtzeit, wenn neue Nachrichten in Ihrem Posteingang eintreffen.
- **Nach Absender, Betreff oder Ordner filtern**: Konfigurieren Sie Ihren Trigger so, dass er nur auf E-Mails reagiert, die bestimmte Bedingungen erfüllen.
- **Anhänge extrahieren und verarbeiten**: Laden Sie Dateianhänge automatisch herunter und verwenden Sie sie in Ihren automatisierten Abläufen.
- **E-Mail-Inhalte parsen und verwenden**: Greifen Sie auf Betreff, Absender, Empfänger, vollständigen Text und andere Metadaten in nachfolgenden Workflow-Schritten zu.
- **Mit jedem E-Mail-Anbieter integrieren**: Funktioniert mit jedem Dienst, der standardmäßigen IMAP-Zugriff bietet, ohne Vendor-Lock-in.
- **Bei ungelesenen, markierten oder benutzerdefinierten Kriterien auslösen**: Richten Sie erweiterte Filter für die Arten von E-Mails ein, die Ihre Workflows starten.
Mit Sim gibt Ihnen die IMAP-Integration die Möglichkeit, E-Mails in eine handlungsfähige Automatisierungsquelle zu verwandeln. Reagieren Sie auf Kundenanfragen, verarbeiten Sie Benachrichtigungen, starten Sie Daten-Pipelines und mehr direkt aus Ihrem E-Mail-Posteingang, ohne manuelles Eingreifen.
{/* MANUAL-CONTENT-END */}
## Nutzungsanleitung
Verbinden Sie sich über das IMAP-Protokoll mit jedem E-Mail-Server, um Workflows auszulösen, wenn neue E-Mails empfangen werden. Unterstützt Gmail, Outlook, Yahoo und jeden anderen IMAP-kompatiblen E-Mail-Anbieter.
## Hinweise
- Kategorie: `triggers`
- Typ: `imap`

View File

@@ -0,0 +1,40 @@
---
title: IMAP Email
description: Trigger workflows when new emails arrive via IMAP (works with any email provider)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="imap"
color="#6366F1"
/>
{/* MANUAL-CONTENT-START:intro */}
The IMAP Email trigger allows your Sim workflows to start automatically whenever a new email is received in any mailbox that supports the IMAP protocol. This works with Gmail, Outlook, Yahoo, and most other email providers.
With the IMAP trigger, you can:
- **Automate email processing**: Start workflows in real time when new messages arrive in your inbox.
- **Filter by sender, subject, or folder**: Configure your trigger to react only to emails that match certain conditions.
- **Extract and process attachments**: Automatically download and use file attachments in your automated flows.
- **Parse and use email content**: Access the subject, sender, recipients, full body, and other metadata in downstream workflow steps.
- **Integrate with any email provider**: Works with any service that provides standard IMAP access, without vendor lock-in.
- **Trigger on unread, flagged, or custom criteria**: Set up advanced filters for the kinds of emails that start your workflows.
With Sim, the IMAP integration gives you the power to turn email into an actionable source of automation. Respond to customer inquiries, process notifications, kick off data pipelines, and more—directly from your email inbox, with no manual intervention.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Connect to any email server via IMAP protocol to trigger workflows when new emails are received. Supports Gmail, Outlook, Yahoo, and any other IMAP-compatible email provider.
## Notes
- Category: `triggers`
- Type: `imap`

View File

@@ -42,6 +42,7 @@
"huggingface",
"hunter",
"image_generator",
"imap",
"incidentio",
"intercom",
"jina",

View File

@@ -0,0 +1,36 @@
---
title: Correo electrónico IMAP
description: Activa flujos de trabajo cuando lleguen nuevos correos electrónicos
a través de IMAP (funciona con cualquier proveedor de correo electrónico)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="imap"
color="#6366F1"
/>
{/* MANUAL-CONTENT-START:intro */}
El activador de correo electrónico IMAP permite que tus flujos de trabajo de Sim se inicien automáticamente cada vez que se reciba un nuevo correo electrónico en cualquier buzón que admita el protocolo IMAP. Esto funciona con Gmail, Outlook, Yahoo y la mayoría de los demás proveedores de correo electrónico.
Con el activador IMAP, puedes:
- **Automatizar el procesamiento de correos electrónicos**: inicia flujos de trabajo en tiempo real cuando lleguen nuevos mensajes a tu bandeja de entrada.
- **Filtrar por remitente, asunto o carpeta**: configura tu activador para que reaccione solo a los correos electrónicos que cumplan ciertas condiciones.
- **Extraer y procesar archivos adjuntos**: descarga y utiliza automáticamente archivos adjuntos en tus flujos automatizados.
- **Analizar y utilizar el contenido del correo electrónico**: accede al asunto, remitente, destinatarios, cuerpo completo y otros metadatos en los pasos posteriores del flujo de trabajo.
- **Integrar con cualquier proveedor de correo electrónico**: funciona con cualquier servicio que proporcione acceso IMAP estándar, sin dependencia de proveedores.
- **Activar según criterios de no leído, marcado o personalizados**: configura filtros avanzados para los tipos de correos electrónicos que inician tus flujos de trabajo.
Con Sim, la integración IMAP te brinda el poder de convertir el correo electrónico en una fuente de automatización procesable. Responde a consultas de clientes, procesa notificaciones, inicia pipelines de datos y más, directamente desde tu bandeja de entrada de correo electrónico, sin intervención manual.
{/* MANUAL-CONTENT-END */}
## Instrucciones de uso
Conéctate a cualquier servidor de correo electrónico a través del protocolo IMAP para activar flujos de trabajo cuando se reciban nuevos correos electrónicos. Compatible con Gmail, Outlook, Yahoo y cualquier otro proveedor de correo electrónico compatible con IMAP.
## Notas
- Categoría: `triggers`
- Tipo: `imap`

View File

@@ -0,0 +1,36 @@
---
title: Email IMAP
description: Déclenchez des workflows lorsque de nouveaux emails arrivent via
IMAP (fonctionne avec n'importe quel fournisseur d'email)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="imap"
color="#6366F1"
/>
{/* MANUAL-CONTENT-START:intro */}
Le déclencheur Email IMAP permet à vos workflows Sim de démarrer automatiquement dès qu'un nouvel email est reçu dans n'importe quelle boîte mail prenant en charge le protocole IMAP. Cela fonctionne avec Gmail, Outlook, Yahoo et la plupart des autres fournisseurs d'email.
Avec le déclencheur IMAP, vous pouvez :
- **Automatiser le traitement des emails** : démarrez des workflows en temps réel lorsque de nouveaux messages arrivent dans votre boîte de réception.
- **Filtrer par expéditeur, objet ou dossier** : configurez votre déclencheur pour réagir uniquement aux emails correspondant à certaines conditions.
- **Extraire et traiter les pièces jointes** : téléchargez et utilisez automatiquement les fichiers joints dans vos flux automatisés.
- **Analyser et utiliser le contenu des emails** : accédez à l'objet, l'expéditeur, les destinataires, le corps complet et d'autres métadonnées dans les étapes suivantes du workflow.
- **Intégrer avec n'importe quel fournisseur d'email** : fonctionne avec tout service offrant un accès IMAP standard, sans dépendance à un fournisseur.
- **Déclencher sur non lu, marqué ou critères personnalisés** : configurez des filtres avancés pour les types d'emails qui démarrent vos workflows.
Avec Sim, l'intégration IMAP vous donne le pouvoir de transformer l'email en une source d'automatisation exploitable. Répondez aux demandes clients, traitez les notifications, lancez des pipelines de données et plus encore, directement depuis votre boîte de réception email, sans intervention manuelle.
{/* MANUAL-CONTENT-END */}
## Instructions d'utilisation
Connectez-vous à n'importe quel serveur email via le protocole IMAP pour déclencher des workflows lorsque de nouveaux emails sont reçus. Prend en charge Gmail, Outlook, Yahoo et tout autre fournisseur d'email compatible IMAP.
## Remarques
- Catégorie : `triggers`
- Type : `imap`

View File

@@ -0,0 +1,35 @@
---
title: IMAPメール
description: IMAP経由で新しいメールが届いたときにワークフローをトリガーすべてのメールプロバイダーで動作
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="imap"
color="#6366F1"
/>
{/* MANUAL-CONTENT-START:intro */}
IMAPメールトリガーを使用すると、IMAPプロトコルをサポートする任意のメールボックスで新しいメールを受信したときに、Simワークフローを自動的に開始できます。Gmail、Outlook、Yahoo、その他ほとんどのメールプロバイダーで動作します。
IMAPトリガーでできること
- **メール処理の自動化**:受信トレイに新しいメッセージが届いたときにリアルタイムでワークフローを開始します。
- **送信者、件名、フォルダーでフィルタリング**:特定の条件に一致するメールにのみ反応するようにトリガーを設定します。
- **添付ファイルの抽出と処理**:自動化フローでファイル添付を自動的にダウンロードして使用します。
- **メールコンテンツの解析と使用**:件名、送信者、受信者、本文全体、その他のメタデータに、ワークフローの後続ステップでアクセスします。
- **あらゆるメールプロバイダーとの統合**ベンダーロックインなしで、標準のIMAPアクセスを提供する任意のサービスで動作します。
- **未読、フラグ付き、カスタム条件でトリガー**:ワークフローを開始するメールの種類に対して高度なフィルターを設定します。
Simを使用すると、IMAP統合により、メールを実行可能な自動化ソースに変える力が得られます。顧客からの問い合わせへの対応、通知の処理、データパイプラインの開始など、手動操作なしで、メール受信トレイから直接実行できます。
{/* MANUAL-CONTENT-END */}
## 使用方法
IMAPプロトコル経由で任意のメールサーバーに接続し、新しいメールを受信したときにワークフローをトリガーします。Gmail、Outlook、Yahoo、その他のIMAP互換メールプロバイダーをサポートします。
## 注意事項
- カテゴリ: `triggers`
- タイプ: `imap`

View File

@@ -0,0 +1,35 @@
---
title: IMAP 邮件
description: 当通过 IMAP 收到新邮件时触发工作流(适用于任何邮箱服务商)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="imap"
color="#6366F1"
/>
{/* MANUAL-CONTENT-START:intro */}
IMAP 邮件触发器可以让你的 Sim 工作流在任何支持 IMAP 协议的邮箱收到新邮件时自动启动。适用于 Gmail、Outlook、Yahoo 及大多数其他邮箱服务商。
使用 IMAP 触发器,你可以:
- **自动化邮件处理**:当新邮件到达收件箱时,实时启动工作流。
- **按发件人、主题或文件夹筛选**:配置触发器,仅对符合特定条件的邮件做出响应。
- **提取并处理附件**:自动下载并在自动化流程中使用邮件附件。
- **解析并利用邮件内容**:在后续工作流步骤中访问主题、发件人、收件人、正文及其他元数据。
- **与任意邮箱服务集成**:支持所有提供标准 IMAP 访问的服务,无需受限于特定厂商。
- **按未读、标记或自定义条件触发**:为启动工作流的邮件设置高级筛选条件。
借助 SimIMAP 集成让你能够将邮件变为可操作的自动化来源。无需人工干预,即可直接从邮箱收件箱响应客户咨询、处理通知、启动数据流程等。
{/* MANUAL-CONTENT-END */}
## 使用说明
通过 IMAP 协议连接任意邮件服务器,在收到新邮件时触发工作流。支持 Gmail、Outlook、Yahoo 及所有兼容 IMAP 的邮箱服务商。
## 注意事项
- 分类:`triggers`
- 类型:`imap`

View File

@@ -50184,3 +50184,16 @@ checksums:
content/12: 3a322eee25c8bd5d81e7ae92f4239300
content/13: a82eb7d47a82c3289a00ccf27a860685
content/14: 26b9713de1a21d662c198154b673fd7d
b92b25e42e07ea0c1acc84c25f897c03:
meta/title: b578e5df9a37263d79d61eea1550b381
meta/description: a3ac2f556f8a1d72eee5058799e45b4f
content/0: 1b031fb0c62c46b177aeed5c3d3f8f80
content/1: 03b3a9b927648526481589ec2205aed1
content/2: ad3b7957de2e6e94935420692f41912b
content/3: b5d66b7cc95f747232f3c39e71e58125
content/4: a97fd9d5ca27813be7aa04fc9162ec5d
content/5: 47006103fb87648dd28524557c946bd0
content/6: 821e6394b0a953e2b0842b04ae8f3105
content/7: 7b29d23aec8fda839f3934c5fc71c6d3
content/8: b3f310d5ef115bea5a8b75bf25d7ea9a
content/9: 79ecd09a7bedc128285814d8b439ed40

View File

@@ -2,8 +2,7 @@ import { render } from '@react-email/components'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import CareersConfirmationEmail from '@/components/emails/careers/careers-confirmation-email'
import CareersSubmissionEmail from '@/components/emails/careers/careers-submission-email'
import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/emails'
import { generateRequestId } from '@/lib/core/utils/request'
import { sendEmail } from '@/lib/messaging/email/mailer'

View File

@@ -156,6 +156,11 @@ describe('Chat OTP API Route', () => {
}),
}))
vi.doMock('@/lib/core/config/env', async () => {
const { createEnvMock } = await import('@sim/testing')
return createEnvMock()
})
vi.doMock('zod', () => ({
z: {
object: vi.fn().mockReturnValue({

View File

@@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, gt } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { renderOTPEmail } from '@/components/emails/render-email'
import { renderOTPEmail } from '@/components/emails'
import { getRedisClient } from '@/lib/core/config/redis'
import { getStorageMethod } from '@/lib/core/storage'
import { generateRequestId } from '@/lib/core/utils/request'

View File

@@ -249,17 +249,13 @@ describe('Chat API Route', () => {
}),
}))
vi.doMock('@/lib/core/config/env', () => ({
env: {
vi.doMock('@/lib/core/config/env', async () => {
const { createEnvMock } = await import('@sim/testing')
return createEnvMock({
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string'
? value.toLowerCase() === 'true' || value === '1'
: Boolean(value),
getEnv: (variable: string) => process.env[variable],
}))
})
})
const validData = {
workflowId: 'workflow-123',
@@ -296,15 +292,13 @@ describe('Chat API Route', () => {
}),
}))
vi.doMock('@/lib/core/config/env', () => ({
env: {
vi.doMock('@/lib/core/config/env', async () => {
const { createEnvMock } = await import('@sim/testing')
return createEnvMock({
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
getEnv: (variable: string) => process.env[variable],
}))
})
})
const validData = {
workflowId: 'workflow-123',

View File

@@ -21,12 +21,13 @@ describe('Copilot API Keys API Route', () => {
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
}))
vi.doMock('@/lib/core/config/env', () => ({
env: {
SIM_AGENT_API_URL: null,
vi.doMock('@/lib/core/config/env', async () => {
const { createEnvMock } = await import('@sim/testing')
return createEnvMock({
SIM_AGENT_API_URL: undefined,
COPILOT_API_KEY: 'test-api-key',
},
}))
})
})
})
afterEach(() => {

View File

@@ -46,12 +46,13 @@ describe('Copilot Stats API Route', () => {
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
}))
vi.doMock('@/lib/core/config/env', () => ({
env: {
SIM_AGENT_API_URL: null,
vi.doMock('@/lib/core/config/env', async () => {
const { createEnvMock } = await import('@sim/testing')
return createEnvMock({
SIM_AGENT_API_URL: undefined,
COPILOT_API_KEY: 'test-api-key',
},
}))
})
})
})
afterEach(() => {

View File

@@ -0,0 +1,90 @@
import { db } from '@sim/db'
import { workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, lt, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
const logger = createLogger('CleanupStaleExecutions')
const STALE_THRESHOLD_MINUTES = 30
export async function GET(request: NextRequest) {
try {
const authError = verifyCronAuth(request, 'Stale execution cleanup')
if (authError) {
return authError
}
logger.info('Starting stale execution cleanup job')
const staleThreshold = new Date(Date.now() - STALE_THRESHOLD_MINUTES * 60 * 1000)
const staleExecutions = await db
.select({
id: workflowExecutionLogs.id,
executionId: workflowExecutionLogs.executionId,
workflowId: workflowExecutionLogs.workflowId,
startedAt: workflowExecutionLogs.startedAt,
})
.from(workflowExecutionLogs)
.where(
and(
eq(workflowExecutionLogs.status, 'running'),
lt(workflowExecutionLogs.startedAt, staleThreshold)
)
)
.limit(100)
logger.info(`Found ${staleExecutions.length} stale executions to clean up`)
let cleaned = 0
let failed = 0
for (const execution of staleExecutions) {
try {
const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime()
const staleDurationMinutes = Math.round(staleDurationMs / 60000)
await db
.update(workflowExecutionLogs)
.set({
status: 'failed',
endedAt: new Date(),
totalDurationMs: staleDurationMs,
executionData: sql`jsonb_set(
COALESCE(execution_data, '{}'::jsonb),
ARRAY['error'],
to_jsonb(${`Execution terminated: worker timeout or crash after ${staleDurationMinutes} minutes`}::text)
)`,
})
.where(eq(workflowExecutionLogs.id, execution.id))
logger.info(`Cleaned up stale execution ${execution.executionId}`, {
workflowId: execution.workflowId,
staleDurationMinutes,
})
cleaned++
} catch (error) {
logger.error(`Failed to clean up execution ${execution.executionId}:`, {
error: error instanceof Error ? error.message : String(error),
})
failed++
}
}
logger.info(`Stale execution cleanup completed. Cleaned: ${cleaned}, Failed: ${failed}`)
return NextResponse.json({
success: true,
found: staleExecutions.length,
cleaned,
failed,
thresholdMinutes: STALE_THRESHOLD_MINUTES,
})
} catch (error) {
logger.error('Error in stale execution cleanup job:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,176 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import {
renderBatchInvitationEmail,
renderCareersConfirmationEmail,
renderCareersSubmissionEmail,
renderCreditPurchaseEmail,
renderEnterpriseSubscriptionEmail,
renderFreeTierUpgradeEmail,
renderHelpConfirmationEmail,
renderInvitationEmail,
renderOTPEmail,
renderPasswordResetEmail,
renderPaymentFailedEmail,
renderPlanWelcomeEmail,
renderUsageThresholdEmail,
renderWelcomeEmail,
renderWorkspaceInvitationEmail,
} from '@/components/emails'
const emailTemplates = {
// Auth emails
otp: () => renderOTPEmail('123456', 'user@example.com', 'email-verification'),
'reset-password': () => renderPasswordResetEmail('John', 'https://sim.ai/reset?token=abc123'),
welcome: () => renderWelcomeEmail('John'),
// Invitation emails
invitation: () => renderInvitationEmail('Jane Doe', 'Acme Corp', 'https://sim.ai/invite/abc123'),
'batch-invitation': () =>
renderBatchInvitationEmail(
'Jane Doe',
'Acme Corp',
'admin',
[
{ workspaceId: 'ws_123', workspaceName: 'Engineering', permission: 'write' },
{ workspaceId: 'ws_456', workspaceName: 'Design', permission: 'read' },
],
'https://sim.ai/invite/abc123'
),
'workspace-invitation': () =>
renderWorkspaceInvitationEmail(
'John Smith',
'Engineering Team',
'https://sim.ai/workspace/invite/abc123'
),
// Support emails
'help-confirmation': () => renderHelpConfirmationEmail('feature_request', 2),
// Billing emails
'usage-threshold': () =>
renderUsageThresholdEmail({
userName: 'John',
planName: 'Pro',
percentUsed: 75,
currentUsage: 15,
limit: 20,
ctaLink: 'https://sim.ai/settings/billing',
}),
'enterprise-subscription': () => renderEnterpriseSubscriptionEmail('John'),
'free-tier-upgrade': () =>
renderFreeTierUpgradeEmail({
userName: 'John',
percentUsed: 90,
currentUsage: 9,
limit: 10,
upgradeLink: 'https://sim.ai/settings/billing',
}),
'plan-welcome-pro': () =>
renderPlanWelcomeEmail({
planName: 'Pro',
userName: 'John',
loginLink: 'https://sim.ai/login',
}),
'plan-welcome-team': () =>
renderPlanWelcomeEmail({
planName: 'Team',
userName: 'John',
loginLink: 'https://sim.ai/login',
}),
'credit-purchase': () =>
renderCreditPurchaseEmail({
userName: 'John',
amount: 50,
newBalance: 75,
}),
'payment-failed': () =>
renderPaymentFailedEmail({
userName: 'John',
amountDue: 20,
lastFourDigits: '4242',
billingPortalUrl: 'https://sim.ai/settings/billing',
failureReason: 'Card declined',
}),
// Careers emails
'careers-confirmation': () => renderCareersConfirmationEmail('John Doe', 'Senior Engineer'),
'careers-submission': () =>
renderCareersSubmissionEmail({
name: 'John Doe',
email: 'john@example.com',
phone: '+1 (555) 123-4567',
position: 'Senior Engineer',
linkedin: 'https://linkedin.com/in/johndoe',
portfolio: 'https://johndoe.dev',
experience: '5-10',
location: 'San Francisco, CA',
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.',
}),
} as const
type EmailTemplate = keyof typeof emailTemplates
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const template = searchParams.get('template') as EmailTemplate | null
if (!template) {
const categories = {
Auth: ['otp', 'reset-password', 'welcome'],
Invitations: ['invitation', 'batch-invitation', 'workspace-invitation'],
Support: ['help-confirmation'],
Billing: [
'usage-threshold',
'enterprise-subscription',
'free-tier-upgrade',
'plan-welcome-pro',
'plan-welcome-team',
'credit-purchase',
'payment-failed',
],
Careers: ['careers-confirmation', 'careers-submission'],
}
const categoryHtml = Object.entries(categories)
.map(
([category, templates]) => `
<h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${category}</h2>
<ul style="list-style: none; padding: 0; margin: 0;">
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #32bd7e; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
</ul>
`
)
.join('')
return new NextResponse(
`<!DOCTYPE html>
<html>
<head>
<title>Email Previews</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
h1 { color: #333; margin-bottom: 32px; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>Email Templates</h1>
${categoryHtml}
</body>
</html>`,
{ headers: { 'Content-Type': 'text/html' } }
)
}
if (!(template in emailTemplates)) {
return NextResponse.json({ error: `Unknown template: ${template}` }, { status: 400 })
}
const html = await emailTemplates[template]()
return new NextResponse(html, {
headers: { 'Content-Type': 'text/html' },
})
}

View File

@@ -118,7 +118,6 @@ ${message}
// Send confirmation email to the user
try {
const confirmationHtml = await renderHelpConfirmationEmail(
email,
type as 'bug' | 'feedback' | 'feature_request' | 'other',
images.length
)

View File

@@ -16,7 +16,7 @@ import {
getEmailSubject,
renderBatchInvitationEmail,
renderInvitationEmail,
} from '@/components/emails/render-email'
} from '@/components/emails'
import { getSession } from '@/lib/auth'
import {
validateBulkInvitations,
@@ -376,8 +376,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const emailHtml = await renderInvitationEmail(
inviter[0]?.name || 'Someone',
organizationEntry[0]?.name || 'organization',
`${getBaseUrl()}/invite/${orgInvitation.id}`,
email
`${getBaseUrl()}/invite/${orgInvitation.id}`
)
emailResult = await sendEmail({

View File

@@ -4,7 +4,7 @@ import { invitation, member, organization, user, userStats } from '@sim/db/schem
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails/render-email'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
@@ -260,8 +260,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const emailHtml = await renderInvitationEmail(
inviter[0]?.name || 'Someone',
organizationEntry[0]?.name || 'organization',
`${getBaseUrl()}/invite/organization?id=${invitationId}`,
normalizedEmail
`${getBaseUrl()}/invite/organization?id=${invitationId}`
)
const emailResult = await sendEmail({

View File

@@ -0,0 +1,101 @@
import { createLogger } from '@sim/logger'
import { ImapFlow } from 'imapflow'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
const logger = createLogger('ImapMailboxesAPI')
interface ImapMailboxRequest {
host: string
port: number
secure: boolean
rejectUnauthorized: boolean
username: string
password: string
}
export async function POST(request: NextRequest) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ success: false, message: 'Unauthorized' }, { status: 401 })
}
try {
const body = (await request.json()) as ImapMailboxRequest
const { host, port, secure, rejectUnauthorized, username, password } = body
if (!host || !username || !password) {
return NextResponse.json(
{ success: false, message: 'Missing required fields: host, username, password' },
{ status: 400 }
)
}
const client = new ImapFlow({
host,
port: port || 993,
secure: secure ?? true,
auth: {
user: username,
pass: password,
},
tls: {
rejectUnauthorized: rejectUnauthorized ?? true,
},
logger: false,
})
try {
await client.connect()
const listResult = await client.list()
const mailboxes = listResult.map((mailbox) => ({
path: mailbox.path,
name: mailbox.name,
delimiter: mailbox.delimiter,
}))
await client.logout()
mailboxes.sort((a, b) => {
if (a.path === 'INBOX') return -1
if (b.path === 'INBOX') return 1
return a.path.localeCompare(b.path)
})
return NextResponse.json({
success: true,
mailboxes,
})
} catch (error) {
try {
await client.logout()
} catch {
// Ignore logout errors
}
throw error
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Error fetching IMAP mailboxes:', errorMessage)
let userMessage = 'Failed to connect to IMAP server'
if (
errorMessage.includes('AUTHENTICATIONFAILED') ||
errorMessage.includes('Invalid credentials')
) {
userMessage = 'Invalid username or password. For Gmail, use an App Password.'
} else if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('getaddrinfo')) {
userMessage = 'Could not find IMAP server. Please check the hostname.'
} else if (errorMessage.includes('ECONNREFUSED')) {
userMessage = 'Connection refused. Please check the port and SSL settings.'
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL')) {
userMessage =
'TLS/SSL error. Try disabling "Verify TLS Certificate" for self-signed certificates.'
} else if (errorMessage.includes('timeout')) {
userMessage = 'Connection timed out. Please check your network and server settings.'
}
return NextResponse.json({ success: false, message: userMessage }, { status: 500 })
}
}

View File

@@ -29,6 +29,10 @@
* DELETE /api/v1/admin/workflows/:id - Delete workflow
* GET /api/v1/admin/workflows/:id/export - Export workflow (JSON)
* POST /api/v1/admin/workflows/import - Import single workflow
* POST /api/v1/admin/workflows/:id/deploy - Deploy workflow
* DELETE /api/v1/admin/workflows/:id/deploy - Undeploy workflow
* GET /api/v1/admin/workflows/:id/versions - List deployment versions
* POST /api/v1/admin/workflows/:id/versions/:vid/activate - Activate specific version
*
* Organizations:
* GET /api/v1/admin/organizations - List all organizations
@@ -65,6 +69,8 @@ export {
unauthorizedResponse,
} from '@/app/api/v1/admin/responses'
export type {
AdminDeploymentVersion,
AdminDeployResult,
AdminErrorResponse,
AdminFolder,
AdminListResponse,
@@ -76,6 +82,7 @@ export type {
AdminSeatAnalytics,
AdminSingleResponse,
AdminSubscription,
AdminUndeployResult,
AdminUser,
AdminUserBilling,
AdminUserBillingWithSubscription,

View File

@@ -599,3 +599,23 @@ export interface AdminSeatAnalytics {
lastActive: string | null
}>
}
export interface AdminDeploymentVersion {
id: string
version: number
name: string | null
isActive: boolean
createdAt: string
createdBy: string | null
deployedByName: string | null
}
export interface AdminDeployResult {
isDeployed: boolean
version: number
deployedAt: string
}
export interface AdminUndeployResult {
isDeployed: boolean
}

View File

@@ -0,0 +1,111 @@
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import {
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowDeployAPI')
const ADMIN_ACTOR_ID = 'admin-api'
interface RouteParams {
id: string
}
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
try {
const [workflowRecord] = await db
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return badRequestResponse('Workflow has no saved state')
}
const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks)
if (!scheduleValidation.isValid) {
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
}
const deployResult = await deployWorkflow({
workflowId,
deployedBy: ADMIN_ACTOR_ID,
workflowName: workflowRecord.name,
})
if (!deployResult.success) {
return internalErrorResponse(deployResult.error || 'Failed to deploy workflow')
}
const scheduleResult = await createSchedulesForDeploy(workflowId, normalizedData.blocks, db)
if (!scheduleResult.success) {
logger.warn(`Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`)
}
logger.info(`Admin API: Deployed workflow ${workflowId} as v${deployResult.version}`)
const response: AdminDeployResult = {
isDeployed: true,
version: deployResult.version!,
deployedAt: deployResult.deployedAt!.toISOString(),
}
return singleResponse(response)
} catch (error) {
logger.error(`Admin API: Failed to deploy workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to deploy workflow')
}
})
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
try {
const [workflowRecord] = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const result = await undeployWorkflow({ workflowId })
if (!result.success) {
return internalErrorResponse(result.error || 'Failed to undeploy workflow')
}
logger.info(`Admin API: Undeployed workflow ${workflowId}`)
const response: AdminUndeployResult = {
isDeployed: false,
}
return singleResponse(response)
} catch (error) {
logger.error(`Admin API: Failed to undeploy workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to undeploy workflow')
}
})

View File

@@ -0,0 +1,58 @@
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminWorkflowActivateVersionAPI')
interface RouteParams {
id: string
versionId: string
}
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId, versionId } = await context.params
try {
const [workflowRecord] = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const versionNum = Number(versionId)
if (!Number.isFinite(versionNum) || versionNum < 1) {
return badRequestResponse('Invalid version number')
}
const result = await activateWorkflowVersion({ workflowId, version: versionNum })
if (!result.success) {
if (result.error === 'Deployment version not found') {
return notFoundResponse('Deployment version')
}
return internalErrorResponse(result.error || 'Failed to activate version')
}
logger.info(`Admin API: Activated version ${versionNum} for workflow ${workflowId}`)
return singleResponse({
success: true,
version: versionNum,
deployedAt: result.deployedAt!.toISOString(),
})
} catch (error) {
logger.error(`Admin API: Failed to activate version for workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to activate deployment version')
}
})

View File

@@ -0,0 +1,52 @@
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { listWorkflowVersions } from '@/lib/workflows/persistence/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminDeploymentVersion } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowVersionsAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
try {
const [workflowRecord] = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const { versions } = await listWorkflowVersions(workflowId)
const response: AdminDeploymentVersion[] = versions.map((v) => ({
id: v.id,
version: v.version,
name: v.name,
isActive: v.isActive,
createdAt: v.createdAt.toISOString(),
createdBy: v.createdBy,
deployedByName: v.deployedByName ?? (v.createdBy === 'admin-api' ? 'Admin' : null),
}))
logger.info(`Admin API: Listed ${versions.length} versions for workflow ${workflowId}`)
return singleResponse({ versions: response })
} catch (error) {
logger.error(`Admin API: Failed to list versions for workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to list deployment versions')
}
})

View File

@@ -0,0 +1,68 @@
import { createLogger } from '@sim/logger'
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { pollImapWebhooks } from '@/lib/webhooks/imap-polling-service'
const logger = createLogger('ImapPollingAPI')
export const dynamic = 'force-dynamic'
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
const LOCK_KEY = 'imap-polling-lock'
const LOCK_TTL_SECONDS = 180 // Same as maxDuration (3 min)
export async function GET(request: NextRequest) {
const requestId = nanoid()
logger.info(`IMAP webhook polling triggered (${requestId})`)
let lockValue: string | undefined
try {
const authError = verifyCronAuth(request, 'IMAP webhook polling')
if (authError) {
return authError
}
lockValue = requestId // unique value to identify the holder
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
if (!locked) {
return NextResponse.json(
{
success: true,
message: 'Polling already in progress skipped',
requestId,
status: 'skip',
},
{ status: 202 }
)
}
const results = await pollImapWebhooks()
return NextResponse.json({
success: true,
message: 'IMAP polling completed',
requestId,
status: 'completed',
...results,
})
} catch (error) {
logger.error(`Error during IMAP polling (${requestId}):`, error)
return NextResponse.json(
{
success: false,
message: 'IMAP polling failed',
error: error instanceof Error ? error.message : 'Unknown error',
requestId,
},
{ status: 500 }
)
} finally {
if (lockValue) {
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
}
}
}

View File

@@ -581,6 +581,56 @@ export async function POST(request: NextRequest) {
}
// --- End RSS specific logic ---
if (savedWebhook && provider === 'grain') {
logger.info(`[${requestId}] Grain provider detected. Creating Grain webhook subscription.`)
try {
const grainHookId = await createGrainWebhookSubscription(
request,
{
id: savedWebhook.id,
path: savedWebhook.path,
providerConfig: savedWebhook.providerConfig,
},
requestId
)
if (grainHookId) {
// Update the webhook record with the external Grain hook ID
const updatedConfig = {
...(savedWebhook.providerConfig as Record<string, any>),
externalId: grainHookId,
}
await db
.update(webhook)
.set({
providerConfig: updatedConfig,
updatedAt: new Date(),
})
.where(eq(webhook.id, savedWebhook.id))
savedWebhook.providerConfig = updatedConfig
logger.info(`[${requestId}] Successfully created Grain webhook`, {
grainHookId,
webhookId: savedWebhook.id,
})
}
} catch (err) {
logger.error(
`[${requestId}] Error creating Grain webhook subscription, rolling back webhook`,
err
)
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
return NextResponse.json(
{
error: 'Failed to create webhook in Grain',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End Grain specific logic ---
const status = targetWebhookId ? 200 : 201
return NextResponse.json({ webhook: savedWebhook }, { status })
} catch (error: any) {
@@ -947,3 +997,103 @@ async function createWebflowWebhookSubscription(
throw error
}
}
// Helper function to create the webhook subscription in Grain
async function createGrainWebhookSubscription(
request: NextRequest,
webhookData: any,
requestId: string
): Promise<string | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, includeHighlights, includeParticipants, includeAiSummary } =
providerConfig || {}
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error(
'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.'
)
}
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const grainApiUrl = 'https://api.grain.com/_/public-api/v2/hooks/create'
const requestBody: Record<string, any> = {
hook_url: notificationUrl,
}
// Build include object based on configuration
const include: Record<string, boolean> = {}
if (includeHighlights) {
include.highlights = true
}
if (includeParticipants) {
include.participants = true
}
if (includeAiSummary) {
include.ai_summary = true
}
if (Object.keys(include).length > 0) {
requestBody.include = include
}
const grainResponse = await fetch(grainApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Public-Api-Version': '2025-10-31',
},
body: JSON.stringify(requestBody),
})
const responseBody = await grainResponse.json()
if (!grainResponse.ok || responseBody.error) {
const errorMessage =
responseBody.error?.message ||
responseBody.error ||
responseBody.message ||
'Unknown Grain API error'
logger.error(
`[${requestId}] Failed to create webhook in Grain for webhook ${webhookData.id}. Status: ${grainResponse.status}`,
{ message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Grain'
if (grainResponse.status === 401) {
userFriendlyMessage =
'Invalid Grain API Key. Please verify your Personal Access Token is correct.'
} else if (grainResponse.status === 403) {
userFriendlyMessage =
'Access denied. Please ensure your Grain API Key has appropriate permissions.'
} else if (errorMessage && errorMessage !== 'Unknown Grain API error') {
userFriendlyMessage = `Grain error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
logger.info(
`[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`,
{
grainWebhookId: responseBody.id,
}
)
return responseBody.id
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
throw error
}
}

View File

@@ -4,12 +4,12 @@ import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { deployWorkflow, loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import {
createSchedulesForDeploy,
deleteSchedulesForWorkflow,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -207,21 +207,11 @@ export async function DELETE(
return createErrorResponse(error.message, error.status)
}
await db.transaction(async (tx) => {
await deleteSchedulesForWorkflow(id, tx)
const result = await undeployWorkflow({ workflowId: id })
if (!result.success) {
return createErrorResponse(result.error || 'Failed to undeploy workflow', 500)
}
await tx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(eq(workflowDeploymentVersion.workflowId, id))
await tx
.update(workflow)
.set({ isDeployed: false, deployedAt: null })
.where(eq(workflow.id, id))
})
// Remove all MCP tools that reference this workflow
await removeMcpToolsForWorkflow(id, requestId)
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)

View File

@@ -1,9 +1,8 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -27,68 +26,24 @@ export async function POST(
const versionNum = Number(version)
if (!Number.isFinite(versionNum)) {
return createErrorResponse('Invalid version', 400)
return createErrorResponse('Invalid version number', 400)
}
const now = new Date()
const result = await activateWorkflowVersion({ workflowId: id, version: versionNum })
if (!result.success) {
return createErrorResponse(result.error || 'Failed to activate deployment', 400)
}
// Get the state of the version being activated for MCP tool sync
const [versionData] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
await db.transaction(async (tx) => {
await tx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
const updated = await tx
.update(workflowDeploymentVersion)
.set({ isActive: true })
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.returning({ id: workflowDeploymentVersion.id })
if (updated.length === 0) {
throw new Error('Deployment version not found')
}
const updateData: Record<string, unknown> = {
isDeployed: true,
deployedAt: now,
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
})
// Sync MCP tools with the activated version's parameter schema
if (versionData?.state) {
if (result.state) {
await syncMcpToolsForWorkflow({
workflowId: id,
requestId,
state: versionData.state,
state: result.state,
context: 'activate',
})
}
return createSuccessResponse({ success: true, deployedAt: now })
return createSuccessResponse({ success: true, deployedAt: result.deployedAt })
} catch (error: any) {
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)
return createErrorResponse(error.message || 'Failed to activate deployment', 500)

View File

@@ -21,7 +21,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(error.message, error.status)
}
const versions = await db
const rawVersions = await db
.select({
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
@@ -36,6 +36,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
.where(eq(workflowDeploymentVersion.workflowId, id))
.orderBy(desc(workflowDeploymentVersion.version))
const versions = rawVersions.map((v) => ({
...v,
deployedBy: v.deployedBy ?? (v.createdBy === 'admin-api' ? 'Admin' : null),
}))
return createSuccessResponse({ versions })
} catch (error: any) {
logger.error(`[${requestId}] Error listing deployments for workflow: ${id}`, error)

View File

@@ -12,7 +12,6 @@ import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { processInputFileFields } from '@/lib/execution/files'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
@@ -24,16 +23,17 @@ import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
import type { WorkflowExecutionPayload } from '@/background/workflow-execution'
import { normalizeName } from '@/executor/constants'
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata, IterationContext } from '@/executor/execution/types'
import type { StreamingExecution } from '@/executor/types'
import { Serializer } from '@/serializer'
import type { SubflowType } from '@/stores/workflows/workflow/types'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
const logger = createLogger('WorkflowExecuteAPI')
const ExecuteWorkflowSchema = z.object({
selectedOutputs: z.array(z.string()).optional().default([]),
triggerType: z.enum(ALL_TRIGGER_TYPES).optional(),
triggerType: z.enum(CORE_TRIGGER_TYPES).optional(),
stream: z.boolean().optional(),
useDraftState: z.boolean().optional(),
input: z.any().optional(),
@@ -541,11 +541,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
blockId: string,
blockName: string,
blockType: string,
iterationContext?: {
iterationCurrent: number
iterationTotal: number
iterationType: SubflowType
}
iterationContext?: IterationContext
) => {
logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType })
sendEvent({
@@ -571,11 +567,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
blockName: string,
blockType: string,
callbackData: any,
iterationContext?: {
iterationCurrent: number
iterationTotal: number
iterationType: SubflowType
}
iterationContext?: IterationContext
) => {
const hasError = callbackData.output?.error
@@ -713,14 +705,25 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
executionId,
})
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
} else {
await PauseResumeManager.persistPauseResult({
workflowId,
executionId,
pausePoints: result.pausePoints || [],
snapshotSeed: result.snapshotSeed,
executorUserId: result.metadata?.userId,
})
try {
await PauseResumeManager.persistPauseResult({
workflowId,
executionId,
pausePoints: result.pausePoints || [],
snapshotSeed: result.snapshotSeed,
executorUserId: result.metadata?.userId,
})
} catch (pauseError) {
logger.error(`[${requestId}] Failed to persist pause result`, {
executionId,
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
})
await loggingSession.markAsFailed(
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
)
}
}
} else {
await PauseResumeManager.processQueuedResumes(executionId)

View File

@@ -6,14 +6,14 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { MAX_EMAIL_RECIPIENTS, MAX_WORKFLOW_IDS } from '../constants'
const logger = createLogger('WorkspaceNotificationAPI')
const levelFilterSchema = z.array(z.enum(['info', 'error']))
const triggerFilterSchema = z.array(z.enum(ALL_TRIGGER_TYPES))
const triggerFilterSchema = z.array(z.enum(CORE_TRIGGER_TYPES))
const alertRuleSchema = z.enum([
'consecutive_failures',

View File

@@ -7,15 +7,15 @@ import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants'
const logger = createLogger('WorkspaceNotificationsAPI')
const notificationTypeSchema = z.enum(['webhook', 'email', 'slack'])
const levelFilterSchema = z.array(z.enum(['info', 'error']))
const triggerFilterSchema = z.array(z.enum(ALL_TRIGGER_TYPES))
const triggerFilterSchema = z.array(z.enum(CORE_TRIGGER_TYPES))
const alertRuleSchema = z.enum([
'consecutive_failures',
@@ -81,7 +81,7 @@ const createNotificationSchema = z
workflowIds: z.array(z.string()).max(MAX_WORKFLOW_IDS).default([]),
allWorkflows: z.boolean().default(false),
levelFilter: levelFilterSchema.default(['info', 'error']),
triggerFilter: triggerFilterSchema.default([...ALL_TRIGGER_TYPES]),
triggerFilter: triggerFilterSchema.default([...CORE_TRIGGER_TYPES]),
includeFinalOutput: z.boolean().default(false),
includeTraceSpans: z.boolean().default(false),
includeRateLimits: z.boolean().default(false),

View File

@@ -11,7 +11,7 @@ import {
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
import { WorkspaceInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'

View File

@@ -87,14 +87,10 @@ describe('Workspace Invitations API Route', () => {
WorkspaceInvitationEmail: vi.fn(),
}))
vi.doMock('@/lib/core/config/env', () => ({
env: {
RESEND_API_KEY: 'test-resend-key',
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
FROM_EMAIL_ADDRESS: 'Sim <noreply@test.sim.ai>',
EMAIL_DOMAIN: 'test.sim.ai',
},
}))
vi.doMock('@/lib/core/config/env', async () => {
const { createEnvMock } = await import('@sim/testing')
return createEnvMock()
})
vi.doMock('@/lib/core/utils/urls', () => ({
getEmailDomain: vi.fn().mockReturnValue('sim.ai'),

View File

@@ -12,7 +12,7 @@ import {
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
import { WorkspaceInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'

View File

@@ -8,35 +8,156 @@ import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
const logger = createLogger('InviteById')
function getErrorMessage(reason: string): string {
switch (reason) {
case 'missing-token':
return 'The invitation link is invalid or missing a required parameter.'
case 'invalid-token':
return 'The invitation link is invalid or has already been used.'
case 'expired':
return 'This invitation has expired. Please ask for a new invitation.'
case 'already-processed':
return 'This invitation has already been accepted or declined.'
case 'email-mismatch':
return 'This invitation was sent to a different email address. Please log in with the correct account.'
case 'workspace-not-found':
return 'The workspace associated with this invitation could not be found.'
case 'user-not-found':
return 'Your user account could not be found. Please try logging out and logging back in.'
case 'already-member':
return 'You are already a member of this organization or workspace.'
case 'already-in-organization':
return 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
case 'invalid-invitation':
return 'This invitation is invalid or no longer exists.'
case 'missing-invitation-id':
return 'The invitation link is missing required information. Please use the original invitation link.'
case 'server-error':
return 'An unexpected error occurred while processing your invitation. Please try again later.'
default:
return 'An unknown error occurred while processing your invitation.'
/** Error codes that can occur during invitation processing */
type InviteErrorCode =
| 'missing-token'
| 'invalid-token'
| 'expired'
| 'already-processed'
| 'email-mismatch'
| 'workspace-not-found'
| 'user-not-found'
| 'already-member'
| 'already-in-organization'
| 'invalid-invitation'
| 'missing-invitation-id'
| 'server-error'
| 'unauthorized'
| 'forbidden'
| 'network-error'
| 'unknown'
interface InviteError {
code: InviteErrorCode
message: string
requiresAuth?: boolean
canRetry?: boolean
}
/**
* Maps error codes to user-friendly error objects with contextual information
*/
function getInviteError(reason: string): InviteError {
const errorMap: Record<string, InviteError> = {
'missing-token': {
code: 'missing-token',
message: 'The invitation link is invalid or missing a required parameter.',
},
'invalid-token': {
code: 'invalid-token',
message: 'The invitation link is invalid or has already been used.',
},
expired: {
code: 'expired',
message: 'This invitation has expired. Please ask for a new invitation.',
},
'already-processed': {
code: 'already-processed',
message: 'This invitation has already been accepted or declined.',
},
'email-mismatch': {
code: 'email-mismatch',
message:
'This invitation was sent to a different email address. Please sign in with the correct account.',
requiresAuth: true,
},
'workspace-not-found': {
code: 'workspace-not-found',
message: 'The workspace associated with this invitation could not be found.',
},
'user-not-found': {
code: 'user-not-found',
message: 'Your user account could not be found. Please try signing out and signing back in.',
requiresAuth: true,
},
'already-member': {
code: 'already-member',
message: 'You are already a member of this organization or workspace.',
},
'already-in-organization': {
code: 'already-in-organization',
message:
'You are already a member of an organization. Leave your current organization before accepting a new invitation.',
},
'invalid-invitation': {
code: 'invalid-invitation',
message: 'This invitation is invalid or no longer exists.',
},
'missing-invitation-id': {
code: 'missing-invitation-id',
message:
'The invitation link is missing required information. Please use the original invitation link.',
},
'server-error': {
code: 'server-error',
message:
'An unexpected error occurred while processing your invitation. Please try again later.',
canRetry: true,
},
unauthorized: {
code: 'unauthorized',
message: 'You need to sign in to accept this invitation.',
requiresAuth: true,
},
forbidden: {
code: 'forbidden',
message:
'You do not have permission to accept this invitation. Please check you are signed in with the correct account.',
requiresAuth: true,
},
'network-error': {
code: 'network-error',
message:
'Unable to connect to the server. Please check your internet connection and try again.',
canRetry: true,
},
}
return (
errorMap[reason] || {
code: 'unknown',
message:
'An unexpected error occurred while processing your invitation. Please try again or contact support.',
canRetry: true,
}
)
}
/**
* Parses API error responses and extracts a standardized error code
*/
function parseApiError(error: unknown, statusCode?: number): InviteErrorCode {
// Handle network/fetch errors
if (error instanceof TypeError && error.message.includes('fetch')) {
return 'network-error'
}
// Handle error message patterns first (more specific matching)
const errorMessage =
typeof error === 'string' ? error.toLowerCase() : (error as Error)?.message?.toLowerCase() || ''
// Check specific patterns before falling back to status codes
// Order matters: more specific patterns must come first
if (errorMessage.includes('already a member of an organization')) return 'already-in-organization'
if (errorMessage.includes('already a member')) return 'already-member'
if (errorMessage.includes('email mismatch') || errorMessage.includes('different email'))
return 'email-mismatch'
if (errorMessage.includes('already processed')) return 'already-processed'
if (errorMessage.includes('unauthorized')) return 'unauthorized'
if (errorMessage.includes('forbidden') || errorMessage.includes('permission')) return 'forbidden'
if (errorMessage.includes('not found') || errorMessage.includes('expired'))
return 'invalid-invitation'
// Handle HTTP status codes as fallback
if (statusCode) {
if (statusCode === 401) return 'unauthorized'
if (statusCode === 403) return 'forbidden'
if (statusCode === 404) return 'invalid-invitation'
if (statusCode === 409) return 'already-in-organization'
if (statusCode >= 500) return 'server-error'
}
return 'unknown'
}
export default function Invite() {
@@ -47,7 +168,7 @@ export default function Invite() {
const { data: session, isPending } = useSession()
const [invitationDetails, setInvitationDetails] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [error, setError] = useState<InviteError | null>(null)
const [isAccepting, setIsAccepting] = useState(false)
const [accepted, setAccepted] = useState(false)
const [isNewUser, setIsNewUser] = useState(false)
@@ -59,7 +180,7 @@ export default function Invite() {
const errorReason = searchParams.get('error')
if (errorReason) {
setError(getErrorMessage(errorReason))
setError(getInviteError(errorReason))
setIsLoading(false)
return
}
@@ -99,11 +220,37 @@ export default function Invite() {
return
}
// Handle workspace invitation errors with specific status codes
if (!workspaceInviteResponse.ok && workspaceInviteResponse.status !== 404) {
const errorCode = parseApiError(null, workspaceInviteResponse.status)
const errorData = await workspaceInviteResponse.json().catch(() => ({}))
logger.error('Workspace invitation fetch failed:', {
status: workspaceInviteResponse.status,
error: errorData,
})
// Refine error code based on response body if available
if (errorData.error) {
const refinedCode = parseApiError(errorData.error, workspaceInviteResponse.status)
setError(getInviteError(refinedCode))
} else {
setError(getInviteError(errorCode))
}
setIsLoading(false)
return
}
try {
const { data } = await client.organization.getInvitation({
const { data, error: orgError } = await client.organization.getInvitation({
query: { id: inviteId },
})
if (orgError) {
logger.error('Organization invitation fetch error:', orgError)
const errorCode = parseApiError(orgError.message || orgError)
throw { code: errorCode, original: orgError }
}
if (data) {
setInvitationType('organization')
@@ -115,7 +262,7 @@ export default function Invite() {
if (activeOrgResponse?.data) {
// User is already in an organization
setCurrentOrgName(activeOrgResponse.data.name)
setError('already-in-organization')
setError(getInviteError('already-in-organization'))
setIsLoading(false)
return
}
@@ -139,14 +286,19 @@ export default function Invite() {
}
}
} else {
throw new Error('Invitation not found or has expired')
throw { code: 'invalid-invitation' }
}
} catch (_err) {
throw new Error('Invitation not found or has expired')
} catch (orgErr: any) {
// If this is our structured error, use it directly
if (orgErr.code) {
throw orgErr
}
throw { code: parseApiError(orgErr) }
}
} catch (err: any) {
logger.error('Error fetching invitation:', err)
setError(err.message || 'Failed to load invitation details')
const errorCode = err.code || parseApiError(err)
setError(getInviteError(errorCode))
} finally {
setIsLoading(false)
}
@@ -168,7 +320,9 @@ export default function Invite() {
const orgId = invitationDetails?.data?.organizationId
if (!orgId) {
throw new Error('Organization ID not found')
setError(getInviteError('invalid-invitation'))
setIsAccepting(false)
return
}
// Use our custom API endpoint that handles Pro usage snapshot
@@ -182,8 +336,15 @@ export default function Invite() {
})
if (!response.ok) {
const data = await response.json().catch(() => ({ error: 'Failed to accept invitation' }))
throw new Error(data.error || 'Failed to accept invitation')
const data = await response.json().catch(() => ({}))
const errorCode = parseApiError(data.error || '', response.status)
logger.error('Failed to accept organization invitation:', {
status: response.status,
error: data,
})
setError(getInviteError(errorCode))
setIsAccepting(false)
return
}
// Set the organization as active
@@ -202,13 +363,8 @@ export default function Invite() {
// Reset accepted state on error
setAccepted(false)
// Check if it's a 409 conflict (already in an organization)
if (err.status === 409 || err.message?.includes('already a member of an organization')) {
setError('already-in-organization')
} else {
setError(err.message || 'Failed to accept invitation')
}
const errorCode = parseApiError(err)
setError(getInviteError(errorCode))
setIsAccepting(false)
}
}
@@ -279,12 +435,10 @@ export default function Invite() {
}
if (error) {
const errorReason = searchParams.get('error')
const isExpiredError = errorReason === 'expired'
const isAlreadyInOrg = error === 'already-in-organization'
const callbackUrl = encodeURIComponent(getCallbackUrl())
// Special handling for already in organization
if (isAlreadyInOrg) {
if (error.code === 'already-in-organization') {
return (
<InviteLayout>
<InviteStatusCard
@@ -293,7 +447,7 @@ export default function Invite() {
description={
currentOrgName
? `You are currently a member of "${currentOrgName}". You must leave your current organization before accepting a new invitation.`
: 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
: error.message
}
icon='users'
actions={[
@@ -313,24 +467,96 @@ export default function Invite() {
)
}
// Use getErrorMessage for consistent error messages
const errorMessage = error.startsWith('You are already') ? error : getErrorMessage(error)
// Handle email mismatch - user needs to sign in with a different account
if (error.code === 'email-mismatch') {
return (
<InviteLayout>
<InviteStatusCard
type='warning'
title='Wrong Account'
description={error.message}
icon='userPlus'
actions={[
{
label: 'Sign in with a different account',
onClick: async () => {
await client.signOut()
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)
},
variant: 'default' as const,
},
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'ghost' as const,
},
]}
/>
</InviteLayout>
)
}
// Handle auth-related errors - prompt user to sign in
if (error.requiresAuth) {
return (
<InviteLayout>
<InviteStatusCard
type='warning'
title='Authentication Required'
description={error.message}
icon='userPlus'
actions={[
{
label: 'Sign in to continue',
onClick: () => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
variant: 'default' as const,
},
{
label: 'Create an account',
onClick: () => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`),
variant: 'outline' as const,
},
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'ghost' as const,
},
]}
/>
</InviteLayout>
)
}
// Handle retryable errors
const actions: Array<{
label: string
onClick: () => void
variant?: 'default' | 'outline' | 'ghost'
}> = []
if (error.canRetry) {
actions.push({
label: 'Try Again',
onClick: () => window.location.reload(),
variant: 'default' as const,
})
}
actions.push({
label: 'Return to Home',
onClick: () => router.push('/'),
variant: error.canRetry ? ('ghost' as const) : ('default' as const),
})
return (
<InviteLayout>
<InviteStatusCard
type='error'
title='Invitation Error'
description={errorMessage}
description={error.message}
icon='error'
isExpiredError={isExpiredError}
actions={[
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'default' as const,
},
]}
isExpiredError={error.code === 'expired'}
actions={actions}
/>
</InviteLayout>
)

View File

@@ -1,5 +1,6 @@
'use client'
import AuthBackground from '@/app/(auth)/components/auth-background'
import Nav from '@/app/(landing)/components/nav/nav'
interface InviteLayoutProps {
@@ -8,12 +9,13 @@ interface InviteLayoutProps {
export default function InviteLayout({ children }: InviteLayoutProps) {
return (
<div className='relative min-h-screen'>
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
<Nav variant='auth' />
<div className='flex min-h-[calc(100vh-120px)] items-center justify-center px-4'>
<div className='w-full max-w-[410px]'>{children}</div>
</div>
</div>
<AuthBackground>
<main className='relative flex min-h-screen flex-col text-foreground'>
<Nav hideAuthButtons={true} variant='auth' />
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
<div className='w-full max-w-lg px-4'>{children}</div>
</div>
</main>
</AuthBackground>
)
}

View File

@@ -1,16 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import {
AlertCircle,
CheckCircle2,
Loader2,
Mail,
RotateCcw,
ShieldX,
UserPlus,
Users2,
} from 'lucide-react'
import { useState } from 'react'
import { ArrowRight, ChevronRight, Loader2, RotateCcw } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { useBrandConfig } from '@/lib/branding/branding'
@@ -32,104 +23,53 @@ interface InviteStatusCardProps {
isExpiredError?: boolean
}
const iconMap = {
userPlus: UserPlus,
mail: Mail,
users: Users2,
error: ShieldX,
success: CheckCircle2,
warning: AlertCircle,
}
const iconColorMap = {
userPlus: 'text-[var(--brand-primary-hex)]',
mail: 'text-[var(--brand-primary-hex)]',
users: 'text-[var(--brand-primary-hex)]',
error: 'text-red-500 dark:text-red-400',
success: 'text-green-500 dark:text-green-400',
warning: 'text-yellow-600 dark:text-yellow-500',
}
const iconBgMap = {
userPlus: 'bg-[var(--brand-primary-hex)]/10',
mail: 'bg-[var(--brand-primary-hex)]/10',
users: 'bg-[var(--brand-primary-hex)]/10',
error: 'bg-red-50 dark:bg-red-950/20',
success: 'bg-green-50 dark:bg-green-950/20',
warning: 'bg-yellow-50 dark:bg-yellow-950/20',
}
export function InviteStatusCard({
type,
title,
description,
icon,
icon: _icon,
actions = [],
isExpiredError = false,
}: InviteStatusCardProps) {
const router = useRouter()
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
const [hoveredButtonIndex, setHoveredButtonIndex] = useState<number | null>(null)
const brandConfig = useBrandConfig()
useEffect(() => {
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('auth-button-custom')
} else {
setButtonClass('auth-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [])
if (type === 'loading') {
return (
<div className={`${soehne.className} space-y-6`}>
<>
<div className='space-y-1 text-center'>
<h1 className='font-medium text-[32px] text-black tracking-tight'>Loading</h1>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Loading
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
{description}
</p>
</div>
<div className='flex w-full items-center justify-center py-8'>
<Loader2 className='h-8 w-8 animate-spin text-[var(--brand-primary-hex)]' />
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</div>
<div
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
>
Need help?{' '}
<a
href='mailto:help@sim.ai'
href={`mailto:${brandConfig.supportEmail}`}
className='auth-link underline-offset-4 transition hover:underline'
>
Contact support
</a>
</div>
</div>
</>
)
}
const IconComponent = icon ? iconMap[icon] : null
const iconColor = icon ? iconColorMap[icon] : ''
const iconBg = icon ? iconBgMap[icon] : ''
return (
<div className={`${soehne.className} space-y-6`}>
<>
<div className='space-y-1 text-center'>
<h1 className='font-medium text-[32px] text-black tracking-tight'>{title}</h1>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
{title}
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
{description}
</p>
@@ -148,28 +88,55 @@ export function InviteStatusCard({
</Button>
)}
{actions.map((action, index) => (
<Button
key={index}
variant={action.variant || 'default'}
className={
(action.variant || 'default') === 'default'
? `${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`
: action.variant === 'outline'
{actions.map((action, index) => {
const isPrimary = (action.variant || 'default') === 'default'
const isHovered = hoveredButtonIndex === index
if (isPrimary) {
return (
<Button
key={index}
onMouseEnter={() => setHoveredButtonIndex(index)}
onMouseLeave={() => setHoveredButtonIndex(null)}
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
onClick={action.onClick}
disabled={action.disabled || action.loading}
>
<span className='flex items-center gap-1'>
{action.loading ? `${action.label}...` : action.label}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
)
}
return (
<Button
key={index}
variant={action.variant}
className={
action.variant === 'outline'
? 'w-full rounded-[10px] border-[var(--brand-primary-hex)] font-medium text-[15px] text-[var(--brand-primary-hex)] transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
: 'w-full rounded-[10px] text-muted-foreground hover:bg-secondary hover:text-foreground'
}
onClick={action.onClick}
disabled={action.disabled || action.loading}
>
{action.loading ? `${action.label}...` : action.label}
</Button>
))}
}
onClick={action.onClick}
disabled={action.disabled || action.loading}
>
{action.loading ? `${action.label}...` : action.label}
</Button>
)
})}
</div>
</div>
<div
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
>
Need help?{' '}
<a
@@ -179,6 +146,6 @@ export function InviteStatusCard({
Contact support
</a>
</div>
</div>
</>
)
}

View File

@@ -74,6 +74,7 @@ import {
TableHeader,
TableRow,
Textarea,
TimePicker,
Tooltip,
Trash,
Trash2,
@@ -125,6 +126,7 @@ export default function PlaygroundPage() {
const [switchValue, setSwitchValue] = useState(false)
const [checkboxValue, setCheckboxValue] = useState(false)
const [sliderValue, setSliderValue] = useState([50])
const [timeValue, setTimeValue] = useState('09:30')
const [activeTab, setActiveTab] = useState('profile')
const [isDarkMode, setIsDarkMode] = useState(false)
@@ -491,6 +493,31 @@ export default function PlaygroundPage() {
</VariantRow>
</Section>
{/* TimePicker */}
<Section title='TimePicker'>
<VariantRow label='default'>
<div className='w-48'>
<TimePicker value={timeValue} onChange={setTimeValue} placeholder='Select time' />
</div>
<span className='text-[var(--text-secondary)] text-sm'>{timeValue}</span>
</VariantRow>
<VariantRow label='size sm'>
<div className='w-48'>
<TimePicker value='14:00' onChange={() => {}} placeholder='Small size' size='sm' />
</div>
</VariantRow>
<VariantRow label='no value'>
<div className='w-48'>
<TimePicker placeholder='Select time...' onChange={() => {}} />
</div>
</VariantRow>
<VariantRow label='disabled'>
<div className='w-48'>
<TimePicker value='09:00' disabled />
</div>
</VariantRow>
</Section>
{/* Breadcrumb */}
<Section title='Breadcrumb'>
<Breadcrumb

View File

@@ -3,5 +3,6 @@ export { LogDetails } from './log-details'
export { FileCards } from './log-details/components/file-download'
export { FrozenCanvas } from './log-details/components/frozen-canvas'
export { TraceSpans } from './log-details/components/trace-spans'
export { LogRowContextMenu } from './log-row-context-menu'
export { LogsList } from './logs-list'
export { AutocompleteSearch, LogsToolbar, NotificationSettings } from './logs-toolbar'

View File

@@ -0,0 +1 @@
export { LogRowContextMenu } from './log-row-context-menu'

View File

@@ -0,0 +1,102 @@
'use client'
import type { RefObject } from 'react'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import type { WorkflowLog } from '@/stores/logs/filters/types'
interface LogRowContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
log: WorkflowLog | null
onCopyExecutionId: () => void
onOpenWorkflow: () => void
onToggleWorkflowFilter: () => void
onClearAllFilters: () => void
isFilteredByThisWorkflow: boolean
hasActiveFilters: boolean
}
/**
* Context menu for log rows.
* Provides quick actions for copying data, navigation, and filtering.
*/
export function LogRowContextMenu({
isOpen,
position,
menuRef,
onClose,
log,
onCopyExecutionId,
onOpenWorkflow,
onToggleWorkflowFilter,
onClearAllFilters,
isFilteredByThisWorkflow,
hasActiveFilters,
}: LogRowContextMenuProps) {
const hasExecutionId = Boolean(log?.executionId)
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Copy Execution ID */}
<PopoverItem
disabled={!hasExecutionId}
onClick={() => {
onCopyExecutionId()
onClose()
}}
>
Copy Execution ID
</PopoverItem>
{/* Open Workflow */}
<PopoverItem
disabled={!hasWorkflow}
onClick={() => {
onOpenWorkflow()
onClose()
}}
>
Open Workflow
</PopoverItem>
{/* Filter by Workflow - only show when not already filtered by this workflow */}
{!isFilteredByThisWorkflow && (
<PopoverItem
disabled={!hasWorkflow}
onClick={() => {
onToggleWorkflowFilter()
onClose()
}}
>
Filter by Workflow
</PopoverItem>
)}
{/* Clear All Filters - show when any filters are active */}
{hasActiveFilters && (
<PopoverItem
onClick={() => {
onClearAllFilters()
onClose()
}}
>
Clear Filters
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -10,6 +10,7 @@ import {
formatDate,
formatDuration,
getDisplayStatus,
LOG_COLUMNS,
StatusBadge,
TriggerBadge,
} from '@/app/workspace/[workspaceId]/logs/utils'
@@ -21,6 +22,7 @@ interface LogRowProps {
log: WorkflowLog
isSelected: boolean
onClick: (log: WorkflowLog) => void
onContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null> | null
}
@@ -29,11 +31,21 @@ interface LogRowProps {
* Uses shallow comparison for the log object.
*/
const LogRow = memo(
function LogRow({ log, isSelected, onClick, selectedRowRef }: LogRowProps) {
function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) {
const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt])
const handleClick = useCallback(() => onClick(log), [onClick, log])
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (onContextMenu) {
e.preventDefault()
onContextMenu(e, log)
}
},
[onContextMenu, log]
)
return (
<div
ref={isSelected ? selectedRowRef : null}
@@ -42,25 +54,28 @@ const LogRow = memo(
isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
)}
onClick={handleClick}
onContextMenu={handleContextMenu}
>
<div className='flex flex-1 items-center'>
{/* Date */}
<span className='w-[8%] min-w-[70px] font-medium text-[12px] text-[var(--text-primary)]'>
<span
className={`${LOG_COLUMNS.date.width} ${LOG_COLUMNS.date.minWidth} font-medium text-[12px] text-[var(--text-primary)]`}
>
{formattedDate.compactDate}
</span>
{/* Time */}
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-primary)]'>
<span
className={`${LOG_COLUMNS.time.width} ${LOG_COLUMNS.time.minWidth} font-medium text-[12px] text-[var(--text-primary)]`}
>
{formattedDate.compactTime}
</span>
{/* Status */}
<div className='w-[12%] min-w-[100px]'>
<div className={`${LOG_COLUMNS.status.width} ${LOG_COLUMNS.status.minWidth}`}>
<StatusBadge status={getDisplayStatus(log.status)} />
</div>
{/* Workflow */}
<div className='flex w-[22%] min-w-[140px] items-center gap-[8px] pr-[8px]'>
<div
className={`flex ${LOG_COLUMNS.workflow.width} ${LOG_COLUMNS.workflow.minWidth} items-center gap-[8px] pr-[8px]`}
>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: log.workflow?.color }}
@@ -70,13 +85,13 @@ const LogRow = memo(
</span>
</div>
{/* Cost */}
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-primary)]'>
<span
className={`${LOG_COLUMNS.cost.width} ${LOG_COLUMNS.cost.minWidth} font-medium text-[12px] text-[var(--text-primary)]`}
>
{typeof log.cost?.total === 'number' ? `$${log.cost.total.toFixed(4)}` : '—'}
</span>
{/* Trigger */}
<div className='w-[14%] min-w-[110px]'>
<div className={`${LOG_COLUMNS.trigger.width} ${LOG_COLUMNS.trigger.minWidth}`}>
{log.trigger ? (
<TriggerBadge trigger={log.trigger} />
) : (
@@ -84,8 +99,7 @@ const LogRow = memo(
)}
</div>
{/* Duration */}
<div className='w-[20%] min-w-[100px]'>
<div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}>
<Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'>
{formatDuration(log.duration) || '—'}
</Badge>
@@ -125,6 +139,7 @@ interface RowProps {
logs: WorkflowLog[]
selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
isFetchingNextPage: boolean
loaderRef: React.RefObject<HTMLDivElement | null>
@@ -140,11 +155,11 @@ function Row({
logs,
selectedLogId,
onLogClick,
onLogContextMenu,
selectedRowRef,
isFetchingNextPage,
loaderRef,
}: RowComponentProps<RowProps>) {
// Show loader for the last item if loading more
if (index >= logs.length) {
return (
<div style={style} className='flex items-center justify-center'>
@@ -171,6 +186,7 @@ function Row({
log={log}
isSelected={isSelected}
onClick={onLogClick}
onContextMenu={onLogContextMenu}
selectedRowRef={isSelected ? selectedRowRef : null}
/>
</div>
@@ -181,6 +197,7 @@ export interface LogsListProps {
logs: WorkflowLog[]
selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
hasNextPage: boolean
isFetchingNextPage: boolean
@@ -198,6 +215,7 @@ export function LogsList({
logs,
selectedLogId,
onLogClick,
onLogContextMenu,
selectedRowRef,
hasNextPage,
isFetchingNextPage,
@@ -208,7 +226,6 @@ export function LogsList({
const containerRef = useRef<HTMLDivElement>(null)
const [listHeight, setListHeight] = useState(400)
// Measure container height for virtualization
useEffect(() => {
const container = containerRef.current
if (!container) return
@@ -226,7 +243,6 @@ export function LogsList({
return () => ro.disconnect()
}, [])
// Handle infinite scroll when nearing the end of the list
const handleRowsRendered = useCallback(
({ stopIndex }: { startIndex: number; stopIndex: number }) => {
const threshold = logs.length - 10
@@ -237,20 +253,27 @@ export function LogsList({
[logs.length, hasNextPage, isFetchingNextPage, onLoadMore]
)
// Calculate total item count including loader row
const itemCount = hasNextPage ? logs.length + 1 : logs.length
// Row props passed to each row component
const rowProps = useMemo<RowProps>(
() => ({
logs,
selectedLogId,
onLogClick,
onLogContextMenu,
selectedRowRef,
isFetchingNextPage,
loaderRef,
}),
[logs, selectedLogId, onLogClick, selectedRowRef, isFetchingNextPage, loaderRef]
[
logs,
selectedLogId,
onLogClick,
onLogContextMenu,
selectedRowRef,
isFetchingNextPage,
loaderRef,
]
)
return (

View File

@@ -22,7 +22,6 @@ import {
import { SlackIcon } from '@/components/icons'
import { Skeleton } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import { ALL_TRIGGER_TYPES, type TriggerType } from '@/lib/logs/types'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import {
type NotificationSubscription,
@@ -34,6 +33,7 @@ import {
} from '@/hooks/queries/notifications'
import { useConnectOAuthService } from '@/hooks/queries/oauth-connections'
import { useSlackAccounts } from '@/hooks/use-slack-accounts'
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
import { SlackChannelSelector } from './components/slack-channel-selector'
import { WorkflowSelector } from './components/workflow-selector'
@@ -133,7 +133,7 @@ export function NotificationSettings({
workflowIds: [] as string[],
allWorkflows: true,
levelFilter: ['info', 'error'] as LogLevel[],
triggerFilter: [...ALL_TRIGGER_TYPES] as TriggerType[],
triggerFilter: [...CORE_TRIGGER_TYPES] as CoreTriggerType[],
includeFinalOutput: false,
includeTraceSpans: false,
includeRateLimits: false,
@@ -203,7 +203,7 @@ export function NotificationSettings({
workflowIds: [],
allWorkflows: true,
levelFilter: ['info', 'error'],
triggerFilter: [...ALL_TRIGGER_TYPES],
triggerFilter: [...CORE_TRIGGER_TYPES],
includeFinalOutput: false,
includeTraceSpans: false,
includeRateLimits: false,
@@ -516,7 +516,7 @@ export function NotificationSettings({
workflowIds: subscription.workflowIds || [],
allWorkflows: subscription.allWorkflows,
levelFilter: subscription.levelFilter as LogLevel[],
triggerFilter: subscription.triggerFilter as TriggerType[],
triggerFilter: subscription.triggerFilter as CoreTriggerType[],
includeFinalOutput: subscription.includeFinalOutput,
includeTraceSpans: subscription.includeTraceSpans,
includeRateLimits: subscription.includeRateLimits,
@@ -849,14 +849,14 @@ export function NotificationSettings({
<div className='flex flex-col gap-[8px]'>
<Label className='text-[var(--text-secondary)]'>Trigger Type Filters</Label>
<Combobox
options={ALL_TRIGGER_TYPES.map((trigger) => ({
options={CORE_TRIGGER_TYPES.map((trigger) => ({
label: trigger.charAt(0).toUpperCase() + trigger.slice(1),
value: trigger,
}))}
multiSelect
multiSelectValues={formData.triggerFilter}
onMultiSelectChange={(values) => {
setFormData({ ...formData, triggerFilter: values as TriggerType[] })
setFormData({ ...formData, triggerFilter: values as CoreTriggerType[] })
setFormErrors({ ...formErrors, triggerFilter: '' })
}}
placeholder='Select trigger types...'

View File

@@ -17,15 +17,15 @@ import {
} from '@/components/emcn'
import { DatePicker } from '@/components/emcn/components/date-picker/date-picker'
import { cn } from '@/lib/core/utils/cn'
import { hasActiveFilters } from '@/lib/logs/filters'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { AutocompleteSearch } from './components/search'
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
const TIME_RANGE_OPTIONS: ComboboxOption[] = [
{ value: 'All time', label: 'All time' },
{ value: 'Past 30 minutes', label: 'Past 30 minutes' },
@@ -182,6 +182,7 @@ export function LogsToolbar({
endDate,
setDateRange,
clearDateRange,
resetFilters,
} = useFilterStore()
const [datePickerOpen, setDatePickerOpen] = useState(false)
@@ -346,23 +347,23 @@ export function LogsToolbar({
setDatePickerOpen(false)
}, [timeRange, startDate, previousTimeRange, setTimeRange])
const hasActiveFilters = useMemo(() => {
return (
level !== 'all' ||
workflowIds.length > 0 ||
folderIds.length > 0 ||
triggers.length > 0 ||
timeRange !== 'All time'
)
}, [level, workflowIds, folderIds, triggers, timeRange])
const filtersActive = useMemo(
() =>
hasActiveFilters({
timeRange,
level,
workflowIds,
folderIds,
triggers,
searchQuery,
}),
[timeRange, level, workflowIds, folderIds, triggers, searchQuery]
)
const handleClearFilters = useCallback(() => {
setLevel('all')
setWorkflowIds([])
setFolderIds([])
setTriggers([])
clearDateRange()
}, [setLevel, setWorkflowIds, setFolderIds, setTriggers, clearDateRange])
resetFilters()
onSearchQueryChange('')
}, [resetFilters, onSearchQueryChange])
return (
<div className='flex flex-col gap-[19px]'>
@@ -462,7 +463,7 @@ export function LogsToolbar({
</div>
<div className='ml-auto flex items-center gap-[8px]'>
{/* Clear Filters Button */}
{hasActiveFilters && (
{filtersActive && (
<Button
variant='active'
onClick={handleClearFilters}

View File

@@ -4,7 +4,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
import {
getEndDateFromTimeRange,
getStartDateFromTimeRange,
hasActiveFilters,
} from '@/lib/logs/filters'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import { useFolders } from '@/hooks/queries/folders'
import { useDashboardLogs, useLogDetail, useLogsList } from '@/hooks/queries/logs'
@@ -12,7 +16,15 @@ import { useDebounce } from '@/hooks/use-debounce'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
import { Dashboard, LogDetails, LogsList, LogsToolbar, NotificationSettings } from './components'
import {
Dashboard,
LogDetails,
LogRowContextMenu,
LogsList,
LogsToolbar,
NotificationSettings,
} from './components'
import { LOG_COLUMN_ORDER, LOG_COLUMNS } from './utils'
const LOGS_PER_PAGE = 50 as const
const REFRESH_SPINNER_DURATION_MS = 1000 as const
@@ -35,10 +47,12 @@ export default function Logs() {
level,
workflowIds,
folderIds,
setWorkflowIds,
setSearchQuery: setStoreSearchQuery,
triggers,
viewMode,
setViewMode,
resetFilters,
} = useFilterStore()
useEffect(() => {
@@ -71,6 +85,11 @@ export default function Logs() {
const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false)
const userPermissions = useUserPermissionsContext()
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [contextMenuLog, setContextMenuLog] = useState<WorkflowLog | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
const logFilters = useMemo(
() => ({
timeRange,
@@ -216,6 +235,56 @@ export default function Logs() {
prevSelectedLogRef.current = null
}, [])
const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => {
e.preventDefault()
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setContextMenuLog(log)
setContextMenuOpen(true)
}, [])
const handleCopyExecutionId = useCallback(() => {
if (contextMenuLog?.executionId) {
navigator.clipboard.writeText(contextMenuLog.executionId)
}
}, [contextMenuLog])
const handleOpenWorkflow = useCallback(() => {
const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
if (wfId) {
window.open(`/workspace/${workspaceId}/w/${wfId}`, '_blank')
}
}, [contextMenuLog, workspaceId])
const handleToggleWorkflowFilter = useCallback(() => {
const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
if (!wfId) return
if (workflowIds.length === 1 && workflowIds[0] === wfId) {
setWorkflowIds([])
} else {
setWorkflowIds([wfId])
}
}, [contextMenuLog, workflowIds, setWorkflowIds])
const handleClearAllFilters = useCallback(() => {
resetFilters()
setSearchQuery('')
}, [resetFilters, setSearchQuery])
const contextMenuWorkflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
const isFilteredByThisWorkflow = Boolean(
contextMenuWorkflowId && workflowIds.length === 1 && workflowIds[0] === contextMenuWorkflowId
)
const filtersActive = hasActiveFilters({
timeRange,
level,
workflowIds,
folderIds,
triggers,
searchQuery: debouncedSearchQuery,
})
useEffect(() => {
if (selectedRowRef.current) {
selectedRowRef.current.scrollIntoView({
@@ -400,27 +469,17 @@ export default function Logs() {
{/* Table header */}
<div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-[24px] py-[10px] dark:bg-[var(--surface-3)]'>
<div className='flex items-center'>
<span className='w-[8%] min-w-[70px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Date
</span>
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Time
</span>
<span className='w-[12%] min-w-[100px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Status
</span>
<span className='w-[22%] min-w-[140px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow
</span>
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Cost
</span>
<span className='w-[14%] min-w-[110px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Trigger
</span>
<span className='w-[20%] min-w-[100px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Duration
</span>
{LOG_COLUMN_ORDER.map((key) => {
const col = LOG_COLUMNS[key]
return (
<span
key={key}
className={`${col.width} ${col.minWidth} font-medium text-[12px] text-[var(--text-tertiary)]`}
>
{col.label}
</span>
)
})}
</div>
</div>
@@ -452,6 +511,7 @@ export default function Logs() {
logs={logs}
selectedLogId={selectedLog?.id ?? null}
onLogClick={handleLogClick}
onLogContextMenu={handleLogContextMenu}
selectedRowRef={selectedRowRef}
hasNextPage={logsQuery.hasNextPage ?? false}
isFetchingNextPage={logsQuery.isFetchingNextPage}
@@ -481,6 +541,20 @@ export default function Logs() {
open={isNotificationSettingsOpen}
onOpenChange={setIsNotificationSettingsOpen}
/>
<LogRowContextMenu
isOpen={contextMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
onClose={() => setContextMenuOpen(false)}
log={contextMenuLog}
onCopyExecutionId={handleCopyExecutionId}
onOpenWorkflow={handleOpenWorkflow}
onToggleWorkflowFilter={handleToggleWorkflowFilter}
onClearAllFilters={handleClearAllFilters}
isFilteredByThisWorkflow={isFilteredByThisWorkflow}
hasActiveFilters={filtersActive}
/>
</div>
)
}

View File

@@ -3,8 +3,32 @@ import { format } from 'date-fns'
import { Badge } from '@/components/emcn'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
/** Column configuration for logs table - shared between header and rows */
export const LOG_COLUMNS = {
date: { width: 'w-[8%]', minWidth: 'min-w-[70px]', label: 'Date' },
time: { width: 'w-[12%]', minWidth: 'min-w-[90px]', label: 'Time' },
status: { width: 'w-[12%]', minWidth: 'min-w-[100px]', label: 'Status' },
workflow: { width: 'w-[22%]', minWidth: 'min-w-[140px]', label: 'Workflow' },
cost: { width: 'w-[12%]', minWidth: 'min-w-[90px]', label: 'Cost' },
trigger: { width: 'w-[14%]', minWidth: 'min-w-[110px]', label: 'Trigger' },
duration: { width: 'w-[20%]', minWidth: 'min-w-[100px]', label: 'Duration' },
} as const
/** Type-safe column key derived from LOG_COLUMNS */
export type LogColumnKey = keyof typeof LOG_COLUMNS
/** Ordered list of column keys for rendering table headers */
export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
'date',
'time',
'status',
'workflow',
'cost',
'trigger',
'duration',
] as const
/** Possible execution status values for workflow logs */
export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled'

View File

@@ -46,6 +46,8 @@ interface DropdownProps {
) => Promise<Array<{ label: string; id: string }>>
/** Field dependencies that trigger option refetch when changed */
dependsOn?: SubBlockConfig['dependsOn']
/** Enable search input in dropdown */
searchable?: boolean
}
/**
@@ -70,6 +72,7 @@ export function Dropdown({
multiSelect = false,
fetchOptions,
dependsOn,
searchable = false,
}: DropdownProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string | string[]>(blockId, subBlockId) as [
string | string[] | null | undefined,
@@ -113,7 +116,13 @@ export function Dropdown({
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
const singleValue = multiSelect ? null : (value as string | null | undefined)
const multiValues = multiSelect ? (value as string[] | null | undefined) || [] : null
const multiValues = multiSelect
? Array.isArray(value)
? value
: value
? [value as string]
: []
: null
const fetchOptionsIfNeeded = useCallback(async () => {
if (!fetchOptions || isPreview || disabled) return
@@ -363,7 +372,7 @@ export function Dropdown({
)
}, [multiSelect, multiValues, optionMap])
const isSearchable = subBlockId === 'operation'
const isSearchable = searchable || (subBlockId === 'operation' && comboboxOptions.length > 5)
return (
<Combobox
@@ -385,7 +394,7 @@ export function Dropdown({
isLoading={isLoadingOptions}
error={fetchError}
searchable={isSearchable}
searchPlaceholder='Search operations...'
searchPlaceholder='Search...'
/>
)
}

View File

@@ -1,8 +1,6 @@
'use client'
import * as React from 'react'
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { TimePicker } from '@/components/emcn'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
interface TimeInputProps {
@@ -15,6 +13,10 @@ interface TimeInputProps {
disabled?: boolean
}
/**
* Time input wrapper for sub-block editor.
* Connects the EMCN TimePicker to the sub-block store.
*/
export function TimeInput({
blockId,
subBlockId,
@@ -26,143 +28,20 @@ export function TimeInput({
}: TimeInputProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
const [isOpen, setIsOpen] = React.useState(false)
// Convert 24h time string to display format (12h with AM/PM)
const formatDisplayTime = (time: string) => {
if (!time) return ''
const [hours, minutes] = time.split(':')
const hour = Number.parseInt(hours, 10)
const ampm = hour >= 12 ? 'PM' : 'AM'
const displayHour = hour % 12 || 12
return `${displayHour}:${minutes} ${ampm}`
}
// Convert display time to 24h format for storage
const formatStorageTime = (hour: number, minute: number, ampm: string) => {
const hours24 = ampm === 'PM' ? (hour === 12 ? 12 : hour + 12) : hour === 12 ? 0 : hour
return `${hours24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
}
const [hour, setHour] = React.useState<string>('12')
const [minute, setMinute] = React.useState<string>('00')
const [ampm, setAmpm] = React.useState<'AM' | 'PM'>('AM')
// Update the time when any component changes
const updateTime = (newHour?: string, newMinute?: string, newAmpm?: 'AM' | 'PM') => {
const handleChange = (newValue: string) => {
if (isPreview || disabled) return
const h = Number.parseInt(newHour ?? hour) || 12
const m = Number.parseInt(newMinute ?? minute) || 0
const p = newAmpm ?? ampm
setStoreValue(formatStorageTime(h, m, p))
}
// Initialize from existing value
React.useEffect(() => {
if (value) {
const [hours, minutes] = value.split(':')
const hour24 = Number.parseInt(hours, 10)
const _minute = Number.parseInt(minutes, 10)
const isAM = hour24 < 12
setHour((hour24 % 12 || 12).toString())
setMinute(minutes)
setAmpm(isAM ? 'AM' : 'PM')
}
}, [value])
const handleBlur = () => {
updateTime()
setIsOpen(false)
setStoreValue(newValue)
}
return (
<Popover
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open)
if (!open) {
handleBlur()
}
}}
>
<PopoverTrigger asChild>
<div className='relative w-full cursor-pointer'>
<Input
readOnly
disabled={isPreview || disabled}
value={value ? formatDisplayTime(value) : ''}
placeholder={placeholder || 'Select time'}
autoComplete='off'
className={cn('cursor-pointer', !value && 'text-muted-foreground', className)}
/>
</div>
</PopoverTrigger>
<PopoverContent className='w-auto p-4'>
<div className='flex items-center space-x-2'>
<Input
className='w-[4rem]'
value={hour}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9]/g, '')
if (val === '') {
setHour('')
return
}
const numVal = Number.parseInt(val)
if (!Number.isNaN(numVal)) {
const newHour = Math.min(12, Math.max(1, numVal)).toString()
setHour(newHour)
updateTime(newHour)
}
}}
onBlur={() => {
const numVal = Number.parseInt(hour) || 12
setHour(numVal.toString())
updateTime(numVal.toString())
}}
type='text'
autoComplete='off'
/>
<span className='text-[var(--text-primary)]'>:</span>
<Input
className='w-[4rem]'
value={minute}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9]/g, '')
if (val === '') {
setMinute('')
return
}
const numVal = Number.parseInt(val)
if (!Number.isNaN(numVal)) {
const newMinute = Math.min(59, Math.max(0, numVal)).toString().padStart(2, '0')
setMinute(newMinute)
updateTime(undefined, newMinute)
}
}}
onBlur={() => {
const numVal = Number.parseInt(minute) || 0
setMinute(numVal.toString().padStart(2, '0'))
updateTime(undefined, numVal.toString())
}}
type='text'
autoComplete='off'
/>
<Button
variant='outline'
className='w-[4rem]'
onClick={() => {
const newAmpm = ampm === 'AM' ? 'PM' : 'AM'
setAmpm(newAmpm)
updateTime(undefined, undefined, newAmpm)
}}
>
{ampm}
</Button>
</div>
</PopoverContent>
</Popover>
<TimePicker
value={value || undefined}
onChange={handleChange}
placeholder={placeholder || 'Select time'}
disabled={isPreview || disabled}
className={className}
/>
)
}

View File

@@ -461,6 +461,7 @@ function SubBlockComponent({
multiSelect={config.multiSelect}
fetchOptions={config.fetchOptions}
dependsOn={config.dependsOn}
searchable={config.searchable}
/>
</div>
)

View File

@@ -24,11 +24,9 @@ export function useFloatBoundarySync({
const positionRef = useRef(position)
const previousDimensionsRef = useRef({ sidebarWidth: 0, panelWidth: 0, terminalHeight: 0 })
// Keep position ref up to date
positionRef.current = position
const checkAndUpdatePosition = useCallback(() => {
// Get current layout dimensions
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
@@ -39,20 +37,17 @@ export function useFloatBoundarySync({
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
// Check if dimensions actually changed
const prev = previousDimensionsRef.current
if (
prev.sidebarWidth === sidebarWidth &&
prev.panelWidth === panelWidth &&
prev.terminalHeight === terminalHeight
) {
return // No change, skip update
return
}
// Update previous dimensions
previousDimensionsRef.current = { sidebarWidth, panelWidth, terminalHeight }
// Calculate bounds
const minX = sidebarWidth
const maxX = window.innerWidth - panelWidth - width
const minY = 0
@@ -60,9 +55,7 @@ export function useFloatBoundarySync({
const currentPos = positionRef.current
// Check if current position is out of bounds
if (currentPos.x < minX || currentPos.x > maxX || currentPos.y < minY || currentPos.y > maxY) {
// Constrain to new bounds
const newPosition = {
x: Math.max(minX, Math.min(maxX, currentPos.x)),
y: Math.max(minY, Math.min(maxY, currentPos.y)),
@@ -75,30 +68,24 @@ export function useFloatBoundarySync({
if (!isOpen) return
const handleResize = () => {
// Cancel any pending animation frame
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
}
// Schedule update on next animation frame for smooth 60fps updates
rafIdRef.current = requestAnimationFrame(() => {
checkAndUpdatePosition()
rafIdRef.current = null
})
}
// Listen for window resize
window.addEventListener('resize', handleResize)
// Create MutationObserver to watch for CSS variable changes
// This fires immediately when sidebar/panel/terminal resize
const observer = new MutationObserver(handleResize)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style'],
})
// Initial check
checkAndUpdatePosition()
return () => {

View File

@@ -22,7 +22,6 @@ export function useFloatDrag({ position, width, height, onPositionChange }: UseF
*/
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only left click
if (e.button !== 0) return
e.preventDefault()
@@ -32,7 +31,6 @@ export function useFloatDrag({ position, width, height, onPositionChange }: UseF
dragStartRef.current = { x: e.clientX, y: e.clientY }
initialPositionRef.current = { ...position }
// Add dragging cursor to body
document.body.style.cursor = 'grabbing'
document.body.style.userSelect = 'none'
},
@@ -54,7 +52,6 @@ export function useFloatDrag({ position, width, height, onPositionChange }: UseF
y: initialPositionRef.current.y + deltaY,
}
// Constrain to bounds
const constrainedPosition = constrainChatPosition(newPosition, width, height)
onPositionChange(constrainedPosition)
},
@@ -69,7 +66,6 @@ export function useFloatDrag({ position, width, height, onPositionChange }: UseF
isDraggingRef.current = false
// Remove dragging cursor
document.body.style.cursor = ''
document.body.style.userSelect = ''
}, [])

View File

@@ -84,13 +84,11 @@ export function useFloatResize({
const isNearLeft = x <= EDGE_THRESHOLD
const isNearRight = x >= rect.width - EDGE_THRESHOLD
// Check corners first (they take priority over edges)
if (isNearTop && isNearLeft) return 'top-left'
if (isNearTop && isNearRight) return 'top-right'
if (isNearBottom && isNearLeft) return 'bottom-left'
if (isNearBottom && isNearRight) return 'bottom-right'
// Check edges
if (isNearTop) return 'top'
if (isNearBottom) return 'bottom'
if (isNearLeft) return 'left'
@@ -155,7 +153,6 @@ export function useFloatResize({
*/
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only left click
if (e.button !== 0) return
const chatElement = e.currentTarget as HTMLElement
@@ -176,7 +173,6 @@ export function useFloatResize({
height,
}
// Set cursor on body
document.body.style.cursor = getCursorForDirection(direction)
document.body.style.userSelect = 'none'
},
@@ -195,7 +191,6 @@ export function useFloatResize({
const initial = initialStateRef.current
const direction = activeDirectionRef.current
// Get layout bounds
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
@@ -206,18 +201,13 @@ export function useFloatResize({
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
// Clamp vertical drag when resizing from the top so the chat does not grow downward
// after its top edge hits the top of the viewport.
if (direction === 'top' || direction === 'top-left' || direction === 'top-right') {
// newY = initial.y + deltaY should never be less than 0
const maxUpwardDelta = initial.y
if (deltaY < -maxUpwardDelta) {
deltaY = -maxUpwardDelta
}
}
// Clamp vertical drag when resizing from the bottom so the chat does not grow upward
// after its bottom edge hits the top of the terminal.
if (direction === 'bottom' || direction === 'bottom-left' || direction === 'bottom-right') {
const maxBottom = window.innerHeight - terminalHeight
const initialBottom = initial.y + initial.height
@@ -228,8 +218,6 @@ export function useFloatResize({
}
}
// Clamp horizontal drag when resizing from the left so the chat does not grow to the right
// after its left edge hits the sidebar.
if (direction === 'left' || direction === 'top-left' || direction === 'bottom-left') {
const minLeft = sidebarWidth
const minDeltaX = minLeft - initial.x
@@ -239,8 +227,6 @@ export function useFloatResize({
}
}
// Clamp horizontal drag when resizing from the right so the chat does not grow to the left
// after its right edge hits the panel.
if (direction === 'right' || direction === 'top-right' || direction === 'bottom-right') {
const maxRight = window.innerWidth - panelWidth
const initialRight = initial.x + initial.width
@@ -256,9 +242,7 @@ export function useFloatResize({
let newWidth = initial.width
let newHeight = initial.height
// Calculate new dimensions based on resize direction
switch (direction) {
// Corners
case 'top-left':
newWidth = initial.width - deltaX
newHeight = initial.height - deltaY
@@ -280,7 +264,6 @@ export function useFloatResize({
newHeight = initial.height + deltaY
break
// Edges
case 'top':
newHeight = initial.height - deltaY
newY = initial.y + deltaY
@@ -297,8 +280,6 @@ export function useFloatResize({
break
}
// Constrain dimensions to min/max. If explicit constraints are not provided,
// fall back to the chat defaults for backward compatibility.
const effectiveMinWidth = typeof minWidth === 'number' ? minWidth : MIN_CHAT_WIDTH
const effectiveMaxWidth = typeof maxWidth === 'number' ? maxWidth : MAX_CHAT_WIDTH
const effectiveMinHeight = typeof minHeight === 'number' ? minHeight : MIN_CHAT_HEIGHT
@@ -310,7 +291,6 @@ export function useFloatResize({
Math.min(effectiveMaxHeight, newHeight)
)
// Adjust position if dimensions were constrained on left/top edges
if (direction === 'top-left' || direction === 'bottom-left' || direction === 'left') {
if (constrainedWidth !== newWidth) {
newX = initial.x + initial.width - constrainedWidth
@@ -322,7 +302,6 @@ export function useFloatResize({
}
}
// Constrain position to bounds
const minX = sidebarWidth
const maxX = window.innerWidth - panelWidth - constrainedWidth
const minY = 0
@@ -331,7 +310,6 @@ export function useFloatResize({
const finalX = Math.max(minX, Math.min(maxX, newX))
const finalY = Math.max(minY, Math.min(maxY, newY))
// Update state
onDimensionsChange({
width: constrainedWidth,
height: constrainedHeight,
@@ -353,7 +331,6 @@ export function useFloatResize({
isResizingRef.current = false
activeDirectionRef.current = null
// Remove cursor from body
document.body.style.cursor = ''
document.body.style.userSelect = ''
setCursor('')

View File

@@ -23,7 +23,8 @@ import {
getSubBlockValue,
} from '@/lib/workflows/schedules/utils'
import { REFERENCE } from '@/executor/constants'
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata } from '@/executor/execution/types'
import type { ExecutionResult } from '@/executor/types'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
import { mergeSubblockState } from '@/stores/workflows/server-utils'
@@ -285,14 +286,25 @@ async function runWorkflowExecution({
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
executionId,
})
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
} else {
await PauseResumeManager.persistPauseResult({
workflowId: payload.workflowId,
executionId,
pausePoints: executionResult.pausePoints || [],
snapshotSeed: executionResult.snapshotSeed,
executorUserId: executionResult.metadata?.userId,
})
try {
await PauseResumeManager.persistPauseResult({
workflowId: payload.workflowId,
executionId,
pausePoints: executionResult.pausePoints || [],
snapshotSeed: executionResult.snapshotSeed,
executorUserId: executionResult.metadata?.userId,
})
} catch (pauseError) {
logger.error(`[${requestId}] Failed to persist pause result`, {
executionId,
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
})
await loggingSession.markAsFailed(
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
)
}
}
} else {
await PauseResumeManager.processQueuedResumes(executionId)
@@ -360,8 +372,7 @@ function calculateNextRunTime(
return nextDate
}
const lastRanAt = schedule.lastRanAt ? new Date(schedule.lastRanAt) : null
return calculateNextTime(scheduleType, scheduleValues, lastRanAt)
return calculateNextTime(scheduleType, scheduleValues)
}
export async function executeScheduleJob(payload: ScheduleExecutionPayload) {

View File

@@ -17,7 +17,8 @@ import {
loadWorkflowFromNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { getWorkflowById } from '@/lib/workflows/utils'
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata } from '@/executor/execution/types'
import type { ExecutionResult } from '@/executor/types'
import { Serializer } from '@/serializer'
import { mergeSubblockState } from '@/stores/workflows/server-utils'
@@ -268,14 +269,25 @@ async function executeWebhookJobInternal(
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
executionId,
})
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
} else {
await PauseResumeManager.persistPauseResult({
workflowId: payload.workflowId,
executionId,
pausePoints: executionResult.pausePoints || [],
snapshotSeed: executionResult.snapshotSeed,
executorUserId: executionResult.metadata?.userId,
})
try {
await PauseResumeManager.persistPauseResult({
workflowId: payload.workflowId,
executionId,
pausePoints: executionResult.pausePoints || [],
snapshotSeed: executionResult.snapshotSeed,
executorUserId: executionResult.metadata?.userId,
})
} catch (pauseError) {
logger.error(`[${requestId}] Failed to persist pause result`, {
executionId,
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
})
await loggingSession.markAsFailed(
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
)
}
}
} else {
await PauseResumeManager.processQueuedResumes(executionId)
@@ -509,14 +521,25 @@ async function executeWebhookJobInternal(
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
executionId,
})
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
} else {
await PauseResumeManager.persistPauseResult({
workflowId: payload.workflowId,
executionId,
pausePoints: executionResult.pausePoints || [],
snapshotSeed: executionResult.snapshotSeed,
executorUserId: executionResult.metadata?.userId,
})
try {
await PauseResumeManager.persistPauseResult({
workflowId: payload.workflowId,
executionId,
pausePoints: executionResult.pausePoints || [],
snapshotSeed: executionResult.snapshotSeed,
executorUserId: executionResult.metadata?.userId,
})
} catch (pauseError) {
logger.error(`[${requestId}] Failed to persist pause result`, {
executionId,
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
})
await loggingSession.markAsFailed(
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
)
}
}
} else {
await PauseResumeManager.processQueuedResumes(executionId)

View File

@@ -7,7 +7,8 @@ import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import { getWorkflowById } from '@/lib/workflows/utils'
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata } from '@/executor/execution/types'
import type { ExecutionResult } from '@/executor/types'
const logger = createLogger('TriggerWorkflowExecution')
@@ -112,14 +113,25 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
executionId,
})
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
} else {
await PauseResumeManager.persistPauseResult({
workflowId,
executionId,
pausePoints: result.pausePoints || [],
snapshotSeed: result.snapshotSeed,
executorUserId: result.metadata?.userId,
})
try {
await PauseResumeManager.persistPauseResult({
workflowId,
executionId,
pausePoints: result.pausePoints || [],
snapshotSeed: result.snapshotSeed,
executorUserId: result.metadata?.userId,
})
} catch (pauseError) {
logger.error(`[${requestId}] Failed to persist pause result`, {
executionId,
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
})
await loggingSession.markAsFailed(
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
)
}
}
} else {
await PauseResumeManager.processQueuedResumes(executionId)

View File

@@ -0,0 +1,53 @@
import { MailServerIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { getTrigger } from '@/triggers'
export const ImapBlock: BlockConfig = {
type: 'imap',
name: 'IMAP Email',
description: 'Trigger workflows when new emails arrive via IMAP (works with any email provider)',
longDescription:
'Connect to any email server via IMAP protocol to trigger workflows when new emails are received. Supports Gmail, Outlook, Yahoo, and any other IMAP-compatible email provider.',
category: 'triggers',
bgColor: '#6366F1',
icon: MailServerIcon,
triggerAllowed: true,
hideFromToolbar: false,
subBlocks: [...getTrigger('imap_poller').subBlocks],
tools: {
access: [],
config: {
tool: () => '',
},
},
inputs: {
host: { type: 'string', description: 'IMAP server hostname' },
port: { type: 'string', description: 'IMAP server port' },
secure: { type: 'boolean', description: 'Use SSL/TLS encryption' },
rejectUnauthorized: { type: 'boolean', description: 'Verify TLS certificate' },
username: { type: 'string', description: 'Email username' },
password: { type: 'string', description: 'Email password' },
mailbox: { type: 'string', description: 'Mailbox to monitor' },
searchCriteria: { type: 'string', description: 'IMAP search criteria' },
markAsRead: { type: 'boolean', description: 'Mark emails as read after processing' },
includeAttachments: { type: 'boolean', description: 'Include email attachments' },
},
outputs: {
messageId: { type: 'string', description: 'RFC Message-ID header' },
subject: { type: 'string', description: 'Email subject line' },
from: { type: 'string', description: 'Sender email address' },
to: { type: 'string', description: 'Recipient email address' },
cc: { type: 'string', description: 'CC recipients' },
date: { type: 'string', description: 'Email date in ISO format' },
bodyText: { type: 'string', description: 'Plain text email body' },
bodyHtml: { type: 'string', description: 'HTML email body' },
mailbox: { type: 'string', description: 'Mailbox/folder where email was received' },
hasAttachments: { type: 'boolean', description: 'Whether email has attachments' },
attachments: { type: 'json', description: 'Array of email attachments' },
timestamp: { type: 'string', description: 'Event timestamp' },
},
triggers: {
enabled: true,
available: ['imap_poller'],
},
}

View File

@@ -128,24 +128,48 @@ export const ScheduleBlock: BlockConfig = {
id: 'timezone',
type: 'dropdown',
title: 'Timezone',
searchable: true,
options: [
// UTC
{ label: 'UTC', id: 'UTC' },
{ label: 'US Eastern (UTC-5)', id: 'America/New_York' },
{ label: 'US Central (UTC-6)', id: 'America/Chicago' },
{ label: 'US Mountain (UTC-7)', id: 'America/Denver' },
// Americas
{ label: 'US Pacific (UTC-8)', id: 'America/Los_Angeles' },
{ label: 'US Mountain (UTC-7)', id: 'America/Denver' },
{ label: 'US Central (UTC-6)', id: 'America/Chicago' },
{ label: 'US Eastern (UTC-5)', id: 'America/New_York' },
{ label: 'US Alaska (UTC-9)', id: 'America/Anchorage' },
{ label: 'US Hawaii (UTC-10)', id: 'Pacific/Honolulu' },
{ label: 'Canada Toronto (UTC-5)', id: 'America/Toronto' },
{ label: 'Canada Vancouver (UTC-8)', id: 'America/Vancouver' },
{ label: 'Mexico City (UTC-6)', id: 'America/Mexico_City' },
{ label: 'São Paulo (UTC-3)', id: 'America/Sao_Paulo' },
{ label: 'Buenos Aires (UTC-3)', id: 'America/Argentina/Buenos_Aires' },
// Europe
{ label: 'London (UTC+0)', id: 'Europe/London' },
{ label: 'Paris (UTC+1)', id: 'Europe/Paris' },
{ label: 'Berlin (UTC+1)', id: 'Europe/Berlin' },
{ label: 'Amsterdam (UTC+1)', id: 'Europe/Amsterdam' },
{ label: 'Madrid (UTC+1)', id: 'Europe/Madrid' },
{ label: 'Rome (UTC+1)', id: 'Europe/Rome' },
{ label: 'Moscow (UTC+3)', id: 'Europe/Moscow' },
// Middle East / Africa
{ label: 'Dubai (UTC+4)', id: 'Asia/Dubai' },
{ label: 'Tel Aviv (UTC+2)', id: 'Asia/Tel_Aviv' },
{ label: 'Cairo (UTC+2)', id: 'Africa/Cairo' },
{ label: 'Johannesburg (UTC+2)', id: 'Africa/Johannesburg' },
// Asia
{ label: 'India (UTC+5:30)', id: 'Asia/Kolkata' },
{ label: 'Bangkok (UTC+7)', id: 'Asia/Bangkok' },
{ label: 'Jakarta (UTC+7)', id: 'Asia/Jakarta' },
{ label: 'Singapore (UTC+8)', id: 'Asia/Singapore' },
{ label: 'China (UTC+8)', id: 'Asia/Shanghai' },
{ label: 'Hong Kong (UTC+8)', id: 'Asia/Hong_Kong' },
{ label: 'Seoul (UTC+9)', id: 'Asia/Seoul' },
{ label: 'Tokyo (UTC+9)', id: 'Asia/Tokyo' },
// Australia / Pacific
{ label: 'Perth (UTC+8)', id: 'Australia/Perth' },
{ label: 'Sydney (UTC+10)', id: 'Australia/Sydney' },
{ label: 'Melbourne (UTC+10)', id: 'Australia/Melbourne' },
{ label: 'Auckland (UTC+12)', id: 'Pacific/Auckland' },
],
value: () => 'UTC',

View File

@@ -50,6 +50,7 @@ import { HuggingFaceBlock } from '@/blocks/blocks/huggingface'
import { HumanInTheLoopBlock } from '@/blocks/blocks/human_in_the_loop'
import { HunterBlock } from '@/blocks/blocks/hunter'
import { ImageGeneratorBlock } from '@/blocks/blocks/image_generator'
import { ImapBlock } from '@/blocks/blocks/imap'
import { IncidentioBlock } from '@/blocks/blocks/incidentio'
import { InputTriggerBlock } from '@/blocks/blocks/input_trigger'
import { IntercomBlock } from '@/blocks/blocks/intercom'
@@ -196,6 +197,7 @@ export const registry: Record<string, BlockConfig> = {
human_in_the_loop: HumanInTheLoopBlock,
hunter: HunterBlock,
image_generator: ImageGeneratorBlock,
imap: ImapBlock,
incidentio: IncidentioBlock,
input_trigger: InputTriggerBlock,
intercom: IntercomBlock,

View File

@@ -0,0 +1,246 @@
/**
* Base styles for all email templates.
* Colors are derived from globals.css light mode tokens.
*/
/** Color tokens from globals.css (light mode) */
export const colors = {
/** Main canvas background */
bgOuter: '#F7F9FC',
/** Card/container background - pure white */
bgCard: '#ffffff',
/** Primary text color */
textPrimary: '#2d2d2d',
/** Secondary text color */
textSecondary: '#404040',
/** Tertiary text color */
textTertiary: '#5c5c5c',
/** Muted text (footer) */
textMuted: '#737373',
/** Brand primary - purple */
brandPrimary: '#6f3dfa',
/** Brand tertiary - green (matches Run/Deploy buttons) */
brandTertiary: '#32bd7e',
/** Border/divider color */
divider: '#ededed',
/** Footer background */
footerBg: '#F7F9FC',
}
/** Typography settings */
export const typography = {
fontFamily: "-apple-system, 'SF Pro Display', 'SF Pro Text', 'Helvetica', sans-serif",
fontSize: {
body: '16px',
small: '14px',
caption: '12px',
},
lineHeight: {
body: '24px',
caption: '20px',
},
}
/** Spacing values */
export const spacing = {
containerWidth: 600,
gutter: 40,
sectionGap: 20,
paragraphGap: 12,
/** Logo width in pixels */
logoWidth: 90,
}
export const baseStyles = {
fontFamily: typography.fontFamily,
/** Main body wrapper with outer background */
main: {
backgroundColor: colors.bgOuter,
fontFamily: typography.fontFamily,
padding: '32px 0',
},
/** Center wrapper for email content */
wrapper: {
maxWidth: `${spacing.containerWidth}px`,
margin: '0 auto',
},
/** Main card container with rounded corners */
container: {
maxWidth: `${spacing.containerWidth}px`,
margin: '0 auto',
backgroundColor: colors.bgCard,
borderRadius: '16px',
overflow: 'hidden',
},
/** Header section with logo */
header: {
padding: `32px ${spacing.gutter}px 16px ${spacing.gutter}px`,
textAlign: 'left' as const,
},
/** Main content area with horizontal padding */
content: {
padding: `0 ${spacing.gutter}px 32px ${spacing.gutter}px`,
},
/** Standard paragraph text */
paragraph: {
fontSize: typography.fontSize.body,
lineHeight: typography.lineHeight.body,
color: colors.textSecondary,
fontWeight: 400,
fontFamily: typography.fontFamily,
margin: `${spacing.paragraphGap}px 0`,
},
/** Bold label text (e.g., "Platform:", "Time:") */
label: {
fontSize: typography.fontSize.body,
lineHeight: typography.lineHeight.body,
color: colors.textSecondary,
fontWeight: 'bold' as const,
fontFamily: typography.fontFamily,
margin: 0,
display: 'inline',
},
/** Primary CTA button - matches app tertiary button style */
button: {
display: 'inline-block',
backgroundColor: colors.brandTertiary,
color: '#ffffff',
fontWeight: 500,
fontSize: '14px',
padding: '6px 12px',
borderRadius: '5px',
textDecoration: 'none',
textAlign: 'center' as const,
margin: '4px 0',
fontFamily: typography.fontFamily,
},
/** Link text style */
link: {
color: colors.brandTertiary,
fontWeight: 'bold' as const,
textDecoration: 'none',
},
/** Horizontal divider */
divider: {
borderTop: `1px solid ${colors.divider}`,
margin: `16px 0`,
},
/** Footer container (inside gray area below card) */
footer: {
maxWidth: `${spacing.containerWidth}px`,
margin: '0 auto',
padding: `32px ${spacing.gutter}px`,
textAlign: 'left' as const,
},
/** Footer text style */
footerText: {
fontSize: typography.fontSize.caption,
lineHeight: typography.lineHeight.caption,
color: colors.textMuted,
fontFamily: typography.fontFamily,
margin: '0 0 10px 0',
},
/** Code/OTP container */
codeContainer: {
margin: '12px 0',
padding: '12px 16px',
backgroundColor: '#f8f9fa',
borderRadius: '6px',
border: `1px solid ${colors.divider}`,
textAlign: 'center' as const,
},
/** Code/OTP text */
code: {
fontSize: '24px',
fontWeight: 'bold' as const,
letterSpacing: '3px',
color: colors.textPrimary,
fontFamily: typography.fontFamily,
margin: 0,
},
/** Highlighted info box (e.g., "What you get with Pro") */
infoBox: {
backgroundColor: colors.bgOuter,
padding: '16px 18px',
borderRadius: '6px',
margin: '16px 0',
},
/** Info box title */
infoBoxTitle: {
fontSize: typography.fontSize.body,
lineHeight: typography.lineHeight.body,
fontWeight: 600,
color: colors.textPrimary,
fontFamily: typography.fontFamily,
margin: '0 0 8px 0',
},
/** Info box list content */
infoBoxList: {
fontSize: typography.fontSize.body,
lineHeight: '1.6',
color: colors.textSecondary,
fontFamily: typography.fontFamily,
margin: 0,
},
/** Section borders - decorative accent line */
sectionsBorders: {
width: '100%',
display: 'flex',
},
sectionBorder: {
borderBottom: `1px solid ${colors.divider}`,
width: '249px',
},
sectionCenter: {
borderBottom: `1px solid ${colors.brandTertiary}`,
width: '102px',
},
/** Spacer row for vertical spacing in tables */
spacer: {
border: 0,
margin: 0,
padding: 0,
fontSize: '1px',
lineHeight: '1px',
},
/** Gutter cell for horizontal padding in tables */
gutter: {
border: 0,
margin: 0,
padding: 0,
fontSize: '1px',
lineHeight: '1px',
width: `${spacing.gutter}px`,
},
/** Info row (e.g., Platform, Device location, Time) */
infoRow: {
fontSize: typography.fontSize.body,
lineHeight: typography.lineHeight.body,
color: colors.textSecondary,
fontFamily: typography.fontFamily,
margin: '8px 0',
},
}

View File

@@ -0,0 +1 @@
export { baseStyles, colors, spacing, typography } from './base'

View File

@@ -0,0 +1,3 @@
export { OTPVerificationEmail } from './otp-verification-email'
export { ResetPasswordEmail } from './reset-password-email'
export { WelcomeEmail } from './welcome-email'

View File

@@ -0,0 +1,57 @@
import { 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 OTPVerificationEmailProps {
otp: string
email?: string
type?: 'sign-in' | 'email-verification' | 'forget-password' | 'chat-access'
chatTitle?: string
}
const getSubjectByType = (type: string, brandName: string, chatTitle?: string) => {
switch (type) {
case 'sign-in':
return `Sign in to ${brandName}`
case 'email-verification':
return `Verify your email for ${brandName}`
case 'forget-password':
return `Reset your ${brandName} password`
case 'chat-access':
return `Verification code for ${chatTitle || 'Chat'}`
default:
return `Verification code for ${brandName}`
}
}
export function OTPVerificationEmail({
otp,
email = '',
type = 'email-verification',
chatTitle,
}: OTPVerificationEmailProps) {
const brand = getBrandConfig()
return (
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)}>
<Text style={baseStyles.paragraph}>Your verification code:</Text>
<Section style={baseStyles.codeContainer}>
<Text style={baseStyles.code}>{otp}</Text>
</Section>
<Text style={baseStyles.paragraph}>This code will expire in 15 minutes.</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Do not share this code with anyone. If you didn't request this code, you can safely ignore
this email.
</Text>
</EmailLayout>
)
}
export default OTPVerificationEmail

View File

@@ -0,0 +1,36 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
interface ResetPasswordEmailProps {
username?: string
resetLink?: string
}
export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPasswordEmailProps) {
const brand = getBrandConfig()
return (
<EmailLayout preview={`Reset your ${brand.name} password`}>
<Text style={baseStyles.paragraph}>Hello {username},</Text>
<Text style={baseStyles.paragraph}>
A password reset was requested for your {brand.name} account. Click below to set a new
password.
</Text>
<Link href={resetLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Reset Password</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
If you didn't request this, you can ignore this email. Link expires in 24 hours.
</Text>
</EmailLayout>
)
}
export default ResetPasswordEmail

View File

@@ -0,0 +1,45 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface WelcomeEmailProps {
userName?: string
}
export function WelcomeEmail({ userName }: WelcomeEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
return (
<EmailLayout preview={`Welcome to ${brand.name}`}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hey ${userName},` : 'Hey,'}
</Text>
<Text style={baseStyles.paragraph}>
Welcome to {brand.name}! Your account is ready. Start building, testing, and deploying AI
workflows in minutes.
</Text>
<Link href={`${baseUrl}/w`} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Get Started</Text>
</Link>
<Text style={baseStyles.paragraph}>
If you have any questions or feedback, just reply to this email. I read every message!
</Text>
<Text style={baseStyles.paragraph}>- Emir, co-founder of {brand.name}</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
You're on the free plan with $10 in credits to get started.
</Text>
</EmailLayout>
)
}
export default WelcomeEmail

View File

@@ -1,85 +0,0 @@
// Base styles for all email templates
export const baseStyles = {
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
main: {
backgroundColor: '#f5f5f7',
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
},
container: {
maxWidth: '580px',
margin: '30px auto',
backgroundColor: '#ffffff',
borderRadius: '5px',
overflow: 'hidden',
},
header: {
padding: '30px 0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#ffffff',
},
content: {
padding: '5px 30px 20px 30px',
},
paragraph: {
fontSize: '16px',
lineHeight: '1.5',
color: '#333333',
margin: '16px 0',
},
button: {
display: 'inline-block',
backgroundColor: '#6F3DFA',
color: '#ffffff',
fontWeight: 'bold',
fontSize: '16px',
padding: '12px 30px',
borderRadius: '5px',
textDecoration: 'none',
textAlign: 'center' as const,
margin: '20px 0',
},
link: {
color: '#6F3DFA',
textDecoration: 'underline',
},
footer: {
maxWidth: '580px',
margin: '0 auto',
padding: '20px 0',
textAlign: 'center' as const,
},
footerText: {
fontSize: '12px',
color: '#666666',
margin: '0',
},
codeContainer: {
margin: '20px 0',
padding: '16px',
backgroundColor: '#f8f9fa',
borderRadius: '5px',
border: '1px solid #eee',
textAlign: 'center' as const,
},
code: {
fontSize: '28px',
fontWeight: 'bold',
letterSpacing: '4px',
color: '#333333',
},
sectionsBorders: {
width: '100%',
display: 'flex',
},
sectionBorder: {
borderBottom: '1px solid #eeeeee',
width: '249px',
},
sectionCenter: {
borderBottom: '1px solid #6F3DFA',
width: '102px',
},
}

View File

@@ -1,168 +0,0 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { baseStyles } from '@/components/emails/base-styles'
import EmailFooter from '@/components/emails/footer'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface WorkspaceInvitation {
workspaceId: string
workspaceName: string
permission: 'admin' | 'write' | 'read'
}
interface BatchInvitationEmailProps {
inviterName: string
organizationName: string
organizationRole: 'admin' | 'member'
workspaceInvitations: WorkspaceInvitation[]
acceptUrl: string
}
const getPermissionLabel = (permission: string) => {
switch (permission) {
case 'admin':
return 'Admin (full access)'
case 'write':
return 'Editor (can edit workflows)'
case 'read':
return 'Viewer (read-only access)'
default:
return permission
}
}
const getRoleLabel = (role: string) => {
switch (role) {
case 'admin':
return 'Admin'
case 'member':
return 'Member'
default:
return role
}
}
export const BatchInvitationEmail = ({
inviterName = 'Someone',
organizationName = 'the team',
organizationRole = 'member',
workspaceInvitations = [],
acceptUrl,
}: BatchInvitationEmailProps) => {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const hasWorkspaces = workspaceInvitations.length > 0
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>
You've been invited to join {organizationName}
{hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}
</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> has invited you to join{' '}
<strong>{organizationName}</strong> on Sim.
</Text>
{/* Team Role Information */}
<Text style={baseStyles.paragraph}>
<strong>Team Role:</strong> {getRoleLabel(organizationRole)}
</Text>
<Text style={baseStyles.paragraph}>
{organizationRole === 'admin'
? "As a Team Admin, you'll be able to manage team members, billing, and workspace access."
: "As a Team Member, you'll have access to shared team billing and can be invited to workspaces."}
</Text>
{/* Workspace Invitations */}
{hasWorkspaces && (
<>
<Text style={baseStyles.paragraph}>
<strong>
Workspace Access ({workspaceInvitations.length} workspace
{workspaceInvitations.length !== 1 ? 's' : ''}):
</strong>
</Text>
{workspaceInvitations.map((ws) => (
<Text
key={ws.workspaceId}
style={{ ...baseStyles.paragraph, marginLeft: '20px' }}
>
• <strong>{ws.workspaceName}</strong> - {getPermissionLabel(ws.permission)}
</Text>
))}
</>
)}
<Link href={acceptUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
<Text style={baseStyles.paragraph}>
By accepting this invitation, you'll join {organizationName}
{hasWorkspaces
? ` and gain access to ${workspaceInvitations.length} workspace(s)`
: ''}
.
</Text>
<Text style={baseStyles.paragraph}>
This invitation will expire in 7 days. If you didn't expect this invitation, you can
safely ignore this email.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default BatchInvitationEmail

View File

@@ -1,19 +1,6 @@
import {
Body,
Column,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { baseStyles } from '@/components/emails/base-styles'
import EmailFooter from '@/components/emails/footer'
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -36,89 +23,74 @@ export function CreditPurchaseEmail({
const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<EmailLayout preview={previewText}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Your credit purchase of <strong>${amount.toFixed(2)}</strong> has been confirmed.
</Text>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.infoBox}>
<Text
style={{
margin: 0,
fontSize: '14px',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Amount Added
</Text>
<Text
style={{
margin: '4px 0 16px',
fontSize: '24px',
fontWeight: 'bold',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
${amount.toFixed(2)}
</Text>
<Text
style={{
margin: 0,
fontSize: '14px',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
New Balance
</Text>
<Text
style={{
margin: '4px 0 0',
fontSize: '24px',
fontWeight: 'bold',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
${newBalance.toFixed(2)}
</Text>
</Section>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Your credit purchase of <strong>${amount.toFixed(2)}</strong> has been confirmed.
</Text>
<Text style={baseStyles.paragraph}>
Credits are applied automatically to your workflow executions.
</Text>
<Section
style={{
background: '#f4f4f5',
borderRadius: '8px',
padding: '16px',
margin: '24px 0',
}}
>
<Text style={{ margin: 0, fontSize: '14px', color: '#71717a' }}>Amount Added</Text>
<Text style={{ margin: '4px 0 16px', fontSize: '24px', fontWeight: 'bold' }}>
${amount.toFixed(2)}
</Text>
<Text style={{ margin: 0, fontSize: '14px', color: '#71717a' }}>New Balance</Text>
<Text style={{ margin: '4px 0 0', fontSize: '24px', fontWeight: 'bold' }}>
${newBalance.toFixed(2)}
</Text>
</Section>
<Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>View Dashboard</Text>
</Link>
<Text style={baseStyles.paragraph}>
These credits will be applied automatically to your workflow executions. Credits are
consumed before any overage charges apply.
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>View Dashboard</Text>
</Link>
<Hr />
<Text style={baseStyles.paragraph}>
You can view your credit balance and purchase history in Settings Subscription.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Purchased on {purchaseDate.toLocaleDateString()}
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Purchased on {purchaseDate.toLocaleDateString()}. View balance in Settings Subscription.
</Text>
</EmailLayout>
)
}

View File

@@ -1,120 +1,50 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { format } from 'date-fns'
import { baseStyles } from '@/components/emails/base-styles'
import EmailFooter from '@/components/emails/footer'
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface EnterpriseSubscriptionEmailProps {
userName?: string
userEmail?: string
loginLink?: string
createdDate?: Date
}
export const EnterpriseSubscriptionEmail = ({
export function EnterpriseSubscriptionEmail({
userName = 'Valued User',
userEmail = '',
loginLink,
createdDate = new Date(),
}: EnterpriseSubscriptionEmailProps) => {
}: EnterpriseSubscriptionEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const effectiveLoginLink = loginLink || `${baseUrl}/login`
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>Your Enterprise Plan is now active on Sim</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<EmailLayout preview={`Your Enterprise Plan is now active on ${brand.name}`}>
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
<Text style={baseStyles.paragraph}>
Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced
features and increased capacity for your workflows.
</Text>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Link href={effectiveLoginLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open {brand.name}</Text>
</Link>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
<Text style={baseStyles.paragraph}>
Great news! Your <strong>Enterprise Plan</strong> has been activated on Sim. You now
have access to advanced features and increased capacity for your workflows.
</Text>
<Text style={baseStyles.paragraph}>
<strong>Next steps:</strong>
<br /> Invite team members to your organization
<br /> Start building your workflows
</Text>
<Text style={baseStyles.paragraph}>
Your account has been set up with full access to your organization. Click below to log
in and start exploring your new Enterprise features:
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Link href={effectiveLoginLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Access Your Enterprise Account</Text>
</Link>
<Text style={baseStyles.paragraph}>
<strong>What's next?</strong>
</Text>
<Text style={baseStyles.paragraph}>
• Invite team members to your organization
<br />• Begin building your workflows
</Text>
<Text style={baseStyles.paragraph}>
If you have any questions or need assistance getting started, our support team is here
to help.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text
style={{
...baseStyles.footerText,
marginTop: '40px',
textAlign: 'left',
color: '#666666',
}}
>
This email was sent on {format(createdDate, 'MMMM do, yyyy')} to {userEmail}
regarding your Enterprise plan activation on Sim.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Questions? Reply to this email or contact us at{' '}
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
{brand.supportEmail}
</Link>
</Text>
</EmailLayout>
)
}

View File

@@ -1,21 +1,7 @@
import {
Body,
Column,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { baseStyles } from '@/components/emails/base-styles'
import EmailFooter from '@/components/emails/footer'
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors, typography } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface FreeTierUpgradeEmailProps {
userName?: string
@@ -23,119 +9,105 @@ interface FreeTierUpgradeEmailProps {
currentUsage: number
limit: number
upgradeLink: string
updatedDate?: Date
}
const proFeatures = [
{ label: '$20/month', desc: 'in credits included' },
{ label: '25 runs/min', desc: 'sync executions' },
{ label: '200 runs/min', desc: 'async executions' },
{ label: '50GB storage', desc: 'for files & assets' },
{ label: 'Unlimited', desc: 'workspaces & invites' },
]
export function FreeTierUpgradeEmail({
userName,
percentUsed,
currentUsage,
limit,
upgradeLink,
updatedDate = new Date(),
}: FreeTierUpgradeEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
<EmailLayout preview={previewText}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
You've used <strong>${currentUsage.toFixed(2)}</strong> of your{' '}
<strong>${limit.toFixed(2)}</strong> free credits ({percentUsed}%). Upgrade to Pro to keep
building without interruption.
</Text>
{/* Pro Features */}
<Section
style={{
backgroundColor: '#f8faf9',
border: `1px solid ${colors.brandTertiary}20`,
borderRadius: '8px',
padding: '16px 20px',
margin: '16px 0',
}}
>
<Text
style={{
fontSize: '14px',
fontWeight: 600,
color: colors.brandTertiary,
fontFamily: typography.fontFamily,
margin: '0 0 12px 0',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
}}
>
Pro includes
</Text>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{proFeatures.map((feature, i) => (
<tr key={i}>
<td
style={{
margin: '0 auto',
padding: '6px 0',
fontSize: '15px',
fontWeight: 600,
color: colors.textPrimary,
fontFamily: typography.fontFamily,
width: '45%',
}}
/>
</Column>
</Row>
</Section>
>
{feature.label}
</td>
<td
style={{
padding: '6px 0',
fontSize: '14px',
color: colors.textMuted,
fontFamily: typography.fontFamily,
}}
>
{feature.desc}
</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Upgrade to Pro</Text>
</Link>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={baseStyles.paragraph}>
You've used <strong>${currentUsage.toFixed(2)}</strong> of your{' '}
<strong>${limit.toFixed(2)}</strong> free credits ({percentUsed}%).
</Text>
<Text style={baseStyles.paragraph}>
To ensure uninterrupted service and unlock the full power of {brand.name}, upgrade to
Pro today.
</Text>
<Section
style={{
backgroundColor: '#f8f9fa',
padding: '20px',
borderRadius: '5px',
margin: '20px 0',
}}
>
<Text
style={{
...baseStyles.paragraph,
marginTop: 0,
marginBottom: 12,
fontWeight: 'bold',
}}
>
What you get with Pro:
</Text>
<Text style={{ ...baseStyles.paragraph, margin: '8px 0', lineHeight: 1.6 }}>
• <strong>$20/month in credits</strong> 2x your free tier
<br />• <strong>Priority support</strong> Get help when you need it
<br />• <strong>Advanced features</strong> Access to premium blocks and
integrations
<br />• <strong>No interruptions</strong> Never worry about running out of credits
</Text>
</Section>
<Hr />
<Text style={baseStyles.paragraph}>Upgrade now to keep building without limits.</Text>
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Upgrade to Pro</Text>
</Link>
<Text style={baseStyles.paragraph}>
Questions? We're here to help.
<br />
<br />
Best regards,
<br />
The {brand.name} Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Sent on {updatedDate.toLocaleDateString()} This is a one-time notification at 90%.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
One-time notification at 90% usage.
</Text>
</EmailLayout>
)
}

View File

@@ -0,0 +1,6 @@
export { CreditPurchaseEmail } from './credit-purchase-email'
export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email'
export { FreeTierUpgradeEmail } from './free-tier-upgrade-email'
export { PaymentFailedEmail } from './payment-failed-email'
export { PlanWelcomeEmail } from './plan-welcome-email'
export { UsageThresholdEmail } from './usage-threshold-email'

View File

@@ -1,21 +1,7 @@
import {
Body,
Column,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { baseStyles } from '@/components/emails/base-styles'
import EmailFooter from '@/components/emails/footer'
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface PaymentFailedEmailProps {
userName?: string
@@ -35,132 +21,88 @@ export function PaymentFailedEmail({
sentDate = new Date(),
}: PaymentFailedEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const previewText = `${brand.name}: Payment Failed - Action Required`
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<EmailLayout preview={previewText}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Text
style={{
...baseStyles.paragraph,
fontSize: '16px',
fontWeight: 600,
color: colors.textPrimary,
}}
>
We were unable to process your payment.
</Text>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Your {brand.name} account has been temporarily blocked to prevent service interruptions and
unexpected charges. To restore access immediately, please update your payment method.
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '18px', fontWeight: 'bold' }}>
We were unable to process your payment.
</Text>
<Section
style={{
backgroundColor: '#fff5f5',
border: '1px solid #fed7d7',
borderRadius: '6px',
padding: '16px 18px',
margin: '16px 0',
}}
>
<Text
style={{
...baseStyles.paragraph,
marginBottom: 8,
marginTop: 0,
fontWeight: 'bold',
}}
>
Payment Details
</Text>
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
Amount due: ${amountDue.toFixed(2)}
</Text>
{lastFourDigits && (
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
Payment method: {lastFourDigits}
</Text>
)}
{failureReason && (
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>Reason: {failureReason}</Text>
)}
</Section>
<Text style={baseStyles.paragraph}>
Your {brand.name} account has been temporarily blocked to prevent service
interruptions and unexpected charges. To restore access immediately, please update
your payment method.
</Text>
<Link href={billingPortalUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Update Payment Method</Text>
</Link>
<Section
style={{
backgroundColor: '#fff5f5',
border: '1px solid #fed7d7',
borderRadius: '5px',
padding: '16px',
margin: '20px 0',
}}
>
<Row>
<Column>
<Text style={{ ...baseStyles.paragraph, marginBottom: 8, marginTop: 0 }}>
<strong>Payment Details</strong>
</Text>
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
Amount due: ${amountDue.toFixed(2)}
</Text>
{lastFourDigits && (
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
Payment method: {lastFourDigits}
</Text>
)}
{failureReason && (
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
Reason: {failureReason}
</Text>
)}
</Column>
</Row>
</Section>
{/* Divider */}
<div style={baseStyles.divider} />
<Link href={billingPortalUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Update Payment Method</Text>
</Link>
<Text style={{ ...baseStyles.paragraph, fontWeight: 'bold' }}>What happens next?</Text>
<Hr />
<Text style={baseStyles.paragraph}>
Your workflows and automations are currently paused
<br /> Update your payment method to restore service immediately
<br /> Stripe will automatically retry the charge once payment is updated
</Text>
<Text style={baseStyles.paragraph}>
<strong>What happens next?</strong>
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={baseStyles.paragraph}>
Your workflows and automations are currently paused
<br /> Update your payment method to restore service immediately
<br /> Stripe will automatically retry the charge once payment is updated
</Text>
<Hr />
<Text style={baseStyles.paragraph}>
<strong>Need help?</strong>
</Text>
<Text style={baseStyles.paragraph}>
Common reasons for payment failures include expired cards, insufficient funds, or
incorrect billing information. If you continue to experience issues, please{' '}
<Link href={`${baseUrl}/support`} style={baseStyles.link}>
contact our support team
</Link>
.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Sent on {sentDate.toLocaleDateString()} This is a critical transactional
notification.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Common issues: expired card, insufficient funds, or incorrect billing info. Need help?{' '}
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
{brand.supportEmail}
</Link>
</Text>
</EmailLayout>
)
}

View File

@@ -1,19 +1,6 @@
import {
Body,
Column,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { baseStyles } from '@/components/emails/base-styles'
import EmailFooter from '@/components/emails/footer'
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -21,15 +8,9 @@ interface PlanWelcomeEmailProps {
planName: 'Pro' | 'Team'
userName?: string
loginLink?: string
createdDate?: Date
}
export function PlanWelcomeEmail({
planName,
userName,
loginLink,
createdDate = new Date(),
}: PlanWelcomeEmailProps) {
export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const cta = loginLink || `${baseUrl}/login`
@@ -37,76 +18,34 @@ export function PlanWelcomeEmail({
const previewText = `${brand.name}: Your ${planName} plan is active`
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<EmailLayout preview={previewText}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Welcome to <strong>{planName}</strong>! You're all set to build, test, and scale your
workflows.
</Text>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Link href={cta} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open {brand.name}</Text>
</Link>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Welcome to the <strong>{planName}</strong> plan on {brand.name}. You're all set to
build, test, and scale your agentic workflows.
</Text>
<Text style={baseStyles.paragraph}>
Want help getting started?{' '}
<Link href='https://cal.com/emirkarabeg/sim-team' style={baseStyles.link}>
Schedule a call
</Link>{' '}
with our team.
</Text>
<Link href={cta} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open {brand.name}</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={baseStyles.paragraph}>
Want to discuss your plan or get personalized help getting started?{' '}
<Link href='https://cal.com/waleedlatif/15min' style={baseStyles.link}>
Schedule a 15-minute call
</Link>{' '}
with our team.
</Text>
<Hr />
<Text style={baseStyles.paragraph}>
Need to invite teammates, adjust usage limits, or manage billing? You can do that from
Settings Subscription.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Sent on {createdDate.toLocaleDateString()}
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Manage your subscription in Settings Subscription.
</Text>
</EmailLayout>
)
}

View File

@@ -1,21 +1,7 @@
import {
Body,
Column,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { baseStyles } from '@/components/emails/base-styles'
import EmailFooter from '@/components/emails/footer'
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'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface UsageThresholdEmailProps {
userName?: string
@@ -24,7 +10,6 @@ interface UsageThresholdEmailProps {
currentUsage: number
limit: number
ctaLink: string
updatedDate?: Date
}
export function UsageThresholdEmail({
@@ -34,89 +19,46 @@ export function UsageThresholdEmail({
currentUsage,
limit,
ctaLink,
updatedDate = new Date(),
}: UsageThresholdEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<EmailLayout preview={previewText}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Text style={baseStyles.paragraph}>
You're approaching your monthly budget on the {planName} plan.
</Text>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Usage</Text>
<Text style={baseStyles.infoBoxList}>
${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%)
</Text>
</Section>
<Text style={baseStyles.paragraph}>
You're approaching your monthly budget on the {planName} plan.
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Section>
<Row>
<Column>
<Text style={{ ...baseStyles.paragraph, marginBottom: 8 }}>
<strong>Usage</strong>
</Text>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%)
</Text>
</Column>
</Row>
</Section>
<Text style={baseStyles.paragraph}>
To avoid interruptions, consider increasing your monthly limit.
</Text>
<Hr />
<Link href={ctaLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Review Limits</Text>
</Link>
<Text style={{ ...baseStyles.paragraph }}>
To avoid interruptions, consider increasing your monthly limit.
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Link href={ctaLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Review limits</Text>
</Link>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Sent on {updatedDate.toLocaleDateString()} This is a one-time notification at 80%.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
One-time notification at 80% usage.
</Text>
</EmailLayout>
)
}

View File

@@ -1,18 +1,7 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { Text } from '@react-email/components'
import { format } from 'date-fns'
import { baseStyles } from '@/components/emails/base-styles'
import EmailFooter from '@/components/emails/footer'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -22,96 +11,46 @@ interface CareersConfirmationEmailProps {
submittedDate?: Date
}
export const CareersConfirmationEmail = ({
export function CareersConfirmationEmail({
name,
position,
submittedDate = new Date(),
}: CareersConfirmationEmailProps) => {
}: CareersConfirmationEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>Your application to {brand.name} has been received</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<EmailLayout preview={`Your application to ${brand.name} has been received`}>
<Text style={baseStyles.paragraph}>Hello {name},</Text>
<Text style={baseStyles.paragraph}>
We've received your application for <strong>{position}</strong>. Our team reviews every
application and will reach out if there's a match.
</Text>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Text style={baseStyles.paragraph}>
In the meantime, explore our{' '}
<a
href='https://docs.sim.ai'
target='_blank'
rel='noopener noreferrer'
style={baseStyles.link}
>
docs
</a>{' '}
or{' '}
<a href={`${baseUrl}/studio`} style={baseStyles.link}>
blog
</a>{' '}
to learn more about what we're building.
</Text>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello {name},</Text>
<Text style={baseStyles.paragraph}>
Thank you for your interest in joining the {brand.name} team! We've received your
application for the <strong>{position}</strong> position.
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={baseStyles.paragraph}>
Our team carefully reviews every application and will get back to you within the next
few weeks. If your qualifications match what we're looking for, we'll reach out to
schedule an initial conversation.
</Text>
<Text style={baseStyles.paragraph}>
In the meantime, feel free to explore our{' '}
<a
href='https://docs.sim.ai'
target='_blank'
rel='noopener noreferrer'
style={{ color: '#6F3DFA', textDecoration: 'none' }}
>
documentation
</a>{' '}
to learn more about what we're building, or check out our{' '}
<a href={`${baseUrl}/studio`} style={{ color: '#6F3DFA', textDecoration: 'none' }}>
blog
</a>{' '}
for the latest updates.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The {brand.name} Team
</Text>
<Text
style={{
...baseStyles.footerText,
marginTop: '40px',
textAlign: 'left',
color: '#666666',
}}
>
This confirmation was sent on {format(submittedDate, 'MMMM do, yyyy')} at{' '}
{format(submittedDate, 'h:mm a')}.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Submitted on {format(submittedDate, 'MMMM do, yyyy')}.
</Text>
</EmailLayout>
)
}

View File

@@ -1,19 +1,7 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { Section, Text } from '@react-email/components'
import { format } from 'date-fns'
import { baseStyles } from '@/components/emails/base-styles'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { baseStyles, colors } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
interface CareersSubmissionEmailProps {
name: string
@@ -39,7 +27,7 @@ const getExperienceLabel = (experience: string) => {
return labels[experience] || experience
}
export const CareersSubmissionEmail = ({
export function CareersSubmissionEmail({
name,
email,
phone,
@@ -50,263 +38,299 @@ export const CareersSubmissionEmail = ({
location,
message,
submittedDate = new Date(),
}: CareersSubmissionEmailProps) => {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
}: CareersSubmissionEmailProps) {
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>New Career Application from {name}</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<EmailLayout preview={`New Career Application from ${name}`} hideFooter>
<Text
style={{
...baseStyles.paragraph,
fontSize: '18px',
fontWeight: 'bold',
color: colors.textPrimary,
}}
>
New Career Application
</Text>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Text style={baseStyles.paragraph}>
A new career application has been submitted on {format(submittedDate, 'MMMM do, yyyy')} at{' '}
{format(submittedDate, 'h:mm a')}.
</Text>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, fontSize: '18px', fontWeight: 'bold' }}>
New Career Application
</Text>
{/* Applicant Information */}
<Section
style={{
marginTop: '24px',
marginBottom: '24px',
padding: '20px',
backgroundColor: colors.bgOuter,
borderRadius: '8px',
border: `1px solid ${colors.divider}`,
}}
>
<Text
style={{
margin: '0 0 16px 0',
fontSize: '16px',
fontWeight: 'bold',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
Applicant Information
</Text>
<Text style={baseStyles.paragraph}>
A new career application has been submitted on{' '}
{format(submittedDate, 'MMMM do, yyyy')} at {format(submittedDate, 'h:mm a')}.
</Text>
{/* Applicant Information */}
<Section
style={{
marginTop: '24px',
marginBottom: '24px',
padding: '20px',
backgroundColor: '#f9f9f9',
borderRadius: '8px',
border: '1px solid #e5e5e5',
}}
>
<Text
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
<tr>
<td
style={{
margin: '0 0 16px 0',
fontSize: '16px',
fontWeight: 'bold',
color: '#333333',
}}
>
Applicant Information
</Text>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
width: '40%',
}}
>
Name:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>{name}</td>
</tr>
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
}}
>
Email:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
<a
href={`mailto:${email}`}
style={{ color: '#6F3DFA', textDecoration: 'none' }}
>
{email}
</a>
</td>
</tr>
{phone && (
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
}}
>
Phone:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
<a href={`tel:${phone}`} style={{ color: '#6F3DFA', textDecoration: 'none' }}>
{phone}
</a>
</td>
</tr>
)}
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
}}
>
Position:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
{position}
</td>
</tr>
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
}}
>
Experience:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
{getExperienceLabel(experience)}
</td>
</tr>
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
}}
>
Location:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
{location}
</td>
</tr>
{linkedin && (
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
}}
>
LinkedIn:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
<a
href={linkedin}
target='_blank'
rel='noopener noreferrer'
style={{ color: '#6F3DFA', textDecoration: 'none' }}
>
View Profile
</a>
</td>
</tr>
)}
{portfolio && (
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
}}
>
Portfolio:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
<a
href={portfolio}
target='_blank'
rel='noopener noreferrer'
style={{ color: '#6F3DFA', textDecoration: 'none' }}
>
View Portfolio
</a>
</td>
</tr>
)}
</table>
</Section>
{/* Message */}
<Section
style={{
marginTop: '24px',
marginBottom: '24px',
padding: '20px',
backgroundColor: '#f9f9f9',
borderRadius: '8px',
border: '1px solid #e5e5e5',
}}
>
<Text
style={{
margin: '0 0 12px 0',
fontSize: '16px',
fontWeight: 'bold',
color: '#333333',
}}
>
About Themselves
</Text>
<Text
style={{
margin: '0',
padding: '8px 0',
fontSize: '14px',
color: '#333333',
lineHeight: '1.6',
whiteSpace: 'pre-wrap',
fontWeight: 'bold',
color: colors.textMuted,
width: '40%',
fontFamily: baseStyles.fontFamily,
}}
>
{message}
</Text>
</Section>
Name:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
{name}
</td>
</tr>
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Email:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
<a href={`mailto:${email}`} style={baseStyles.link}>
{email}
</a>
</td>
</tr>
{phone && (
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Phone:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
<a href={`tel:${phone}`} style={baseStyles.link}>
{phone}
</a>
</td>
</tr>
)}
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Position:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
{position}
</td>
</tr>
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Experience:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
{getExperienceLabel(experience)}
</td>
</tr>
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Location:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
{location}
</td>
</tr>
{linkedin && (
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
LinkedIn:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
<a
href={linkedin}
target='_blank'
rel='noopener noreferrer'
style={baseStyles.link}
>
View Profile
</a>
</td>
</tr>
)}
{portfolio && (
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Portfolio:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
<a
href={portfolio}
target='_blank'
rel='noopener noreferrer'
style={baseStyles.link}
>
View Portfolio
</a>
</td>
</tr>
)}
</tbody>
</table>
</Section>
<Text style={baseStyles.paragraph}>
Please review this application and reach out to the candidate at your earliest
convenience.
</Text>
</Section>
</Container>
</Body>
</Html>
{/* Message */}
<Section
style={{
marginTop: '24px',
marginBottom: '24px',
padding: '20px',
backgroundColor: colors.bgOuter,
borderRadius: '8px',
border: `1px solid ${colors.divider}`,
}}
>
<Text
style={{
margin: '0 0 12px 0',
fontSize: '16px',
fontWeight: 'bold',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
About Themselves
</Text>
<Text
style={{
margin: '0',
fontSize: '14px',
color: colors.textPrimary,
lineHeight: '1.6',
whiteSpace: 'pre-wrap',
fontFamily: baseStyles.fontFamily,
}}
>
{message}
</Text>
</Section>
</EmailLayout>
)
}

View File

@@ -0,0 +1,2 @@
export { CareersConfirmationEmail } from './careers-confirmation-email'
export { CareersSubmissionEmail } from './careers-submission-email'

View File

@@ -0,0 +1,233 @@
import { Container, Img, Link, Section } from '@react-email/components'
import { baseStyles, colors, spacing, typography } from '@/components/emails/_styles'
import { getBrandConfig } from '@/lib/branding/branding'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface UnsubscribeOptions {
unsubscribeToken?: string
email?: string
}
interface EmailFooterProps {
baseUrl?: string
unsubscribe?: UnsubscribeOptions
messageId?: string
}
/**
* Email footer component styled to match Stripe's email design.
* Sits in the gray area below the main white card.
*/
export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }: EmailFooterProps) {
const brand = getBrandConfig()
const footerLinkStyle = {
color: colors.textMuted,
textDecoration: 'underline',
fontWeight: 'normal' as const,
fontFamily: typography.fontFamily,
}
return (
<Section
style={{
backgroundColor: colors.footerBg,
width: '100%',
}}
>
<Container style={{ maxWidth: `${spacing.containerWidth}px`, margin: '0 auto' }}>
<table
cellPadding={0}
cellSpacing={0}
border={0}
width='100%'
style={{ minWidth: `${spacing.containerWidth}px` }}
>
<tbody>
<tr>
<td style={baseStyles.spacer} height={32}>
&nbsp;
</td>
</tr>
{/* Social links row */}
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td>
<table cellPadding={0} cellSpacing={0} style={{ border: 0 }}>
<tbody>
<tr>
<td align='left' style={{ padding: '0 8px 0 0' }}>
<Link href='https://x.com/simdotai' rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/x-icon.png`}
width='20'
height='20'
alt='X'
/>
</Link>
</td>
<td align='left' style={{ padding: '0 8px' }}>
<Link href='https://discord.gg/Hr4UWYEcTT' rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/discord-icon.png`}
width='20'
height='20'
alt='Discord'
/>
</Link>
</td>
<td align='left' style={{ padding: '0 8px' }}>
<Link href='https://github.com/simstudioai/sim' rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/github-icon.png`}
width='20'
height='20'
alt='GitHub'
/>
</Link>
</td>
</tr>
</tbody>
</table>
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={16}>
&nbsp;
</td>
</tr>
{/* Address row */}
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
{brand.name}
{isHosted && <>, 80 Langton St, San Francisco, CA 94133, USA</>}
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={8}>
&nbsp;
</td>
</tr>
{/* Contact row */}
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
Questions?{' '}
<a href={`mailto:${brand.supportEmail}`} style={footerLinkStyle}>
{brand.supportEmail}
</a>
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={8}>
&nbsp;
</td>
</tr>
{/* Message ID row (optional) */}
{messageId && (
<>
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
Need to refer to this message? Use this ID: {messageId}
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={8}>
&nbsp;
</td>
</tr>
</>
)}
{/* Links row */}
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
<a href={`${baseUrl}/privacy`} style={footerLinkStyle} rel='noopener noreferrer'>
Privacy Policy
</a>{' '}
{' '}
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
Terms of Service
</a>{' '}
{' '}
<a
href={
unsubscribe?.unsubscribeToken && unsubscribe?.email
? `${baseUrl}/unsubscribe?token=${unsubscribe.unsubscribeToken}&email=${encodeURIComponent(unsubscribe.email)}`
: `mailto:${brand.supportEmail}?subject=Unsubscribe%20Request&body=Please%20unsubscribe%20me%20from%20all%20emails.`
}
style={footerLinkStyle}
rel='noopener noreferrer'
>
Unsubscribe
</a>
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
{/* Copyright row */}
<tr>
<td style={baseStyles.spacer} height={16}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
© {new Date().getFullYear()} {brand.name}, All Rights Reserved
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={32}>
&nbsp;
</td>
</tr>
</tbody>
</table>
</Container>
</Section>
)
}
export default EmailFooter

View File

@@ -0,0 +1,52 @@
import { Body, Container, Head, Html, Img, Preview, Section } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailFooter } from '@/components/emails/components/email-footer'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface EmailLayoutProps {
/** Preview text shown in email client list view */
preview: string
/** Email content to render inside the layout */
children: React.ReactNode
/** Optional: hide footer for internal emails */
hideFooter?: boolean
}
/**
* Shared email layout wrapper providing consistent structure.
* Includes Html, Head, Body, Container with logo header, and Footer.
*/
export function EmailLayout({ preview, children, hideFooter = false }: EmailLayoutProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
return (
<Html>
<Head />
<Preview>{preview}</Preview>
<Body style={baseStyles.main}>
{/* Main card container */}
<Container style={baseStyles.container}>
{/* Header with logo */}
<Section style={baseStyles.header}>
<Img
src={brand.logoUrl || `${baseUrl}/brand/color/email/type.png`}
width='70'
alt={brand.name}
style={{ display: 'block' }}
/>
</Section>
{/* Content */}
<Section style={baseStyles.content}>{children}</Section>
</Container>
{/* Footer in gray section */}
{!hideFooter && <EmailFooter baseUrl={baseUrl} />}
</Body>
</Html>
)
}
export default EmailLayout

View File

@@ -0,0 +1,2 @@
export { EmailFooter } from './email-footer'
export { EmailLayout } from './email-layout'

View File

@@ -1,151 +0,0 @@
import { Container, Img, Link, Section, Text } from '@react-email/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface UnsubscribeOptions {
unsubscribeToken?: string
email?: string
}
interface EmailFooterProps {
baseUrl?: string
unsubscribe?: UnsubscribeOptions
}
export const EmailFooter = ({ baseUrl = getBaseUrl(), unsubscribe }: EmailFooterProps) => {
const brand = getBrandConfig()
return (
<Container>
<Section style={{ maxWidth: '580px', margin: '0 auto', padding: '20px 0' }}>
<table style={{ width: '100%' }}>
<tr>
<td align='center'>
<table cellPadding={0} cellSpacing={0} style={{ border: 0 }}>
<tr>
<td align='center' style={{ padding: '0 8px' }}>
<Link href='https://x.com/simdotai' rel='noopener noreferrer'>
<Img src={`${baseUrl}/static/x-icon.png`} width='24' height='24' alt='X' />
</Link>
</td>
<td align='center' style={{ padding: '0 8px' }}>
<Link href='https://discord.gg/Hr4UWYEcTT' rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/discord-icon.png`}
width='24'
height='24'
alt='Discord'
/>
</Link>
</td>
<td align='center' style={{ padding: '0 8px' }}>
<Link href='https://github.com/simstudioai/sim' rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/github-icon.png`}
width='24'
height='24'
alt='GitHub'
/>
</Link>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align='center' style={{ paddingTop: '12px' }}>
<Text
style={{
fontSize: '12px',
color: '#706a7b',
margin: '8px 0 0 0',
}}
>
© {new Date().getFullYear()} {brand.name}, All Rights Reserved
<br />
If you have any questions, please contact us at{' '}
<a
href={`mailto:${brand.supportEmail}`}
style={{
color: '#706a7b !important',
textDecoration: 'underline',
fontWeight: 'normal',
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
}}
>
{brand.supportEmail}
</a>
{isHosted && (
<>
<br />
Sim, 80 Langton St, San Francisco, CA 94133, USA
</>
)}
</Text>
<table cellPadding={0} cellSpacing={0} style={{ width: '100%', marginTop: '4px' }}>
<tr>
<td align='center'>
<p
style={{
fontSize: '12px',
color: '#706a7b',
margin: '8px 0 0 0',
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
}}
>
<a
href={`${baseUrl}/privacy`}
style={{
color: '#706a7b !important',
textDecoration: 'underline',
fontWeight: 'normal',
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
}}
rel='noopener noreferrer'
>
Privacy Policy
</a>{' '}
{' '}
<a
href={`${baseUrl}/terms`}
style={{
color: '#706a7b !important',
textDecoration: 'underline',
fontWeight: 'normal',
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
}}
rel='noopener noreferrer'
>
Terms of Service
</a>{' '}
{' '}
<a
href={
unsubscribe?.unsubscribeToken && unsubscribe?.email
? `${baseUrl}/unsubscribe?token=${unsubscribe.unsubscribeToken}&email=${encodeURIComponent(unsubscribe.email)}`
: `mailto:${brand.supportEmail}?subject=Unsubscribe%20Request&body=Please%20unsubscribe%20me%20from%20all%20emails.`
}
style={{
color: '#706a7b !important',
textDecoration: 'underline',
fontWeight: 'normal',
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
}}
rel='noopener noreferrer'
>
Unsubscribe
</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</Section>
</Container>
)
}
export default EmailFooter

View File

@@ -1,134 +0,0 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { format } from 'date-fns'
import { baseStyles } from '@/components/emails/base-styles'
import EmailFooter from '@/components/emails/footer'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface HelpConfirmationEmailProps {
userEmail?: string
type?: 'bug' | 'feedback' | 'feature_request' | 'other'
attachmentCount?: number
submittedDate?: Date
}
const getTypeLabel = (type: string) => {
switch (type) {
case 'bug':
return 'Bug Report'
case 'feedback':
return 'Feedback'
case 'feature_request':
return 'Feature Request'
case 'other':
return 'General Inquiry'
default:
return 'Request'
}
}
export const HelpConfirmationEmail = ({
userEmail = '',
type = 'other',
attachmentCount = 0,
submittedDate = new Date(),
}: HelpConfirmationEmailProps) => {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const typeLabel = getTypeLabel(type)
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>Your {typeLabel.toLowerCase()} has been received</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
Thank you for your <strong>{typeLabel.toLowerCase()}</strong> submission. We've
received your request and will get back to you as soon as possible.
</Text>
{attachmentCount > 0 && (
<Text style={baseStyles.paragraph}>
You attached{' '}
<strong>
{attachmentCount} image{attachmentCount > 1 ? 's' : ''}
</strong>{' '}
with your request.
</Text>
)}
<Text style={baseStyles.paragraph}>
We typically respond to{' '}
{type === 'bug'
? 'bug reports'
: type === 'feature_request'
? 'feature requests'
: 'inquiries'}{' '}
within a few hours. If you need immediate assistance, please don't hesitate to reach
out to us directly.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The {brand.name} Team
</Text>
<Text
style={{
...baseStyles.footerText,
marginTop: '40px',
textAlign: 'left',
color: '#666666',
}}
>
This confirmation was sent on {format(submittedDate, 'MMMM do, yyyy')} for your{' '}
{typeLabel.toLowerCase()} submission from {userEmail}.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default HelpConfirmationEmail

View File

@@ -1,12 +1,17 @@
export * from './base-styles'
export { BatchInvitationEmail } from './batch-invitation-email'
export { EnterpriseSubscriptionEmail } from './billing/enterprise-subscription-email'
export { PlanWelcomeEmail } from './billing/plan-welcome-email'
export { UsageThresholdEmail } from './billing/usage-threshold-email'
export { default as EmailFooter } from './footer'
export { HelpConfirmationEmail } from './help-confirmation-email'
export { InvitationEmail } from './invitation-email'
export { OTPVerificationEmail } from './otp-verification-email'
export * from './render-email'
export { ResetPasswordEmail } from './reset-password-email'
export { WorkspaceInvitationEmail } from './workspace-invitation'
// Styles
export * from './_styles'
// Auth emails
export * from './auth'
// Billing emails
export * from './billing'
// Careers emails
export * from './careers'
// Shared components
export * from './components'
// Invitation emails
export * from './invitations'
// Render functions and subjects
export * from './render'
export * from './subjects'
// Support emails
export * from './support'

View File

@@ -1,125 +0,0 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { createLogger } from '@sim/logger'
import { format } from 'date-fns'
import { baseStyles } from '@/components/emails/base-styles'
import EmailFooter from '@/components/emails/footer'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface InvitationEmailProps {
inviterName?: string
organizationName?: string
inviteLink?: string
invitedEmail?: string
updatedDate?: Date
}
const logger = createLogger('InvitationEmail')
export const InvitationEmail = ({
inviterName = 'A team member',
organizationName = 'an organization',
inviteLink = '',
invitedEmail = '',
updatedDate = new Date(),
}: InvitationEmailProps) => {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
// Extract invitation ID or token from inviteLink if present
let enhancedLink = inviteLink
// Check if link contains an ID (old format) and append token parameter if needed
if (inviteLink && !inviteLink.includes('token=')) {
try {
const url = new URL(inviteLink)
const invitationId = url.pathname.split('/').pop()
if (invitationId) {
enhancedLink = `${baseUrl}/invite/${invitationId}?token=${invitationId}`
}
} catch (e) {
logger.error('Error parsing invite link:', e)
}
}
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>You've been invited to join {organizationName} on Sim</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> has invited you to join{' '}
<strong>{organizationName}</strong> on Sim. Sim is a powerful, user-friendly platform
for building, testing, and optimizing agentic workflows.
</Text>
<Link href={enhancedLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
<Text style={baseStyles.paragraph}>
This invitation will expire in 48 hours. If you believe this invitation was sent in
error, please ignore this email.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text
style={{
...baseStyles.footerText,
marginTop: '40px',
textAlign: 'left',
color: '#666666',
}}
>
This email was sent on {format(updatedDate, 'MMMM do, yyyy')} to {invitedEmail} with
an invitation to join {organizationName} on Sim.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default InvitationEmail

View File

@@ -0,0 +1,105 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
interface WorkspaceInvitation {
workspaceId: string
workspaceName: string
permission: 'admin' | 'write' | 'read'
}
interface BatchInvitationEmailProps {
inviterName: string
organizationName: string
organizationRole: 'admin' | 'member'
workspaceInvitations: WorkspaceInvitation[]
acceptUrl: string
}
const getPermissionLabel = (permission: string) => {
switch (permission) {
case 'admin':
return 'Admin (full access)'
case 'write':
return 'Editor (can edit workflows)'
case 'read':
return 'Viewer (read-only access)'
default:
return permission
}
}
const getRoleLabel = (role: string) => {
switch (role) {
case 'admin':
return 'Admin'
case 'member':
return 'Member'
default:
return role
}
}
export function BatchInvitationEmail({
inviterName = 'Someone',
organizationName = 'the team',
organizationRole = 'member',
workspaceInvitations = [],
acceptUrl,
}: BatchInvitationEmailProps) {
const brand = getBrandConfig()
const hasWorkspaces = workspaceInvitations.length > 0
return (
<EmailLayout
preview={`You've been invited to join ${organizationName}${hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}`}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> has invited you to join <strong>{organizationName}</strong>{' '}
on {brand.name}.
</Text>
{/* Team Role Information */}
<Text style={baseStyles.paragraph}>
<strong>Team Role:</strong> {getRoleLabel(organizationRole)}
</Text>
<Text style={baseStyles.paragraph}>
{organizationRole === 'admin'
? "As a Team Admin, you'll be able to manage team members, billing, and workspace access."
: "As a Team Member, you'll have access to shared team billing and can be invited to workspaces."}
</Text>
{/* Workspace Invitations */}
{hasWorkspaces && (
<>
<Text style={baseStyles.paragraph}>
<strong>
Workspace Access ({workspaceInvitations.length} workspace
{workspaceInvitations.length !== 1 ? 's' : ''}):
</strong>
</Text>
{workspaceInvitations.map((ws) => (
<Text key={ws.workspaceId} style={{ ...baseStyles.paragraph, marginLeft: '20px' }}>
<strong>{ws.workspaceName}</strong> - {getPermissionLabel(ws.permission)}
</Text>
))}
</>
)}
<Link href={acceptUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Invitation expires in 7 days. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
)
}
export default BatchInvitationEmail

View File

@@ -0,0 +1,3 @@
export { BatchInvitationEmail } from './batch-invitation-email'
export { InvitationEmail } from './invitation-email'
export { WorkspaceInvitationEmail } from './workspace-invitation-email'

View File

@@ -0,0 +1,60 @@
import { Link, Text } from '@react-email/components'
import { createLogger } from '@sim/logger'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface InvitationEmailProps {
inviterName?: string
organizationName?: string
inviteLink?: string
}
const logger = createLogger('InvitationEmail')
export function InvitationEmail({
inviterName = 'A team member',
organizationName = 'an organization',
inviteLink = '',
}: InvitationEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
let enhancedLink = inviteLink
if (inviteLink && !inviteLink.includes('token=')) {
try {
const url = new URL(inviteLink)
const invitationId = url.pathname.split('/').pop()
if (invitationId) {
enhancedLink = `${baseUrl}/invite/${invitationId}?token=${invitationId}`
}
} catch (e) {
logger.error('Error parsing invite link:', e)
}
}
return (
<EmailLayout preview={`You've been invited to join ${organizationName} on ${brand.name}`}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}
{brand.name}.
</Text>
<Link href={enhancedLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Invitation expires in 48 hours. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
)
}
export default InvitationEmail

View File

@@ -0,0 +1,65 @@
import { Link, Text } from '@react-email/components'
import { createLogger } from '@sim/logger'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('WorkspaceInvitationEmail')
interface WorkspaceInvitationEmailProps {
workspaceName?: string
inviterName?: string
invitationLink?: string
}
export function WorkspaceInvitationEmail({
workspaceName = 'Workspace',
inviterName = 'Someone',
invitationLink = '',
}: WorkspaceInvitationEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
let enhancedLink = invitationLink
try {
if (
invitationLink.includes('/api/workspaces/invitations/accept') ||
invitationLink.match(/\/api\/workspaces\/invitations\/[^?]+\?token=/)
) {
const url = new URL(invitationLink)
const token = url.searchParams.get('token')
if (token) {
enhancedLink = `${baseUrl}/invite/${token}?token=${token}`
}
}
} catch (e) {
logger.error('Error enhancing invitation link:', e)
}
return (
<EmailLayout
preview={`You've been invited to join the "${workspaceName}" workspace on ${brand.name}!`}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited you to join the <strong>{workspaceName}</strong>{' '}
workspace on {brand.name}.
</Text>
<Link href={enhancedLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Invitation expires in 7 days. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
)
}
export default WorkspaceInvitationEmail

View File

@@ -1,114 +0,0 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { baseStyles } from '@/components/emails/base-styles'
import EmailFooter from '@/components/emails/footer'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface OTPVerificationEmailProps {
otp: string
email?: string
type?: 'sign-in' | 'email-verification' | 'forget-password' | 'chat-access'
chatTitle?: string
}
const getSubjectByType = (type: string, brandName: string, chatTitle?: string) => {
switch (type) {
case 'sign-in':
return `Sign in to ${brandName}`
case 'email-verification':
return `Verify your email for ${brandName}`
case 'forget-password':
return `Reset your ${brandName} password`
case 'chat-access':
return `Verification code for ${chatTitle || 'Chat'}`
default:
return `Verification code for ${brandName}`
}
}
export const OTPVerificationEmail = ({
otp,
email = '',
type = 'email-verification',
chatTitle,
}: OTPVerificationEmailProps) => {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
// Get a message based on the type
const getMessage = () => {
switch (type) {
case 'sign-in':
return `Sign in to ${brand.name}`
case 'forget-password':
return `Reset your password for ${brand.name}`
case 'chat-access':
return `Access ${chatTitle || 'the chat'}`
default:
return `Welcome to ${brand.name}`
}
}
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>{getSubjectByType(type, brand.name, chatTitle)}</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>{getMessage()}</Text>
<Text style={baseStyles.paragraph}>Your verification code is:</Text>
<Section style={baseStyles.codeContainer}>
<Text style={baseStyles.code}>{otp}</Text>
</Section>
<Text style={baseStyles.paragraph}>This code will expire in 15 minutes.</Text>
<Text style={baseStyles.paragraph}>
If you didn't request this code, you can safely ignore this email.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default OTPVerificationEmail

View File

@@ -1,19 +1,31 @@
import { render } from '@react-email/components'
import { OTPVerificationEmail, ResetPasswordEmail, WelcomeEmail } from '@/components/emails/auth'
import {
CreditPurchaseEmail,
EnterpriseSubscriptionEmail,
FreeTierUpgradeEmail,
PaymentFailedEmail,
PlanWelcomeEmail,
UsageThresholdEmail,
} from '@/components/emails/billing'
import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/emails/careers'
import {
BatchInvitationEmail,
EnterpriseSubscriptionEmail,
HelpConfirmationEmail,
InvitationEmail,
OTPVerificationEmail,
PlanWelcomeEmail,
ResetPasswordEmail,
UsageThresholdEmail,
} from '@/components/emails'
import CreditPurchaseEmail from '@/components/emails/billing/credit-purchase-email'
import FreeTierUpgradeEmail from '@/components/emails/billing/free-tier-upgrade-email'
import { getBrandConfig } from '@/lib/branding/branding'
WorkspaceInvitationEmail,
} from '@/components/emails/invitations'
import { HelpConfirmationEmail } from '@/components/emails/support'
import { getBaseUrl } from '@/lib/core/utils/urls'
export type { EmailSubjectType } from './subjects'
export { getEmailSubject } from './subjects'
interface WorkspaceInvitation {
workspaceId: string
workspaceName: string
permission: 'admin' | 'write' | 'read'
}
export async function renderOTPEmail(
otp: string,
email: string,
@@ -27,34 +39,23 @@ export async function renderPasswordResetEmail(
username: string,
resetLink: string
): Promise<string> {
return await render(
ResetPasswordEmail({ username, resetLink: resetLink, updatedDate: new Date() })
)
return await render(ResetPasswordEmail({ username, resetLink }))
}
export async function renderInvitationEmail(
inviterName: string,
organizationName: string,
invitationUrl: string,
email: string
invitationUrl: string
): Promise<string> {
return await render(
InvitationEmail({
inviterName,
organizationName,
inviteLink: invitationUrl,
invitedEmail: email,
updatedDate: new Date(),
})
)
}
interface WorkspaceInvitation {
workspaceId: string
workspaceName: string
permission: 'admin' | 'write' | 'read'
}
export async function renderBatchInvitationEmail(
inviterName: string,
organizationName: string,
@@ -74,13 +75,11 @@ export async function renderBatchInvitationEmail(
}
export async function renderHelpConfirmationEmail(
userEmail: string,
type: 'bug' | 'feedback' | 'feature_request' | 'other',
attachmentCount = 0
): Promise<string> {
return await render(
HelpConfirmationEmail({
userEmail,
type,
attachmentCount,
submittedDate: new Date(),
@@ -88,19 +87,14 @@ export async function renderHelpConfirmationEmail(
)
}
export async function renderEnterpriseSubscriptionEmail(
userName: string,
userEmail: string
): Promise<string> {
export async function renderEnterpriseSubscriptionEmail(userName: string): Promise<string> {
const baseUrl = getBaseUrl()
const loginLink = `${baseUrl}/login`
return await render(
EnterpriseSubscriptionEmail({
userName,
userEmail,
loginLink,
createdDate: new Date(),
})
)
}
@@ -121,7 +115,6 @@ export async function renderUsageThresholdEmail(params: {
currentUsage: params.currentUsage,
limit: params.limit,
ctaLink: params.ctaLink,
updatedDate: new Date(),
})
)
}
@@ -140,61 +133,10 @@ export async function renderFreeTierUpgradeEmail(params: {
currentUsage: params.currentUsage,
limit: params.limit,
upgradeLink: params.upgradeLink,
updatedDate: new Date(),
})
)
}
export function getEmailSubject(
type:
| 'sign-in'
| 'email-verification'
| 'forget-password'
| 'reset-password'
| 'invitation'
| 'batch-invitation'
| 'help-confirmation'
| 'enterprise-subscription'
| 'usage-threshold'
| 'free-tier-upgrade'
| 'plan-welcome-pro'
| 'plan-welcome-team'
| 'credit-purchase'
): string {
const brandName = getBrandConfig().name
switch (type) {
case 'sign-in':
return `Sign in to ${brandName}`
case 'email-verification':
return `Verify your email for ${brandName}`
case 'forget-password':
return `Reset your ${brandName} password`
case 'reset-password':
return `Reset your ${brandName} password`
case 'invitation':
return `You've been invited to join a team on ${brandName}`
case 'batch-invitation':
return `You've been invited to join a team and workspaces on ${brandName}`
case 'help-confirmation':
return 'Your request has been received'
case 'enterprise-subscription':
return `Your Enterprise Plan is now active on ${brandName}`
case 'usage-threshold':
return `You're nearing your monthly budget on ${brandName}`
case 'free-tier-upgrade':
return `You're at 90% of your free credits on ${brandName}`
case 'plan-welcome-pro':
return `Your Pro plan is now active on ${brandName}`
case 'plan-welcome-team':
return `Your Team plan is now active on ${brandName}`
case 'credit-purchase':
return `Credits added to your ${brandName} account`
default:
return brandName
}
}
export async function renderPlanWelcomeEmail(params: {
planName: 'Pro' | 'Team'
userName?: string
@@ -205,11 +147,14 @@ export async function renderPlanWelcomeEmail(params: {
planName: params.planName,
userName: params.userName,
loginLink: params.loginLink,
createdDate: new Date(),
})
)
}
export async function renderWelcomeEmail(userName?: string): Promise<string> {
return await render(WelcomeEmail({ userName }))
}
export async function renderCreditPurchaseEmail(params: {
userName?: string
amount: number
@@ -224,3 +169,73 @@ export async function renderCreditPurchaseEmail(params: {
})
)
}
export async function renderWorkspaceInvitationEmail(
inviterName: string,
workspaceName: string,
invitationLink: string
): Promise<string> {
return await render(
WorkspaceInvitationEmail({
inviterName,
workspaceName,
invitationLink,
})
)
}
export async function renderPaymentFailedEmail(params: {
userName?: string
amountDue: number
lastFourDigits?: string
billingPortalUrl: string
failureReason?: string
}): Promise<string> {
return await render(
PaymentFailedEmail({
userName: params.userName,
amountDue: params.amountDue,
lastFourDigits: params.lastFourDigits,
billingPortalUrl: params.billingPortalUrl,
failureReason: params.failureReason,
})
)
}
export async function renderCareersConfirmationEmail(
name: string,
position: string
): Promise<string> {
return await render(
CareersConfirmationEmail({
name,
position,
})
)
}
export async function renderCareersSubmissionEmail(params: {
name: string
email: string
phone?: string
position: string
linkedin?: string
portfolio?: string
experience: string
location: string
message: string
}): Promise<string> {
return await render(
CareersSubmissionEmail({
name: params.name,
email: params.email,
phone: params.phone,
position: params.position,
linkedin: params.linkedin,
portfolio: params.portfolio,
experience: params.experience,
location: params.location,
message: params.message,
})
)
}

View File

@@ -1,101 +0,0 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { format } from 'date-fns'
import { baseStyles } from '@/components/emails/base-styles'
import EmailFooter from '@/components/emails/footer'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface ResetPasswordEmailProps {
username?: string
resetLink?: string
updatedDate?: Date
}
export const ResetPasswordEmail = ({
username = '',
resetLink = '',
updatedDate = new Date(),
}: ResetPasswordEmailProps) => {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>Reset your {brand.name} password</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello {username},</Text>
<Text style={baseStyles.paragraph}>
You recently requested to reset your password for your {brand.name} account. Use the
button below to reset it. This password reset is only valid for the next 24 hours.
</Text>
<Link href={resetLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Reset Your Password</Text>
</Link>
<Text style={baseStyles.paragraph}>
If you did not request a password reset, please ignore this email or contact support
if you have concerns.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The {brand.name} Team
</Text>
<Text
style={{
...baseStyles.footerText,
marginTop: '40px',
textAlign: 'left',
color: '#666666',
}}
>
This email was sent on {format(updatedDate, 'MMMM do, yyyy')} because a password reset
was requested for your account.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default ResetPasswordEmail

View File

@@ -0,0 +1,60 @@
import { getBrandConfig } from '@/lib/branding/branding'
/** Email subject type for all supported email templates */
export type EmailSubjectType =
| 'sign-in'
| 'email-verification'
| 'forget-password'
| 'reset-password'
| 'invitation'
| 'batch-invitation'
| 'help-confirmation'
| 'enterprise-subscription'
| 'usage-threshold'
| 'free-tier-upgrade'
| 'plan-welcome-pro'
| 'plan-welcome-team'
| 'credit-purchase'
| 'welcome'
/**
* Returns the email subject line for a given email type.
* @param type - The type of email being sent
* @returns The subject line for the email
*/
export function getEmailSubject(type: EmailSubjectType): string {
const brandName = getBrandConfig().name
switch (type) {
case 'sign-in':
return `Sign in to ${brandName}`
case 'email-verification':
return `Verify your email for ${brandName}`
case 'forget-password':
return `Reset your ${brandName} password`
case 'reset-password':
return `Reset your ${brandName} password`
case 'invitation':
return `You've been invited to join a team on ${brandName}`
case 'batch-invitation':
return `You've been invited to join a team and workspaces on ${brandName}`
case 'help-confirmation':
return 'Your request has been received'
case 'enterprise-subscription':
return `Your Enterprise Plan is now active on ${brandName}`
case 'usage-threshold':
return `You're nearing your monthly budget on ${brandName}`
case 'free-tier-upgrade':
return `You're at 90% of your free credits on ${brandName}`
case 'plan-welcome-pro':
return `Your Pro plan is now active on ${brandName}`
case 'plan-welcome-team':
return `Your Team plan is now active on ${brandName}`
case 'credit-purchase':
return `Credits added to your ${brandName} account`
case 'welcome':
return `Welcome to ${brandName}`
default:
return brandName
}
}

Some files were not shown because too many files have changed in this diff Show More