Samva is in early access — self-serve signup is limited. Have a team invite? Sign up with that email. Contact us for access.

Astro

Send email from Astro Actions and API endpoints with the samva SDK.

Astro

Send email from Astro with the samva SDK in server code. Use an Astro Action for progressive-enhancement forms, or use an API endpoint when a JSON client needs to trigger the send.

Samva sends from the verified sender configured on your account, so Astro examples should not pass a from field.

Install

bun add samva
bunx astro add cloudflare

This guide uses the Cloudflare adapter to show the Worker path. If you deploy to Node, Vercel, or Netlify, swap in that adapter; the Samva client and send calls stay the same.

Configure a typed server secret with astro:env:

import cloudflare from "@astrojs/cloudflare";
import { defineConfig, envField } from "astro/config";

export default defineConfig({
  output: "server",
  adapter: cloudflare(),
  env: {
    schema: {
      SAMVA_API_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});

Create a server-only SDK module and import it only from Actions, endpoints, or server-rendered .astro frontmatter:

import { SAMVA_API_KEY } from "astro:env/server";
import { createClient } from "samva";

export const samva = createClient({ apiKey: SAMVA_API_KEY });

Astro Action

Actions are the best default for contact forms because Astro handles form posts, input validation, and the post-submit result.

import { ActionError, defineAction } from "astro:actions";
import { z } from "astro/zod";

import { samva } from "../lib/samva";

const escapeHtml = (value: string) =>
  value
    .replaceAll("&", "&")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");

export const server = {
  send: defineAction({
    accept: "form",
    input: z.object({
      email: z.email(),
      subject: z.string().min(1),
      message: z.string().min(1),
    }),
    handler: async ({ email, message, subject }) => {
      try {
        await samva.messages.send({
          to: [{ email }],
          channel: "email",
          email: {
            subject,
            html: `<p>${escapeHtml(message).replaceAll("\n", "<br />")}</p>`,
            text: message,
          },
        });
      } catch {
        throw new ActionError({
          code: "INTERNAL_SERVER_ERROR",
          message: "Failed to send email.",
        });
      }

      return { ok: true };
    },
  }),
};

Use the action from an on-demand Astro page:

---
import { actions } from "astro:actions";

const result = Astro.getActionResult(actions.send);
---

<form method="POST" action={actions.send}>
  <input type="email" name="email" required />
  <input name="subject" required />
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

{result?.data?.ok && <p>Email sent.</p>}
{result?.error && <p>{result.error.message}</p>}

Actions are public endpoints. Add the same authorization, rate limiting, and abuse checks you would add to an API route.

API endpoint

Use a server endpoint for headless clients or non-Astro frontends.

import type { APIRoute } from "astro";
import { z } from "astro/zod";

import { samva } from "../../lib/samva";

export const prerender = false;

const emailInput = z.email();

const escapeHtml = (value: string) =>
  value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");

export const POST: APIRoute = async ({ request }) => {
  let body: unknown;

  try {
    body = await request.json();
  } catch {
    return Response.json({ error: "Expected a JSON request body." }, { status: 400 });
  }

  if (
    typeof body !== "object" ||
    body === null ||
    !("email" in body) ||
    !("subject" in body) ||
    !("message" in body) ||
    typeof body.email !== "string" ||
    typeof body.subject !== "string" ||
    typeof body.message !== "string" ||
    !emailInput.safeParse(body.email).success ||
    body.email.length === 0 ||
    body.subject.length === 0 ||
    body.message.length === 0
  ) {
    return Response.json(
      { error: "email, subject, and message are required string fields." },
      { status: 400 },
    );
  }

  try {
    await samva.messages.send({
      to: [{ email: body.email }],
      channel: "email",
      email: {
        subject: body.subject,
        html: `<p>${escapeHtml(body.message).replaceAll("\n", "<br />")}</p>`,
        text: body.message,
      },
    });
  } catch {
    return Response.json({ error: "Failed to send email." }, { status: 502 });
  }

  return Response.json({ ok: true });
};

Email sends must happen at runtime. Use output: "server" globally or export const prerender = false on the send endpoint.

Cloudflare Workers and React Email

The SDK is fetch-based and works under @astrojs/cloudflare, so you can render and send on the Worker. For React Email templates, import the bare render package and let the bundler pick the edge build:

import { render } from "@react-email/render";

const html = await render(<WelcomeEmail name="Ada" />);

For deeper template patterns, see the React Email integration.

Cookbook and example

On this page