Email triggers_
Execute an Appwrite Function when an email arrives at its address. Use the built-in address of the function or connect a custom domain.
10 min read
An email trigger executes your function when an email arrives at one of the function's addresses. Each function has a built-in address, and you can also connect a custom domain. Appwrite receives the email and executes your function with the email and its attachments in the request body. Your function can save the email to a table, reply to the sender, or send the content to another service.
Email triggers are available on Appwrite Cloud, and they only receive email. To send email from your app, such as a reply, use Messaging.
How email triggers work
- The email trigger is off by default. A function receives email only after you turn on its email trigger.
- An email execution is a
POSTrequest to the/path of your function, with thex-appwrite-triggerheader set toemail. - Appwrite executes your function while the sending mail server waits, and accepts the email after the function finishes.
- Each delivery creates one execution. A delivery is one copy of an email that a mail server hands to Appwrite. If a delivery includes several addresses of the same function, the function executes one time. If it also includes addresses of other functions, each of those functions executes one time.
- A sending mail server can split one email into several deliveries, for example for addresses at different domains or for more than 50 recipients. Each delivery creates its own execution.
- When your function returns an error, Appwrite still accepts the email. The execution shows as failed, and the sender does not get a bounce message.
- When your function cannot run, or does not finish within the time limit, Appwrite does not accept the email. The sending mail server keeps the email and sends it again later, so your function can receive the same email more than one time.
- Appwrite rejects an email when the email trigger of the function is off, the function is disabled, the function has no active deployment, or the custom domain is not verified. Appwrite also rejects email to an address that does not belong to a function, and email that exceeds the limits. The sending mail server then returns the email to the sender.
Anyone who knows one of your function's addresses can send email to it. The execute access of the function does not apply to email executions.
Turn on the email trigger

Turn on the email trigger of a function before it can receive email at its built-in address or at a custom domain.
- In the Appwrite Console's sidebar, click Functions.
- Open your function.
- Under the Settings tab, click Executions.
- In the Email card, turn on Email trigger.
- Click Update.
Built-in address
Each function has a built-in address that does not need DNS records.
- Domain:
<FUNCTION_ID>-<PROJECT_ID>.appwrite.email, with the function ID in lowercase. - Default address: the Email card in the Console shows
contact@<FUNCTION_ID>-<PROJECT_ID>.appwrite.email. - Other addresses: any name before the
@works. For example,support@<FUNCTION_ID>-<PROJECT_ID>.appwrite.emailandbilling@<FUNCTION_ID>-<PROJECT_ID>.appwrite.emailboth execute the same function. - Receiving address: the function reads which address received the email from
envelope.to.
A function does not have a built-in address when:
- The function ID contains a
.or a_. - The project ID ends with a
-. - The function ID and the project ID together are longer than 62 characters.
To receive email for such a function, connect a custom domain.
Custom domain

You can connect a domain that you own, such as mail.example.com, to your function. Every email sent to that domain goes to your function, so use a domain that you don't use for any other email.
A custom domain needs two DNS records. The MX record sends the domain's email to Appwrite, and the TXT record proves that you own the domain. For mail.example.com, the records are:
| Type | Name | Value |
|---|---|---|
MX | mail | The Appwrite mail server that the Console shows. Any priority works. |
TXT | _appwrite.mail | Your project ID |
Most DNS providers expect names relative to your domain's DNS zone, as in this table. For an apex domain such as example.com, which has no subdomain, use @ for the MX record and _appwrite for the TXT record.
Email to the domain executes the function only after Appwrite verifies the domain. DNS records can take up to 48 hours to propagate.
To connect a domain:
- Turn on the email trigger of your function.
- Under the Domains tab of your function, click Add domain.
- Under Type, select Email.
- In Domain name, enter your domain, such as
mail.example.com. - Click Add.
- At your DNS provider, add the
MXandTXTrecords that the Console shows. - Click Verify.
If the records have not propagated yet, the domain stays unverified. To verify it later, under the Domains tab, click Retry next to the domain. Then click Verify.
DNS does not allow an MX record on a name that has a CNAME record. If support.example.com already points to a site or function with a CNAME record, receive email on another subdomain, such as mail.example.com.
Handle an email
Appwrite sends these headers with each email execution:
x-appwrite-trigger:emailx-appwrite-email-id: the delivery ID. It is the same as theidfield of the email.x-appwrite-email-recipient: the first address of your function in the delivery.x-appwrite-email-domain: the domain of the address inx-appwrite-email-recipient.
Email executions do not receive the x-appwrite-execution-id, x-appwrite-client-ip, x-appwrite-country-code, x-appwrite-continent-code, or x-appwrite-continent-eu headers.
The request body is multipart/form-data. It has an email part with the email as JSON, and one part for each attachment, as Request body describes. This example saves each email as a row in a table and logs the size of each attachment. The table has from and subject columns, and a mediumtext column named text that fits an email body of up to 8 MB. To let the ephemeral API key of the function create rows, give the function the rows.write scope.
The Node.js, Deno, and Go examples read the body with a built-in multipart parser. The other examples split the body at the boundary from the content-type header.
import { Client, ID, TablesDB } from 'node-appwrite';
export default async ({ req, res, log }) => { if (req.headers['x-appwrite-trigger'] !== 'email') { return res.text('Not an email', 400); }
const form = await new Response(req.bodyBinary, { headers: { 'content-type': req.headers['content-type'] } }).formData(); const email = JSON.parse(form.get('email'));
const client = new Client() .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT) .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID) .setKey(req.headers['x-appwrite-key']);
const tablesDB = new TablesDB(client);
await tablesDB.createRow({ databaseId: '<DATABASE_ID>', tableId: '<TABLE_ID>', rowId: ID.unique(), data: { from: email.from, subject: email.subject, text: email.text } });
for (const attachment of email.attachments) { const file = form.get(attachment.id); log(`Attachment ${attachment.name}: ${file.size} bytes`); }
return res.empty();};Request body
The request body of an email execution is multipart/form-data, with the boundary in the content-type header. The first part is named email and contains the email as JSON. Each attachment follows in its own part, which is named after the attachment's id and contains the file's original bytes:
--appwrite-email-9b2f6c0d4e8a1f3b5c7d9e0a2b4c6d8eContent-Disposition: form-data; name="email"Content-Type: application/json
{"id":"5f0c2a9e3b7d4e1f8a6c0b2d4e6f8a1c","envelope":{...},...}--appwrite-email-9b2f6c0d4e8a1f3b5c7d9e0a2b4c6d8eContent-Disposition: form-data; name="attachment-0"; filename="receipt.pdf"Content-Type: application/pdf
<file bytes>--appwrite-email-9b2f6c0d4e8a1f3b5c7d9e0a2b4c6d8e--The email part has this format:
{ "id": "5f0c2a9e3b7d4e1f8a6c0b2d4e6f8a1c", "envelope": { "from": "walter@example.net", "to": ["support@6a51290fe3d0727e293a-6a5128c4001f2d3e8a7b.appwrite.email"] }, "from": "Walter O'Brien <walter@example.net>", "to": "support@6a51290fe3d0727e293a-6a5128c4001f2d3e8a7b.appwrite.email", "cc": "", "replyTo": "", "subject": "Order 1042 did not arrive", "messageId": "<CAF3x9k2@mail.example.net>", "date": "Wed, 23 Sep 2026 09:14:02 +0000", "text": "Hi, my order 1042 did not arrive yet.", "html": "<p>Hi, my order 1042 did not arrive yet.</p>", "headers": { "message-id": ["<CAF3x9k2@mail.example.net>"], "date": ["Wed, 23 Sep 2026 09:14:02 +0000"], "from": ["Walter O'Brien <walter@example.net>"], "to": ["support@6a51290fe3d0727e293a-6a5128c4001f2d3e8a7b.appwrite.email"], "subject": ["Order 1042 did not arrive"] }, "attachments": [ { "id": "attachment-0", "name": "receipt.pdf", "contentType": "application/pdf", "contentId": "", "disposition": "attachment", "size": 48213 } ], "transport": { "remoteIp": "203.0.113.10", "helo": "mail.example.net", "tls": false }, "receivedAt": "2026-09-23T09:14:03.482913571Z", "spam": { "score": 0.0213, "verdict": "ham", "model": "1fb022917838" }}id: the delivery ID. It is the same as thex-appwrite-email-idheader.envelope: the sender and recipient addresses that the sending mail server gave during the SMTP connection. They can differ from thefromandtoheaders.envelope.from: the sender address in the envelope. It is empty when the sending server uses an empty sender, as bounce messages and many automatic replies do.envelope.to: the addresses of your function in the delivery. When you receive an email as a BCC recipient or as a forwarded email, onlyenvelope.toshows the address of your function.from,to,cc,replyTo,subject,messageId, anddate: the values of these headers in the email.textandhtml: the plain text and HTML versions of the email body. A value is empty when the email does not have that version.headers: these email headers:message-id,date,from,to,cc,reply-to,subject,in-reply-to, andreferences. The keys are lowercase, and each value is a list of the raw header values. A header that the email does not have is not in the object, and when the email has none of these headers, the value is an empty array ([]).attachments: the attached files and other non-text parts of the email, such as embedded images. Each has anid, which is also the name of the part that contains the file, aname, acontentType, acontentId, adisposition, and asizein bytes.transport: details of the SMTP connection that delivered the email:remoteIp, the name that the sending server gave in itsHELOorEHLOgreeting (helo), and whether the connection used TLS (tls).receivedAt: the time when Appwrite received the email, as an RFC 3339 timestamp in UTC that can include fractions of a second.spam: the spam score of the email.
Attachments
Each attachment is a part of the request body, and the part's name is the attachment's id. The part contains the decoded file, so its size matches the size of the attachment. An image that the HTML body embeds has a contentId, which the HTML references as cid:<contentId>.
Appwrite does not keep a copy of the attachments. To keep a file, upload it to Storage from your function.
Spam score
Appwrite scores each email with a spam filter and adds the result to the spam field:
score: a number from0to1. A score near0means that the email looks legitimate, and a score near1means that it looks like spam.verdict:ham(legitimate) for a score below0.2,spamfor a score of0.99or more, andunsurefor any score in between.model: the version of the spam filter that scored the email.
The value of spam is null when Appwrite cannot score the email. Appwrite does not reject or delay an email because of its score, so your function decides what to do with spam.
The spam filter can score legitimate automated email, such as order confirmations and sign-in codes, as unsure or spam. Before your function drops email with a spam verdict, send it the kind of email that you expect to receive and check the scores.
Duplicate emails
Your function can receive the same email more than one time. When Appwrite does not accept an email, for example because a function did not finish in time, the sending mail server sends the email again later. Each function in the delivery then executes again, including the functions that already finished. The sending mail server can also send an email again when the connection closes before Appwrite accepts the email.
Each copy is a new delivery with a new id. To detect a duplicate, compare the messageId value, which stays the same when a mail server sends the email again. Some emails do not have a Message-ID header, so messageId can be empty.
Limits
| Limit | Value |
|---|---|
| Size of an email as sent, including encoded attachments | 25 MB |
| Size of the request body: the email JSON and the decoded attachments | 20 MB |
| Combined size of the plain text and HTML bodies after decoding | 8 MB |
| Size of the email headers | 256 KB |
| Recipients in one delivery | 50. The sending mail server delivers to more recipients later, in a separate delivery. |
| MIME parts in one email | 200, nested at most 5 levels deep |
| Addresses with non-ASCII characters | Not supported |
| Duration of an email execution | Plan for 60 seconds. Appwrite can stop an email execution after 60 seconds, even when the timeout of your function is longer. The sending mail server then sends the email again later. |
Was this page helpful?
Share what worked or what we should fix. Once approved, our agents automatically apply suggested updates to the docs.