Skip to content

Application email ​

Sprout apps can send transactional email from manifest-declared aliases and route received email into Functions. Sprout manages the email provider (Amazon SES, or MailPace on older installations), address allocation, webhook verification, delivery metadata, and retries. Provider credentials are never app secrets.

Sprout authentication and onboarding mail remain separate and continue to use the platform's infrastructure-mail provider.

Declare addresses ​

An app can declare several identities:

yaml
functions:
  - name: receive-support
    entrypoint: functions/receive-support/index.ts
    access: system

email:
  addresses:
    - name: support
      displayName: Acme Support
      function: receive-support
    - name: notifications
      displayName: Acme Notifications

The name is the alias passed to the SDK. Adding function makes the alias receive-capable. An alias without function is sending-only. The handler must name a system Function declared in the same manifest.

After a successful deployment, the app dashboard's Emails section shows the exact allocated addresses. On installations that send through Amazon SES, an alias sends from, and receives at, the app's own host:

text
send:    <alias>@<app-slug>.<base-domain>
receive: <alias>@<app-slug>.<base-domain>

Receiving needs the installation's receiving setup and DNS for the app host; the Emails section says whether receiving is configured. An installation that used MailPace also keeps each alias's earlier <alias>-<app-slug>@m.<base-domain> receive address, and any older UUID-suffixed one, for replies to messages sent from them. They keep reaching the same alias after the installation receives them through Amazon SES, but removing the alias, its function, or the app retires them for good.

On MailPace installations both addresses use <alias>-<app-slug>@m.<base-domain>. The app slug already ends in the same six-character suffix shown in the app's HTTPS URL, so Sprout does not append a second UUID fragment. If the complete local part would exceed 64 characters, Sprout shortens the readable portion of the slug while preserving that suffix. Former UUID-suffixed receive addresses remain accepted for replies to messages sent before the migration.

Branch previews use their configured preview base domain. The former staging domain is retired.

Send from a Function ​

Use the email client from the deployment-pinned @opencloud/server 2.2.0:

ts
import { defineFunction, schema } from "@opencloud/server";

export default defineFunction({
  input: schema.object({
    to: schema.string({ minLength: 3, maxLength: 320 }),
    name: schema.optional(schema.string({ minLength: 1, maxLength: 120 })),
  }),
  handler: ({ email, input, requestId }) =>
    email.send(
      {
        from: "notifications",
        to: input.to,
        subject: "Welcome",
        text: "Hello " + (input.name ?? "there"),
        html: "<p>Hello " + escapeHtml(input.name ?? "there") + "</p>",
        tags: ["welcome"],
      },
      {
        idempotencyKey: "welcome:" + requestId,
      },
    ),
});

const htmlReplacements: Record<string, string> = {
  "&": "&amp;",
  "<": "&lt;",
  ">": "&gt;",
  '"': "&quot;",
  "'": "&#39;",
};

function escapeHtml(value: string): string {
  return value.replace(
    /[&<>"']/g,
    (character) => htmlReplacements[character] ?? character,
  );
}

from is a declared alias, not an email address. The platform selects the actual From address and display name. A receive-capable alias uses that same address for inbound mail and as the default Reply-To.

Every send requires an idempotencyKey of at most 128 characters using letters, numbers, period, underscore, colon, or hyphen. Reuse the same key when safely retrying the same logical message. Reusing a key with different content is an error.

Supported inputs include To, Cc, Bcc, subject, text, HTML, Reply-To, In-Reply-To, References, List-Unsubscribe, tags, and allow-listed base64 attachments. SES accepts at most 50 recipients across To, Cc and Bcc and 40 MB per message; MailPace accepts at most 50 To addresses and 50 MB. Prefer links over large attachments.

Production sending protects the installation's sender reputation:

  • A recipient whose mailbox permanently bounced, or who reported one of this app's messages as spam, is suppressed for this app. A send that includes a suppressed address fails with HTTP 422 and code EMAIL_RECIPIENT_SUPPRESSED; the error lists the addresses. Remove them and send again.
  • If too many of the app's recent recipients bounce or complain, the platform suspends the app's production sending. Sends fail with HTTP 403 and code APP_EMAIL_SUSPENDED, and the Emails section explains why. A platform administrator lifts the suspension after the cause is fixed.

Development sessions keep capturing mail in both cases, and receiving is not affected.

The result includes the Sprout message ID, provider ID, current status, allocated From and Reply-To addresses, and whether the result was idempotent.

Receive in a Function ​

The declared handler receives an Sprout email event:

ts
import {
  defineFunction,
  schema,
  type OpenCloudInboundEmailEvent,
} from "@opencloud/server";

const inboundEmailInput = schema.object({
  source: schema.literal("opencloud.email"),
  type: schema.literal("email.received"),
  version: schema.literal(1),
  id: schema.string({ minLength: 1, maxLength: 200 }),
  address: schema.object({
    name: schema.string({ minLength: 1, maxLength: 30 }),
    value: schema.string({ minLength: 3, maxLength: 320 }),
  }),
  message: schema.record(),
  receivedAt: schema.string({ minLength: 1, maxLength: 80 }),
});

export default defineFunction({
  input: inboundEmailInput,
  handler: async ({ input, data, log }) => {
    const event: OpenCloudInboundEmailEvent = input;
    if (
      event.source !== "opencloud.email" ||
      event.type !== "email.received"
    ) {
      throw new Error("Unexpected event");
    }

    const message = event.message;
    await data.table("received_emails").create({
      id: event.id,
      alias: event.address.name,
      sender: typeof message.from === "string" ? message.from : "",
      subject: typeof message.subject === "string" ? message.subject : null,
      text_body: typeof message.text === "string" ? message.text : null,
      received_at: event.receivedAt,
    });
    log.info("email received", {
      eventId: event.id,
      alias: event.address.name,
    });
    return { accepted: true };
  },
});

Email headers and bodies are external, untrusted input. Do not use From, Reply-To, DKIM-looking headers, or message text as authentication. Require a separate application authorization step before destructive, privileged, billing, deployment, or agent actions.

Delivery is at least once. Make the handler idempotent using event.id or the provider messageId. Sprout deduplicates the provider webhook and retries failed handler executions, but a handler can still complete its own side effect just before a network failure.

The inbound event can include parsed text/HTML, headers, and base64 attachments; MailPace deliveries also include the raw RFC 822 message. In event.message.attachments, each attachment has base64 bytes in content, its MIME type in contentType, and its name in name or filename. Validate those fields before decoding and storing the bytes through files.upload. The development injection request uses contentBase64; Sprout translates that request field to the same inbound content field used in delivery. Avoid logging those fields.

On SES installations a received message may be up to 40 MB. The Function receives the bytes of at most 20 attachments whose filename extension is one of jpeg, jpg, png, gif, txt, pdf, docx, xlsx, pptx, csv, att, ics, ical, html, zip, auf, xml or dwg. event.message.attachmentMetadata lists every attachment with its name, MIME type, size and SHA-256 digest, including any delivered without bytes. Bytes are read from the provider's copy of the message, which expires after seven days; a message processed after that arrives with metadata only. Successful processing clears Sprout's transient provider payload. Bounded normalized text/HTML, safe headers and threading fields, envelope details, and attachment metadata and digests remain visible to authorized app readers for the configured message-retention period. Raw MIME and attachment bytes are not retained after processing.

Development and previews ​

Use three levels of email testing:

  1. In unit tests, mock context.email.send and construct OpenCloudInboundEmailEvent fixtures. No provider or control plane is involved.
  2. In an Sprout development session, every Function send is captured even when production uses SES or MailPace. The provider is never contacted.
  3. Run a separately authorized, low-volume branch-preview round trip only when validating DKIM, DNS, provider delivery, or signed webhooks.

Inspect and exercise an active development mailbox with the CLI:

bash
sprout app dev email inject "$APP_DIR" \
  --to support --from customer@example.test \
  --subject "Round-trip test" --text "Please acknowledge this message."
sprout app dev email list "$APP_DIR"
sprout app dev email get "$APP_DIR" "$MESSAGE_ID"

Use --text-file, --html-file, repeatable --header, and repeatable --attachment when fixtures live on disk. Paths resolve relative to the app directory. The equivalent MCP tools are:

  • list_dev_email_captures lists bounded outbound metadata.
  • get_dev_email_capture opens one captured body and attachment metadata.
  • inject_dev_email delivers synthetic mail to a receive-capable alias.

A capture includes To/Cc/Bcc, subject, text, HTML, threading fields, tags, and attachment names, content types, sizes, and SHA-256 digests. Attachment bytes are not retained. A successful development send has status captured and a null provider ID.

Injection accepts only reserved .test sender and Reply-To addresses, queues the normal OpenCloudInboundEmailEvent into the exact active dev revision, and captures any reply in the same session.

Captured bodies are scoped to the app, dev session, and revision and are deleted when the session stops or expires. Synthetic injection exercises app routing and handler logic; it deliberately does not claim to test MailPace signatures, DKIM, DNS, or public webhook delivery.

Inbound mail sent to a real allocated address still routes only to the active production deployment. A branch preview can perform a live acceptance round trip through m.ocd.dev only when its separately scoped MailPace token is injected and that branch owns the single preview inbound endpoint. Preview email budgets are intentionally low; use synthetic recipients and content.

Operations and limits ​

The Emails dashboard shows allocated aliases and cursor-paginated message history. Filter history by alias, inbound or outbound direction, and a bounded date range. Select a row to inspect its envelope, routing, provider IDs, timestamps, safe headers, threading fields, bounded text/HTML, and attachment metadata and SHA-256 digests. Text or HTML can be copied explicitly. HTML is rendered in a sandboxed, network-disabled frame; raw MIME and attachment bytes are never exposed. Older messages whose bodies were discarded before content retention was introduced show content as unavailable.

Authorized terminal users can inspect the same retained data:

bash
sprout app email list "$APP_ID" \
  --alias support --direction inbound \
  --from 2026-01-01T00:00:00Z --to 2026-02-01T00:00:00Z --limit 25
sprout app email get "$APP_ID" "$MESSAGE_ID"

Pass nextCursor from a list response back through --cursor. On full MCP, use list_app_email_messages and get_app_email_message. These production read tools are intentionally absent from the focused /build surface.

The hosted defaults allow 5,000 outbound recipients and 1,000 inbound Function triggers per app per rolling hour. Operators can configure different values. These are platform protection limits, not a promise of provider burst capacity. By default, production sending is suspended when more than 5% of the recipients of the app's last 24 hours of production mail hard-bounce, or more than 0.5% report it as spam, once at least 200 recipients are in that window.

Delivery states are pending, queued, delivered, deferred, bounced, spam, and failed. pending means Sprout reserved the message but cannot confirm that the provider accepted it; reuse the same idempotency key rather than sending it again with a new key. delivered means the recipient's SMTP server accepted an outbound message; it does not prove inbox placement or that a person read it. An inbound message progresses through queued, processing, processed, or failed. processed means the app's inbound Function completed successfully.

See the manifest reference and the MailPace product decision.

Grow the apps your life needs.