Email triggers

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:

FlowAddress
IngestOrderingest-order@…
DeliveryNotedelivery-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:

InputTypeDescription
emailIdIDThe id of the stored email
fromTextThe sender's address
fromNameText?The sender's display name, if the email has one
toText[]The addresses in the To header
subjectTextThe subject line
textMarkdown?The plain-text body
htmlText?The HTML body
attachmentsFile[]The attached files
receivedAtTimestampWhen 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:

PatternMatches
orders@acme.comThat address only
*@acme.comAny address at acme.com
*@*.acme.comAny 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 allowFrom list, 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:

StatusMeaning
RUN_STARTEDA run is in progress
COMPLETEDThe run completed
RUN_FAILEDThe run failed
CANCELLEDThe run was cancelled
REJECTEDThe email failed a check and did not start a run. The reason is recorded with the email
DUPLICATEThe flow already processed an email with the same Message-ID
NO_FLOWThe 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 @emailTrigger attribute.
  • @emailTrigger cannot be combined with @preventConcurrency. Every email starts its own run, and skipping a run would drop an email.
  • @emailTrigger cannot be combined with @externalAccess. Only an email can start the flow.
  • Combining @emailTrigger with @schedule gives 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.