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 21:04:23 +00:00
committed by GitHub
co-authored by ellipsis-dev[bot] Ruben Fiszel
parent d2ac5a64ca
commit 36bbde6239
73 changed files with 2297 additions and 368 deletions
@@ -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 }
])
)