email triggers UI

This commit is contained in:
HugoCasa
2025-09-09 00:08:09 +02:00
parent 486cb10c1c
commit 2d8e6d768e
41 changed files with 1699 additions and 243 deletions
@@ -63,4 +63,8 @@ CREATE POLICY see_extra_perms_groups_delete ON email_trigger FOR DELETE TO wind
USING (exists(
SELECT key, value FROM jsonb_each_text(extra_perms)
WHERE SPLIT_PART(key, '/', 1) = 'g' AND key = ANY(regexp_split_to_array(current_setting('session.pgroups'), ',')::text[])
AND value::boolean));
AND value::boolean));
ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'runnable_email';
UPDATE TABLE capture SET trigger_kind = 'runnable_email' WHERE trigger_kind = 'email';
UPDATE TABLE capture_config SET trigger_kind = 'runnable_email' WHERE trigger_kind = 'email';
+240 -1
View File
@@ -10814,6 +10814,171 @@ paths:
schema:
type: string
/w/{workspace}/email_triggers/create:
post:
summary: create email trigger
operationId: createEmailTrigger
tags:
- email_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: new email trigger
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NewEmailTrigger"
responses:
"201":
description: email trigger created
content:
text/plain:
schema:
type: string
/w/{workspace}/email_triggers/update/{path}:
post:
summary: update email trigger
operationId: updateEmailTrigger
tags:
- email_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
requestBody:
description: updated trigger
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/EditEmailTrigger"
responses:
"200":
description: email trigger updated
content:
text/plain:
schema:
type: string
/w/{workspace}/email_triggers/delete/{path}:
delete:
summary: delete email trigger
operationId: deleteEmailTrigger
tags:
- email_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: email trigger deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/email_triggers/get/{path}:
get:
summary: get email trigger
operationId: getEmailTrigger
tags:
- email_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: email trigger deleted
content:
application/json:
schema:
$ref: "#/components/schemas/EmailTrigger"
/w/{workspace}/email_triggers/list:
get:
summary: list email triggers
operationId: listEmailTriggers
tags:
- email_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
required: true
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
- name: path
description: filter by path
in: query
schema:
type: string
- name: is_flow
in: query
schema:
type: boolean
- name: path_start
in: query
schema:
type: string
responses:
"200":
description: email trigger list
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/EmailTrigger"
/w/{workspace}/email_triggers/exists/{path}:
get:
summary: does email trigger exists
operationId: existsEmailTrigger
tags:
- email_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
responses:
"200":
description: email trigger exists
content:
application/json:
schema:
type: boolean
/w/{workspace}/email_triggers/local_part_exists:
post:
summary: does email local part exists
operationId: existsEmailLocalPart
tags:
- email_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: email local part exists request
required: true
content:
application/json:
schema:
type: object
properties:
local_part:
type: string
workspaced_local_part:
type: boolean
trigger_path:
type: string
required:
- local_part
responses:
"200":
description: email local part exists
content:
application/json:
schema:
type: boolean
/groups/list:
get:
summary: list instance groups
@@ -11899,6 +12064,7 @@ paths:
mqtt_trigger,
gcp_trigger,
sqs_trigger,
email_trigger,
]
responses:
"200":
@@ -11943,6 +12109,7 @@ paths:
mqtt_trigger,
gcp_trigger,
sqs_trigger,
email_trigger,
]
requestBody:
description: acl to add
@@ -11998,6 +12165,7 @@ paths:
mqtt_trigger,
gcp_trigger,
sqs_trigger,
email_trigger,
]
requestBody:
description: acl to add
@@ -16031,6 +16199,8 @@ components:
type: number
email_count:
type: number
runnable_email_count:
type: number
websocket_count:
type: number
postgres_count:
@@ -16986,6 +17156,75 @@ components:
- subjects
- is_flow
EmailTrigger:
allOf:
- $ref: "#/components/schemas/TriggerExtraProperty"
type: object
properties:
local_part:
type: string
workspaced_local_part:
type: boolean
error_handler_path:
type: string
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
retry:
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
required:
- local_part
NewEmailTrigger:
type: object
properties:
path:
type: string
script_path:
type: string
local_part:
type: string
workspaced_local_part:
type: boolean
is_flow:
type: boolean
error_handler_path:
type: string
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
retry:
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
required:
- path
- script_path
- local_part
- is_flow
EditEmailTrigger:
type: object
properties:
path:
type: string
script_path:
type: string
local_part:
type: string
workspaced_local_part:
type: boolean
is_flow:
type: boolean
error_handler_path:
type: string
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
retry:
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
required:
- path
- script_path
- is_flow
Group:
type: object
properties:
@@ -18041,7 +18280,7 @@ components:
CaptureTriggerKind:
type: string
enum:
[webhook, http, websocket, kafka, email, nats, postgres, sqs, mqtt, gcp]
[webhook, http, websocket, kafka, runnable_email, nats, postgres, sqs, mqtt, gcp, email]
Capture:
type: object
+8
View File
@@ -146,6 +146,12 @@ struct HttpTriggerConfig {
wrap_body: Option<bool>,
}
#[cfg(feature = "http_trigger")]
#[derive(Serialize, Deserialize)]
struct EmailTriggerConfig {
local_part: String,
}
#[cfg(all(feature = "enterprise", feature = "kafka", feature = "private"))]
#[derive(Serialize, Deserialize)]
pub struct KafkaTriggerConfig {
@@ -241,6 +247,8 @@ enum TriggerConfig {
Mqtt(MqttTriggerConfig),
#[cfg(all(feature = "enterprise", feature = "gcp_trigger", feature = "private"))]
Gcp(GcpTriggerConfig),
#[cfg(all(feature = "enterprise", feature = "smtp", feature = "private"))]
Email(EmailTriggerConfig),
}
#[derive(Serialize, Deserialize)]
+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
+25 -1
View File
@@ -736,6 +736,16 @@ pub fn generate_trigger_routers() -> Router {
);
}
#[cfg(all(feature = "enterprise", feature = "smtp", feature = "private"))]
{
use crate::triggers::email::EmailTrigger;
router = router.nest(
EmailTrigger::ROUTE_PREFIX,
complete_trigger_routes(EmailTrigger),
);
}
router
}
@@ -751,6 +761,7 @@ pub struct TriggersCount {
http_routes_count: i64,
webhook_count: i64,
email_count: i64,
runnable_email_count: i64,
websocket_count: i64,
kafka_count: i64,
nats_count: i64,
@@ -871,6 +882,18 @@ pub async fn get_triggers_count_internal(
};
#[cfg(not(all(feature = "gcp_trigger", feature = "enterprise", feature = "private")))]
let gcp_count = 0;
#[cfg(all(feature = "smtp", feature = "enterprise", feature = "private"))]
let email_count = {
use crate::triggers::email::EmailTrigger;
let count = EmailTrigger
.trigger_count(&mut tx, w_id, is_flow, path)
.await;
count
};
#[cfg(not(all(feature = "smtp", feature = "enterprise", feature = "private")))]
let email_count = 0;
tx.commit().await?;
let webhook_count = (if is_flow {
@@ -889,7 +912,7 @@ pub async fn get_triggers_count_internal(
.await?
.unwrap_or(0);
let email_count = (if is_flow {
let runnable_email_count = (if is_flow {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM token WHERE label LIKE 'email-%' AND workspace_id = $1 AND scopes @> ARRAY['run:flow/' || $2]::text[]",
w_id,
@@ -910,6 +933,7 @@ pub async fn get_triggers_count_internal(
schedule_count,
http_routes_count,
webhook_count,
runnable_email_count,
email_count,
websocket_count,
kafka_count,
+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,
RunnableEmail,
Email,
Nats,
Mqtt,
@@ -28,6 +29,7 @@ impl TriggerKind {
TriggerKind::Websocket => "websocket".to_string(),
TriggerKind::Kafka => "kafka".to_string(),
TriggerKind::Email => "email".to_string(),
TriggerKind::RunnableEmail => "runnable_email".to_string(),
TriggerKind::Nats => "nats".to_string(),
TriggerKind::Mqtt => "mqtt".to_string(),
TriggerKind::Sqs => "sqs".to_string(),
@@ -45,6 +47,7 @@ impl fmt::Display for TriggerKind {
TriggerKind::Websocket => "websocket",
TriggerKind::Kafka => "kafka",
TriggerKind::Email => "email",
TriggerKind::RunnableEmail => "runnable_email",
TriggerKind::Nats => "nats",
TriggerKind::Mqtt => "mqtt",
TriggerKind::Sqs => "sqs",
@@ -615,7 +615,7 @@
new Triggers(
[
{ type: 'webhook', path: '', isDraft: false },
{ type: 'email', path: '', isDraft: false },
{ type: 'runnable_email', path: '', isDraft: false },
...(draftTriggersFromUrl ?? savedFlow?.draft?.draft_triggers ?? [])
],
selectedTriggerIndexFromUrl,
+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: 'runnable_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}
@@ -40,7 +40,7 @@
{#if captureInfo}
<CaptureSection
captureType="email"
captureType="runnable_email"
disabled={false}
{captureInfo}
{captureLoading}
@@ -123,7 +123,7 @@
{/key}
{/key}
<Alert title="Email trigger" size="xs">
<Alert title="Runnable email" size="xs">
To trigger the job by email, send an email to the address above. The job will receive two
arguments: `raw_email` containing the raw email as string, and `parsed_email` containing the
parsed email as an object.
@@ -4,13 +4,13 @@
import { generateRandomString } from '$lib/utils'
import HighlightTheme from '../HighlightTheme.svelte'
import Alert from '../common/alert/Alert.svelte'
import { SettingService } from '$lib/gen'
import Skeleton from '../common/skeleton/Skeleton.svelte'
import TriggerTokens from '../triggers/TriggerTokens.svelte'
import Description from '../Description.svelte'
import Section from '../Section.svelte'
import { createEventDispatcher } from 'svelte'
import EmailTriggerConfigSection from './EmailTriggerConfigSection.svelte'
import RunnableEmailConfigSection from './RunnableEmailConfigSection.svelte'
import { getEmailDomain } from '../triggers/email/utils'
let userSettings: UserSettings
@@ -26,15 +26,11 @@
let triggerTokens: TriggerTokens | undefined = undefined
let loading = true
async function getEmailDomain() {
emailDomain =
((await SettingService.getGlobal({
key: 'email_domain'
})) as any) ?? 'mail.test.com'
loading = false
}
getEmailDomain()
getEmailDomain().then((domain) => {
emailDomain = domain
loading = false
})
$: emailDomain && dispatch('email-domain', emailDomain)
</script>
@@ -52,19 +48,20 @@
{scopes}
/>
<Section label="Email trigger" class="flex flex-col gap-4">
<Section label="Runnable email" 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.
Runnable email is a partially fixed email address that can be used to trigger a script or flow.
The email address is composed of the encoded workspace and script or flow path as well as the
token.
</Description>
{#if loading}
<Skeleton layout={[[18]]} />
{:else}
{#if emailDomain}
<EmailTriggerConfigSection {hash} {token} {path} {isFlow} {userSettings} {emailDomain} />
<RunnableEmailConfigSection {hash} {token} {path} {isFlow} {userSettings} {emailDomain} />
{:else}
<div>
<Alert title="Email triggers are disabled" size="xs" type="warning">
<Alert title="Runnable emails 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
@@ -75,7 +72,7 @@
{#if !$enterpriseLicense}
<Alert title="Community Edition limitations" type="warning" size="xs">
Email triggers on Windmill Community Edition are limited to 100 emails per day.
Runnable emails on Windmill Community Edition are limited to 100 emails per day.
</Alert>
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import { Calendar, Mail, Webhook, Unplug, Database, Terminal } from 'lucide-svelte'
import { Calendar, Mail, Webhook, Unplug, Database, Terminal, MailWarning } from 'lucide-svelte'
import { Loader2 } from 'lucide-svelte'
import { type Component, type ComponentType } from 'svelte'
import { Route } from 'lucide-svelte'
@@ -56,6 +56,7 @@
websocket: { icon: Unplug, countKey: 'websocket_count' },
postgres: { icon: Database, countKey: 'postgres_count' },
kafka: { icon: KafkaIcon, countKey: 'kafka_count', disabled: !$enterpriseLicense },
runnable_email: { icon: MailWarning, countKey: 'runnable_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',
'runnable_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'
| 'runnable_emails'
| 'schedules'
| 'cli'
| 'routes'
@@ -61,6 +62,8 @@ export function captureTriggerKindToTriggerKind(kind: CaptureTriggerKind): Trigg
return 'webhooks'
case 'email':
return 'emails'
case 'runnable_email':
return 'runnable_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 RunnableEmailCapture from '../details/RunnableEmailCapture.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 === 'runnable_email'}
<RunnableEmailCapture
{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}
@@ -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 ?? {}), runnable_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?.runnable_email_count}
/>
</div>
{:else}
@@ -92,7 +92,7 @@
>
{`${webhookToken} token${webhookToken > 1 ? 's' : ''}`}
</span>
{:else if trigger.type === 'email' && emailToken}
{:else if trigger.type === 'runnable_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 !['runnable_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 RunnableEmailPanel from '../details/RunnableEmailPanel.svelte'
import SchedulePanel from '$lib/components/SchedulePanel.svelte'
import PostgresTriggersPanel from './postgres/PostgresTriggersPanel.svelte'
import KafkaTriggerPanel from './kafka/KafkaTriggersPanel.svelte'
@@ -71,8 +72,8 @@
scopes={isFlow ? [`jobs:run:flows:${currentPath}`] : [`jobs:run:scripts:${currentPath}`]}
{newItem}
/>
{:else if selectedTrigger.type === 'email'}
<EmailTriggerPanel
{:else if selectedTrigger.type === 'runnable_email'}
<RunnableEmailPanel
token=""
scopes={isFlow ? [`jobs:run:flows:${currentPath}`] : [`jobs:run:scripts:${currentPath}`]}
path={initialPath || fakeInitialPath}
@@ -153,6 +154,15 @@
{customLabel}
{...props}
/>
{:else if selectedTrigger.type === 'email'}
<EmailTriggerPanel
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
{customLabel}
{...props}
/>
{:else if selectedTrigger.type === 'poll'}
<ScheduledPollPanel />
{:else if selectedTrigger.type === 'cli'}
@@ -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 || !/^\w[a-z0-9._]*\w$/.test(localPart)) {
addressError =
'Local part not valid, only accepts lowercase alphanumeric characters, dots and underscores'
} 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}
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,383 @@
<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=62' : '/scripts/add?hub=hub%2F19669'}
target="_blank">Create from template</Button
>
{/if}
</div>
</div>
</Section>
{/if}
<EmailTriggerEditorConfigSection
initialTriggerPath={initialPath}
bind:local_part
bind:isValid
bind:workspaced_local_part
{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,56 @@
<script lang="ts">
import EmailTriggerEditorInner from './EmailTriggerEditorInner.svelte'
import Description from '$lib/components/Description.svelte'
import { userStore } from '$lib/stores'
import { Alert } from '$lib/components/common'
import { onMount } from 'svelte'
let emailTriggerEditor = $state<EmailTriggerEditorInner | null>(null)
let {
selectedTrigger,
isFlow,
path,
defaultValues = undefined,
isEditor = false,
customLabel = undefined,
...restProps
} = $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)
}
})
</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}
</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'
)
}
@@ -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)
])
}
}
+38 -7
View File
@@ -1,4 +1,13 @@
import { Webhook, Mail, Calendar, Route, Unplug, Database, Terminal } from 'lucide-svelte'
import {
Webhook,
Mail,
Calendar,
Route,
Unplug,
Database,
Terminal,
MailWarning
} from 'lucide-svelte'
import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte'
import NatsIcon from '$lib/components/icons/NatsIcon.svelte'
import MqttIcon from '$lib/components/icons/MqttIcon.svelte'
@@ -25,6 +34,7 @@ import { saveMqttTriggerFromCfg } from './mqtt/utils'
import { saveGcpTriggerFromCfg } from './gcp/utils'
import type { Triggers } from './triggers.svelte'
import { emptyString } from '$lib/utils'
import { saveEmailTriggerFromCfg } from './email/utils'
export const CLOUD_DISABLED_TRIGGER_TYPES = [
'nats',
@@ -38,6 +48,7 @@ export const CLOUD_DISABLED_TRIGGER_TYPES = [
export type TriggerType =
| 'webhook'
| 'runnable_email'
| 'email'
| 'schedule'
| 'http'
@@ -48,6 +59,7 @@ export type TriggerType =
| 'mqtt'
| 'sqs'
| 'gcp'
| 'email'
| 'poll'
| 'cli'
@@ -68,6 +80,7 @@ export type Trigger = {
export const triggerIconMap = {
webhook: Webhook,
email: Mail,
runnable_email: MailWarning,
schedule: Calendar,
http: Route,
websocket: Unplug,
@@ -92,6 +105,7 @@ export function triggerTypeToCaptureKind(triggerType: TriggerType): CaptureTrigg
const capturableTriggerTypes: TriggerType[] = [
'webhook',
'email',
'runnable_email',
'http',
'websocket',
'postgres',
@@ -120,7 +134,7 @@ export function updateTriggersCount(
// Map trigger types to their corresponding count property names
const countPropertyMap: Record<TriggerType, string | undefined> = {
webhook: undefined,
email: undefined,
runnable_email: undefined,
schedule: 'schedule_count',
http: 'http_routes_count',
websocket: 'websocket_count',
@@ -130,6 +144,7 @@ export function updateTriggersCount(
mqtt: 'mqtt_count',
sqs: 'sqs_count',
gcp: 'gcp_count',
email: 'email_count',
poll: undefined,
cli: undefined
}
@@ -178,6 +193,8 @@ export function triggerKindToTriggerType(kind: TriggerKind): TriggerType | undef
return 'webhook'
case 'emails':
return 'email'
case 'runnable_emails':
return 'runnable_email'
case 'schedules':
return 'schedule'
case 'routes':
@@ -225,7 +242,7 @@ export async function deployTriggers(
// Map of trigger types to their save functions
const triggerSaveFunctions: Record<TriggerType, Function | undefined> = {
webhook: undefined,
email: undefined,
runnable_email: undefined,
schedule: (trigger: Trigger) => {
if (trigger.isPrimary && initialPath) {
trigger.draftConfig = {
@@ -301,6 +318,15 @@ export async function deployTriggers(
workspaceId,
usedTriggerKinds
),
email: (trigger: Trigger) =>
saveEmailTriggerFromCfg(
trigger.path ?? trigger.draftConfig?.path ?? '',
trigger.draftConfig ?? {},
!trigger.isDraft,
workspaceId,
isAdmin,
usedTriggerKinds
),
poll: undefined,
cli: undefined
}
@@ -398,6 +424,8 @@ export function getLightConfig(
return { queue_url: trigger.queue_url }
} else if (triggerType === 'gcp') {
return { gcp_resource_path: trigger.gcp_resource_path, topic: trigger.topic }
} else if (triggerType === 'email') {
return { local_part: trigger.local_part }
} else {
return undefined
}
@@ -409,8 +437,8 @@ export function getTriggerLabel(trigger: Trigger): string {
if (type === 'webhook') {
return 'Webhook'
} else if (type === 'email') {
return 'Email'
} else if (type === 'runnable_email') {
return 'Runnable email'
} else if (type === 'cli') {
return 'CLI'
} else if (type === 'http' && !emptyString(config?.route_path)) {
@@ -430,6 +458,8 @@ export function getTriggerLabel(trigger: Trigger): string {
return `${config?.gcp_resource_path} - ${config?.topic}`
} else if (type === 'websocket' && config?.url) {
return `${config?.url}`
} else if (type === 'email' && config?.local_part) {
return `${config?.local_part}`
} else if (isDraft && draftConfig?.path) {
return `${draftConfig?.path}`
} else if (isDraft) {
@@ -443,7 +473,7 @@ export function sortTriggers(triggers: Trigger[]): Trigger[] {
const triggerTypeOrder = [
'webhook',
'cli',
'email',
'runnable_email',
'poll',
'schedule',
'http',
@@ -453,7 +483,8 @@ export function sortTriggers(triggers: Trigger[]): Trigger[] {
'nats',
'mqtt',
'sqs',
'gcp'
'gcp',
'email'
]
return triggers.sort((a, b) => {
@@ -0,0 +1,5 @@
export function load() {
return {
stuff: { title: 'Email triggers' }
}
}
@@ -0,0 +1,442 @@
<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 { isCloudHosted } from '$lib/cloud'
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)
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)
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()
})
let emailDomain: string | null = $state(null)
getEmailDomain().then((domain) => {
emailDomain = domain
})
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}`}
<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}
{isCloudHosted() || workspaced_local_part
? workspace_id + '/' + local_part
: local_part}
{/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(
getEmailAddress(
local_part,
workspaced_local_part ?? false,
workspace_id,
emailDomain ?? ''
)
)}
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: 'runnable_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: 'runnable_email', path: '', isDraft: false },
{ type: 'cli', path: '', isDraft: false }
])
)