feat: email triggers (#6548)

* feat: email triggers

* Change down migration to drop email_trigger table

* email triggers UI

* bug fix

* Apply suggestion from @ellipsis-dev[bot]

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* cli and git sync

* Revert "cli and git sync"

This reverts commit 220fd50d13.

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
hugocasa
2025-09-09 23:04:23 +02:00
committed by GitHub
parent d2ac5a64ca
commit 36bbde6239
73 changed files with 2297 additions and 368 deletions
@@ -38,7 +38,8 @@
"postgres",
"sqs",
"mqtt",
"gcp"
"gcp",
"default_email"
]
}
}
@@ -0,0 +1,54 @@
{
"db_name": "PostgreSQL",
"query": "SELECT script_path, is_flow, workspace_id, edited_by, email, path FROM email_trigger WHERE workspace_id = $1 AND local_part = $2 AND (workspaced_local_part = TRUE OR $3 IS TRUE)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Bool"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "23fb2099fe211c9c5388f28097ed8635198a4144a3415121800f5df52b2a133f"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(\n SELECT 1 \n FROM email_trigger \n WHERE \n ((workspaced_local_part IS TRUE AND workspace_id || '-' || local_part = $1) \n OR (workspaced_local_part IS FALSE AND local_part = $1))\n AND ($2::TEXT IS NULL OR path != $2)\n )\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "28a5caa6e8a8a95d228908cc83e9109986a0803d50164a259aabcdee81014d07"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT script_path, is_flow, workspace_id, edited_by, email, path FROM email_trigger WHERE local_part = $1 AND workspaced_local_part = FALSE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "is_flow",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "edited_by",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "3700706bb0408d6593be7f15f8aee4d6a023109e5dcfee7009a25ddf5db5e28d"
}
@@ -22,7 +22,8 @@
"postgres",
"sqs",
"mqtt",
"gcp"
"gcp",
"default_email"
]
}
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT \n owner, \n email,\n path,\n is_flow\n FROM \n capture_config\n WHERE \n workspace_id = $1\n AND trigger_config->>'local_part' = $2\n AND trigger_kind = 'email'::TRIGGER_KIND \n AND last_client_ping > NOW() - INTERVAL '10 seconds'\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "owner",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "is_flow",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "4c70aac8f83e9b76628a65df351c16cb630aa963d20898b084b043a59bfc4ba8"
}
@@ -30,7 +30,8 @@
"postgres",
"sqs",
"mqtt",
"gcp"
"gcp",
"default_email"
]
}
}
@@ -66,7 +67,8 @@
"postgres",
"sqs",
"mqtt",
"gcp"
"gcp",
"default_email"
]
}
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(\n SELECT 1 \n FROM email_trigger \n WHERE \n workspace_id = $1\n AND local_part = $2 \n AND ($3::TEXT IS NULL OR path != $3)\n )\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "54ee00c98dc50ca3da0e2195a78e6df77fd1e8c969667b267aed3347335c91ab"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE email_trigger \n SET \n script_path = $1,\n path = $2,\n is_flow = $3,\n local_part = $4,\n workspaced_local_part = $5,\n edited_by = $6,\n email = $7,\n edited_at = now(),\n error_handler_path = $8,\n error_handler_args = $9,\n retry = $10\n WHERE \n workspace_id = $11 AND path = $12\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Varchar",
"Jsonb",
"Jsonb",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "6fafc23924eded970689040bd4a94d4d23ebee4f2b7d37bb54c47edb7720be00"
}
@@ -25,7 +25,8 @@
"postgres",
"sqs",
"mqtt",
"gcp"
"gcp",
"default_email"
]
}
}
@@ -33,7 +33,8 @@
"postgres",
"sqs",
"mqtt",
"gcp"
"gcp",
"default_email"
]
}
}
@@ -30,7 +30,8 @@
"postgres",
"sqs",
"mqtt",
"gcp"
"gcp",
"default_email"
]
}
}
@@ -1,11 +1,10 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n http_trigger \n SET \n workspaced_route = $1,\n wrap_body = $2,\n raw_string = $3,\n authentication_resource_path = $4,\n script_path = $5, \n path = $6, \n is_flow = $7, \n http_method = $8, \n static_asset_config = $9, \n edited_by = $10, \n email = $11, \n is_async = $12, \n authentication_method = $13, \n summary = $14,\n description = $15,\n edited_at = now(), \n is_static_website = $16,\n error_handler_path = $17,\n error_handler_args = $18,\n retry = $19\n WHERE \n workspace_id = $20 AND \n path = $21\n ",
"query": "\n UPDATE \n http_trigger \n SET \n wrap_body = $1,\n raw_string = $2,\n authentication_resource_path = $3,\n script_path = $4, \n path = $5, \n is_flow = $6, \n http_method = $7, \n static_asset_config = $8, \n edited_by = $9, \n email = $10, \n is_async = $11, \n authentication_method = $12, \n summary = $13,\n description = $14,\n edited_at = now(), \n is_static_website = $15,\n error_handler_path = $16,\n error_handler_args = $17,\n retry = $18\n WHERE \n workspace_id = $19 AND \n path = $20\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Bool",
"Bool",
"Varchar",
@@ -57,5 +56,5 @@
},
"nullable": []
},
"hash": "6ca3ae85ddeb2334c9ae4bd7682daef744a1b10b4f47bc7355a97011bc366aae"
"hash": "a7a23229d6915d7fdeea8073d31be6e7d9f7a8581bdbb5914f2c5b49f37dbc36"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE email_trigger \n SET \n script_path = $1,\n path = $2,\n is_flow = $3,\n edited_by = $4,\n email = $5,\n edited_at = now(),\n error_handler_path = $6,\n error_handler_args = $7,\n retry = $8\n WHERE \n workspace_id = $9 AND path = $10\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Varchar",
"Jsonb",
"Jsonb",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "acb094aef60bba9083087264d65034fce38417099f15e8312be72a386f10bc1f"
}
@@ -22,7 +22,8 @@
"postgres",
"sqs",
"mqtt",
"gcp"
"gcp",
"default_email"
]
}
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\",\n EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS \"gcp_used!\"\n ",
"query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\",\n EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS \"gcp_used!\",\n EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS \"email_used!\"\n ",
"describe": {
"columns": [
{
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "gcp_used!",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "email_used!",
"type_info": "Bool"
}
],
"parameters": {
@@ -57,8 +62,9 @@
null,
null,
null,
null,
null
]
},
"hash": "aa0215d4174c1aeda8631bcd582c895329d2daf722d360fbcbdef6f04bb1400f"
"hash": "d1d9a3184f3470949840cc07414d08641527a4fe329204c65c49ab82d9ee4afb"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO email_trigger (\n workspace_id,\n path,\n script_path,\n is_flow,\n local_part,\n workspaced_local_part,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, now(), $9, $10, $11\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Varchar",
"Jsonb",
"Jsonb"
]
},
"nullable": []
},
"hash": "d328d00e5393b0e8d2c4b3674221fabbae580fc404e0d4481fd8d7fb51272c94"
}
@@ -22,7 +22,8 @@
"postgres",
"sqs",
"mqtt",
"gcp"
"gcp",
"default_email"
]
}
}
+1 -1
View File
@@ -1 +1 @@
28adb8c6253e798056fceedeed5dac3eaf64c568
29e2930269c744fe6136334f8b159afc9dd26bab
@@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE email_trigger;
@@ -0,0 +1,68 @@
-- Add up migration script here
CREATE TABLE email_trigger (
path VARCHAR(255) NOT NULL,
local_part VARCHAR(255) NOT NULL,
workspaced_local_part BOOLEAN NOT NULL,
script_path VARCHAR(255) NOT NULL,
is_flow BOOLEAN NOT NULL,
workspace_id VARCHAR(50) NOT NULL,
edited_by VARCHAR(50) NOT NULL,
email VARCHAR(255) NOT NULL,
edited_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
extra_perms JSONB NOT NULL DEFAULT '{}',
error_handler_path VARCHAR(255) NULL,
error_handler_args JSONB NULL,
retry JSONB NULL,
PRIMARY KEY (path, workspace_id)
);
GRANT ALL ON email_trigger TO windmill_user;
GRANT ALL ON email_trigger TO windmill_admin;
ALTER TABLE email_trigger ENABLE ROW LEVEL SECURITY;
CREATE POLICY admin_policy ON email_trigger FOR ALL TO windmill_admin USING (true);
CREATE POLICY see_folder_extra_perms_user_select ON email_trigger FOR SELECT TO windmill_user
USING (SPLIT_PART(email_trigger.path, '/', 1) = 'f' AND SPLIT_PART(email_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_read'), ',')::text[]));
CREATE POLICY see_folder_extra_perms_user_insert ON email_trigger FOR INSERT TO windmill_user
WITH CHECK (SPLIT_PART(email_trigger.path, '/', 1) = 'f' AND SPLIT_PART(email_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));
CREATE POLICY see_folder_extra_perms_user_update ON email_trigger FOR UPDATE TO windmill_user
USING (SPLIT_PART(email_trigger.path, '/', 1) = 'f' AND SPLIT_PART(email_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));
CREATE POLICY see_folder_extra_perms_user_delete ON email_trigger FOR DELETE TO windmill_user
USING (SPLIT_PART(email_trigger.path, '/', 1) = 'f' AND SPLIT_PART(email_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.folders_write'), ',')::text[]));
CREATE POLICY see_own ON email_trigger FOR ALL TO windmill_user
USING (SPLIT_PART(email_trigger.path, '/', 1) = 'u' AND SPLIT_PART(email_trigger.path, '/', 2) = current_setting('session.user'));
CREATE POLICY see_member ON email_trigger FOR ALL TO windmill_user
USING (SPLIT_PART(email_trigger.path, '/', 1) = 'g' AND SPLIT_PART(email_trigger.path, '/', 2) = any(regexp_split_to_array(current_setting('session.groups'), ',')::text[]));
CREATE POLICY see_extra_perms_user_select ON email_trigger FOR SELECT TO windmill_user
USING (extra_perms ? CONCAT('u/', current_setting('session.user')));
CREATE POLICY see_extra_perms_user_insert ON email_trigger FOR INSERT TO windmill_user
WITH CHECK ((extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean);
CREATE POLICY see_extra_perms_user_update ON email_trigger FOR UPDATE TO windmill_user
USING ((extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean);
CREATE POLICY see_extra_perms_user_delete ON email_trigger FOR DELETE TO windmill_user
USING ((extra_perms ->> CONCAT('u/', current_setting('session.user')))::boolean);
CREATE POLICY see_extra_perms_groups_select ON email_trigger FOR SELECT TO windmill_user
USING (extra_perms ?| regexp_split_to_array(current_setting('session.pgroups'), ',')::text[]);
CREATE POLICY see_extra_perms_groups_insert ON email_trigger FOR INSERT TO windmill_user
WITH CHECK (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));
CREATE POLICY see_extra_perms_groups_update ON email_trigger FOR UPDATE TO windmill_user
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));
CREATE POLICY see_extra_perms_groups_delete ON email_trigger FOR DELETE TO windmill_user
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));
ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'default_email';
@@ -0,0 +1,3 @@
-- Add down migration script here
UPDATE capture SET trigger_kind = 'email' WHERE trigger_kind = 'default_email';
UPDATE capture_config SET trigger_kind = 'email' WHERE trigger_kind = 'default_email';
@@ -0,0 +1,3 @@
-- Add up migration script here
UPDATE capture SET trigger_kind = 'default_email' WHERE trigger_kind = 'email';
UPDATE capture_config SET trigger_kind = 'default_email' WHERE trigger_kind = 'email';
+243 -1
View File
@@ -2907,6 +2907,8 @@ paths:
type: boolean
sqs_used:
type: boolean
email_used:
type: boolean
required:
- http_routes_used
- websocket_used
@@ -2916,6 +2918,7 @@ paths:
- mqtt_used
- gcp_used
- sqs_used
- email_used
/w/{workspace}/users/list:
get:
summary: list users
@@ -10914,6 +10917,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 retrieved
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
@@ -11999,6 +12167,7 @@ paths:
mqtt_trigger,
gcp_trigger,
sqs_trigger,
email_trigger,
]
responses:
"200":
@@ -12043,6 +12212,7 @@ paths:
mqtt_trigger,
gcp_trigger,
sqs_trigger,
email_trigger,
]
requestBody:
description: acl to add
@@ -12098,6 +12268,7 @@ paths:
mqtt_trigger,
gcp_trigger,
sqs_trigger,
email_trigger,
]
requestBody:
description: acl to add
@@ -16131,6 +16302,8 @@ components:
type: number
email_count:
type: number
default_email_count:
type: number
websocket_count:
type: number
postgres_count:
@@ -17086,6 +17259,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:
@@ -18141,7 +18383,7 @@ components:
CaptureTriggerKind:
type: string
enum:
[webhook, http, websocket, kafka, email, nats, postgres, sqs, mqtt, gcp]
[webhook, http, websocket, kafka, default_email, nats, postgres, sqs, mqtt, gcp, email]
Capture:
type: object
+8
View File
@@ -146,6 +146,12 @@ struct HttpTriggerConfig {
wrap_body: Option<bool>,
}
#[cfg(all(feature = "enterprise", feature = "smtp", feature = "private"))]
#[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)]
+8 -4
View File
@@ -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?
}
+3
View File
@@ -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),
+1
View File
@@ -27,6 +27,7 @@ fn build_trigger_scope_domains() -> Vec<ScopeDomain> {
("sqs_triggers", "AWS SQS"),
("gcp_triggers", "GCP Pub/Sub"),
("postgres_triggers", "PostgreSQL"),
("email_triggers", "Email"),
];
TRIGGER_DOMAINS
@@ -0,0 +1,68 @@
#[cfg(not(feature = "private"))]
use crate::triggers::TriggerData;
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::EmailTrigger,
crate::{
db::{ApiAuthed, DB},
triggers::TriggerCrud,
},
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},
windmill_git_sync::DeployedObject,
};
#[cfg(not(feature = "private"))]
#[async_trait]
impl TriggerCrud for EmailTrigger {
type Trigger = ();
type TriggerConfig = ();
type TriggerConfigRequest = ();
type TestConnectionConfig = ();
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_ENABLED: bool = false;
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/email_triggers";
const DEPLOYMENT_NAME: &'static str = "";
const IS_CLOUD_HOSTED: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::EmailTrigger { path }
}
async fn create_trigger(
&self,
_db: &DB,
_tx: &mut PgConnection,
_authed: &ApiAuthed,
_w_id: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"Email triggers are not available in open source version".to_string(),
))
}
async fn update_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_workspace_id: &str,
_path: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"Email triggers are not available in open source version".to_string(),
))
}
}
@@ -0,0 +1,11 @@
#[cfg(feature = "private")]
mod handler_ee;
pub mod handler_oss;
#[cfg(feature = "private")]
mod mod_ee;
#[cfg(feature = "private")]
pub use mod_ee::*;
#[derive(Copy, Clone)]
pub struct EmailTrigger;
+26 -3
View File
@@ -231,8 +231,7 @@ pub trait TriggerCrud: Send + Sync + 'static {
edited_by = $3,
edited_at = now(),
server_id = NULL,
error = NULL,
last_server_ping = NULL
error = NULL
WHERE
workspace_id = $4 AND
path = $5
@@ -737,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
}
@@ -752,6 +761,7 @@ pub struct TriggersCount {
http_routes_count: i64,
webhook_count: i64,
email_count: i64,
default_email_count: i64,
websocket_count: i64,
kafka_count: i64,
nats_count: i64,
@@ -872,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 {
@@ -890,7 +912,7 @@ pub async fn get_triggers_count_internal(
.await?
.unwrap_or(0);
let email_count = (if is_flow {
let default_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,
@@ -911,6 +933,7 @@ pub async fn get_triggers_count_internal(
schedule_count,
http_routes_count,
webhook_count,
default_email_count,
email_count,
websocket_count,
kafka_count,
@@ -458,8 +458,7 @@ impl TriggerCrud for HttpTrigger {
}
let route_path_key =
check_if_route_exist(db, &trigger.config, workspace_id, Some(path))
.await?;
check_if_route_exist(db, &trigger.config, workspace_id, Some(path)).await?;
sqlx::query!(
r#"
@@ -524,31 +523,29 @@ impl TriggerCrud for HttpTrigger {
UPDATE
http_trigger
SET
workspaced_route = $1,
wrap_body = $2,
raw_string = $3,
authentication_resource_path = $4,
script_path = $5,
path = $6,
is_flow = $7,
http_method = $8,
static_asset_config = $9,
edited_by = $10,
email = $11,
is_async = $12,
authentication_method = $13,
summary = $14,
description = $15,
wrap_body = $1,
raw_string = $2,
authentication_resource_path = $3,
script_path = $4,
path = $5,
is_flow = $6,
http_method = $7,
static_asset_config = $8,
edited_by = $9,
email = $10,
is_async = $11,
authentication_method = $12,
summary = $13,
description = $14,
edited_at = now(),
is_static_website = $16,
error_handler_path = $17,
error_handler_args = $18,
retry = $19
is_static_website = $15,
error_handler_path = $16,
error_handler_args = $17,
retry = $18
WHERE
workspace_id = $20 AND
path = $21
workspace_id = $19 AND
path = $20
"#,
trigger.config.workspaced_route,
trigger.config.wrap_body,
trigger.config.raw_string,
trigger.config.authentication_resource_path,
+2
View File
@@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
use sqlx::{types::Json as SqlxJson, FromRow};
use std::{collections::HashMap, fmt::Debug};
#[cfg(all(feature = "smtp", feature = "enterprise", feature = "private"))]
pub mod email;
#[cfg(all(feature = "gcp_trigger", feature = "enterprise", feature = "private"))]
pub mod gcp;
#[cfg(feature = "http_trigger")]
+3 -1
View File
@@ -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
)
+5 -2
View File
@@ -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,
DefaultEmail,
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::DefaultEmail => "email".to_string(), // to the user we also show kind email for default email
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::DefaultEmail => "default_email",
TriggerKind::Nats => "nats",
TriggerKind::Mqtt => "mqtt",
TriggerKind::Sqs => "sqs",
+3
View File
@@ -34,6 +34,7 @@ pub enum DeployedObject {
MqttTrigger { path: String },
SqsTrigger { path: String },
GcpTrigger { path: String },
EmailTrigger { path: String },
Settings { setting_type: String },
Key { key_type: String },
}
@@ -59,6 +60,7 @@ impl DeployedObject {
DeployedObject::MqttTrigger { path } => path.to_owned(),
DeployedObject::SqsTrigger { path } => path.to_owned(),
DeployedObject::GcpTrigger { path } => path.to_owned(),
DeployedObject::EmailTrigger { path } => path.to_owned(),
DeployedObject::Settings { .. } => "settings.yaml".to_string(),
DeployedObject::Key { .. } => "encryption_key.yaml".to_string(),
}
@@ -95,6 +97,7 @@ impl DeployedObject {
DeployedObject::MqttTrigger { .. } => None,
DeployedObject::SqsTrigger { .. } => None,
DeployedObject::GcpTrigger { .. } => None,
DeployedObject::EmailTrigger { .. } => None,
DeployedObject::Settings { .. } => None,
DeployedObject::Key { .. } => None,
}
+4 -1
View File
@@ -1011,7 +1011,10 @@ async fn run_agent(
let output_value = match content {
Some(content_str) => match has_output_properties {
true => serde_json::from_str::<Box<RawValue>>(&content_str).map_err(|_e| {
Error::internal_err(format!("Failed to parse structured output: {}", content_str))
Error::internal_err(format!(
"Failed to parse structured output: {}",
content_str
))
})?,
false => to_raw_value(&content_str),
},
@@ -615,7 +615,7 @@
new Triggers(
[
{ type: 'webhook', path: '', isDraft: false },
{ type: 'email', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
...(draftTriggersFromUrl ?? savedFlow?.draft?.draft_triggers ?? [])
],
selectedTriggerIndexFromUrl,
+8 -1
View File
@@ -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
}
@@ -251,7 +251,7 @@
new Triggers(
[
{ type: 'webhook', path: '', isDraft: false },
{ type: 'email', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
...(script.draft_triggers ?? [])
],
undefined,
@@ -33,6 +33,7 @@
| 'sqs_trigger'
| 'postgres_trigger'
| 'gcp_trigger'
| 'email_trigger'
let kind: Kind
let path: string = $state('')
@@ -1,184 +0,0 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import { Tabs, Tab } from '$lib/components/common'
import {
CalendarCheck2,
MailIcon,
Route,
Terminal,
Webhook,
Unplug,
PlugZap,
Database
} from 'lucide-svelte'
import HighlightTheme from '../HighlightTheme.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import { MqttIcon, NatsIcon, KafkaIcon, AwsIcon } from '../icons'
import GoogleCloudIcon from '../icons/GoogleCloudIcon.svelte'
interface Props {
triggerSelected?:
| 'webhooks'
| 'emails'
| 'schedules'
| 'cli'
| 'routes'
| 'websockets'
| 'kafka'
| 'postgres'
| 'nats'
| 'sqs'
| 'mqtt'
| 'gcp'
| 'scheduledPoll'
simplfiedPoll?: boolean
eventStreamType?: 'kafka' | 'nats' | 'sqs' | 'mqtt' | 'gcp'
webhooks?: import('svelte').Snippet
routes?: import('svelte').Snippet
emails?: import('svelte').Snippet
schedules?: import('svelte').Snippet
websockets?: import('svelte').Snippet
postgres?: import('svelte').Snippet
kafka?: import('svelte').Snippet
nats?: import('svelte').Snippet
sqs?: import('svelte').Snippet
mqtt?: import('svelte').Snippet
gcp?: import('svelte').Snippet
cli?: import('svelte').Snippet
}
let {
triggerSelected = $bindable('webhooks'),
simplfiedPoll = false,
eventStreamType = $bindable('kafka'),
webhooks,
routes,
emails,
schedules,
websockets,
postgres,
kafka,
nats,
sqs,
mqtt,
gcp,
cli
}: Props = $props()
run(() => {
if (
triggerSelected === 'kafka' ||
triggerSelected === 'nats' ||
triggerSelected === 'sqs' ||
triggerSelected === 'mqtt' ||
triggerSelected === 'gcp'
) {
eventStreamType = triggerSelected
}
})
</script>
<HighlightTheme />
{#if !simplfiedPoll}
<div class="flex flex-col h-full">
<Tabs bind:selected={triggerSelected} wrapperClass="flex-none w-full">
<Tab value="webhooks">
<span class="flex flex-row gap-2 items-center text-xs">
<Webhook size={12} />
Webhooks
</span>
</Tab>
<Tab value="schedules">
<span class="flex flex-row gap-2 items-center text-xs">
<CalendarCheck2 size={12} />
Schedules
</span>
</Tab>
<Tab value="routes">
<span class="flex flex-row gap-2 items-center text-xs">
<Route size={12} />
HTTP
</span>
</Tab>
<Tab value="websockets">
<span class="flex flex-row gap-2 items-center text-xs">
<Unplug size={12} />
WebSockets
</span>
</Tab>
<Tab value="postgres">
<span class="flex flex-row gap-2 items-center text-xs">
<Database size={12} />
Postgres
</span>
</Tab>
<Tab value="kafka" otherValues={['nats', 'sqs', 'mqtt', 'gcp']}>
<span class="flex flex-row gap-2 items-center text-xs">
<PlugZap size={12} />
Event streams
</span>
</Tab>
<Tab value="emails">
<span class="flex flex-row gap-2 items-center text-xs">
<MailIcon size={12} />
Email
</span>
</Tab>
<Tab value="cli">
<span class="flex flex-row gap-2 items-center text-xs">
<Terminal size={12} />
CLI
</span>
</Tab>
{#snippet content()}
<div class="min-h-0 grow overflow-y-auto">
{#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'}
<div class="m-1.5">
<ToggleButtonGroup bind:selected={eventStreamType}>
{#snippet children({ item })}
<ToggleButton value="kafka" label="Kafka" icon={KafkaIcon} {item} />
<ToggleButton value="nats" label="NATS" icon={NatsIcon} {item} />
<ToggleButton value="mqtt" label="MQTT" icon={MqttIcon} {item} />
<ToggleButton value="sqs" label="SQS" icon={AwsIcon} {item} />
<ToggleButton value="gcp" label="GCP Pub/Sub" icon={GoogleCloudIcon} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
{#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}
</div>
{/snippet}
</Tabs>
</div>
{:else}
{@render schedules?.()}
{/if}
@@ -1,84 +0,0 @@
<script lang="ts">
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import UserSettings from '../UserSettings.svelte'
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'
let userSettings: UserSettings
const dispatch = createEventDispatcher()
export let token: string
export let scopes: string[] = []
export let isFlow: boolean = false
export let hash: string | undefined = undefined
export let path: string
let emailDomain: string | null = null
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()
$: emailDomain && dispatch('email-domain', emailDomain)
</script>
<HighlightTheme />
<UserSettings
bind:this={userSettings}
on:tokenCreated={(e) => {
token = e.detail
triggerTokens?.listTokens()
}}
newTokenWorkspace={$workspaceStore}
newTokenLabel={`email-${$userStore?.username ?? 'superadmin'}-${generateRandomString(4)}`}
{scopes}
/>
<Section label="Email trigger" class="flex flex-col gap-4">
<Description link="https://www.windmill.dev/docs/advanced/email_triggers">
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.
</Description>
{#if loading}
<Skeleton layout={[[18]]} />
{:else}
{#if emailDomain}
<EmailTriggerConfigSection {hash} {token} {path} {isFlow} {userSettings} {emailDomain} />
{:else}
<div>
<Alert title="Email triggers are disabled" size="xs" type="warning">
Ask an instance superadmin to setup the instance for email triggering (<a
target="_blank"
href="https://windmill.dev/docs/advanced/email_triggers">docs</a
>) and to set the email domain in the instance settings.
</Alert>
</div>
{/if}
{#if !$enterpriseLicense}
<Alert title="Community Edition limitations" type="warning" size="xs">
Email triggers on Windmill Community Edition are limited to 100 emails per day.
</Alert>
{/if}
<TriggerTokens bind:this={triggerTokens} {isFlow} {path} labelPrefix="email" />
{/if}
</Section>
@@ -56,6 +56,7 @@
websocket: { icon: Unplug, countKey: 'websocket_count' },
postgres: { icon: Database, countKey: 'postgres_count' },
kafka: { icon: KafkaIcon, countKey: 'kafka_count', disabled: !$enterpriseLicense },
default_email: { icon: Mail, countKey: 'default_email_count' },
email: { icon: Mail, countKey: 'email_count' },
nats: { icon: NatsIcon, countKey: 'nats_count', disabled: !$enterpriseLicense },
mqtt: { icon: MqttIcon, countKey: 'mqtt_count', disabled: !$enterpriseLicense },
@@ -72,11 +73,12 @@
'websocket',
'postgres',
'kafka',
'email',
'default_email',
'nats',
'mqtt',
'sqs',
'gcp',
'email',
'poll',
'cli'
]
@@ -21,6 +21,7 @@
DollarSignIcon,
HomeIcon,
LayoutDashboardIcon,
MailIcon,
PlayIcon,
Route,
Search,
@@ -144,6 +145,13 @@
action: (newtab: boolean = false) => gotoPage('/mqtt_triggers', newtab),
icon: MqttIcon,
disabled: $userStore?.operator
},
{
search_id: 'nav:email_triggers',
label: 'Go to Email triggers',
action: (newtab: boolean = false) => gotoPage('/email_triggers', newtab),
icon: MailIcon,
disabled: $userStore?.operator
}
]
@@ -114,6 +114,11 @@
id: 'triggers',
href: `${base}/mqtt_triggers`
},
{
label: 'Email triggers',
id: 'triggers',
href: `${base}/email_triggers`
},
{
label: 'Audit logs',
id: 'audit_logs',
@@ -34,7 +34,8 @@
Unplug,
AlertCircle,
Database,
Pyramid
Pyramid,
MailIcon
} from 'lucide-svelte'
import UserMenu from './UserMenu.svelte'
import DiscordIcon from '../icons/brands/Discord.svelte'
@@ -261,6 +262,15 @@
kind: 'mqtt',
aiId: 'sidebar-menu-link-mqtt',
aiDescription: 'Button to navigate to MQTT triggers'
},
{
label: 'Email',
href: '/email_triggers',
icon: MailIcon,
disabled: $userStore?.operator,
kind: 'email',
aiId: 'sidebar-menu-link-email',
aiDescription: 'Button to navigate to Email triggers'
}
])
let triggerMenuLinks = $derived([
+3
View File
@@ -44,6 +44,7 @@ export function setScheduledPollSchedule(
export type TriggerKind =
| 'webhooks'
| 'emails'
| 'default_emails'
| 'schedules'
| 'cli'
| 'routes'
@@ -61,6 +62,8 @@ export function captureTriggerKindToTriggerKind(kind: CaptureTriggerKind): Trigg
return 'webhooks'
case 'email':
return 'emails'
case 'default_email':
return 'default_emails'
case 'http':
return 'routes'
case 'websocket':
@@ -85,6 +85,12 @@
icon: triggerIconMap.gcp,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Email',
action: () => onAddDraftTrigger?.('email'),
icon: triggerIconMap.email,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Scheduled Poll',
action: (e) => {
@@ -7,7 +7,7 @@
import type { ConnectionInfo } from '../common/alert/ConnectionIndicator.svelte'
import type { CaptureInfo } from './CaptureSection.svelte'
import WebhooksCapture from './webhook/WebhooksCapture.svelte'
import EmailTriggerCaptures from '../details/EmailTriggerCaptures.svelte'
import DefaultEmailCapture from './email/DefaultEmailCapture.svelte'
import WebsocketCapture from './websocket/WebsocketCapture.svelte'
import PostgresCapture from './postgres/PostgresCapture.svelte'
import KafkaCapture from './kafka/KafkaCapture.svelte'
@@ -15,6 +15,7 @@
import MqttCapture from './mqtt/MqttCapture.svelte'
import SqsCapture from './sqs/SqsCapture.svelte'
import GcpCapture from './gcp/GcpCapture.svelte'
import EmailCapture from './email/EmailCapture.svelte'
interface Props {
isFlow: boolean
@@ -238,8 +239,8 @@
on:captureToggle={handleCapture}
on:testWithArgs
/>
{:else if captureType === 'email'}
<EmailTriggerCaptures
{:else if captureType === 'default_email'}
<DefaultEmailCapture
{path}
{isFlow}
emailDomain={data?.emailDomain}
@@ -323,6 +324,21 @@
on:captureToggle={handleCapture}
on:testWithArgs
/>
{:else if captureType === 'email'}
<EmailCapture
local_part={args.local_part}
emailDomain={data?.emailDomain}
{isValid}
{captureInfo}
{hasPreprocessor}
{isFlow}
{captureLoading}
on:applyArgs
on:updateSchema
on:addPreprocessor
on:captureToggle={handleCapture}
on:testWithArgs
/>
{/if}
</div>
{/key}
@@ -18,6 +18,14 @@
{label}
</span>
{#if trigger.type === 'default_email'}
<span
class="ml-2 bg-blue-50 dark:bg-blue-900/40 px-1.5 py-0.5 rounded text-xs text-blue-700 dark:text-blue-100 whitespace-nowrap"
>
Default
</span>
{/if}
{#if trigger.isPrimary}
<span
class="ml-2 bg-blue-50 dark:bg-blue-900/40 px-1.5 py-0.5 rounded text-xs text-blue-700 dark:text-blue-100 whitespace-nowrap"
@@ -22,7 +22,7 @@
: await ScriptService.listTokensOfScript({ workspace: $workspaceStore!, path })
).filter((x) => x.label && x.label.startsWith(labelPrefix + '-'))
if (labelPrefix == 'email') {
$triggersCount = { ...($triggersCount ?? {}), email_count: tokens?.length }
$triggersCount = { ...($triggersCount ?? {}), default_email_count: tokens?.length }
} else {
$triggersCount = { ...($triggersCount ?? {}), webhook_count: tokens?.length }
}
@@ -29,7 +29,8 @@
MqttTriggerService,
HttpTriggerService,
GcpTriggerService,
SqsTriggerService
SqsTriggerService,
EmailTriggerService
} from '$lib/gen'
import { sendUserToast } from '$lib/toast'
@@ -105,7 +106,8 @@
gcp: () => GcpTriggerService.deleteGcpTrigger,
sqs: () => SqsTriggerService.deleteSqsTrigger,
mqtt: () => MqttTriggerService.deleteMqttTrigger,
http: () => HttpTriggerService.deleteHttpTrigger
http: () => HttpTriggerService.deleteHttpTrigger,
email: () => EmailTriggerService.deleteEmailTrigger
}
const deleteHandler = deleteHandlers[triggerType as keyof typeof deleteHandlers]
@@ -225,6 +227,14 @@
isFlow,
$userStore
)
} else if (triggerType === 'email') {
await triggersState.fetchEmailTriggers(
triggersCount,
$workspaceStore,
currentPath,
isFlow,
$userStore
)
}
triggersState.selectedTriggerIndex = triggersState.triggers.findIndex(
@@ -285,7 +295,7 @@
onDeleteDraft={deleteTrigger}
onReset={handleResetDraft}
webhookToken={$triggersCount?.webhook_count}
emailToken={$triggersCount?.email_count}
emailToken={$triggersCount?.default_email_count}
/>
</div>
{:else}
@@ -359,8 +369,8 @@
onReset={() => {
handleResetDraft(triggersState.selectedTriggerIndex)
}}
on:email-domain={({ detail }) => {
emailDomain = detail
onEmailDomain={(domain) => {
emailDomain = domain
}}
/>
</div>
@@ -92,7 +92,7 @@
>
{`${webhookToken} token${webhookToken > 1 ? 's' : ''}`}
</span>
{:else if trigger.type === 'email' && emailToken}
{:else if trigger.type === 'default_email' && emailToken}
<span
class="ml-2 text-xs rounded-md bg-tertiary/50 group-hover:bg-primary text-primary-inverse px-1.5 py-0.5"
>
@@ -101,7 +101,7 @@
{/if}
</div>
{#if !['email', 'webhook', 'cli'].includes(trigger.type)}
{#if !['default_email', 'webhook', 'cli'].includes(trigger.type)}
{#if trigger.isDraft}
<DeleteTriggerButton {trigger} onDelete={() => onDeleteDraft?.(index)} small />
{:else if !!trigger.draftConfig && !trigger.isDraft}
@@ -1,7 +1,8 @@
<script lang="ts">
import RoutesPanel from './http/RoutesPanel.svelte'
import WebhooksPanel from './webhook/WebhooksPanel.svelte'
import EmailTriggerPanel from '../details/EmailTriggerPanel.svelte'
import EmailTriggerPanel from './email/EmailTriggerPanel.svelte'
import DefaultEmailPanel from './email/DefaultEmailPanel.svelte'
import SchedulePanel from '$lib/components/SchedulePanel.svelte'
import PostgresTriggersPanel from './postgres/PostgresTriggersPanel.svelte'
import KafkaTriggerPanel from './kafka/KafkaTriggersPanel.svelte'
@@ -35,6 +36,7 @@
onUpdate?: (path: string) => void
onDelete?: () => void
onReset?: () => void
onEmailDomain: (domain: string) => void
}
let {
@@ -48,6 +50,7 @@
args,
newItem,
schema,
onEmailDomain,
...props
}: Props = $props()
</script>
@@ -71,14 +74,14 @@
scopes={isFlow ? [`jobs:run:flows:${currentPath}`] : [`jobs:run:scripts:${currentPath}`]}
{newItem}
/>
{:else if selectedTrigger.type === 'email'}
<EmailTriggerPanel
{:else if selectedTrigger.type === 'default_email'}
<DefaultEmailPanel
token=""
scopes={isFlow ? [`jobs:run:flows:${currentPath}`] : [`jobs:run:scripts:${currentPath}`]}
path={initialPath || fakeInitialPath}
{isFlow}
{hash}
on:email-domain
{onEmailDomain}
/>
{:else if selectedTrigger.type === 'schedule'}
<SchedulePanel
@@ -153,6 +156,16 @@
{customLabel}
{...props}
/>
{:else if selectedTrigger.type === 'email'}
<EmailTriggerPanel
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
{customLabel}
{onEmailDomain}
{...props}
/>
{:else if selectedTrigger.type === 'poll'}
<ScheduledPollPanel />
{:else if selectedTrigger.type === 'cli'}
@@ -2,8 +2,8 @@
import Label from '$lib/components/Label.svelte'
import { workspaceStore } from '$lib/stores'
import { base32 } from 'rfc4648'
import ClipboardPanel from './ClipboardPanel.svelte'
import CaptureSection, { type CaptureInfo } from '../triggers/CaptureSection.svelte'
import ClipboardPanel from '../../details/ClipboardPanel.svelte'
import CaptureSection, { type CaptureInfo } from '../CaptureSection.svelte'
import { fade } from 'svelte/transition'
interface Props {
@@ -40,7 +40,7 @@
{#if captureInfo}
<CaptureSection
captureType="email"
captureType="default_email"
disabled={false}
{captureInfo}
{captureLoading}
@@ -1,6 +1,4 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import Button from '$lib/components/common/button/Button.svelte'
import Label from '$lib/components/Label.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
@@ -9,20 +7,15 @@
import { SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON } from '$lib/consts'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ClipboardPanel from './ClipboardPanel.svelte'
import ClipboardPanel from '../../details/ClipboardPanel.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { base32 } from 'rfc4648'
import { emptyString } from '$lib/utils'
import UserSettings from '$lib/components/UserSettings.svelte'
let requestType: 'hash' | 'path' = $state('path')
function emailAddress(
requestType: 'hash' | 'path',
path: string,
hash: string | undefined,
isFlow: boolean,
token: string
) {
function emailAddress() {
const pathOrHash = requestType === 'hash' ? hash : path.replaceAll('/', '.')
const plainPrefix = `${$workspaceStore}+${
(requestType === 'hash' ? 'hash.' : isFlow ? 'flow.' : '') + pathOrHash
@@ -40,7 +33,7 @@
isFlow?: boolean
hash?: string | undefined
path: string
userSettings: any
userSettings: UserSettings
emailDomain?: string | null
email?: string
}
@@ -55,8 +48,8 @@
email = $bindable('')
}: Props = $props()
run(() => {
email = emailAddress(requestType, path, hash, isFlow, token)
$effect(() => {
email = emailAddress()
})
</script>
@@ -0,0 +1,93 @@
<script lang="ts">
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import UserSettings from '../../UserSettings.svelte'
import { generateRandomString } from '$lib/utils'
import HighlightTheme from '../../HighlightTheme.svelte'
import Alert from '../../common/alert/Alert.svelte'
import Skeleton from '../../common/skeleton/Skeleton.svelte'
import TriggerTokens from '../TriggerTokens.svelte'
import Description from '../../Description.svelte'
import Section from '../../Section.svelte'
import DefaultEmailConfigSection from './DefaultEmailConfigSection.svelte'
import { getEmailDomain } from './utils'
let userSettings: UserSettings | undefined = $state(undefined)
interface Props {
token: string
scopes?: string[]
isFlow?: boolean
hash?: string | undefined
path: string
onEmailDomain: (domain: string) => void
}
let {
token = $bindable(),
scopes = [],
isFlow = false,
hash = undefined,
path,
onEmailDomain
}: Props = $props()
let emailDomain: string | null = $state(null)
let triggerTokens: TriggerTokens | undefined = $state(undefined)
let loading = $state(true)
getEmailDomain().then((domain) => {
emailDomain = domain
loading = false
})
$effect(() => {
if (emailDomain) {
onEmailDomain(emailDomain)
}
})
</script>
<HighlightTheme />
<UserSettings
bind:this={userSettings}
on:tokenCreated={(e) => {
token = e.detail
triggerTokens?.listTokens()
}}
newTokenWorkspace={$workspaceStore}
newTokenLabel={`email-${$userStore?.username ?? 'superadmin'}-${generateRandomString(4)}`}
{scopes}
/>
<Section label="Default email trigger" class="flex flex-col gap-4">
<Description link="https://www.windmill.dev/docs/advanced/email_triggers">
Default email trigger 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.
</Description>
{#if loading}
<Skeleton layout={[[18]]} />
{:else}
{#if emailDomain}
<DefaultEmailConfigSection {hash} {token} {path} {isFlow} {userSettings} {emailDomain} />
{:else}
<div>
<Alert title="Email triggers are disabled" size="xs" type="warning">
Ask an instance superadmin to setup the instance for email triggering (<a
target="_blank"
href="https://windmill.dev/docs/advanced/email_triggers">docs</a
>) and to set the email domain in the instance settings.
</Alert>
</div>
{/if}
{#if !$enterpriseLicense}
<Alert title="Community Edition limitations" type="warning" size="xs">
Email triggers on Windmill Community Edition are limited to 100 emails per day.
</Alert>
{/if}
<TriggerTokens bind:this={triggerTokens} {isFlow} {path} labelPrefix="email" />
{/if}
</Section>
@@ -0,0 +1,62 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import Label from '$lib/components/Label.svelte'
// import { page } from '$app/stores'
import type { CaptureInfo } from '../CaptureSection.svelte'
import CaptureSection from '../CaptureSection.svelte'
import { fade } from 'svelte/transition'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
interface Props {
local_part: string | undefined
emailDomain: string | null
captureInfo?: CaptureInfo | undefined
isValid?: boolean | undefined
hasPreprocessor?: boolean
isFlow?: boolean
captureLoading?: boolean
}
let {
local_part,
emailDomain = null,
captureInfo = undefined,
isValid = undefined,
hasPreprocessor = false,
isFlow = false,
captureLoading = false
}: Props = $props()
let captureEmail = $derived(`capture+${$workspaceStore}-${local_part}@${emailDomain}`)
</script>
{#if captureInfo}
<CaptureSection
captureType="email"
disabled={isValid === false}
{captureInfo}
{captureLoading}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
{hasPreprocessor}
{isFlow}
>
{#snippet description()}
{#if captureInfo.active}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Send an email to the test address below to simulate an email trigger.
</p>
{:else}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Start capturing to listen to email events on this test address.
</p>
{/if}
{/snippet}
<Label label="Test email address" disabled={!captureInfo.active}>
<ClipboardPanel content={captureEmail} disabled={!captureInfo.active} />
</Label>
</CaptureSection>
{/if}
@@ -0,0 +1,35 @@
<script lang="ts">
import { tick } from 'svelte'
import EmailTriggerEditorInner from './EmailTriggerEditorInner.svelte'
import type { EditEmailTrigger, EmailTrigger } from '$lib/gen'
interface Props {
onUpdate?: (cfg?: Record<string, any>) => void
customSaveBehavior?: (cfg: EmailTrigger | EditEmailTrigger) => void
}
let { onUpdate = undefined, customSaveBehavior }: Props = $props()
let open = $state(false)
export async function openEdit(ePath: string, isFlow: boolean) {
open = true
await tick()
drawer?.openEdit(ePath, isFlow)
}
export async function openNew(
is_flow: boolean,
initial_script_path?: string,
defaultValues?: Record<string, any>
) {
open = true
await tick()
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
let drawer: EmailTriggerEditorInner | undefined = $state()
</script>
{#if open}
<EmailTriggerEditorInner {customSaveBehavior} {onUpdate} bind:this={drawer} />
{/if}
@@ -0,0 +1,166 @@
<script lang="ts">
import { Alert } from '$lib/components/common'
import Required from '$lib/components/Required.svelte'
import Section from '$lib/components/Section.svelte'
import { userStore, workspaceStore } from '$lib/stores'
// import { page } from '$app/stores'
import { getEmailAddress, getEmailDomain } from './utils'
import { isCloudHosted } from '$lib/cloud'
import Toggle from '$lib/components/Toggle.svelte'
import TestingBadge from '../testingBadge.svelte'
import { untrack } from 'svelte'
import { EmailTriggerService } from '$lib/gen'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
interface Props {
initialTriggerPath?: string | undefined
dirtyLocalPart?: boolean
local_part: string | undefined
can_write?: boolean
headless?: boolean
workspaced_local_part?: boolean
isValid?: boolean
isDraftOnly?: boolean
showTestingBadge?: boolean
}
let {
initialTriggerPath = undefined,
dirtyLocalPart = $bindable(false),
local_part = $bindable(),
can_write = false,
headless = false,
workspaced_local_part = $bindable(false),
isValid = $bindable(false),
isDraftOnly = true,
showTestingBadge = false
}: Props = $props()
let validateTimeout: NodeJS.Timeout | undefined = undefined
let addressError: string = $state('')
async function validateEmailAddress(
localPart: string | undefined,
workspaced_local_part: boolean
): Promise<void> {
if (validateTimeout) {
clearTimeout(validateTimeout)
}
validateTimeout = setTimeout(async () => {
if (!localPart || !/^[a-z0-9._]{1,64}$/.test(localPart)) {
addressError =
'Local part not valid, only accepts lowercase alphanumeric characters, dots and underscores, and must be between 1 and 64 characters'
} else if (await emailTriggerExists(localPart, workspaced_local_part)) {
addressError = 'Email address already taken'
} else {
addressError = ''
}
validateTimeout = undefined
}, 500)
}
async function emailTriggerExists(local_part: string, workspaced_local_part: boolean) {
return await EmailTriggerService.existsEmailLocalPart({
workspace: $workspaceStore!,
requestBody: {
local_part,
trigger_path: initialTriggerPath,
workspaced_local_part: workspaced_local_part
}
})
}
$effect.pre(() => {
;[local_part, workspaced_local_part]
untrack(() => {
validateEmailAddress(local_part, workspaced_local_part)
})
})
$effect.pre(() => {
isValid = addressError === ''
})
let emailDomain: string | null = $state(null)
getEmailDomain().then((domain) => {
emailDomain = domain
})
let fullEmailAddress = $derived(
getEmailAddress(local_part, workspaced_local_part, $workspaceStore ?? '', emailDomain ?? '')
)
$effect.pre(() => {
local_part === undefined && (local_part = '')
})
let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
let userCanEditConfig = $derived(userIsAdmin || isDraftOnly) // User can edit config if they are admin or if the trigger is a draft which will not be saved
</script>
<div>
<Section label="Email" {headless}>
{#snippet header()}
{#if showTestingBadge}
<TestingBadge />
{/if}
{/snippet}
{#if !userCanEditConfig && isDraftOnly}
<Alert type="info" title="Admin only" collapsible size="xs">
Email triggers can only be edited by workspace admins
</Alert>
<div class="my-2"></div>
{/if}
<div class="flex flex-col w-full gap-4">
<label class="block grow w-full">
<div class="flex flex-col gap-1">
<div class="text-secondary text-sm flex items-center gap-1 w-full justify-between">
<div>
Local part
<Required required={true} />
</div>
</div>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
autocomplete="off"
bind:value={local_part}
disabled={!userCanEditConfig || !can_write}
class={addressError === ''
? ''
: 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'}
oninput={() => {
dirtyLocalPart = true
}}
/>
</div>
</label>
<div class="flex flex-col w-full">
<ClipboardPanel content={fullEmailAddress} />
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
>{dirtyLocalPart ? addressError : ''}</div
>
{#if !isCloudHosted()}
<div class="mt-1">
<Toggle
size="sm"
checked={workspaced_local_part}
disabled={!can_write || !userCanEditConfig}
on:change={() => {
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'
}}
/>
</div>
{/if}
</div>
</div>
</Section>
</div>
@@ -0,0 +1,384 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Path from '$lib/components/Path.svelte'
import Required from '$lib/components/Required.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import {
EmailTriggerService,
type ErrorHandler,
type EmailTrigger,
type NewEmailTrigger,
type Retry
} from '$lib/gen'
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, emptyString, sendUserToast } from '$lib/utils'
import Section from '$lib/components/Section.svelte'
import { Loader2 } from 'lucide-svelte'
import Label from '$lib/components/Label.svelte'
import EmailTriggerEditorConfigSection from './EmailTriggerEditorConfigSection.svelte'
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import { getHandlerType, handleConfigChange } from '../utils'
import { untrack } from 'svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
import { saveEmailTriggerFromCfg } from './utils'
let {
useDrawer = true,
hideTarget = false,
description = undefined,
isEditor = false,
customLabel = undefined,
allowDraft = false,
isDeployed = false,
onConfigChange = undefined,
onCaptureConfigChange = undefined,
onUpdate = undefined,
onDelete = undefined,
onReset = undefined,
trigger = undefined,
customSaveBehavior = undefined
} = $props()
// Form data state
let initialPath = $state('')
let edit = $state(true)
let itemKind = $state<'flow' | 'script'>('script')
let is_flow = $state(false)
let script_path = $state('')
let initialScriptPath = $state('')
let fixedScriptPath = $state('')
let path = $state('')
let pathError = $state('')
let isValid = $state(false)
let dirtyLocalPart = $state(false)
let dirtyPath = $state(false)
let local_part = $state('')
let workspaced_local_part = $state(false)
let drawerLoading = $state(true)
let showLoader = $state(false)
let can_write = $state(true)
let extraPerms = $state<Record<string, boolean> | undefined>(undefined)
let error_handler_path: string | undefined = $state()
let error_handler_args: Record<string, any> = $state({})
let retry: Retry | undefined = $state()
// Component references
let drawer = $state<Drawer | undefined>(undefined)
let initialConfig: NewEmailTrigger | undefined = undefined
let deploymentLoading = $state(false)
let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler')
let errorHandlerSelected: ErrorHandler = $state('slack')
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
const routeConfig = $derived.by(getEmailTriggerConfig)
const captureConfig = $derived.by(isEditor ? getCaptureConfig : () => ({}))
const saveDisabled = $derived(
drawerLoading || !can_write || pathError != '' || !isValid || emptyString(script_path)
)
$effect(() => {
is_flow = itemKind === 'flow'
})
export async function openEdit(
ePath: string,
isFlow: boolean,
defaultConfig?: Partial<NewEmailTrigger>
) {
drawerLoading = true
let loader = setTimeout(() => {
showLoader = true
}, 100) // if loading takes less than 100ms, we don't show the loader
try {
drawer?.openDrawer()
initialPath = ePath
path = ePath
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
dirtyLocalPart = false
await loadTrigger(defaultConfig)
} catch (err) {
sendUserToast(`Could not load email trigger: ${err}`, true)
} finally {
if (!defaultConfig) {
// If the email trigger is loaded from the backend, we to set the initial config
initialConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
}
clearTimeout(loader)
drawerLoading = false
showLoader = false
}
}
export async function openNew(
nis_flow: boolean,
fixedScriptPath_?: string,
defaultValues?: Partial<EmailTrigger>
) {
drawerLoading = true
let loader = setTimeout(() => {
showLoader = true
}, 100) // if loading takes less than 100ms, we don't show the loader
try {
drawer?.openDrawer()
is_flow = defaultValues?.is_flow ?? nis_flow
edit = false
itemKind = nis_flow ? 'flow' : 'script'
local_part = defaultValues?.local_part ?? ''
dirtyLocalPart = false
initialScriptPath = ''
fixedScriptPath = fixedScriptPath_ ?? ''
script_path = fixedScriptPath
path = defaultValues?.path ?? ''
initialPath = ''
dirtyPath = false
workspaced_local_part = defaultValues?.workspaced_local_part ?? false
error_handler_path = defaultValues?.error_handler_path ?? undefined
error_handler_args = defaultValues?.error_handler_args ?? {}
retry = defaultValues?.retry ?? undefined
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
} finally {
clearTimeout(loader)
drawerLoading = false
showLoader = false
}
}
function loadTriggerConfig(cfg?: Partial<EmailTrigger>): void {
script_path = cfg?.script_path ?? ''
initialScriptPath = cfg?.script_path ?? ''
is_flow = cfg?.is_flow ?? false
path = cfg?.path ?? ''
local_part = cfg?.local_part ?? ''
workspaced_local_part = cfg?.workspaced_local_part ?? false
extraPerms = cfg?.extra_perms ?? undefined
can_write = canWrite(path, cfg?.extra_perms ?? {}, $userStore)
error_handler_path = cfg?.error_handler_path
error_handler_args = cfg?.error_handler_args ?? {}
retry = cfg?.retry
errorHandlerSelected = getHandlerType(error_handler_path ?? '')
}
async function loadTrigger(defaultConfig?: Partial<EmailTrigger>): Promise<void> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await EmailTriggerService.getEmailTrigger({
workspace: $workspaceStore!,
path: initialPath
})
loadTriggerConfig(s)
}
}
async function triggerScript(): Promise<void> {
if (customSaveBehavior) {
customSaveBehavior(routeConfig)
drawer?.closeDrawer()
} else {
deploymentLoading = true
const saveCfg = routeConfig
const isSaved = await saveEmailTriggerFromCfg(
initialPath,
saveCfg,
edit,
$workspaceStore!,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds
)
if (isSaved) {
onUpdate(saveCfg.path)
drawer?.closeDrawer()
}
deploymentLoading = false
}
}
function getEmailTriggerConfig(): NewEmailTrigger {
const nCfg = {
script_path,
is_flow,
path,
local_part,
workspaced_local_part,
extra_perms: extraPerms,
error_handler_path,
error_handler_args,
retry
}
return nCfg
}
// Update config for captures
function getCaptureConfig() {
const newCaptureConfig = {
local_part: routeConfig.local_part,
path: routeConfig.path
}
//
return newCaptureConfig
}
$effect(() => {
const args = [captureConfig, isValid] as const
untrack(() => onCaptureConfigChange?.(...args))
})
$effect(() => {
if (!drawerLoading) {
handleConfigChange(routeConfig, initialConfig, saveDisabled, edit, onConfigChange)
}
})
</script>
{#snippet config()}
{#if drawerLoading}
{#if showLoader}
<Loader2 class="animate-spin" />
{/if}
{:else}
<div class="flex flex-col gap-12">
<Section label="Metadata">
<div class="flex flex-col gap-2">
<Label label="Path">
<Path
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path
{initialPath}
checkInitialPathExistence={!edit}
namePlaceholder="email_trigger"
kind="email_trigger"
hideUser
disableEditing={!can_write}
/>
</Label>
</div>
</Section>
{#if !hideTarget}
<Section label="Target">
<p class="text-xs mt-3 mb-1 text-tertiary">
Pick a script or flow to be triggered<Required required={true} />
</p>
<div class="flex flex-col gap-2">
<div class="flex flex-row mb-2">
<ScriptPicker
disabled={fixedScriptPath != '' || !can_write}
initialPath={fixedScriptPath || initialScriptPath}
kinds={['script']}
allowFlow={true}
bind:itemKind
bind:scriptPath={script_path}
allowRefresh={can_write}
allowEdit={!$userStore?.operator}
clearable
/>
{#if emptyString(script_path)}
<Button
btnClasses="ml-4 mt-2"
color="dark"
size="xs"
href={itemKind === 'flow' ? '/flows/add?hub=72' : '/scripts/add?hub=hub%2F19813'}
target="_blank">Create from template</Button
>
{/if}
</div>
</div>
</Section>
{/if}
<EmailTriggerEditorConfigSection
initialTriggerPath={initialPath}
bind:local_part
bind:isValid
bind:workspaced_local_part
bind:dirtyLocalPart
{can_write}
showTestingBadge={isEditor}
isDraftOnly={trigger ? trigger.isDraft : false}
/>
<Section label="Advanced" collapsable>
<div class="flex flex-col gap-4">
<div class="min-h-96">
<Tabs bind:selected={optionTabSelected}>
<Tab value="error_handler">Error Handler</Tab>
<Tab value="retries">Retries</Tab>
</Tabs>
<div class="mt-4">
<TriggerRetriesAndErrorHandler
{optionTabSelected}
{itemKind}
{can_write}
bind:errorHandlerSelected
bind:error_handler_path
bind:error_handler_args
bind:retry
/>
</div>
</div>
</div>
</Section>
</div>
{/if}
{/snippet}
{#snippet saveButton()}
{#if !drawerLoading}
<TriggerEditorToolbar
{trigger}
permissions={drawerLoading || !can_write ? 'none' : can_write && isAdmin ? 'create' : 'write'}
{saveDisabled}
enabled={undefined}
{allowDraft}
{edit}
isLoading={deploymentLoading}
onUpdate={triggerScript}
{onReset}
{onDelete}
{isDeployed}
/>
{/if}
{/snippet}
{#if useDrawer}
<Drawer size="700px" bind:this={drawer}>
<DrawerContent
title={edit
? can_write
? `Edit email trigger ${initialPath}`
: `Email trigger ${initialPath}`
: 'New email trigger'}
on:close={() => drawer?.closeDrawer()}
>
{#snippet actions()}
{@render saveButton()}
{/snippet}
{@render config()}
</DrawerContent>
</Drawer>
{:else}
<Section label={!customLabel ? 'Email trigger' : ''} headerClass="grow min-w-0 h-[30px]">
{#snippet header()}
{#if customLabel}
{@render customLabel()}
{/if}
{/snippet}
{#snippet action()}
{@render saveButton()}
{/snippet}
{#if description}
{@render description()}
{/if}
{@render config()}
</Section>
{/if}
@@ -0,0 +1,87 @@
<script lang="ts">
import EmailTriggerEditorInner from './EmailTriggerEditorInner.svelte'
import Description from '$lib/components/Description.svelte'
import { enterpriseLicense, userStore } from '$lib/stores'
import { Alert } from '$lib/components/common'
import { onMount, type Snippet } from 'svelte'
import { getEmailDomain } from './utils'
import type { Trigger } from '../utils'
let emailTriggerEditor = $state<EmailTriggerEditorInner | null>(null)
interface Props {
selectedTrigger: Trigger
isFlow: boolean
path: string
defaultValues?: Record<string, any>
isEditor?: boolean
customLabel: Snippet
onEmailDomain: (domain: string) => void
}
let {
selectedTrigger,
isFlow,
path,
defaultValues = undefined,
isEditor = false,
customLabel,
onEmailDomain,
...restProps
}: Props = $props()
async function openEmailTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
emailTriggerEditor?.openNew(isFlow, path, defaultValues)
} else {
emailTriggerEditor?.openEdit(selectedTrigger.path ?? '', isFlow, defaultValues)
}
}
onMount(() => {
if (emailTriggerEditor) {
openEmailTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
}
})
let emailDomain: string | null = $state(null)
getEmailDomain().then((domain) => {
emailDomain = domain
})
$effect(() => {
if (emailDomain) {
onEmailDomain(emailDomain)
}
})
</script>
<EmailTriggerEditorInner
useDrawer={false}
bind:this={emailTriggerEditor}
hideTarget
{isEditor}
{customLabel}
trigger={selectedTrigger}
allowDraft
{...restProps}
>
{#snippet description()}
<div class="flex flex-col gap-2 pb-4">
<Description link="https://www.windmill.dev/docs/advanced/email_triggers">
Email triggers execute scripts and flows when emails are sent to specific addresses. Each
trigger can be configured with a specific local part.
</Description>
{#if !$userStore?.is_admin && !$userStore?.is_super_admin && selectedTrigger.isDraft}
<Alert title="Only workspace admins can create email triggers" type="info" size="xs" />
{/if}
{#if !$enterpriseLicense}
<Alert title="Community Edition limitations" type="warning" size="xs">
Email triggers on Windmill Community Edition are limited to 100 emails per day.
</Alert>
{/if}
</div>
{/snippet}
</EmailTriggerEditorInner>
@@ -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<string, any>,
edit: boolean,
workspace: string,
isAdmin: boolean,
usedTriggerKinds: Writable<string[]>
): Promise<boolean> {
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<string> {
return (
((await SettingService.getGlobal({
key: 'email_domain'
})) as any) ?? 'mail.test.com'
)
}
@@ -166,7 +166,7 @@
<Toggle
size="sm"
checked={workspaced_route}
disabled={!can_write}
disabled={!can_write || !userCanEditConfig}
on:change={() => {
workspaced_route = !workspaced_route
dirtyRoutePath = true
@@ -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) {
@@ -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<TriggersCount | undefined>,
workspaceId: string | undefined,
path: string,
isFlow: boolean,
user: UserExt | undefined = undefined
): Promise<void> {
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<TriggersCount | undefined>,
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)
])
}
}
+27 -5
View File
@@ -25,6 +25,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 +39,7 @@ export const CLOUD_DISABLED_TRIGGER_TYPES = [
export type TriggerType =
| 'webhook'
| 'default_email'
| 'email'
| 'schedule'
| 'http'
@@ -48,6 +50,7 @@ export type TriggerType =
| 'mqtt'
| 'sqs'
| 'gcp'
| 'email'
| 'poll'
| 'cli'
@@ -68,6 +71,7 @@ export type Trigger = {
export const triggerIconMap = {
webhook: Webhook,
email: Mail,
default_email: Mail,
schedule: Calendar,
http: Route,
websocket: Unplug,
@@ -92,6 +96,7 @@ export function triggerTypeToCaptureKind(triggerType: TriggerType): CaptureTrigg
const capturableTriggerTypes: TriggerType[] = [
'webhook',
'email',
'default_email',
'http',
'websocket',
'postgres',
@@ -120,7 +125,7 @@ export function updateTriggersCount(
// Map trigger types to their corresponding count property names
const countPropertyMap: Record<TriggerType, string | undefined> = {
webhook: undefined,
email: undefined,
default_email: undefined,
schedule: 'schedule_count',
http: 'http_routes_count',
websocket: 'websocket_count',
@@ -130,6 +135,7 @@ export function updateTriggersCount(
mqtt: 'mqtt_count',
sqs: 'sqs_count',
gcp: 'gcp_count',
email: 'email_count',
poll: undefined,
cli: undefined
}
@@ -178,6 +184,8 @@ export function triggerKindToTriggerType(kind: TriggerKind): TriggerType | undef
return 'webhook'
case 'emails':
return 'email'
case 'default_emails':
return 'default_email'
case 'schedules':
return 'schedule'
case 'routes':
@@ -225,7 +233,7 @@ export async function deployTriggers(
// Map of trigger types to their save functions
const triggerSaveFunctions: Record<TriggerType, Function | undefined> = {
webhook: undefined,
email: undefined,
default_email: undefined,
schedule: (trigger: Trigger) => {
if (trigger.isPrimary && initialPath) {
trigger.draftConfig = {
@@ -301,6 +309,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 +415,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,7 +428,7 @@ export function getTriggerLabel(trigger: Trigger): string {
if (type === 'webhook') {
return 'Webhook'
} else if (type === 'email') {
} else if (type === 'default_email') {
return 'Email'
} else if (type === 'cli') {
return 'CLI'
@@ -430,6 +449,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 +464,7 @@ export function sortTriggers(triggers: Trigger[]): Trigger[] {
const triggerTypeOrder = [
'webhook',
'cli',
'email',
'default_email',
'poll',
'schedule',
'http',
@@ -453,7 +474,8 @@ export function sortTriggers(triggers: Trigger[]): Trigger[] {
'nats',
'mqtt',
'sqs',
'gcp'
'gcp',
'email'
]
return triggers.sort((a, b) => {
@@ -201,7 +201,8 @@
nats_used,
sqs_used,
mqtt_used,
gcp_used
gcp_used,
email_used
} = await WorkspaceService.getUsedTriggers({
workspace: $workspaceStore ?? ''
})
@@ -229,6 +230,9 @@
if (gcp_used) {
usedKinds.push('gcp')
}
if (email_used) {
usedKinds.push('email')
}
$usedTriggerKinds = usedKinds
}
@@ -0,0 +1,5 @@
export function load() {
return {
stuff: { title: 'Email triggers' }
}
}
@@ -0,0 +1,434 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import {
EmailTriggerService,
WorkspaceService,
type EmailTrigger,
type WorkspaceDeployUISettings
} from '$lib/gen'
import {
canWrite,
copyToClipboard,
displayDate,
getLocalSetting,
storeLocalSetting,
removeTriggerKindIfUnused,
sendUserToast
} from '$lib/utils'
import { base } from '$app/paths'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Button, Skeleton } from '$lib/components/common'
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import {
userStore,
workspaceStore,
userWorkspaces,
enterpriseLicense,
usedTriggerKinds
} from '$lib/stores'
import { Mail, Code, Eye, Pen, Plus, Share, Trash, FileUp, ClipboardCopy } from 'lucide-svelte'
import { goto } from '$lib/navigation'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { setQuery } from '$lib/navigation'
import { onMount } from 'svelte'
import EmailTriggerEditor from '$lib/components/triggers/email/EmailTriggerEditor.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import { getEmailAddress, getEmailDomain } from '$lib/components/triggers/email/utils'
type TriggerW = EmailTrigger & { canWrite: boolean }
let triggers: TriggerW[] = $state([])
let shareModal: ShareModal | undefined = $state()
let loading = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
let emailDomain: string | null = $state(null)
async function getDeployUiSettings() {
if (!$enterpriseLicense) {
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
async function loadTriggers(): Promise<void> {
triggers = (await EmailTriggerService.listEmailTriggers({ workspace: $workspaceStore! })).map(
(x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
}
)
$usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'emails', $usedTriggerKinds)
emailDomain = await getEmailDomain()
loading = false
}
run(() => {
if ($workspaceStore && $userStore) {
loadTriggers()
}
})
let emailTriggerEditor: EmailTriggerEditor | undefined = $state()
let filteredItems: (TriggerW & { marked?: any })[] | undefined = $state([])
let items: typeof filteredItems | undefined = $state([])
let preFilteredItems: typeof filteredItems | undefined = $state([])
let filter = $state('')
let ownerFilter: string | undefined = $state(undefined)
let nbDisplayed = $state(15)
const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of'
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
let selectedFilterKind = $state(
(getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger'
)
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
run(() => {
storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind)
})
run(() => {
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
})
function filterItemsPathsBaseOnUserFilters(
item: TriggerW,
selectedFilterKind: 'trigger' | 'script_flow',
filterUserFolders: boolean
) {
if ($workspaceStore == 'admins') return true
if (filterUserFolders) {
if (selectedFilterKind === 'trigger') {
return (
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
)
} else {
return (
!item.script_path.startsWith('u/') ||
item.script_path.startsWith('u/' + $userStore?.username + '/')
)
}
} else {
return true
}
}
run(() => {
preFilteredItems =
ownerFilter != undefined
? selectedFilterKind === 'trigger'
? triggers?.filter(
(x) =>
x.path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter(
(x) =>
x.script_path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
: triggers?.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders)
)
})
run(() => {
if ($workspaceStore) {
ownerFilter = undefined
}
})
let owners = $derived(
selectedFilterKind === 'trigger'
? Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
: Array.from(
new Set(
filteredItems
?.filter((x) => x.script_path)
.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? []
)
).sort()
)
run(() => {
items = filter !== '' ? filteredItems : preFilteredItems
})
function updateQueryFilters(selectedFilterKind, filterUserFolders) {
setQuery(
new URL(window.location.href),
TRIGGER_PATH_KIND_FILTER_SETTING,
selectedFilterKind
).then(() => {
setQuery(
new URL(window.location.href),
FILTER_USER_FOLDER_SETTING_NAME,
String(filterUserFolders)
)
})
}
function loadQueryFilters() {
let url = new URL(window.location.href)
let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING)
let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME)
if (queryFilterKind) {
selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow'
}
if (queryFilterUserFolders) {
filterUserFolders = queryFilterUserFolders == 'true'
}
}
onMount(() => {
loadQueryFilters()
})
run(() => {
updateQueryFilters(selectedFilterKind, filterUserFolders)
})
</script>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<EmailTriggerEditor onUpdate={loadTriggers} bind:this={emailTriggerEditor} />
<SearchItems
{filter}
items={preFilteredItems}
bind:filteredItems
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Custom email triggers"
tooltip="Every script and flow already has a canonical email trigger attached to it, this is to create additional parametrizable ones."
documentationLink="https://www.windmill.dev/docs/advanced/email_triggers"
>
{#if $userStore?.is_admin || $userStore?.is_super_admin}
<Button
size="md"
startIcon={{ icon: Plus }}
on:click={() => emailTriggerEditor?.openNew(false)}
>
New&nbsp;email trigger
</Button>
{/if}
</PageHeader>
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input
type="text"
placeholder="Search email triggers"
bind:value={filter}
class="search-item"
/>
<div class="flex flex-row items-center gap-2 mt-6">
<div class="text-sm shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
{#snippet children({ item })}
<ToggleButton small value="trigger" label="Email" icon={Mail} {item} />
<ToggleButton small value="script_flow" label="Script/Flow" icon={Code} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<div class="text-center text-sm text-tertiary mt-2"> No email triggers </div>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#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}`}
{@const emailAddress = getEmailAddress(
local_part,
workspaced_local_part ?? false,
workspace_id,
emailDomain ?? ''
)}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
onclick={() => emailTriggerEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-primary flex-wrap text-left text-md font-semibold mb-1 truncate">
{#if marked}
<span class="text-xs">
{@html marked}
</span>
{:else}
{emailAddress}
{/if}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
{path}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
runnable: {script_path}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
</div>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() => copyToClipboard(emailAddress)}
color="dark"
size="xs"
startIcon={{ icon: ClipboardCopy }}
>
Copy email address
</Button>
<Button
on:click={() => emailTriggerEditor?.openEdit(path, is_flow)}
size="xs"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
color="dark"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
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')
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-[0.7em] text-tertiary gap-1 items-center justify-end truncate pr-2"
>
<div class="truncate">edited by {edited_by}</div>
<div class="truncate">at {displayDate(edited_at)}</div>
</div>
</div>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs"
>{nbDisplayed} items out of {items.length}
<button class="ml-4" onclick={() => (nbDisplayed += 30)}>load 30 more</button></span
>
{/if}
</CenteredPage>
{/if}
<ShareModal
bind:this={shareModal}
on:change={() => {
loadTriggers()
}}
/>
@@ -83,7 +83,7 @@
const triggersState = $state(
new Triggers([
{ type: 'webhook', path: '', isDraft: false },
{ type: 'email', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
{ type: 'cli', path: '', isDraft: false }
])
)
@@ -504,7 +504,7 @@
{#if flow?.archived}
<Alert type="error" title="Archived">This flow was archived</Alert>
{/if}
<div class="mb-1">
{#if !emptyString(flow?.description)}
<GfmMarkdown md={defaultIfEmptyString(flow?.description, 'No description')} />
@@ -106,7 +106,7 @@
const triggersState = $state(
new Triggers([
{ type: 'webhook', path: '', isDraft: false },
{ type: 'email', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
{ type: 'cli', path: '', isDraft: false }
])
)