Email triggers
A flow can be started by email. Each email sent to the flow's address starts a new run, with the email's sender, subject, body and attachments as the run's inputs. Use email triggers for work that already arrives in an inbox: purchase orders from customers, delivery notes from suppliers, invoices, remittance advices.
Defining an email trigger
Add the @emailTrigger attribute to your flow definition:
flow IngestOrder {
@emailTrigger(allowFrom: ["*@bigcustomer.co.uk"])
@permission(roles: [Operations])
}Every email from an address at bigcustomer.co.uk now starts a run of IngestOrder. Leave out allowFrom to accept email from any sender.
The flow's address
Each email-triggered flow receives mail on its own address. The part before the @ is the flow's name in kebab case:
| Flow | Address |
|---|---|
IngestOrder | ingest-order@… |
DeliveryNote | delivery-note@… |
The domain is specific to each environment. The Console shows the full address on the flow.
Anything after a + in the address is ignored, so senders can label their mail. ingest-order+urgent@… reaches IngestOrder like ingest-order@… does.
Renaming a flow changes its address. Update anyone who sends mail to the old address.
Email inputs
An email-triggered flow cannot declare inputs. Keel generates them from the email:
| Input | Type | Description |
|---|---|---|
emailId | ID | The id of the stored email |
from | Text | The sender's address |
fromName | Text? | The sender's display name, if the email has one |
to | Text[] | The addresses in the To header |
subject | Text | The subject line |
text | Markdown? | The plain-text body |
html | Text? | The HTML body |
attachments | File[] | The attached files |
receivedAt | Timestamp | When the email was received |
text and html are capped at 256 KB each. Longer bodies are cut at that size in the run's inputs.
Attachments are regular files. Read their contents with read(), or store them on a model's File field.
Implementing an email-triggered flow
Run keel generate to scaffold flows/ingestOrder.ts. The flow receives the email as inputs:
import { IngestOrder, models } from "@teamkeel/sdk";
export default IngestOrder({}, async (ctx, inputs) => {
const order = await ctx.step("create order", async () => {
const purchaseOrder = inputs.attachments[0];
const created = await models.order.create({
reference: inputs.subject,
customer: inputs.fromName ?? inputs.from,
purchaseOrder: purchaseOrder ?? null,
});
return { id: created.id };
});
await ctx.step("import lines", async () => {
const purchaseOrder = inputs.attachments[0];
if (!purchaseOrder) {
return;
}
const csv = (await purchaseOrder.read()).toString("utf8");
for (const row of csv.split("\n").slice(1).filter(Boolean)) {
const [sku, quantity] = row.split(",");
await models.orderLine.create({
orderId: order.id,
sku,
quantity: Number(quantity),
});
}
});
return { orderId: order.id };
});Wrap your work in function steps as in any other flow. Email-triggered flows can also include UI steps, for example to have someone check an order before it's confirmed.
Restricting senders
allowFrom takes a list of senders. Each entry is one of:
| Pattern | Matches |
|---|---|
orders@acme.com | That address only |
*@acme.com | Any address at acme.com |
*@*.acme.com | Any address at a subdomain of acme.com, such as uk.acme.com |
flow ProcessInvoice {
@emailTrigger(allowFrom: ["invoices@supplier.com", "*@*.acme.com"])
@permission(roles: [Finance])
}Matching is case-insensitive. The list must contain at least one sender.
The From header of an email is easy to forge, so a match only counts when the sender's domain passes DMARC. An email whose From matches allowFrom but fails DMARC is rejected. If a sender's mail is rejected for this reason, ask them to set up DMARC for their domain.
Which emails start a run
Keel stores every email it receives, then decides whether it starts a run. An email does not start a run when:
- It fails the virus or spam check.
- It is an automatic reply or bulk mail, such as an out-of-office message.
- The sender is not in the flow's
allowFromlist, or matches it but fails DMARC. - The flow already received an email with the same
Message-ID. The same message delivered twice runs once. - The address matches no email-triggered flow.
Email statuses
Each received email has a status:
| Status | Meaning |
|---|---|
RUN_STARTED | A run is in progress |
COMPLETED | The run completed |
RUN_FAILED | The run failed |
CANCELLED | The run was cancelled |
REJECTED | The email failed a check and did not start a run. The reason is recorded with the email |
DUPLICATE | The flow already processed an email with the same Message-ID |
NO_FLOW | The address matches no email-triggered flow |
You can retry an email that is RUN_FAILED, CANCELLED or REJECTED. A retry starts a fresh run of the flow with the same email. You can also send an email with status NO_FLOW to an email-triggered flow of your choice.
Emails that fail the virus check keep no content and can't be retried.
Routing emails to other flows
Use one address for several kinds of email and hand each one off to the flow that handles it. ctx.flows.handoff passes the email's inputs on to the successor:
flow SupplierInbox {
@emailTrigger(allowFrom: ["*@supplier.com"])
@permission(roles: [Operations])
}
flow ReceiveDeliveryNote {
@emailTrigger
@permission(roles: [Operations])
}import { SupplierInbox } from "@teamkeel/sdk";
import ReceiveDeliveryNote from "./receiveDeliveryNote";
export default SupplierInbox({}, async (ctx, inputs) => {
const kind = await ctx.step("classify", async () =>
inputs.subject.toLowerCase().includes("delivery") ? "delivery" : "other"
);
if (kind === "delivery") {
return ctx.flows.handoff(ReceiveDeliveryNote, { inputs });
}
});Give the successor @emailTrigger too, so its inputs match the email's. The email's status follows the journey: it is COMPLETED only when the last flow in the journey completes.
Restrictions
- A flow can have one
@emailTriggerattribute. @emailTriggercannot be combined with@preventConcurrency. Every email starts its own run, and skipping a run would drop an email.@emailTriggercannot be combined with@externalAccess. Only an email can start the flow.- Combining
@emailTriggerwith@schedulegives a warning, because scheduled runs have empty email inputs. Use a separate flow for scheduled work. - Two email-triggered flows whose names give the same address fail validation. Rename one of them.
- Email-triggered flows cannot be started from the flows API. To run an email again, retry it.
Permissions
The flow's @permission rules decide who can see the emails it received and who can retry them. Seeing a flow's emails requires access to its run history. Retrying an email requires permission to run the target flow.
Testing locally
When your project is running with keel run, open the flow in the Console and send it a test email. The email goes through the same parsing and checks as real mail, and starts a run you can follow in the Console.