diff --git a/backend/migrations/20250905153025_add_email_triggers.up.sql b/backend/migrations/20250905153025_add_email_triggers.up.sql index c7fe606690..97399d7675 100644 --- a/backend/migrations/20250905153025_add_email_triggers.up.sql +++ b/backend/migrations/20250905153025_add_email_triggers.up.sql @@ -63,4 +63,8 @@ CREATE POLICY see_extra_perms_groups_delete ON email_trigger FOR DELETE TO wind USING (exists( SELECT key, value FROM jsonb_each_text(extra_perms) WHERE SPLIT_PART(key, '/', 1) = 'g' AND key = ANY(regexp_split_to_array(current_setting('session.pgroups'), ',')::text[]) - AND value::boolean)); \ No newline at end of file + AND value::boolean)); + +ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'runnable_email'; +UPDATE TABLE capture SET trigger_kind = 'runnable_email' WHERE trigger_kind = 'email'; +UPDATE TABLE capture_config SET trigger_kind = 'runnable_email' WHERE trigger_kind = 'email'; \ No newline at end of file diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 836ac97cb5..183a29233e 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -10814,6 +10814,171 @@ paths: schema: type: string + /w/{workspace}/email_triggers/create: + post: + summary: create email trigger + operationId: createEmailTrigger + tags: + - email_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: new email trigger + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewEmailTrigger" + responses: + "201": + description: email trigger created + content: + text/plain: + schema: + type: string + + /w/{workspace}/email_triggers/update/{path}: + post: + summary: update email trigger + operationId: updateEmailTrigger + tags: + - email_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Path" + requestBody: + description: updated trigger + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EditEmailTrigger" + responses: + "200": + description: email trigger updated + content: + text/plain: + schema: + type: string + + /w/{workspace}/email_triggers/delete/{path}: + delete: + summary: delete email trigger + operationId: deleteEmailTrigger + tags: + - email_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Path" + responses: + "200": + description: email trigger deleted + content: + text/plain: + schema: + type: string + + /w/{workspace}/email_triggers/get/{path}: + get: + summary: get email trigger + operationId: getEmailTrigger + tags: + - email_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Path" + responses: + "200": + description: email trigger deleted + content: + application/json: + schema: + $ref: "#/components/schemas/EmailTrigger" + + /w/{workspace}/email_triggers/list: + get: + summary: list email triggers + operationId: listEmailTriggers + tags: + - email_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + required: true + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PerPage" + - name: path + description: filter by path + in: query + schema: + type: string + - name: is_flow + in: query + schema: + type: boolean + - name: path_start + in: query + schema: + type: string + responses: + "200": + description: email trigger list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/EmailTrigger" + + /w/{workspace}/email_triggers/exists/{path}: + get: + summary: does email trigger exists + operationId: existsEmailTrigger + tags: + - email_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Path" + responses: + "200": + description: email trigger exists + content: + application/json: + schema: + type: boolean + + /w/{workspace}/email_triggers/local_part_exists: + post: + summary: does email local part exists + operationId: existsEmailLocalPart + tags: + - email_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: email local part exists request + required: true + content: + application/json: + schema: + type: object + properties: + local_part: + type: string + workspaced_local_part: + type: boolean + trigger_path: + type: string + + required: + - local_part + responses: + "200": + description: email local part exists + content: + application/json: + schema: + type: boolean + /groups/list: get: summary: list instance groups @@ -11899,6 +12064,7 @@ paths: mqtt_trigger, gcp_trigger, sqs_trigger, + email_trigger, ] responses: "200": @@ -11943,6 +12109,7 @@ paths: mqtt_trigger, gcp_trigger, sqs_trigger, + email_trigger, ] requestBody: description: acl to add @@ -11998,6 +12165,7 @@ paths: mqtt_trigger, gcp_trigger, sqs_trigger, + email_trigger, ] requestBody: description: acl to add @@ -16031,6 +16199,8 @@ components: type: number email_count: type: number + runnable_email_count: + type: number websocket_count: type: number postgres_count: @@ -16986,6 +17156,75 @@ components: - subjects - is_flow + EmailTrigger: + allOf: + - $ref: "#/components/schemas/TriggerExtraProperty" + type: object + properties: + local_part: + type: string + workspaced_local_part: + type: boolean + error_handler_path: + type: string + error_handler_args: + $ref: "#/components/schemas/ScriptArgs" + retry: + $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" + + required: + - local_part + + NewEmailTrigger: + type: object + properties: + path: + type: string + script_path: + type: string + local_part: + type: string + workspaced_local_part: + type: boolean + is_flow: + type: boolean + error_handler_path: + type: string + error_handler_args: + $ref: "#/components/schemas/ScriptArgs" + retry: + $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" + + required: + - path + - script_path + - local_part + - is_flow + + EditEmailTrigger: + type: object + properties: + path: + type: string + script_path: + type: string + local_part: + type: string + workspaced_local_part: + type: boolean + is_flow: + type: boolean + error_handler_path: + type: string + error_handler_args: + $ref: "#/components/schemas/ScriptArgs" + retry: + $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" + required: + - path + - script_path + - is_flow + Group: type: object properties: @@ -18041,7 +18280,7 @@ components: CaptureTriggerKind: type: string enum: - [webhook, http, websocket, kafka, email, nats, postgres, sqs, mqtt, gcp] + [webhook, http, websocket, kafka, runnable_email, nats, postgres, sqs, mqtt, gcp, email] Capture: type: object diff --git a/backend/windmill-api/src/capture.rs b/backend/windmill-api/src/capture.rs index 0b56bd143d..139db01489 100644 --- a/backend/windmill-api/src/capture.rs +++ b/backend/windmill-api/src/capture.rs @@ -146,6 +146,12 @@ struct HttpTriggerConfig { wrap_body: Option, } +#[cfg(feature = "http_trigger")] +#[derive(Serialize, Deserialize)] +struct EmailTriggerConfig { + local_part: String, +} + #[cfg(all(feature = "enterprise", feature = "kafka", feature = "private"))] #[derive(Serialize, Deserialize)] pub struct KafkaTriggerConfig { @@ -241,6 +247,8 @@ enum TriggerConfig { Mqtt(MqttTriggerConfig), #[cfg(all(feature = "enterprise", feature = "gcp_trigger", feature = "private"))] Gcp(GcpTriggerConfig), + #[cfg(all(feature = "enterprise", feature = "smtp", feature = "private"))] + Email(EmailTriggerConfig), } #[derive(Serialize, Deserialize)] diff --git a/backend/windmill-api/src/granular_acls.rs b/backend/windmill-api/src/granular_acls.rs index 0cdac8c3f0..a9612d4a7e 100644 --- a/backend/windmill-api/src/granular_acls.rs +++ b/backend/windmill-api/src/granular_acls.rs @@ -23,7 +23,7 @@ use windmill_common::{ utils::{not_found_if_none, StripPath}, }; -const KINDS: [&str; 14] = [ +const KINDS: [&str; 18] = [ "script", "group_", "resource", @@ -37,7 +37,11 @@ const KINDS: [&str; 14] = [ "websocket_trigger", "kafka_trigger", "nats_trigger", - "mqtt_trigger" + "postgres_trigger", + "mqtt_trigger", + "gcp_trigger", + "sqs_trigger", + "email_trigger", ]; pub fn workspaced_service() -> Router { @@ -131,7 +135,7 @@ async fn add_granular_acl( &w_id, DeployedObject::Folder { path: format!("f/{}", path) }, Some(format!("Folder '{}' changed permissions", path)), - true, + true, ) .await? } @@ -243,7 +247,7 @@ async fn remove_granular_acl( &w_id, DeployedObject::Folder { path: format!("f/{}", path) }, Some(format!("Folder '{}' changed permissions", path)), - true, + true, ) .await? } diff --git a/backend/windmill-api/src/scopes.rs b/backend/windmill-api/src/scopes.rs index 70f2122db5..758684d1db 100644 --- a/backend/windmill-api/src/scopes.rs +++ b/backend/windmill-api/src/scopes.rs @@ -255,6 +255,7 @@ pub enum ScopeDomain { SqsTriggers, GcpTriggers, PostgresTriggers, + EmailTriggers, // System domains Audit, @@ -308,6 +309,7 @@ impl ScopeDomain { Self::SqsTriggers => "sqs_triggers", Self::GcpTriggers => "gcp_triggers", Self::PostgresTriggers => "postgres_triggers", + Self::EmailTriggers => "email_triggers", Self::Audit => "audit", Self::Settings => "settings", Self::Workers => "workers", @@ -354,6 +356,7 @@ impl ScopeDomain { "sqs_triggers" => Some(Self::SqsTriggers), "gcp_triggers" => Some(Self::GcpTriggers), "postgres_triggers" => Some(Self::PostgresTriggers), + "email_triggers" => Some(Self::EmailTriggers), "audit" => Some(Self::Audit), "settings" => Some(Self::Settings), "workers" => Some(Self::Workers), diff --git a/backend/windmill-api/src/token.rs b/backend/windmill-api/src/token.rs index 9ed17dfc6a..f38ce642e6 100644 --- a/backend/windmill-api/src/token.rs +++ b/backend/windmill-api/src/token.rs @@ -27,6 +27,7 @@ fn build_trigger_scope_domains() -> Vec { ("sqs_triggers", "AWS SQS"), ("gcp_triggers", "GCP Pub/Sub"), ("postgres_triggers", "PostgreSQL"), + ("email_triggers", "Email"), ]; TRIGGER_DOMAINS diff --git a/backend/windmill-api/src/triggers/handler.rs b/backend/windmill-api/src/triggers/handler.rs index 6696f1fb79..9ca46a7307 100644 --- a/backend/windmill-api/src/triggers/handler.rs +++ b/backend/windmill-api/src/triggers/handler.rs @@ -736,6 +736,16 @@ pub fn generate_trigger_routers() -> Router { ); } + #[cfg(all(feature = "enterprise", feature = "smtp", feature = "private"))] + { + use crate::triggers::email::EmailTrigger; + + router = router.nest( + EmailTrigger::ROUTE_PREFIX, + complete_trigger_routes(EmailTrigger), + ); + } + router } @@ -751,6 +761,7 @@ pub struct TriggersCount { http_routes_count: i64, webhook_count: i64, email_count: i64, + runnable_email_count: i64, websocket_count: i64, kafka_count: i64, nats_count: i64, @@ -871,6 +882,18 @@ pub async fn get_triggers_count_internal( }; #[cfg(not(all(feature = "gcp_trigger", feature = "enterprise", feature = "private")))] let gcp_count = 0; + + #[cfg(all(feature = "smtp", feature = "enterprise", feature = "private"))] + let email_count = { + use crate::triggers::email::EmailTrigger; + let count = EmailTrigger + .trigger_count(&mut tx, w_id, is_flow, path) + .await; + count + }; + #[cfg(not(all(feature = "smtp", feature = "enterprise", feature = "private")))] + let email_count = 0; + tx.commit().await?; let webhook_count = (if is_flow { @@ -889,7 +912,7 @@ pub async fn get_triggers_count_internal( .await? .unwrap_or(0); - let email_count = (if is_flow { + let runnable_email_count = (if is_flow { sqlx::query_scalar!( "SELECT COUNT(*) FROM token WHERE label LIKE 'email-%' AND workspace_id = $1 AND scopes @> ARRAY['run:flow/' || $2]::text[]", w_id, @@ -910,6 +933,7 @@ pub async fn get_triggers_count_internal( schedule_count, http_routes_count, webhook_count, + runnable_email_count, email_count, websocket_count, kafka_count, diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index c2b9e448c2..037f67c995 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -2012,6 +2012,7 @@ struct UsedTriggers { pub mqtt_used: bool, pub sqs_used: bool, pub gcp_used: bool, + pub email_used: bool, } async fn get_used_triggers( @@ -2031,7 +2032,8 @@ async fn get_used_triggers( EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS "postgres_used!", EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS "mqtt_used!", EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS "sqs_used!", - EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS "gcp_used!" + EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS "gcp_used!", + EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS "email_used!" "#, w_id ) diff --git a/backend/windmill-common/src/triggers.rs b/backend/windmill-common/src/triggers.rs index 08356f24ad..2d99129da2 100644 --- a/backend/windmill-common/src/triggers.rs +++ b/backend/windmill-common/src/triggers.rs @@ -5,13 +5,14 @@ use std::fmt; use strum_macros::EnumIter; #[derive(sqlx::Type, Serialize, Deserialize, Debug, Clone, Eq, PartialEq, Hash, EnumIter)] -#[sqlx(type_name = "TRIGGER_KIND", rename_all = "lowercase")] -#[serde(rename_all = "lowercase")] +#[sqlx(type_name = "TRIGGER_KIND", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] pub enum TriggerKind { Webhook, Http, Websocket, Kafka, + RunnableEmail, Email, Nats, Mqtt, @@ -28,6 +29,7 @@ impl TriggerKind { TriggerKind::Websocket => "websocket".to_string(), TriggerKind::Kafka => "kafka".to_string(), TriggerKind::Email => "email".to_string(), + TriggerKind::RunnableEmail => "runnable_email".to_string(), TriggerKind::Nats => "nats".to_string(), TriggerKind::Mqtt => "mqtt".to_string(), TriggerKind::Sqs => "sqs".to_string(), @@ -45,6 +47,7 @@ impl fmt::Display for TriggerKind { TriggerKind::Websocket => "websocket", TriggerKind::Kafka => "kafka", TriggerKind::Email => "email", + TriggerKind::RunnableEmail => "runnable_email", TriggerKind::Nats => "nats", TriggerKind::Mqtt => "mqtt", TriggerKind::Sqs => "sqs", diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 3d72311db1..3a5bef5ab8 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -615,7 +615,7 @@ new Triggers( [ { type: 'webhook', path: '', isDraft: false }, - { type: 'email', path: '', isDraft: false }, + { type: 'runnable_email', path: '', isDraft: false }, ...(draftTriggersFromUrl ?? savedFlow?.draft?.draft_triggers ?? []) ], selectedTriggerIndexFromUrl, diff --git a/frontend/src/lib/components/Path.svelte b/frontend/src/lib/components/Path.svelte index 2d2abd8c18..5fdbc08a20 100644 --- a/frontend/src/lib/components/Path.svelte +++ b/frontend/src/lib/components/Path.svelte @@ -21,7 +21,8 @@ NatsTriggerService, MqttTriggerService, SqsTriggerService, - GcpTriggerService + GcpTriggerService, + EmailTriggerService } from '$lib/gen' import { superadmin, userStore, workspaceStore } from '$lib/stores' import { createEventDispatcher, getContext, untrack } from 'svelte' @@ -52,6 +53,7 @@ | 'mqtt_trigger' | 'sqs_trigger' | 'gcp_trigger' + | 'email_trigger' let meta: Meta | undefined = $state(undefined) interface Props { fullNamePlaceholder?: string | undefined @@ -290,6 +292,11 @@ workspace: $workspaceStore!, path: path }) + } else if (kind === 'email_trigger') { + return await EmailTriggerService.existsEmailTrigger({ + workspace: $workspaceStore!, + path: path + }) } else { return false } diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index dc5746d1e7..5467b1b0cf 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -251,7 +251,7 @@ new Triggers( [ { type: 'webhook', path: '', isDraft: false }, - { type: 'email', path: '', isDraft: false }, + { type: 'runnable_email', path: '', isDraft: false }, ...(script.draft_triggers ?? []) ], undefined, diff --git a/frontend/src/lib/components/ShareModal.svelte b/frontend/src/lib/components/ShareModal.svelte index e4dc7df614..68ed40b83c 100644 --- a/frontend/src/lib/components/ShareModal.svelte +++ b/frontend/src/lib/components/ShareModal.svelte @@ -33,6 +33,7 @@ | 'sqs_trigger' | 'postgres_trigger' | 'gcp_trigger' + | 'email_trigger' let kind: Kind let path: string = $state('') diff --git a/frontend/src/lib/components/details/DetailPageTriggerPanel.svelte b/frontend/src/lib/components/details/DetailPageTriggerPanel.svelte deleted file mode 100644 index 9c1b6b2392..0000000000 --- a/frontend/src/lib/components/details/DetailPageTriggerPanel.svelte +++ /dev/null @@ -1,184 +0,0 @@ - - - - -{#if !simplfiedPoll} -
- - - - - Webhooks - - - - - - Schedules - - - - - - HTTP - - - - - - WebSockets - - - - - - Postgres - - - - - - Event streams - - - - - - Email - - - - - - CLI - - - - {#snippet content()} -
- {#if triggerSelected === 'webhooks'} - {@render webhooks?.()} - {:else if triggerSelected === 'routes'} - {@render routes?.()} - {:else if triggerSelected === 'emails'} - {@render emails?.()} - {:else if triggerSelected === 'schedules'} - {@render schedules?.()} - {:else if triggerSelected === 'websockets'} - {@render websockets?.()} - {:else if triggerSelected === 'postgres'} - {@render postgres?.()} - {:else if triggerSelected === 'kafka' || triggerSelected === 'nats' || triggerSelected === 'sqs' || triggerSelected === 'mqtt' || triggerSelected === 'gcp'} -
- - {#snippet children({ item })} - - - - - - {/snippet} - -
- {#if eventStreamType === 'kafka'} - {@render kafka?.()} - {:else if eventStreamType === 'nats'} - {@render nats?.()} - {:else if eventStreamType === 'sqs'} - {@render sqs?.()} - {:else if eventStreamType === 'mqtt'} - {@render mqtt?.()} - {:else if eventStreamType === 'gcp'} - {@render gcp?.()} - {/if} - {:else if triggerSelected === 'cli'} - {@render cli?.()} - {/if} -
- {/snippet} -
-
-{:else} - {@render schedules?.()} -{/if} diff --git a/frontend/src/lib/components/details/EmailTriggerCaptures.svelte b/frontend/src/lib/components/details/RunnableEmailCapture.svelte similarity index 98% rename from frontend/src/lib/components/details/EmailTriggerCaptures.svelte rename to frontend/src/lib/components/details/RunnableEmailCapture.svelte index 80fa612f30..f6e91dba67 100644 --- a/frontend/src/lib/components/details/EmailTriggerCaptures.svelte +++ b/frontend/src/lib/components/details/RunnableEmailCapture.svelte @@ -40,7 +40,7 @@ {#if captureInfo} + To trigger the job by email, send an email to the address above. The job will receive two arguments: `raw_email` containing the raw email as string, and `parsed_email` containing the parsed email as an object. diff --git a/frontend/src/lib/components/details/EmailTriggerPanel.svelte b/frontend/src/lib/components/details/RunnableEmailPanel.svelte similarity index 71% rename from frontend/src/lib/components/details/EmailTriggerPanel.svelte rename to frontend/src/lib/components/details/RunnableEmailPanel.svelte index a38aa4c035..d2c6c9154b 100644 --- a/frontend/src/lib/components/details/EmailTriggerPanel.svelte +++ b/frontend/src/lib/components/details/RunnableEmailPanel.svelte @@ -4,13 +4,13 @@ import { generateRandomString } from '$lib/utils' import HighlightTheme from '../HighlightTheme.svelte' import Alert from '../common/alert/Alert.svelte' - import { SettingService } from '$lib/gen' import Skeleton from '../common/skeleton/Skeleton.svelte' import TriggerTokens from '../triggers/TriggerTokens.svelte' import Description from '../Description.svelte' import Section from '../Section.svelte' import { createEventDispatcher } from 'svelte' - import EmailTriggerConfigSection from './EmailTriggerConfigSection.svelte' + import RunnableEmailConfigSection from './RunnableEmailConfigSection.svelte' + import { getEmailDomain } from '../triggers/email/utils' let userSettings: UserSettings @@ -26,15 +26,11 @@ let triggerTokens: TriggerTokens | undefined = undefined let loading = true - async function getEmailDomain() { - emailDomain = - ((await SettingService.getGlobal({ - key: 'email_domain' - })) as any) ?? 'mail.test.com' - loading = false - } - getEmailDomain() + getEmailDomain().then((domain) => { + emailDomain = domain + loading = false + }) $: emailDomain && dispatch('email-domain', emailDomain) @@ -52,19 +48,20 @@ {scopes} /> -
+
- Email triggers execute scripts and flows when emails are sent to specific addresses. Each - trigger has its own unique email address that can be used to invoke the script or flow. + Runnable email is a partially fixed email address that can be used to trigger a script or flow. + The email address is composed of the encoded workspace and script or flow path as well as the + token. {#if loading} {:else} {#if emailDomain} - + {:else}
- + Ask an instance superadmin to setup the instance for email triggering (docs - Email triggers on Windmill Community Edition are limited to 100 emails per day. + Runnable emails on Windmill Community Edition are limited to 100 emails per day. {/if} diff --git a/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte b/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte index c45ec03615..6b1d49a676 100644 --- a/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte +++ b/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte @@ -1,5 +1,5 @@ + +{#if captureInfo} + + {#snippet description()} + {#if captureInfo.active} +

+ Send an email to the test address below to simulate an email trigger. +

+ {:else} +

+ Start capturing to listen to email events on this test address. +

+ {/if} + {/snippet} + +
+{/if} diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditor.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditor.svelte new file mode 100644 index 0000000000..77014d0dac --- /dev/null +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditor.svelte @@ -0,0 +1,35 @@ + + +{#if open} + +{/if} diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorConfigSection.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorConfigSection.svelte new file mode 100644 index 0000000000..d25b6ee4d0 --- /dev/null +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorConfigSection.svelte @@ -0,0 +1,166 @@ + + +
+
+ {#snippet header()} + {#if showTestingBadge} + + {/if} + {/snippet} + {#if !userCanEditConfig && isDraftOnly} + + Email triggers can only be edited by workspace admins + +
+ {/if} +
+ + +
+ + +
{dirtyLocalPart ? addressError : ''}
+ {#if !isCloudHosted()} +
+ { + workspaced_local_part = !workspaced_local_part + dirtyLocalPart = true + }} + options={{ + right: 'Prefix with workspace', + rightTooltip: + 'Prefixes the email address with the workspace ID (e.g., ${workspace_id}-${local_part}@). Note: deploying the email trigger to another workspace updates the email address workspace prefix accordingly.', + rightDocumentationLink: + 'https://www.windmill.dev/docs/advanced/email_triggers#workspace-prefix' + }} + /> +
+ {/if} +
+
+
+
diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte new file mode 100644 index 0000000000..e7a4a4c25c --- /dev/null +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -0,0 +1,383 @@ + + +{#snippet config()} + {#if drawerLoading} + {#if showLoader} + + {/if} + {:else} +
+
+
+ +
+
+ + {#if !hideTarget} +
+

+ Pick a script or flow to be triggered +

+
+
+ + + {#if emptyString(script_path)} + + {/if} +
+
+
+ {/if} + + + +
+
+
+ + Error Handler + Retries + +
+ +
+
+
+
+
+ {/if} +{/snippet} + +{#snippet saveButton()} + {#if !drawerLoading} + + {/if} +{/snippet} + +{#if useDrawer} + + drawer?.closeDrawer()} + > + {#snippet actions()} + {@render saveButton()} + {/snippet} + {@render config()} + + +{:else} +
+ {#snippet header()} + {#if customLabel} + {@render customLabel()} + {/if} + {/snippet} + {#snippet action()} + {@render saveButton()} + {/snippet} + {#if description} + {@render description()} + {/if} + {@render config()} +
+{/if} diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte new file mode 100644 index 0000000000..4aab7fa951 --- /dev/null +++ b/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte @@ -0,0 +1,56 @@ + + + + {#snippet description()} +
+ + Email triggers execute scripts and flows when emails are sent to specific addresses. Each + trigger can be configured with a specific local part. + + + {#if !$userStore?.is_admin && !$userStore?.is_super_admin && selectedTrigger.isDraft} + + {/if} +
+ {/snippet} +
diff --git a/frontend/src/lib/components/triggers/email/utils.ts b/frontend/src/lib/components/triggers/email/utils.ts new file mode 100644 index 0000000000..82de2a0a0d --- /dev/null +++ b/frontend/src/lib/components/triggers/email/utils.ts @@ -0,0 +1,68 @@ +import { isCloudHosted } from '$lib/cloud' +import { type NewEmailTrigger, EmailTriggerService, SettingService } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import type { Writable } from 'svelte/store' +import { get } from 'svelte/store' + +export function getEmailAddress( + local_part: string | undefined, + workspaced_local_part: boolean, + workspace_id: string, + emailDomain: string +) { + return `${isCloudHosted() || workspaced_local_part ? workspace_id + '-' : ''}${local_part ?? ''}@${emailDomain}` +} + +export async function saveEmailTriggerFromCfg( + initialPath: string, + routeCfg: Record, + edit: boolean, + workspace: string, + isAdmin: boolean, + usedTriggerKinds: Writable +): Promise { + const requestBody: NewEmailTrigger = { + path: routeCfg.path, + script_path: routeCfg.script_path, + local_part: routeCfg.local_part, + is_flow: routeCfg.is_flow, + workspaced_local_part: routeCfg.workspaced_local_part, + error_handler_path: routeCfg.error_handler_path, + error_handler_args: routeCfg.error_handler_path ? routeCfg.error_handler_args : undefined, + retry: routeCfg.retry + } + try { + if (edit) { + await EmailTriggerService.updateEmailTrigger({ + workspace: workspace, + path: initialPath, + requestBody: { + ...requestBody, + local_part: isAdmin || !edit ? routeCfg.local_part : undefined + } + }) + sendUserToast(`Route ${routeCfg.path} updated`) + } else { + await EmailTriggerService.createEmailTrigger({ + workspace: workspace, + requestBody: requestBody + }) + sendUserToast(`Route ${routeCfg.path} created`) + } + if (!get(usedTriggerKinds).includes('email')) { + usedTriggerKinds.update((t) => [...t, 'email']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } +} + +export async function getEmailDomain(): Promise { + return ( + ((await SettingService.getGlobal({ + key: 'email_domain' + })) as any) ?? 'mail.test.com' + ) +} diff --git a/frontend/src/lib/components/triggers/http/utils.ts b/frontend/src/lib/components/triggers/http/utils.ts index a41bdf2c31..7591ccc4bd 100644 --- a/frontend/src/lib/components/triggers/http/utils.ts +++ b/frontend/src/lib/components/triggers/http/utils.ts @@ -60,7 +60,7 @@ export async function saveHttpRouteFromCfg( summary: routeCfg.summary, error_handler_path: routeCfg.error_handler_path, error_handler_args: routeCfg.error_handler_path ? routeCfg.error_handler_args : undefined, - retry: routeCfg.retry, + retry: routeCfg.retry } try { if (edit) { diff --git a/frontend/src/lib/components/triggers/triggers.svelte.ts b/frontend/src/lib/components/triggers/triggers.svelte.ts index 916d4202fe..9025237643 100644 --- a/frontend/src/lib/components/triggers/triggers.svelte.ts +++ b/frontend/src/lib/components/triggers/triggers.svelte.ts @@ -13,7 +13,9 @@ import { type TriggersCount, type HttpTrigger, HttpTriggerService, - GcpTriggerService + GcpTriggerService, + type EmailTrigger, + EmailTriggerService } from '$lib/gen' import { getLightConfig, sortTriggers, updateTriggersCount, type Trigger } from './utils' import type { Writable } from 'svelte/store' @@ -430,6 +432,32 @@ export class Triggers { } } + async fetchEmailTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const emailTriggers: EmailTrigger[] = await EmailTriggerService.listEmailTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const emailCount = this.updateTriggers(emailTriggers, 'email', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + email_count: emailCount + } + }) + } catch (error) { + console.error('Failed to fetch email triggers:', error) + } + } + async fetchTriggers( triggersCountStore: Writable, workspaceId: string | undefined, @@ -450,7 +478,8 @@ export class Triggers { this.fetchNatsTriggers(triggersCountStore, workspaceId, path, isFlow, user), this.fetchMqttTriggers(triggersCountStore, workspaceId, path, isFlow, user), this.fetchSqsTriggers(triggersCountStore, workspaceId, path, isFlow, user), - this.fetchGcpTriggers(triggersCountStore, workspaceId, path, isFlow, user) + this.fetchGcpTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchEmailTriggers(triggersCountStore, workspaceId, path, isFlow, user) ]) } } diff --git a/frontend/src/lib/components/triggers/utils.ts b/frontend/src/lib/components/triggers/utils.ts index 3c6780921b..89848c10ae 100644 --- a/frontend/src/lib/components/triggers/utils.ts +++ b/frontend/src/lib/components/triggers/utils.ts @@ -1,4 +1,13 @@ -import { Webhook, Mail, Calendar, Route, Unplug, Database, Terminal } from 'lucide-svelte' +import { + Webhook, + Mail, + Calendar, + Route, + Unplug, + Database, + Terminal, + MailWarning +} from 'lucide-svelte' import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte' import NatsIcon from '$lib/components/icons/NatsIcon.svelte' import MqttIcon from '$lib/components/icons/MqttIcon.svelte' @@ -25,6 +34,7 @@ import { saveMqttTriggerFromCfg } from './mqtt/utils' import { saveGcpTriggerFromCfg } from './gcp/utils' import type { Triggers } from './triggers.svelte' import { emptyString } from '$lib/utils' +import { saveEmailTriggerFromCfg } from './email/utils' export const CLOUD_DISABLED_TRIGGER_TYPES = [ 'nats', @@ -38,6 +48,7 @@ export const CLOUD_DISABLED_TRIGGER_TYPES = [ export type TriggerType = | 'webhook' + | 'runnable_email' | 'email' | 'schedule' | 'http' @@ -48,6 +59,7 @@ export type TriggerType = | 'mqtt' | 'sqs' | 'gcp' + | 'email' | 'poll' | 'cli' @@ -68,6 +80,7 @@ export type Trigger = { export const triggerIconMap = { webhook: Webhook, email: Mail, + runnable_email: MailWarning, schedule: Calendar, http: Route, websocket: Unplug, @@ -92,6 +105,7 @@ export function triggerTypeToCaptureKind(triggerType: TriggerType): CaptureTrigg const capturableTriggerTypes: TriggerType[] = [ 'webhook', 'email', + 'runnable_email', 'http', 'websocket', 'postgres', @@ -120,7 +134,7 @@ export function updateTriggersCount( // Map trigger types to their corresponding count property names const countPropertyMap: Record = { webhook: undefined, - email: undefined, + runnable_email: undefined, schedule: 'schedule_count', http: 'http_routes_count', websocket: 'websocket_count', @@ -130,6 +144,7 @@ export function updateTriggersCount( mqtt: 'mqtt_count', sqs: 'sqs_count', gcp: 'gcp_count', + email: 'email_count', poll: undefined, cli: undefined } @@ -178,6 +193,8 @@ export function triggerKindToTriggerType(kind: TriggerKind): TriggerType | undef return 'webhook' case 'emails': return 'email' + case 'runnable_emails': + return 'runnable_email' case 'schedules': return 'schedule' case 'routes': @@ -225,7 +242,7 @@ export async function deployTriggers( // Map of trigger types to their save functions const triggerSaveFunctions: Record = { webhook: undefined, - email: undefined, + runnable_email: undefined, schedule: (trigger: Trigger) => { if (trigger.isPrimary && initialPath) { trigger.draftConfig = { @@ -301,6 +318,15 @@ export async function deployTriggers( workspaceId, usedTriggerKinds ), + email: (trigger: Trigger) => + saveEmailTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + isAdmin, + usedTriggerKinds + ), poll: undefined, cli: undefined } @@ -398,6 +424,8 @@ export function getLightConfig( return { queue_url: trigger.queue_url } } else if (triggerType === 'gcp') { return { gcp_resource_path: trigger.gcp_resource_path, topic: trigger.topic } + } else if (triggerType === 'email') { + return { local_part: trigger.local_part } } else { return undefined } @@ -409,8 +437,8 @@ export function getTriggerLabel(trigger: Trigger): string { if (type === 'webhook') { return 'Webhook' - } else if (type === 'email') { - return 'Email' + } else if (type === 'runnable_email') { + return 'Runnable email' } else if (type === 'cli') { return 'CLI' } else if (type === 'http' && !emptyString(config?.route_path)) { @@ -430,6 +458,8 @@ export function getTriggerLabel(trigger: Trigger): string { return `${config?.gcp_resource_path} - ${config?.topic}` } else if (type === 'websocket' && config?.url) { return `${config?.url}` + } else if (type === 'email' && config?.local_part) { + return `${config?.local_part}` } else if (isDraft && draftConfig?.path) { return `${draftConfig?.path}` } else if (isDraft) { @@ -443,7 +473,7 @@ export function sortTriggers(triggers: Trigger[]): Trigger[] { const triggerTypeOrder = [ 'webhook', 'cli', - 'email', + 'runnable_email', 'poll', 'schedule', 'http', @@ -453,7 +483,8 @@ export function sortTriggers(triggers: Trigger[]): Trigger[] { 'nats', 'mqtt', 'sqs', - 'gcp' + 'gcp', + 'email' ] return triggers.sort((a, b) => { diff --git a/frontend/src/routes/(root)/(logged)/email_triggers/+page.js b/frontend/src/routes/(root)/(logged)/email_triggers/+page.js new file mode 100644 index 0000000000..4995d68d95 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/email_triggers/+page.js @@ -0,0 +1,5 @@ +export function load() { + return { + stuff: { title: 'Email triggers' } + } +} diff --git a/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte new file mode 100644 index 0000000000..93c8ef449e --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte @@ -0,0 +1,442 @@ + + + + + + (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} +/> + +{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} + +{:else} + + + {#if $userStore?.is_admin || $userStore?.is_super_admin} + + {/if} + +
+
+ +
+
Filter by path of
+ + {#snippet children({ item })} + + + {/snippet} + +
+ + +
+ {#if $userStore?.is_super_admin && $userStore.username.includes('@')} + + {:else if $userStore?.is_admin || $userStore?.is_super_admin} + + {/if} +
+
+ {#if loading} + {#each new Array(6) as _} + + {/each} + {:else if !triggers?.length} +
No email triggers
+ {:else if items?.length} +
+ {#each items.slice(0, nbDisplayed) as { workspace_id, workspaced_local_part, path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, marked, local_part } (path)} + {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} + +
+
+ + + emailTriggerEditor?.openEdit(path, is_flow)} + class="min-w-0 grow hover:underline decoration-gray-400" + > +
+ {#if marked} + + {@html marked} + + {:else} + {isCloudHosted() || workspaced_local_part + ? workspace_id + '/' + local_part + : local_part} + {/if} +
+
+ {path} +
+
+ runnable: {script_path} +
+
+ + + +
+ + + { + goto(href) + } + }, + { + displayName: 'Delete', + type: 'delete', + icon: Trash, + disabled: + !canWrite || !($userStore?.is_admin || $userStore?.is_super_admin), + action: async () => { + try { + await EmailTriggerService.deleteEmailTrigger({ + workspace: $workspaceStore ?? '', + path + }) + sendUserToast(`Successfully deleted Email trigger: ${path}`) + loadTriggers() + } catch (error) { + sendUserToast(error.body || error.message, true) + } + } + }, + { + displayName: canWrite ? 'Edit' : 'View', + icon: canWrite ? Pen : Eye, + action: () => { + emailTriggerEditor?.openEdit(path, is_flow) + } + }, + ...(isDeployable('trigger', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'trigger', { + triggers: { + kind: 'routes' + } + }) + } + } + ] + : []), + { + displayName: 'Audit logs', + icon: Eye, + href: `${base}/audit_logs?resource=${path}` + }, + { + displayName: canWrite ? 'Share' : 'See Permissions', + icon: Share, + action: () => { + shareModal?.openDrawer(path, 'email_trigger') + } + } + ]} + /> +
+
+
+
+
edited by {edited_by}
+
at {displayDate(edited_at)}
+
+
+
+ {/each} +
+ {:else} + + {/if} +
+ {#if items && items?.length > 15 && nbDisplayed < items.length} + {nbDisplayed} items out of {items.length} + + {/if} +
+{/if} + + { + loadTriggers() + }} +/> diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index 167770b964..18a2541024 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -83,7 +83,7 @@ const triggersState = $state( new Triggers([ { type: 'webhook', path: '', isDraft: false }, - { type: 'email', path: '', isDraft: false }, + { type: 'runnable_email', path: '', isDraft: false }, { type: 'cli', path: '', isDraft: false } ]) ) @@ -504,7 +504,7 @@ {#if flow?.archived} This flow was archived {/if} - +
{#if !emptyString(flow?.description)} diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index 6b58497faf..d8013797d8 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -106,7 +106,7 @@ const triggersState = $state( new Triggers([ { type: 'webhook', path: '', isDraft: false }, - { type: 'email', path: '', isDraft: false }, + { type: 'runnable_email', path: '', isDraft: false }, { type: 'cli', path: '', isDraft: false } ]) )