Integrations · September 23, 2026

Dify Email Integration: An Inbox per AI Agent

Dify's email plugin sends as one workspace SMTP account. Give each AI agent workflow its own inbox, a scoped key, and sends that survive node retries.

Dify’s Email plugin sends over SMTP, and the SMTP credential lives in the workspace, under Integrations > Tools. Set it once and every app in that workspace sends as the same account: the support Chatflow, the nightly report workflow, the agent someone built on a Friday. So the default Dify email integration an AI agent inherits has no per-workflow sender, no per-workflow authority, and nothing a recipient can look at to tell which workflow composed the message. Replies land in whichever mailbox owns that SMTP login, and a person has to work out which automation caused them.

This piece is the other arrangement: each workflow owns an address, an HTTP Request node carries a credential scoped to one inbox, and the send is safe to retry. Every Dify claim below was read on its current documentation on 23 September 2026, because the surface moved recently: Plugins is now Integrations, and triggers are a type of Start node. For the same pattern in other frameworks, the integrations hub has one page each.

What Dify’s email tooling puts on the From line

One account, chosen at the workspace level. Dify’s tool documentation says of plugin credentials that you “set workspace-level credentials in Integrations > Tools > Tool Plugin, or in the tool’s settings inside an app or node.” The per-node override is real, so this is not a hard ceiling. But it selects a different credential for the same plugin, which means a second SMTP account to own, configure and rotate, and attribution still stops at the mailbox. Nothing in the message records that workflow X composed it while workflow Y did not.

The langgenius/email plugin on the Marketplace is send-only: SMTP server, port, username, password, encryption, and a single action supporting CC, BCC, attachments and markdown-to-HTML. There is no inbound half, which is why the ranked guides on this topic all wrap Gmail or a mailbox API and inherit somebody’s personal mailbox with it.

One constraint shapes everything below. Dify’s start-node documentation is blunt about it: “Only User Input workflows can be published as web apps, MCP servers, backend service APIs, or reused as tools in other Dify apps.” A workflow that begins with a trigger is none of those things.

Wiring a Dify email integration for an AI agent onto the HTTP Request node

One node. Add an HTTP Request node, set the method to POST, and point it at the messages endpoint for an inbox the project owns:

https://api.emailforagents.ai/v1/projects/{project_id}/inboxes/{inbox_id}/messages

Under Authentication, choose API Key with the Bearer subtype. Store the key as a Secret-type environment variable rather than typing it into the field. Dify’s HTTP Request documentation states that secret-type values are masked in workflow run logs and in that node’s request log, which is the difference between a shared run history and a leaked credential.

Add one header by hand, because Dify’s auth block will not produce it:

Idempotency-Key: reply-{{inbound.message_id}}-v1

Then a JSON body, using Dify’s {{variable}} substitution:

{
  "to": [{ "email": "{{inbound.from_email}}" }],
  "subject": "Re: {{inbound.subject}}",
  "reply_to_message_id": "{{inbound.message_id}}",
  "text": "{{llm.text}}",
  "metadata": { "dify_app": "invoice-intake", "dify_node": "send_reply" }
}

The request schema is strict, which catches a class of workflow bug early. to is an array of objects with an email field, not a bare address string. subject is required and runs 1 to 998 characters. At least one of text or html must be present, each capped at 512,000 characters. attachment_ids takes at most 10, and the unique recipients across to, cc and bcc may not exceed 25 (packages/contracts/src/events.ts, LIMITS.uniqueRecipients). An unrecognised top-level field is rejected rather than silently ignored, so a stray from that an LLM node invented does not get dropped on the floor. The full request and response shapes are in the developer surface.

Now the part that will cost you an afternoon if nobody tells you. A schema rejection returns HTTP 400 invalid_request with a generic message, and it names the offending key only sometimes. The handler builds details.fields by joining each validation issue’s path, falling back to the literal string request when that path is empty (packages/api/src/index.ts). A bad address gives you fields: ["to.0.email"], which is actionable. An unrecognised top-level key has no path, so you get fields: ["request"] and the word from appears nowhere in the response. The “at least one of text or html” rule behaves the same way. In a Dify canvas, where the body is assembled from {{variable}} substitutions and an LLM node can add a key you never wrote, that is the difference between a two-minute fix and an afternoon bisecting JSON. When you see fields: ["request"], stop reading the message and diff your body against the field list above.

metadata is a flat map of string keys to string, number, boolean or null values, and the whole object must serialise to 4,096 bytes or less (packages/contracts/src/schemas.ts). Oversize metadata is the one case that does point at itself: fields: ["metadata"]. This is where the attribution goes. Stamp the Dify app and node identifiers into it and every message in the console names the workflow that composed it, which is exactly what a shared SMTP account cannot do.

One limit sits above all of this: the 1 MiB JSON body cap is enforced by middleware before the request is authenticated, so an over-large body returns 413 payload_too_large and your key is never looked at. A node that inlines a rendered HTML report into html can hit that.

Both success paths return HTTP 202, and you branch on result. queued carries a message_id and a thread_id. approval_required carries a draft_id, an approval_id and reason_codes, and means no email exists yet.

The Idempotency-Key that survives Dify’s retry

Derive the key from the thing you meant to send, never from the attempt. Dify’s HTTP Request node documents automatic retries of up to 10 attempts with a configurable interval capped at 5,000 milliseconds. That is a tight loop by the standards of an email API, and a send that timed out after the request left the building is the one request you must not replay blind.

The header is mandatory: a send without it fails before anything is evaluated, with HTTP 400 invalid_request and the message “Idempotency-Key is required for send operations.” (packages/api/src/index.ts). What the key guarantees is narrower than “no duplicates”. The body is normalised into a canonical payload and hashed, so the key binds to that exact message. Reuse with an identical payload replays the stored response. Reuse with a changed payload is HTTP 409 idempotency_conflict rather than a second email. The replay window is 30 days and the tombstone is 365 (packages/contracts/src/events.ts).

There is a third outcome, and Dify’s retry loop is one of the few clients fast enough to find it. The idempotency record is claimed at the top of the send transaction and the response snapshot is written at the bottom. If a retry arrives while the first attempt is still running, the record exists with no snapshot yet, and the API answers HTTP 503 temporarily_unavailable with “A matching send is already in progress.” (packages/domain/src/services.ts). It carries retryable: true, which is correct: back off, ask again, and once the first attempt commits you get the replayed 202. It is neither a duplicate nor a failure. Since the retry interval caps at 5,000 milliseconds, set it near that cap rather than near zero, so the loop spends its ten attempts waiting rather than racing itself.

Then the Dify-specific trap, which nobody writing about this platform mentions. The Variable Inspector lets you edit a variable’s value and re-run a single downstream node without re-running the workflow. That is an excellent debugging tool and a loaded gun pointed at a send node:

Both outcomes are right and both surprise people mid-debug. Build the key from an identifier that already exists outside Dify, such as the inbound message ID or an invoice number, so it is stable across a node re-run and a whole workflow re-run. The full idempotency rules cover the four outcomes of key reuse and the one case where retrying is wrong regardless of the key.

Scoping the credential so one workflow cannot send as another

Mint one key per workflow and pin it to one inbox. Scope strings are exact:

Two scopes are easy to miss, because the field that needs them is optional. Sending reply_to_message_id also requires messages:read, and sending attachment_ids also requires attachments:read, both checked inside the send path (packages/domain/src/services.ts). A reply workflow scoped for sending alone works right up to the first threaded reply, then returns permission_denied on a request that looks identical to the one that worked.

That third set is the useful one. A credential holding drafts:submit without messages:send produces fully formed messages that always land in the approval queue. Be precise about the marker: draft-only is computed as drafts:submit present and messages:send absent, so adding messages:send to a draft-only key does not widen it, it removes the gate entirely. And reason_codes reads ["draft_only_principal"] only when no inbox policy rule also fired; if an always rule matched you get ["always"] instead, from the same credential, for the same underlying reason. Branch on the presence of result: "approval_required", not on a particular reason code. Prompting an LLM node not to send is a suggestion. Removing the scope is a fact.

Three enforcement behaviours change how you plan the key inventory. A credential cannot exceed its creator, so asking for a scope your own role lacks fails at creation with permission_denied. An efa_test_ prefix resolves only to test projects and test inboxes, and no flag crosses that line. And authority is re-read per request, so removing a person’s project assignment narrows every key they minted on the next call rather than at the next rotation. The full scope list and what each one unlocks has all 32 strings.

Underneath the credential, the inbox policy is the second belt: send_enabled, an approval_mode of direct or always, allow and block lists for domains and addresses at up to 100 entries each, and a daily_recipient_limit. Writes carry expected_version, so two operators editing the same policy get a version_conflict instead of a silent overwrite. The scope governs what the workflow may ask for; the controls surface governs what the inbox will do regardless of who asks.

A blocked send is not a 202. It is HTTP 403 policy_denied with reason_codes naming the rule that stopped it: send_disabled, blocked_recipient, or recipient_not_allowed when an allow list exists and the address is not on it. That distinction matters on this platform specifically. A 403 is a decision, not a hiccup, and ten retries at five-second intervals against a policy denial buys ten identical rejections and a misleading error in the run log. Branch on the status before you let the retry setting see it.

Catching inbound mail with a Webhook Trigger

Point our webhook at the URL a Dify Webhook Trigger generates. Four things about that trigger, all from Dify’s current docs, decide whether this works on the first try.

Triggers are available for Workflow applications only, and User Input and triggers are mutually exclusive on a canvas. So the workflow that reacts to inbound mail cannot also be published as a tool, an MCP server or a Service API. In practice you build two: a trigger-started workflow that handles mail, and a User-Input-started workflow holding the send logic, exposed as a tool the first one calls.

The HTTP method and content-type you configure must match what the sender uses, or the request is rejected outright. You extract query, header and body parameters by name, and the variable name you define has to match the key in the request. An unpublished trigger only captures requests while it is listening, which means clicking Run this step before you fire a test, against the separate test webhook URL Dify provides.

Pair that listening requirement with how our delivery gives up. A delivery is retried up to 8 attempts on a fixed backoff of 10 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours, but any 4xx other than 408, 409, 425 or 429 is terminal on the first attempt (packages/domain/src/webhook-jobs.ts). Whatever a trigger that is not listening, a stale URL from a workflow you re-published, or a method mismatch answers with, if it lands in that range the delivery is exhausted at once with no backoff to fix it in. So confirm the trigger is live and the method and content-type match before you rely on a delivery, and when one is missing, check the deliveries listing for that endpoint and replay from the events cursor rather than hoping the webhook comes round again.

The signature is where this gets awkward. Every callback carries webhook-id (the event ID), webhook-timestamp (Unix seconds) and webhook-signature, which is v1, followed by the base64 HMAC-SHA256 of id.timestamp.rawBody keyed on your signing secret, with a 300-second clock tolerance. Verification has to run over the exact raw bytes, before parsing. Dify’s webhook trigger documentation describes extracting named parameters into variables; it does not document exposing the unparsed request body. Unless you can get those bytes, do not pretend to verify.

So treat the callback as a notification, not as evidence. Extract webhook-id as a header parameter, deduplicate on it, then read authoritative state back over the API with your scoped key: page the project events cursor, or fetch the message body. The cursor is a decimal project sequence number, limit is 1 to 100 and defaults to 25, and a cursor that has fallen behind the oldest retained event returns HTTP 410 cursor_expired with retryable: false, so restart without a cursor rather than looping. The header layout and a verification implementation are in the events and webhooks reference.

Two approval gates, and which one should own the decision

Dify already has one. The Human Input node pauses a workflow and delivers a form by web app or by email, to workspace members or arbitrary addresses, and anyone with the link can respond without a Dify account. Decision buttons expose __action_id and __action_value downstream, the request closes after the first response, and the default timeout is three days with a timeout branch you should wire to something.

It gates the workflow. It does not gate the mailbox. If that workflow’s credential holds messages:send, then a second workflow with the same credential, a node added below the gate, or a debug re-run can all transmit without the form appearing: Human Input is control flow, the inbox policy is authority. The server-side gate sits at the send endpoint, so it applies no matter which node, workflow or client made the call, and the reason_codes on the 202 name which rule fired: always, new_recipient_domain, attachments, or draft_only_principal.

One caveat before you build a handler. Only approval.requested and approval.rejected are emitted by this build (packages/domain/src/services.ts:1678 and :2094). approval.approved and approval.invalidated are declared in packages/contracts/src/events.ts and are fired by no code path, so a subscription to either is accepted and then never delivers anything. Poll the approvals endpoint or watch the console for the approved case. How the approval queue behaves sets out the whole lifecycle.

If you run both gates, know that they keep different clocks and neither can see the other. Dify’s Human Input request defaults to expiring after three days and then takes the node’s timeout branch. Our approval row is written with expires_at at seven days (packages/domain/src/services.ts), after which state reads expired and the approve call refuses it. Stack them naively and you get a four-day window where the Dify run has already declared the request dead down its timeout branch, while a live approval still sits in the console waiting for a human who can click approve and transmit the mail anyway. Either drive the server-side approval from the Human Input outcome rather than in parallel with it, or set the Human Input timeout to seven days so both give up together.

Where the decision livesUse it whenWhat it cannot do
Dify Human InputA person judges content and picks a branchStop a send from any other node, workflow or client
Inbox policy alwaysA person must authorise transmissionSteer what the workflow does next
Draft-only credentialThe workflow must never be able to transmitBe relaxed per message without reissuing the key

The recommendation flips on one question: is this workflow the only path to the mailbox? If it is, and the reviewers are all workspace members, Human Input alone is fine and simpler. The moment a second workflow, a debug re-run or an MCP session holds the same credential, the gate has to live on the inbox instead.

Connecting through Dify’s MCP tool type instead

Dify’s MCP tool type imports a remote server’s tools so apps call them like any other tool. Its documentation is specific: only servers with HTTP transport are supported, you add one by URL, display name and a unique identifier, Dynamic Client Registration is on by default so Dify can obtain OAuth credentials itself, and there is a Custom Headers option documented for servers that authenticate with a static token or API key.

That last option does not apply here, and it is the mistake to avoid. An API key is rejected at https://mcp.emailforagents.ai/mcp by design. Only an OAuth connection grant may call the tools, so pasting Authorization: Bearer efa_live_... into Custom Headers will not authenticate however the header is spelled.

What you get over MCP is six tools, list_inboxes, list_threads, list_messages, get_message, create_draft and send_message, where the tool list is itself the permission boundary. tools/list is filtered by grant and tools/call re-checks it, so a read-and-draft grant yields a session in which send_message is not present at all. An Agent node cannot call a tool it cannot see. create_draft cannot transmit mail under any grant. What you give up is header control, so if you need to choose the idempotency key yourself, keep the send leg on an HTTP Request node and use MCP for reading.

Rehearsing before the workflow can reach a real person

Build against a test project first. A project’s environment is fixed at creation and returns environment_immutable if you try to flip it, so you keep two projects and swap the key. Test inboxes get a non-routable .mail.invalid address and a simulated transport, so a misconfigured deploy fails closed instead of emailing a customer.

To drive inbound without any mail existing, post to the simulation endpoint with a credential holding test:simulate:

curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/test/inbound" \
  -H "Authorization: Bearer $EFA_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inbox_id": "ibx_...",
    "from": {"email":"ap@example.com","name":"Accounts Payable"},
    "to": [{"email":"invoices@....mail.invalid"}],
    "subject": "Invoice 4471",
    "text": "Please confirm receipt."
  }'

That produces a real inbound message and a real event, which fires your webhook, which starts the Dify workflow. Point it at the test webhook URL and click Run this step first so the trigger is listening.

Be exact about what this rehearsal is. It is a non-routable inbox plus API injection into the agent’s own mailbox, so nothing crosses a transport. Other vendors’ inbound testing generally means a routable hosted address and a real message transmitted into a stand-in mailbox. Both are legitimate and they answer different questions: ours proves the workflow, not the routing. Test mode allows 3 active test inboxes per workspace, retains the last 100 messages up to 10 MB, and expires content after 7 days. Test sends consume neither the daily recipient cap nor the monthly allowance. The full sandbox checklist covers what it does not reproduce.

What this does not give you

Live mail is received and sent on agents.emailforagents.ai. Custom domains require a paid plan and paid plans are not purchasable yet, so the From line is a subdomain address rather than your own domain. If a brand-owned From is a hard requirement this quarter, the SMTP plugin pointed at a domain you already run is the honest answer, and the shared sender identity is what you accept in exchange.

The free allowance is one live inbox and 100 email units a month per verified owner identity, claimed by that identity’s first eligible workspace. A unit is one message received or one recipient sent to, so a reply to three people costs three, and a second workspace does not renew it. When it runs out sending stops, and mail already received stays readable.

There is no published uptime SLA, no independent security review, and no compliance certification to point at. approval.approved and approval.invalidated never fire. A 202 with result: "queued" means stored and accepted for processing, not received, and accepted only means a provider took it. On the Dify side, their documentation states that Sandbox accounts are limited to 2 triggers per workflow and that trigger events draw on a monthly quota varying by plan, so an inbound workflow on a small plan has a ceiling on how much mail it can react to.

Start with a read-only credential and a test project, get the Webhook Trigger extracting webhook-id and re-reading state over the events cursor, then add messages:send to a key pinned to one inbox.


Keep reading

All guides · Documentation · Integrations