Skip to content

Application email

OpenCloud apps can send transactional email from manifest-declared aliases and route received email into Functions. OpenCloud manages MailPace credentials, address allocation, webhook verification, delivery metadata, and retries. Provider credentials are never app secrets.

OpenCloud 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 allocated addresses. For production they use:

text
send:    <alias>-<app-slug>@m.opencloud.ai
receive: <alias>-<app-slug>@m.opencloud.ai

The app slug already ends in the same six-character suffix shown in the app's HTTPS URL, so OpenCloud does not append a second UUID fragment. If the complete local part would exceed 64 characters, OpenCloud 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. MailPace accepts at most 50 To addresses and a total message size of 50 MB. Prefer links over large attachments.

The result includes the OpenCloud 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 OpenCloud 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. OpenCloud 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 the raw RFC 822 message, parsed text/HTML, headers, and base64 attachments. Avoid logging those fields. Successful processing clears OpenCloud'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 OpenCloud development session, every Function send is captured even when production uses MailPace. The provider is never contacted.
  3. Run a separately authorized, low-volume branch-preview round trip only when validating DKIM, DNS, MailPace delivery, or signed webhooks.

Inspect and exercise an active development mailbox with the CLI:

bash
opencloud app dev email inject "$APP_DIR" \
  --to support --from customer@example.test \
  --subject "Round-trip test" --text "Please acknowledge this message."
opencloud app dev email list "$APP_DIR"
opencloud 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
opencloud app email list "$APP_ID" \
  --alias support --direction inbound \
  --from 2026-01-01T00:00:00Z --to 2026-02-01T00:00:00Z --limit 25
opencloud 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 MailPace burst capacity.

MailPace delivery states are queued, delivered, deferred, bounced, and spam. 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.

Self-hosted infrastructure for agent-built applications.