Assets, sites, domains, email, AI
assets (primary storage API)
Section titled “assets (primary storage API)”Direct-to-S3 asset storage, 1 byte to 5 TiB. Flat key namespace per project.
Bulk directories: use deploy apply with assets slice: additive assets: { put: [...] }; declarative sync assets: { put: [...], sync: { prefix, prune: true, confirm? } }. No run402 assets sync; apply is canonical so HTML + asset URLs stage atomically.
run402 assets put <file> [files...] [--project <id>] [--key <dest>] [--content-type <mime>] [--private] [--immutable] [--meta <k=v>] [--exif-policy keep|strip] [--concurrency N] [--no-resume] [--stream]— without--stream, stdout is the final results array (JSON). With--stream, stdout is NDJSON per-file progress events.--jsonis a deprecated alias for--stream(writes a deprecation warning to stderr). Stdout carries the canonical snake_case wire shape only (see theassets putoutput example below); the SDK’s camelCaseAssetRefconveniences never appear on CLI stdout.run402 assets get <key> --output <file> [--project <id>]run402 assets ls [--project <id>] [--prefix <p>] [--limit <n>] [--sort key:asc|createdAt:asc|createdAt:desc] [--filter <k=v> ...]run402 assets rm <key> [--project <id>]run402 assets sign <key> [--project <id>] [--ttl <seconds>]— signed URL TTL must be an integer from 60 to 604800 seconds.run402 assets diagnose <url> [--project <id>]— inspect live CDN state for a public URLrun402 cdn wait-fresh <url> --sha <hex> [--timeout <secs>] [--project <id>]— poll a mutable URL until it serves the expected SHA-256
Project resolution for every command above: --project <id> > RUN402_PROJECT_ID (the canonical env var, same as every other project-scoped command) > the deprecated RUN402_PROJECT alias > the active project. RUN402_PROJECT works as a fallback but prints one deprecation line to stderr; switch to RUN402_PROJECT_ID.
put flags:
--meta key=valuerepeatable; coercion: numeric-looking -> number,true|false-> boolean, comma ->string[], else string. Serialized total <=4 KB; no nested objects; invalid ->INVALID_ASSET_METADATA.--exif-policy keep|strip; defaultkeep;stripremoves EXIF bytes +image_exif; invalid ->INVALID_EXIF_POLICY.
ls flags:
--sort key:asc|createdAt:asc|createdAt:desc— result ordering. Defaultkey:asc(legacy bare-key cursor). ThecreatedAt:*variants use a base64url JSON{s, ts, key}cursor; reusing a cursor across sort keys returns400 INVALID_CURSOR_FOR_SORT.--filter key=value— repeatable media-picker filter. Allowed keys:uploaded_by,tag,format,is_image(true/false),min_width,max_width,min_height,max_height(non-negative ints). Unknown keys are rejected client-side withINVALID_FILTER_KEY.
Examples:
run402 assets put ./artifact.tgz --project prj_abc123run402 assets put ./hero.jpg --project prj_abc123 --meta uploaded_by=agent_abc --meta version=3 --meta tags=hero,banner --exif-policy striprun402 assets put ./dist/**/*.png --project prj_abc123 --key assets/run402 assets put ./asset --project prj_abc123 --key assets/logo --content-type image/svg+xmlrun402 assets put huge.bin --project prj_abc123 --immutablerun402 assets get images/logo.png --output /tmp/logo.png --project prj_abc123run402 assets ls --project prj_abc123 --prefix images/run402 assets ls --project prj_abc123 --sort createdAt:desc --filter is_image=true --filter min_width=320 --filter format=webprun402 assets ls --project prj_abc123 --filter uploaded_by=agent_abc --filter tag=herorun402 assets diagnose https://app.run402.com/_blob/avatar.png --project prj_abc123run402 cdn wait-fresh https://app.run402.com/_blob/avatar.png --sha ba78... --timeout 120CLI assets put output (canonical snake_case wire shape — one entry per file, plus the local file path):
[ { "file": "./hero.png", "key": "hero.png", "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", "size_bytes": 1234, "content_type": "image/png", "visibility": "public", "immutable": true, "url": "https://pr-abc.run402.com/_blob/hero.png", "immutable_url": "https://pr-abc.run402.com/_blob/hero-ba7816bf.png", "cdn_url": "https://pr-abc.run402.com/_blob/hero.png", "cdn_immutable_url": "https://pr-abc.run402.com/_blob/hero-ba7816bf.png", "sri": "sha256-unhbz…", "etag": "\"sha256-ba7816bf…\"", "content_digest": "sha-256=:unhbz…:", "metadata": null, "image_format": "png", "image_info": { "has_alpha": true }, "image_exif": null, "image_exif_policy": "keep" }]Image uploads additionally carry the snake_case image fields (width_px, height_px, blurhash, variant_spec_version, display_url, display_immutable_url, variants, blurhash_data_url, asset_schema). The SDK’s typed AssetRef (below) exposes camelCase conveniences (cdnUrl, immutableUrl, contentSha256, size, …) whose values are guaranteed identical to their snake_case wire twins; the CLI emits the wire shape only.
SDK put response (AssetRef):
const asset = await client.assets.put(projectId, key, { bytes }); // defaults to immutable: truehtml += asset.scriptTag(); // <script src=... defer integrity=... crossorigin></script>html += asset.linkTag(); // <link rel="stylesheet" href=... integrity=... crossorigin>html += asset.imgTag("Company logo"); // <img src=... alt="Company logo" width=... height=... loading="lazy" decoding="async">html += asset.imgTagWithSrcSet({ alt: "Hero", sizes: "(max-width: 800px) 100vw, 1920px" });// → <picture><source type="image/webp" srcset="<thumb> 320w, <medium> 800w, <large> 1920w" sizes="…">// <img src="<display_url>" alt="Hero" width="…" height="…" loading="lazy" decoding="async"></picture>immutable: true is the default. SDK/CLI compute SHA-256; gateway returns content-addressed URL + SRI. Immutable URL needs no invalidation/redeploy fix/cdn wait-fresh. Use { immutable: false } only for mutable URL/cache semantics; tag emitters throw without immutable URL/SRI. Emitters include defer, loading="lazy", decoding="async".
AssetRef fields:
cdnUrl— content-addressed emitter URL,https://pr-<public_id>.run402.com/_blob/<key-without-ext>-<8hex>.<ext>, served through the CDN, guaranteed reachable.cdnMutableUrl— mutable auto-subdomain URL; eventual consistency; prefercdnUrl.url/immutableUrl— preferred-host forms on claimed/custom domain; currently not CDN-served; use for direct API consumers, not<script>/<img>.etag— strong"sha256-<hex>"ETag (whenimmutable).sri—sha256-<base64>for<script integrity={sri}>if you must construct tags by hand.contentDigest— RFC 9530sha-256=:<base64>:for HTTP integrity.cacheKind—"immutable" | "mutable" | "private".cdn.{version,invalidationId,invalidationStatus,ready,hint}— CloudFront invalidation envelope;cdn.ready === truefor immutable uploads.
Image variants (@run402/sdk@2.3.0+, image MIME >=320x320):
width_px,height_px: post-EXIF display dims;imgTagemits width/height to avoid CLS.blurhash: ~30-byte LQIP; decode withblurhashnpm package.variant_spec_version: URL identity tied to encoder generation; bumps create new URLs without invalidating old.display_url/display_immutable_url: browser-displayable; jpeg/png/webp/avif =cdn_url; HEIC/HEIF -> JPEGdisplay_jpeg, original bytes preserved in CAS.imgTag/imgTagWithSrcSetdefault<img src>todisplay_url.variants.thumb|medium|large: WebP 320w/800w/1920w withurl,cdn_url,width_px,height_px,format,sha256; usevariants.thumb.cdn_urlfor grids.variants.display_jpeg: HEIC/HEIF only, full-res JPEG quality 90 sRGB.thumbUrl/displayUrlSDK conveniences:thumbUrl = variants.thumb.cdn_url ?? displayUrl,displayUrl = display_url ?? cdn_url;undefinedfor non-images.imgTagWithSrcSet(opts)emits WebP<picture>+display_urlfallback; throws on missingopts.sizesor missingvariants; useimgTag()when no variants. AVIF deferred.r.assets.put(...)andr.project(id).apply({ assets: { put: [...] } })produce identicalAssetRefshape.- Encoder errors: 422
IMAGE_DECODE_FAILED, 413IMAGE_INPUT_TOO_LARGE(>40 MP or >12000 px any axis), 504IMAGE_ENCODE_TIMEOUT, 429TOO_MANY_ENCODES_QUEUED(retry after 2s).
Metadata/EXIF/intrinsics (@run402/sdk@2.4.0+; flat shape, not image:{}):
metadata: flatstring | number | boolean | string[], <=4 KB serialized,nullif absent; nested invalid client-sideINVALID_ASSET_METADATA.image_format:jpeg|png|webp|avif|heic|tiff|svg|bmp,nullnon-image.image_info:has_alpha,color_space,animated,frame_count,bit_depth,orientation;nullnon-image; future keys opaque.image_exif: EXIF block;nullnon-image, stripped, or formats without EXIF.image_exif_policy:"keep"default or"strip";nullnon-image.
Shape contract (@run402/sdk@2.12.0+, atomic with variants):
blurhash_data_url: pre-decoded PNG data URL (~600-1200 bytes at 16x16); embed as placeholder background;nullonly decoder failed; absent on rows stored without it.asset_schema: highest satisfied shape contract ("v1.49" | "v1.50" | "v1.54" | null);null= partial shape; absent on rows stored without it. Strict consumers skip partial-shape rows without per-field branching.- Enables
@run402/astro@1.0+<Run402Image>: pre-decoded placeholder + WebP ladder<picture>+ width/height, optionalimageDefaults.strict: { onSchema: ">=v1.49" }; Astro + React output byte-identical.
Typed errors a caller can branch on (catch (e) { if (e.code === "...") }):
INVALID_ASSET_METADATA(HTTP 400 orLocalErrorpre-network)INVALID_EXIF_POLICY(HTTP 400 orLocalErrorpre-network)INVALID_FILTER_KEY(HTTP 400 orLocalErrorpre-network)INVALID_SORT(HTTP 400 orLocalErrorpre-network)INVALID_CURSOR_FOR_SORT(HTTP 400 — cross-sort cursor reuse)IMAGE_DECODE_FAILED(HTTP 422 — no partial row written)
Mutable URL loop only: run402 cdn wait-fresh <mutable-url> --sha <new-sha> blocks until CDN serves new SHA. Do not use on immutable URLs.
Resume removed in v2.1.0. CLI delegates to sdk.assets.put via unified apply (apply/v1/plans -> content/v1/plans -> S3 PUT -> commit). --concurrency / --no-resume accepted but ignored; resume semantics live at apply-plan level (24h TTL).
Private blobs (--private): no CDN URL returned; read via authenticated gateway path GET /storage/v1/blob/<key> with apikey, or via run402 assets sign for time-boxed external sharing.
Content-Type: infers MIME from destination key extension; use --content-type <mime> for extensionless/uncommon/override; applies to every file in invocation.
diagnose exit codes: 0 when CDN serves expected SHA, 1 otherwise; until run402 assets diagnose <url>; do sleep 1; done waits. Probe vantage single-region us-east-1; stderr caveat # probed once from gateway-us-east-1; not a global view.
Uploading from Node/agent code
Section titled “Uploading from Node/agent code”import { run402, dir } from "@run402/sdk/node";
const client = run402();
// Single key — bytes/string source.const asset = await client.assets.put(projectId, "uploads/report.pdf", { bytes });// asset.cdnUrl is the preferred content-addressed URL for public blobs.
// Whole directory in one apply (staged with a combined release before activation).const manifest = await client.assets.uploadDir("./assets", { project: projectId, prefix: "static/",});console.log(manifest.byKey["static/logo.png"].cdn_url);
// Or as part of a release apply (assets promote inside the same activation// transaction that flips live_release_id).await (await client.project(projectId)).apply({ project: projectId, assets: { put: [{ key: "static/logo.png", source: bytes }] }, site: dir("./dist"),});run402 sites deploy --manifest <file> [--project <id>]run402 sites deploy-dir <path> [--project <id>] [--quiet] [--dry-run] [--confirm-prune]
--project defaults to the active project. Manifest: {"files":[{"file":"index.html","data":"..."},{"file":"style.css","data":"..."}]}. Must include index.html. Free with active tier. If the project already has a subdomain, redeploying auto-reassigns it to the new deployment (response includes subdomain_urls).
To inspect deploy status, use run402 deploy events <operation_id> or run402 deploy list --project <id> instead of polling a deployment artifact.
CAS-backed transport: Both sites deploy and sites deploy-dir hash each file locally and only PUT bytes the gateway doesn’t already have. Re-deploying an unchanged tree returns immediately with bytes_uploaded: 0.
Dry-run: run402 sites deploy-dir ./dist --project prj_... --dry-run calls the gateway’s POST /apply/v1/plans?dry_run=true, prints {status:"ok", dry_run:true, plan_id:null, operation_id:null, manifest_digest, diff, warnings, expected_events, missing_content_count}, and exits without uploading bytes or committing a release. Use it to preview the server-authoritative plan/diff envelope.
Progress events: sites deploy and sites deploy-dir stream unified DeployEvent JSON lines to stderr by default; the final result payload (release/deployment metadata, no status wrapper) still goes to stdout. Pipe streams separately: run402 sites deploy-dir ./dist --project p > result.json 2> events.log. Pass --quiet to suppress events (stdout still gets the result payload).
subdomains
Section titled “subdomains”run402 subdomains claim <name> [--release <id> | --deployment <id>] [--project <id>]run402 subdomains list [--project <id>]run402 subdomains delete <name> --confirm [--project <id>]—--confirmrequired (irreversible release).
All options default to the active project. With neither --release nor --deployment, claim binds the project’s live (active) release — no prior deploy apply bookkeeping needed; a project with no live site answers 404. --release rel_… pins a release; --deployment takes a legacy dpl_… id (rel_…/op_… accepted). Names: 3-63 chars, lowercase alphanumeric + hyphens. Creates <name>.run402.com. Declaring "subdomains": { "set": ["<name>"] } in the deploy manifest does the same at deploy time.
Subdomain auto-reassignment: You only need to claim a subdomain once. Every subsequent run402 sites deploy or run402 deploy to the same project automatically updates the subdomain to point to the new deployment. The response includes subdomain_urls showing which subdomains were reassigned. No need to re-claim after each deploy.
domains
Section titled “domains”run402 domains connect <domain> --project <id> [--web] [--authority manual-dns|hosted-zone] [--email-send] [--email-receive] [--mailbox-addresses primary|alias|managed|none] [--addresses <csv>]run402 domains list [--project <id>]run402 domains status <domain> [--project <id>]run402 domains dns <domain> [--project <id>] [--format json|bind]run402 domains check <domain> [--project <id>]run402 domains repair <domain> [--project <id>]run402 domains test-receive <domain> --to <local-part|address> [--project <id>]run402 domains wait <domain> [--project <id>] [--until active|safe|receive-active]run402 domains activate <domain> [--project <id>]run402 domains disconnect <domain> --confirm [--project <id>]—--confirmrequired.
ProjectDomain is the project-scoped lifecycle surface for web custom domains, custom email sending, inbound receive routing, mailbox address activation, and drift checks.
Auth is control-plane auth: current wallet, operator session, or delegate. Domain commands do not require a local project-key cache entry.
--authority on connect (web domains): hosted-zone puts the domain’s DNS zone on Run402 — the domain owner makes ONE nameserver change at their registrar and Run402 applies every record, verifies ownership, and issues TLS automatically. This is the only workable path for a ROOT domain (example.com) at most registrars, since a root CNAME is illegal without flattening/ALIAS support. manual-dns (the default) returns the records to add yourself. Pre-existing MX/TXT are imported into a hosted zone before the nameserver change is recommended, so mail keeps working. The connect response carries hosted_zone.ns_assigned (the pair to hand the owner) and the CLI prints that instruction to stderr; stdout stays pure JSON.
Root-domain flow (hosted zone):
run402 domains connect example.com --project prj_123 --web --authority hosted-zone- Give the domain owner the two nameservers from the output; they set them at their registrar
run402 domains wait example.com --project prj_123 --until active
Setup flow:
run402 domains connect example.com --project prj_123 --web --email-send --email-receive --mailbox-addresses primary --addresses inforun402 domains dns example.com --project prj_123 --format bind— copy DNS records to your registrar without clobbering existing MX unless full takeover is explicitly confirmedrun402 domains check example.com --project prj_123— preflight desired vs observed state and driftrun402 domains test-receive example.com --project prj_123 --to info— create an inbound token and send the test mailrun402 domains activate example.com --project prj_123— switch mailbox addresses after receive checks pass
run402 apps browse [--tag <tag>]run402 apps fork <version_id> <name> [--subdomain <name>] [--bootstrap '<json>']run402 apps inspect <version_id>run402 apps publish <id> [--description "..."] [--tags a,b] [--visibility <public|private>] [--fork-allowed]run402 apps <versions|delete> <id> [<version_id>]run402 apps update <id> <version_id> [--description "..."] [--tags a,b]
Forking clones schema, site, and functions into a new project. If the app includes a bootstrap function, it runs automatically with the provided variables — use it for first-admin setup, demo data seeding, or app configuration. Response includes bootstrap_result (the function’s return value) or bootstrap_error if it failed. Use run402 apps inspect to see what bootstrap_variables an app expects.
$0.03 per image, via x402, MPP on Tempo, or MPP Lightning (whichever rail the allowance targets).
run402 image generate "<prompt>" [--aspect <square|landscape|portrait>] [--output <file>] [--org <org_id>]
Without --output, returns {"aspect":"...","content_type":"image/png","image":"<base64>"}.
The paying organization matters only on the Lightning rail: a principal that belongs to more than one organization is refused 400 ORGANIZATION_SELECTION_REQUIRED (details.organization_ids lists the candidates) unless the request names one. --org <org_id> names it; omitted, the command derives it from the one shared org chain (RUN402_ORG or RUN402_ROOM’s org half, else the .run402.json binding, else run402 org use, else the active project’s owning org — see the Organizations slice) and sends that. An empty chain sends no org, which is never an error here: x402 and Tempo ignore it, and a single-org principal needs none. When the gateway still answers ORGANIZATION_SELECTION_REQUIRED, the SDK retries once with the single candidate that matches a local context (the current org, the active project’s owning org, else a stored project’s owning org); zero or several matches surface the code with details.organization_ids, a hint naming --org, and edit_request next actions for --org <org_id> and run402 org use <org_id>. A 403 on a derived org (no billing role there) reports details.org_source / org_source_detail so you can see which rung supplied it.
Built-in AI helpers. Translation requires the AI Translation add-on on the project. Moderation is free for all projects.
run402 ai translate <id> "<text>" --to <lang> [--from <lang>] [--context "<hint>"]— translate text to a target language (ISO 639-1 codes). Source language auto-detected if--fromomitted. Context hint guides tone/register (max 200 chars).run402 ai moderate <id> "<text>"— run content moderation. Returns flagged status and per-category scores.run402 ai usage <id>— check translation word quota for the current billing period (used, included, remaining).
Max 5 mailboxes/project. Inspect defaults and footer policy with email mailboxes; set default_outbound_mailbox_id / auth_sender_mailbox_id via email defaults; set per-mailbox outbound footer policy with email update --footer-policy run402_transparency|none. Omit --mailbox to use outbound default; branch on DEFAULT_MAILBOX_REQUIRED / DEFAULT_MAILBOX_INVALID + next_actions. footer_policy=none requires hobby/team; prototype projects are locked to run402_transparency and return FOOTER_POLICY_TIER_REQUIRED. Send modes: template or raw HTML; one recipient/send. Rate limits: prototype 10/day, hobby 50/day, team 500/day. Unique recipients/lease: prototype 25 / 200 / 1000.
Run402 Core uses the same CLI after run402 init --api-base=http://my-core:4020. The Core operator must configure the gateway outbound provider first; email mailboxes surfaces provider_readiness, can_send, send_blocked_reason, and next_actions when setup is missing. Core’s first slice supports raw outbound mail with attachments; managed templates, inbound reply handling, sender-domain automation, and delivery operations may remain Cloud-only until the Core gateway adds them.
Templates: project_invite (project_name, invite_url), magic_link (project_name, link_url, expires_in), notification (project_name, message max 500 chars).
run402 email create <slug> [--project <id>]— create a project-scoped mailbox local part. The response’smanaged_addressis<slug>@<project-mail-host>.mail.run402.com; another project may use the same slug. NOT idempotent: a same-project conflict (slug already in use, address in cooldown, or the project already has 5 mailboxes) returns a 409 error rather than an existing mailbox.run402 email mailboxes [--project <id>]— list mailboxes plusmailbox_settings,address/managed_address, default-role/readiness/footer-policy metadata (is_default_outbound,is_auth_sender,can_send,can_receive,send_blocked_reason,domain_kind,footer_policy,effective_footer_policy,footer_policy_locked_reason), and gatewaynext_actions.run402 email defaults [--outbound <slug|mbx_id>] [--auth-sender <slug|mbx_id>] [--clear-outbound] [--clear-auth-sender] [--project <id>]— show current defaults with no flags, or set/cleardefault_outbound_mailbox_idand/orauth_sender_mailbox_id. Slugs are resolved throughemail mailboxes; SDK PATCH uses mailbox ids.run402 email update [<slug|mbx_id>] --footer-policy <run402_transparency|none> [--mailbox <slug|mbx_id>] [--project <id>]— set the mailbox’s outbound footer policy throughPATCH /mailboxes/v1/:mailbox_id. The optional positional target and--mailboxare equivalent; omit only on single-mailbox projects.run402 email status [--mailbox <slug|id>] [--project <id>]— show mailbox info (ID, address, slug, footer policy)run402 email send --template <name> --to <email> [--var key=value ...] [--from-name <name>] [--mailbox <slug|id>] [--project <id>]run402 email send --to <email> --subject <subject> --html <html> [--text <text>] [--attach <path>[:content-type] ...] [--from-name <name>] [--mailbox <slug|id>] [--project <id>]run402 email list [--direction <inbound|outbound>] [--mailbox <slug|id>] [--project <id>]— lists BOTH sent + received by default;--direction inboundlists received replies (the reconciliation backstop if a reply_received webhook is lost)run402 email get <message_id> [--mailbox <slug|id>] [--project <id>]run402 email reply <message_id> --html <html> [--text <text>] [--subject <subject>] [--from-name <name>] [--mailbox <slug|id>] [--project <id>]— reply to an inbound message (threads via In-Reply-To)run402 email get-raw <message_id> --output <file> [--mailbox <slug|id>] [--project <id>]— fetch inbound raw RFC-822 bytes (DKIM/zk-email).--outputrequired; bytes to file, stdout{ message_id, bytes, output }. Outbound returns 404.run402 email delete [<slug|mailbox_id>] --confirm [--project <id>]— delete a mailbox (irreversible). Target a specific mailbox by slug or id; on a project with one mailbox the target may be omitted.run402 email webhooks list [--mailbox <slug|id>] [--project <id>]— list all webhooks registered on the mailboxrun402 email webhooks get <webhook_id> [--mailbox <slug|id>] [--project <id>]— get webhook detailsrun402 email webhooks delete <webhook_id> [--mailbox <slug|id>] [--project <id>]— delete a webhookrun402 email webhooks update <webhook_id> [--url <url>] [--events <e1,e2>] [--mailbox <slug|id>] [--project <id>]— update webhook URL and/or eventsrun402 email webhooks register --url <url> --events <e1,e2> [--mailbox <slug|id>] [--project <id>]— register a new webhook. Valid events: delivery, bounced, complained, reply_received, mailbox_suspendedrun402 email webhooks deliveries [--status <pending|in_flight|delivered|failed_permanent>] [--mailbox <slug|id>] [--project <id>]— durable delivery rows. At-least-once with bounded retries/backoff;failed_permanent= DLQ. Body envelope{ id, type, created_at, schema_version, idempotency_key, payload }; dedupe onidempotency_key.run402 email webhooks redrive <delivery_id> [--mailbox <slug|id>] [--project <id>]— re-queue a dead-lettered (failed_permanent) delivery for another attempt
Raw HTML: --subject max 998 chars, --html max 1 MB. If --text omitted, plaintext auto-generated. --attach <path>[:content-type] raw-HTML only, repeatable max 5, <=7 MB total, content-type inferred if suffix omitted. --from-name sets From display name. Success may include mailbox_id, from_address.
Slug rules: 3-63 chars, lowercase alphanumeric + hyphens, no consecutive hyphens. --project defaults to the active project.
Functions: import { email } from '@run402/functions'; uses project service context + configured outbound default, so set mailbox defaults first:
import { email } from '@run402/functions';
// Template modeawait email.send({ to: "user@example.com", template: "notification", variables: { project_name: "My App", message: "Hello!" } });
// Raw HTML modeawait email.send({ to: "user@example.com", subject: "Welcome!", html: "<h1>Hi</h1>", from_name: "My App" });Throws on rate limit, suppression, missing/invalid default, no mailbox.