---
layout: article
title: Email triggers
description: Execute an Appwrite Function when an email arrives at its address. Use the built-in address of the function or connect a custom domain.
difficulty: beginner
readtime: 7
---

An email trigger executes your function when an email arrives at one of the function's addresses.
Each function has a [built-in address](#built-in-address), and you can also connect a [custom domain](#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](/docs/products/messaging).

# How email triggers work

- The email trigger is off by default. A function receives email only after you [turn on its email trigger](#turn-on).
- An email execution is a `POST` request to the `/` path of your function, with the `x-appwrite-trigger` header set to `email`.
- 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](#limits), 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](#duplicate-emails).
- 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](#limits). The sending mail server then returns the email to the sender.

**Treat email as untrusted input**

Anyone who knows one of your function's addresses can send email to it.
The [execute access](/docs/products/functions/functions#execution-access) of the function does not apply to email executions.

# Turn on the email trigger

![Email card with Email trigger turned on and the built-in address under Default address](/images/docs/functions/email/built-in-address.avif)

Turn on the email trigger of a function before it can receive email at its built-in address or at a custom domain.

1. In the Appwrite Console's sidebar, click **Functions**.
1. Open your function.
1. Under the **Settings** tab, click **Executions**.
1. In the **Email** card, turn on **Email trigger**.
1. 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.email` and `billing@<FUNCTION_ID>-<PROJECT_ID>.appwrite.email` both execute the same function.
- **Receiving address:** the function reads which address received the email from [`envelope.to`](#request-body).

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).

# Custom domain

![Verify email domain screen with the MX and TXT records for mail.example.com](/images/docs/functions/email/custom-domain.avif)

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:

1. [Turn on the email trigger](#turn-on) of your function.
1. Under the **Domains** tab of your function, click **Add domain**.
1. Under **Type**, select **Email**.
1. In **Domain name**, enter your domain, such as `mail.example.com`.
1. Click **Add**.
1. At your DNS provider, add the `MX` and `TXT` records that the Console shows.
1. 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**.

**Domains with a CNAME record**

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](/docs/products/functions/develop#headers) with each email execution:

- `x-appwrite-trigger`: `email`
- `x-appwrite-email-id`: the delivery ID. It is the same as the `id` field 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 in `x-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](#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](/docs/products/functions/develop#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.

```server-nodejs
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();
};
```
```php
<?php

require(__DIR__ . '/../vendor/autoload.php');

use Appwrite\Client;
use Appwrite\ID;
use Appwrite\Services\TablesDB;

// Splits the multipart body into its parts, keyed by part name.
function parseBody($req): array
{
    $delimiter = "\r\n--" . explode('boundary=', $req->headers['content-type'])[1];
    $parts = [];
    foreach (explode($delimiter, $req->bodyBinary) as $part) {
        if (!str_contains($part, "\r\n\r\n")) {
            continue;
        }
        [$head, $content] = explode("\r\n\r\n", $part, 2);
        $name = explode('"', explode(' name="', $head)[1])[0];
        $parts[$name] = $content;
    }
    return $parts;
}

return function ($context) {
    if (($context->req->headers['x-appwrite-trigger'] ?? '') !== 'email') {
        return $context->res->text('Not an email', 400);
    }

    $parts = parseBody($context->req);
    $email = json_decode($parts['email'], true);

    $client = (new Client())
        ->setEndpoint(getenv('APPWRITE_FUNCTION_API_ENDPOINT'))
        ->setProject(getenv('APPWRITE_FUNCTION_PROJECT_ID'))
        ->setKey($context->req->headers['x-appwrite-key']);

    $tablesDB = new TablesDB($client);

    $tablesDB->createRow(
        databaseId: '<DATABASE_ID>',
        tableId: '<TABLE_ID>',
        rowId: ID::unique(),
        data: [
            'from' => $email['from'],
            'subject' => $email['subject'],
            'text' => $email['text'],
        ]
    );

    foreach ($email['attachments'] as $attachment) {
        $size = strlen($parts[$attachment['id']]);
        $context->log("Attachment {$attachment['name']}: {$size} bytes");
    }

    return $context->res->empty();
};
```
```python
import json
import os

from appwrite.client import Client
from appwrite.id import ID
from appwrite.services.tables_db import TablesDB


# Splits the multipart body into its parts, keyed by part name.
def parse_body(req):
    boundary = req.headers["content-type"].split("boundary=")[1]
    parts = {}
    for part in req.body_binary.split(b"\r\n--" + boundary.encode()):
        head, separator, content = part.partition(b"\r\n\r\n")
        if separator:
            name = head.split(b' name="')[1].split(b'"')[0].decode()
            parts[name] = content
    return parts


def main(context):
    if context.req.headers.get("x-appwrite-trigger") != "email":
        return context.res.text("Not an email", 400)

    parts = parse_body(context.req)
    email = json.loads(parts["email"])

    client = (
        Client()
        .set_endpoint(os.environ["APPWRITE_FUNCTION_API_ENDPOINT"])
        .set_project(os.environ["APPWRITE_FUNCTION_PROJECT_ID"])
        .set_key(context.req.headers["x-appwrite-key"])
    )

    tables_db = TablesDB(client)

    tables_db.create_row(
        database_id="<DATABASE_ID>",
        table_id="<TABLE_ID>",
        row_id=ID.unique(),
        data={
            "from": email["from"],
            "subject": email["subject"],
            "text": email["text"],
        },
    )

    for attachment in email["attachments"]:
        size = len(parts[attachment["id"]])
        context.log(f"Attachment {attachment['name']}: {size} bytes")

    return context.res.empty()
```
```ruby
require 'json'
require 'appwrite'

include Appwrite

# Splits the multipart body into its parts, keyed by part name.
def parse_body(req)
  delimiter = "\r\n--#{req.headers['content-type'].split('boundary=').last}"
  req.body_binary.b.split(delimiter).each_with_object({}) do |part, parts|
    head, content = part.split("\r\n\r\n", 2)
    parts[head[/ name="([^"]*)"/, 1]] = content unless content.nil?
  end
end

def main(context)
  if context.req.headers['x-appwrite-trigger'] != 'email'
    return context.res.text('Not an email', 400)
  end

  parts = parse_body(context.req)
  email = JSON.parse(parts['email'])

  client = Client.new
    .set_endpoint(ENV['APPWRITE_FUNCTION_API_ENDPOINT'])
    .set_project(ENV['APPWRITE_FUNCTION_PROJECT_ID'])
    .set_key(context.req.headers['x-appwrite-key'])

  tables_db = TablesDB.new(client)

  tables_db.create_row(
    database_id: '<DATABASE_ID>',
    table_id: '<TABLE_ID>',
    row_id: ID.unique(),
    data: {
      from: email['from'],
      subject: email['subject'],
      text: email['text']
    }
  )

  email['attachments'].each do |attachment|
    size = parts[attachment['id']].bytesize
    context.log("Attachment #{attachment['name']}: #{size} bytes")
  end

  context.res.empty
end
```
```deno
import { Client, ID, TablesDB } from "npm:node-appwrite";

export default async ({ req, res, log }: any) => {
  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") as string);

  const client = new Client()
    .setEndpoint(Deno.env.get("APPWRITE_FUNCTION_API_ENDPOINT") ?? "")
    .setProject(Deno.env.get("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) as File;
    log(`Attachment ${attachment.name}: ${file.size} bytes`);
  }

  return res.empty();
};
```
```go
package handler

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime"
	"mime/multipart"
	"os"

	"github.com/appwrite/sdk-for-go/v7/appwrite"
	"github.com/appwrite/sdk-for-go/v7/id"
	"github.com/open-runtimes/types-for-go/v4/openruntimes"
)

type Email struct {
	From        string `json:"from"`
	Subject     string `json:"subject"`
	Text        string `json:"text"`
	Attachments []struct {
		Id   string `json:"id"`
		Name string `json:"name"`
	} `json:"attachments"`
}

// Reads the multipart body into its parts, keyed by part name.
func parseBody(req openruntimes.ContextRequest) (map[string][]byte, error) {
	_, params, err := mime.ParseMediaType(req.Headers["content-type"])
	if err != nil {
		return nil, err
	}
	reader := multipart.NewReader(bytes.NewReader(req.BodyBinary()), params["boundary"])
	parts := map[string][]byte{}
	for {
		part, err := reader.NextPart()
		if err == io.EOF {
			return parts, nil
		}
		if err != nil {
			return nil, err
		}
		content, err := io.ReadAll(part)
		if err != nil {
			return nil, err
		}
		parts[part.FormName()] = content
	}
}

func Main(Context openruntimes.Context) openruntimes.Response {
	if Context.Req.Headers["x-appwrite-trigger"] != "email" {
		return Context.Res.Text("Not an email", Context.Res.WithStatusCode(400))
	}

	var email Email
	parts, err := parseBody(Context.Req)
	if err == nil {
		err = json.Unmarshal(parts["email"], &email)
	}
	if err != nil {
		return Context.Res.Text("Invalid email payload", Context.Res.WithStatusCode(400))
	}

	appwriteClient := appwrite.NewClient(
		appwrite.WithEndpoint(os.Getenv("APPWRITE_FUNCTION_API_ENDPOINT")),
		appwrite.WithProject(os.Getenv("APPWRITE_FUNCTION_PROJECT_ID")),
		appwrite.WithKey(Context.Req.Headers["x-appwrite-key"]),
	)

	tablesDB := appwrite.NewTablesDB(appwriteClient)

	_, err = tablesDB.CreateRow(
		"<DATABASE_ID>",
		"<TABLE_ID>",
		id.Unique(),
		map[string]interface{}{
			"from":    email.From,
			"subject": email.Subject,
			"text":    email.Text,
		},
	)
	if err != nil {
		Context.Error(fmt.Sprintf("Failed to save email: %v", err))
		return Context.Res.Text("Failed to save email", Context.Res.WithStatusCode(500))
	}

	for _, attachment := range email.Attachments {
		Context.Log(fmt.Sprintf("Attachment %s: %d bytes", attachment.Name, len(parts[attachment.Id])))
	}

	return Context.Res.Empty()
}
```
```dart
import 'dart:convert';
import 'dart:io';
import 'package:dart_appwrite/dart_appwrite.dart';

// Splits the multipart body into its parts, keyed by part name.
Map<String, List<int>> parseBody(dynamic req) {
  final boundary = req.headers['content-type'].split('boundary=').last;
  // Latin-1 maps each byte to one character, so the attachment bytes stay intact.
  final body = latin1.decode(req.bodyBinary);
  final parts = <String, List<int>>{};
  for (final part in body.split('\r\n--$boundary')) {
    final separator = part.indexOf('\r\n\r\n');
    if (separator == -1) continue;
    final name = part.substring(0, separator).split(' name="')[1].split('"')[0];
    parts[name] = latin1.encode(part.substring(separator + 4));
  }
  return parts;
}

Future<dynamic> main(final context) async {
  if (context.req.headers['x-appwrite-trigger'] != 'email') {
    return context.res.text('Not an email', 400);
  }

  final parts = parseBody(context.req);
  final email = jsonDecode(utf8.decode(parts['email']!));

  final client = Client()
      .setEndpoint(Platform.environment['APPWRITE_FUNCTION_API_ENDPOINT']!)
      .setProject(Platform.environment['APPWRITE_FUNCTION_PROJECT_ID']!)
      .setKey(context.req.headers['x-appwrite-key']);

  final tablesDB = 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 (final attachment in email['attachments']) {
    final size = parts[attachment['id']]!.length;
    context.log('Attachment ${attachment['name']}: $size bytes');
  }

  return context.res.empty();
}
```
```kotlin
package io.openruntimes.kotlin.src

import com.google.gson.Gson
import io.appwrite.Client
import io.appwrite.ID
import io.appwrite.services.TablesDB
import io.openruntimes.kotlin.RuntimeContext
import io.openruntimes.kotlin.RuntimeOutput
import io.openruntimes.kotlin.RuntimeRequest
import kotlinx.coroutines.runBlocking

class Main {
    // Splits the multipart body into its parts, keyed by part name.
    private fun parseBody(req: RuntimeRequest): Map<String, ByteArray> {
        val delimiter = "\r\n--" + req.headers["content-type"]!!.substringAfter("boundary=")
        // ISO-8859-1 maps each byte to one character, so the attachment bytes stay intact.
        val body = String(req.bodyBinary, Charsets.ISO_8859_1)
        return body.split(delimiter)
            .filter { it.contains("\r\n\r\n") }
            .associate { part ->
                val name = part.substringAfter(" name=\"").substringBefore('"')
                name to part.substringAfter("\r\n\r\n").toByteArray(Charsets.ISO_8859_1)
            }
    }

    fun main(context: RuntimeContext): RuntimeOutput {
        if (context.req.headers["x-appwrite-trigger"] != "email") {
            return context.res.text("Not an email", 400)
        }

        val parts = parseBody(context.req)
        val email = Gson().fromJson(String(parts["email"]!!), Map::class.java) as Map<String, Any>

        val client = Client()
            .setEndpoint(System.getenv("APPWRITE_FUNCTION_API_ENDPOINT"))
            .setProject(System.getenv("APPWRITE_FUNCTION_PROJECT_ID"))
            .setKey(context.req.headers["x-appwrite-key"] ?: "")

        val tablesDB = TablesDB(client)

        runBlocking {
            tablesDB.createRow(
                databaseId = "<DATABASE_ID>",
                tableId = "<TABLE_ID>",
                rowId = ID.unique(),
                data = mapOf(
                    "from" to email["from"],
                    "subject" to email["subject"],
                    "text" to email["text"]
                )
            )
        }

        for (attachment in email["attachments"] as List<Map<String, Any>>) {
            val size = parts[attachment["id"] as String]!!.size
            context.log("Attachment ${attachment["name"]}: $size bytes")
        }

        return context.res.empty()
    }
}
```
```java
package io.openruntimes.java.src;

import com.google.gson.Gson;
import io.appwrite.Client;
import io.appwrite.ID;
import io.appwrite.coroutines.CoroutineCallback;
import io.appwrite.services.TablesDB;
import io.openruntimes.java.RuntimeContext;
import io.openruntimes.java.RuntimeOutput;
import io.openruntimes.java.RuntimeRequest;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Pattern;

public class Main {
    // Splits the multipart body into its parts, keyed by part name.
    private Map<String, byte[]> parseBody(RuntimeRequest req) {
        String boundary = req.getHeaders().get("content-type").split("boundary=")[1];
        // ISO-8859-1 maps each byte to one character, so the attachment bytes stay intact.
        String body = new String(req.getBodyBinary(), StandardCharsets.ISO_8859_1);
        Map<String, byte[]> parts = new HashMap<>();
        for (String part : body.split(Pattern.quote("\r\n--" + boundary))) {
            int separator = part.indexOf("\r\n\r\n");
            if (separator == -1) {
                continue;
            }
            String name = part.substring(0, separator).split(" name=\"")[1].split("\"")[0];
            parts.put(name, part.substring(separator + 4).getBytes(StandardCharsets.ISO_8859_1));
        }
        return parts;
    }

    public RuntimeOutput main(RuntimeContext context) throws Exception {
        if (!"email".equals(context.getReq().getHeaders().get("x-appwrite-trigger"))) {
            return context.getRes().text("Not an email", 400);
        }

        Map<String, byte[]> parts = parseBody(context.getReq());
        Map<String, Object> email = new Gson().fromJson(
            new String(parts.get("email"), StandardCharsets.UTF_8), Map.class);

        Client client = new Client()
            .setEndpoint(System.getenv("APPWRITE_FUNCTION_API_ENDPOINT"))
            .setProject(System.getenv("APPWRITE_FUNCTION_PROJECT_ID"))
            .setKey(context.getReq().getHeaders().get("x-appwrite-key"));

        TablesDB tablesDB = new TablesDB(client);
        CompletableFuture<Throwable> result = new CompletableFuture<>();

        tablesDB.createRow(
            "<DATABASE_ID>",
            "<TABLE_ID>",
            ID.unique(),
            Map.of(
                "from", email.get("from"),
                "subject", email.get("subject"),
                "text", email.get("text")
            ),
            new CoroutineCallback<>((row, error) -> result.complete(error))
        );

        Throwable error = result.get();
        if (error != null) {
            context.error("Failed to save email: " + error.getMessage());
            throw new Exception(error);
        }

        for (Map<String, Object> attachment : (List<Map<String, Object>>) email.get("attachments")) {
            int size = parts.get((String) attachment.get("id")).length;
            context.log("Attachment " + attachment.get("name") + ": " + size + " bytes");
        }

        return context.getRes().empty();
    }
}
```
```swift
import Appwrite
import Foundation

// Splits the multipart body into its parts, keyed by part name.
func parseBody(_ req: RuntimeRequest) -> [String: Data] {
    let boundary = (req.headers["content-type"] ?? "").components(separatedBy: "boundary=").last ?? ""
    // ISO Latin 1 maps each byte to one character, so the attachment bytes stay intact.
    let body = String(data: req.bodyBinary, encoding: .isoLatin1) ?? ""
    var parts: [String: Data] = [:]
    for part in body.components(separatedBy: "\r\n--" + boundary) {
        guard let separator = part.range(of: "\r\n\r\n") else { continue }
        let name = part[..<separator.lowerBound]
            .components(separatedBy: " name=\"")[1]
            .components(separatedBy: "\"")[0]
        parts[name] = part[separator.upperBound...].data(using: .isoLatin1)
    }
    return parts
}

func main(context: RuntimeContext) async throws -> RuntimeOutput {
    if context.req.headers["x-appwrite-trigger"] != "email" {
        return context.res.text("Not an email", statusCode: 400)
    }

    let parts = parseBody(context.req)
    let email = try JSONSerialization.jsonObject(with: parts["email"] ?? Data()) as? [String: Any] ?? [:]

    let client = Client()
        .setEndpoint(ProcessInfo.processInfo.environment["APPWRITE_FUNCTION_API_ENDPOINT"] ?? "")
        .setProject(ProcessInfo.processInfo.environment["APPWRITE_FUNCTION_PROJECT_ID"] ?? "")
        .setKey(context.req.headers["x-appwrite-key"] ?? "")

    let tablesDB = TablesDB(client)

    _ = try await tablesDB.createRow(
        databaseId: "<DATABASE_ID>",
        tableId: "<TABLE_ID>",
        rowId: ID.unique(),
        data: [
            "from": email["from"] ?? "",
            "subject": email["subject"] ?? "",
            "text": email["text"] ?? ""
        ]
    )

    for attachment in email["attachments"] as? [[String: Any]] ?? [] {
        let size = parts[attachment["id"] as? String ?? ""]?.count ?? 0
        context.log("Attachment \(attachment["name"] as? String ?? ""): \(size) bytes")
    }

    return context.res.empty()
}
```
```csharp
using System.Text;
using System.Text.Json;
using Appwrite;
using Appwrite.Services;

namespace DotNetRuntime;

public class Handler
{
    // Splits the multipart body into its parts, keyed by part name.
    private static Dictionary<string, byte[]> ParseBody(RuntimeRequest req)
    {
        var delimiter = "\r\n--" + req.Headers["content-type"].Split("boundary=")[1];
        // Latin-1 maps each byte to one character, so the attachment bytes stay intact.
        var body = Encoding.Latin1.GetString(req.BodyBinary);
        var parts = new Dictionary<string, byte[]>();
        foreach (var part in body.Split(delimiter))
        {
            var separator = part.IndexOf("\r\n\r\n", StringComparison.Ordinal);
            if (separator == -1)
            {
                continue;
            }
            var name = part[..separator].Split(" name=\"")[1].Split('"')[0];
            parts[name] = Encoding.Latin1.GetBytes(part[(separator + 4)..]);
        }
        return parts;
    }

    public async Task<RuntimeOutput> Main(RuntimeContext Context)
    {
        if (Context.Req.Headers.GetValueOrDefault("x-appwrite-trigger") != "email")
        {
            return Context.Res.Text("Not an email", 400);
        }

        var parts = ParseBody(Context.Req);
        var email = JsonDocument.Parse(parts["email"]).RootElement;

        var client = new Client()
            .SetEndpoint(Environment.GetEnvironmentVariable("APPWRITE_FUNCTION_API_ENDPOINT"))
            .SetProject(Environment.GetEnvironmentVariable("APPWRITE_FUNCTION_PROJECT_ID"))
            .SetKey(Context.Req.Headers["x-appwrite-key"]);

        var tablesDB = new TablesDB(client);

        await tablesDB.CreateRow(
            databaseId: "<DATABASE_ID>",
            tableId: "<TABLE_ID>",
            rowId: ID.Unique(),
            data: new Dictionary<string, object>
            {
                ["from"] = email.GetProperty("from").GetString()!,
                ["subject"] = email.GetProperty("subject").GetString()!,
                ["text"] = email.GetProperty("text").GetString()!
            });

        foreach (var attachment in email.GetProperty("attachments").EnumerateArray())
        {
            var size = parts[attachment.GetProperty("id").GetString()!].Length;
            Context.Log($"Attachment {attachment.GetProperty("name").GetString()}: {size} bytes");
        }

        return Context.Res.Empty();
    }
}
```
```rust
use appwrite::id::ID;
use appwrite::services::tables_db::TablesDB;
use appwrite::Client;
use openruntimes::{Context, Response};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::env;

// Splits the multipart body into its parts, keyed by part name.
fn parse_body(context: &Context) -> HashMap<String, Vec<u8>> {
    let content_type = context.req.headers.get("content-type").cloned().unwrap_or_default();
    let boundary = content_type.split("boundary=").last().unwrap_or_default();
    let delimiter = format!("\r\n--{}", boundary);
    // Each byte becomes one char, so the attachment bytes stay intact.
    let body: String = context.req.body_binary().iter().map(|&byte| byte as char).collect();

    body.split(&delimiter)
        .filter_map(|part| part.split_once("\r\n\r\n"))
        .map(|(head, content)| {
            let name = head.split(" name=\"").nth(1).unwrap_or_default();
            let name = name.split('"').next().unwrap_or_default().to_string();
            (name, content.chars().map(|c| c as u8).collect())
        })
        .collect()
}

pub fn main(context: Context) -> Response {
    if context.req.headers.get("x-appwrite-trigger").map(String::as_str) != Some("email") {
        return context.res.text("Not an email", Some(400), None);
    }

    let parts = parse_body(&context);
    let email: Value = serde_json::from_slice(&parts["email"]).unwrap_or(Value::Null);

    let client = Client::new()
        .set_endpoint(env::var("APPWRITE_FUNCTION_API_ENDPOINT").unwrap_or_default())
        .set_project(env::var("APPWRITE_FUNCTION_PROJECT_ID").unwrap_or_default())
        .set_key(
            context
                .req
                .headers
                .get("x-appwrite-key")
                .cloned()
                .unwrap_or_default(),
        );

    let tables_db = TablesDB::new(&client);

    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();

    let result = runtime.block_on(tables_db.create_row(
        "<DATABASE_ID>",
        "<TABLE_ID>",
        ID::unique(),
        json!({
            "from": email["from"],
            "subject": email["subject"],
            "text": email["text"]
        }),
        None,
        None,
    ));

    if let Err(e) = result {
        context.error(format!("Failed to save email: {}", e));
        return context.res.text("Failed to save email", Some(500), None);
    }

    for attachment in email["attachments"].as_array().into_iter().flatten() {
        let id = attachment["id"].as_str().unwrap_or_default();
        let size = parts.get(id).map_or(0, Vec::len);
        let name = attachment["name"].as_str().unwrap_or_default();
        context.log(format!("Attachment {}: {} bytes", name, size));
    }

    context.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:

```text
--appwrite-email-9b2f6c0d4e8a1f3b5c7d9e0a2b4c6d8e
Content-Disposition: form-data; name="email"
Content-Type: application/json

{"id":"5f0c2a9e3b7d4e1f8a6c0b2d4e6f8a1c","envelope":{...},...}
--appwrite-email-9b2f6c0d4e8a1f3b5c7d9e0a2b4c6d8e
Content-Disposition: form-data; name="attachment-0"; filename="receipt.pdf"
Content-Type: application/pdf

<file bytes>
--appwrite-email-9b2f6c0d4e8a1f3b5c7d9e0a2b4c6d8e--
```

The `email` part has this format:

```json
{
  "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 the `x-appwrite-email-id` header.
- `envelope`: the sender and recipient addresses that the sending mail server gave during the SMTP connection. They can differ from the `from` and `to` headers.
- `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, only `envelope.to` shows the address of your function.
- `from`, `to`, `cc`, `replyTo`, `subject`, `messageId`, and `date`: the values of these headers in the email.
- `text` and `html`: 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`, and `references`. 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 an `id`, which is also the name of the part that contains the file, a `name`, a `contentType`, a `contentId`, a `disposition`, and a `size` in bytes.
- `transport`: details of the SMTP connection that delivered the email: `remoteIp`, the name that the sending server gave in its `HELO` or `EHLO` greeting (`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](#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](/docs/products/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 from `0` to `1`. A score near `0` means that the email looks legitimate, and a score near `1` means that it looks like spam.
- `verdict`: `ham` (legitimate) for a score below `0.2`, `spam` for a score of `0.99` or more, and `unsure` for 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.

**Test the spam score with your own email**

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. |
