Opencode transactional email is one of those small app features that becomes real the moment a new user receives a welcome message or can recover a forgotten password. With the right prompt, a server-side route, and a verified sending domain, you can have that path working in an AI-built app without turning your frontend into an accidental open relay.

Why email is the first real integration in an AI-built app

A vibe-coded product can look complete long before it behaves like a product. You may have a sign-up form, a dashboard, and polished empty states—but users still need confirmation that an account exists, a way back in when they forget a password, and receipts or alerts when something important happens.

That is what transactional email does. It is application email triggered by an individual event: a user signs up, requests a password reset, receives an invitation, pays an invoice, changes an account setting, or needs a security notification. The message is not a manually assembled campaign and should not depend on someone remembering to send it.

For an Opencode user, this is a good first external integration because it reveals the difference between generating a UI and operating a service:

  • Your agent needs to identify the server boundary in the existing app.
  • The email credential must stay out of browser code and source control.
  • The app needs a predictable way to handle a failed provider request.
  • A retry cannot create three welcome emails for one sign-up.
  • Your sender domain needs authentication before production delivery is trustworthy.

The coding portion is small. The design decisions around it matter more. Volanea exposes a REST endpoint for sending one message at a time at POST /v1/send, accepts secret keys, and supports an Idempotency-Key header for safe retries. That makes it a straightforward fit for an app route, server action, API handler, worker, or background job rather than a client-side component.

What Opencode can do—and what it should not decide for you

OpenCode is an open-source coding agent available in terminal, desktop, and IDE forms. It can inspect a codebase, edit files, run commands, and work through a multi-step implementation. Its CLI also supports one-shot prompts with opencode run, while the interactive experience can use agents with different permissions and modes.

That is useful for email integration because the agent can answer practical repository-specific questions before it writes code:

  1. Is this a Next.js App Router project, an Express API, a Remix app, or something else?
  2. Where does sign-up actually happen?
  3. Does the app already have a database transaction, queue, authentication provider, or environment-variable convention?
  4. Is there an existing email abstraction that should be extended instead of bypassed?
  5. How are tests run locally and in CI?

But an agent should not silently choose production behavior that has business or security consequences. It should not decide that a browser is allowed to send arbitrary email. It should not hard-code a sender address you do not control. It should not commit an API key to a .env file that is tracked by Git. And it should not treat a successful HTTP response as proof that a recipient saw the message.

A productive division of work is simple: you specify the product behavior and constraints; Opencode maps those requirements onto the files and framework in your repository; Volanea performs the actual email submission; and you review the resulting diff before merging.

The minimum architecture for reliable transactional email

Before prompting an agent, be precise about the architecture you want. The safest minimal flow is:

Browser or mobile app
        ↓
Your server-side sign-up / reset / event handler
        ↓
Volanea REST API
        ↓
Recipient mailbox

The Volanea key belongs only on the server. The browser calls your endpoint or performs a normal sign-up form submission. Your backend validates the request, performs its own business logic, selects a recipient and approved sender, and then calls the email provider.

Keep the sending key server-side

A public JavaScript bundle is public, even if it came from a framework file with a reassuring name. Any key included in client code, exposed through a public environment-variable prefix, or returned from an API route can be copied and used by someone else.

Use a server-only environment variable such as:

VOLANEA_API_KEY=sk_test_replace_with_your_test_key
EMAIL_FROM="Acme App <hello@updates.example.com>"

The exact place you store those values depends on your hosting platform. Locally, use an ignored environment file. In deployment, add them in the platform’s encrypted secret configuration. Do not paste a live key into an Opencode prompt, a chat transcript, an issue, or a commit.

Use a test key while developing. Volanea separates test and live data based on the secret key used for authentication, which lets an integration be exercised without confusing test messages and production reporting.

Use a verified From domain

Your From address is not arbitrary branding text. It must use a domain you have verified with the provider. Authentication records such as SPF and DKIM help receiving systems evaluate whether your mail is legitimately authorized, while a DMARC policy tells receivers how to handle authentication failures.

Ask Opencode to read your configuration, but do not ask it to invent DNS entries. Copy the DNS hostnames and values supplied in the Volanea domain setup screen exactly, then wait for verification. DNS details vary by provider configuration and domain, so this is a point where a human should confirm the dashboard values rather than accept a plausible-looking record from an AI.

Make sending idempotent

A sign-up request can be retried because a function times out after the provider accepts a request, because a worker is redelivered, or because a user double-clicks a submit button. The email side effect needs a stable identity.

For a welcome email, a useful idempotency key can be based on the newly created user ID:

welcome:user_123

For a password reset, include the reset-request ID instead:

password-reset:resetreq_456

Do not generate a new random idempotency key every time a retry occurs. A fresh value tells the provider that the retry is a distinct request, which defeats the purpose.

The prompt to give Opencode

Good prompts describe the desired behavior, boundaries, and acceptance criteria—not merely the outcome. “Add email” leaves too much open. It may result in a client-side fetch, an untested provider call, an invented SDK, or an email send buried inside a UI component.

Here is a practical prompt for a TypeScript app using Next.js App Router. Replace the file paths and sign-up route details to match your own project.

Inspect this repository before editing. I need to add a transactional
welcome email after a user account is successfully created.

Requirements:
- This is a Next.js App Router application.
- Keep the Volanea API key server-only. Never use NEXT_PUBLIC_ for it.
- Add VOLANEA_API_KEY and EMAIL_FROM to .env.example with placeholder values.
- Create a small server-only email module at src/lib/email.ts.
- Send through Volanea's REST endpoint: POST https://api.volanea.com/v1/send.
- Use the official request syntax from Volanea docs; do not install or invent
  an unverified SDK.
- The module should accept recipient email and recipient name, produce both
  HTML and plain-text content, set a five-second abort timeout, and throw a
  useful error if Volanea rejects the request.
- Include an Idempotency-Key header based on the user ID so a retry cannot
  send duplicate welcome messages.
- Call the module only after the user has been created successfully.
- Do not expose the provider response or API key to the browser.
- Add a unit test for the email module that mocks fetch and verifies headers,
  endpoint, and payload shape.
- Show me the plan first, then make the smallest focused diff. Run the
  relevant tests and summarize files changed plus any manual setup I must do.

This prompt gives the agent enough information to work efficiently without asking it to guess business rules. It names the boundary, tells it where secrets belong, defines error behavior, and asks for a test. It also explicitly tells the agent to verify provider syntax in the official documentation, which is worthwhile because generated code should not rely on an imagined package or outdated endpoint.

If you work from the command line, OpenCode supports a non-interactive form such as:

opencode run "Inspect this repository and implement the transactional email requirements in EMAIL_TASK.md. Show the plan before editing."

For a longer implementation, start the interactive session from the repository root instead. That gives you the chance to approve a plan, inspect changes, and answer questions about the correct event location.

The resulting Volanea email module

Below is a focused example of what the server-side module can look like. It uses the verified Volanea REST base URL and single-message send endpoint. It deliberately uses the platform fetch API, so it does not add a dependency solely to send a welcome email.

// src/lib/email.ts
import "server-only";

type WelcomeEmailInput = {
  userId: string;
  to: string;
  name?: string | null;
};

const VOLANEA_SEND_URL = "https://api.volanea.com/v1/send";

function requiredEnv(name: "VOLANEA_API_KEY" | "EMAIL_FROM") {
  const value = process.env[name];

  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`);
  }

  return value;
}

export async function sendWelcomeEmail({
  userId,
  to,
  name,
}: WelcomeEmailInput): Promise<void> {
  const apiKey = requiredEnv("VOLANEA_API_KEY");
  const from = requiredEnv("EMAIL_FROM");
  const firstName = name?.trim() || "there";

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5_000);

  try {
    const response = await fetch(VOLANEA_SEND_URL, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `welcome:${userId}`,
      },
      body: JSON.stringify({
        from,
        to,
        subject: "Welcome to Acme App",
        text: `Hi ${firstName},\n\nWelcome to Acme App. Your account is ready.\n\n— The Acme App team`,
        html: `
          <p>Hi ${escapeHtml(firstName)},</p>
          <p>Welcome to <strong>Acme App</strong>. Your account is ready.</p>
          <p>— The Acme App team</p>
        `,
      }),
      signal: controller.signal,
    });

    if (!response.ok) {
      const detail = await response.text();
      throw new Error(
        `Volanea welcome email request failed (${response.status}): ${detail}`,
      );
    }
  } finally {
    clearTimeout(timeout);
  }
}

function escapeHtml(value: string): string {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;");
}

The core request is intentionally ordinary: a POST to https://api.volanea.com/v1/send, a secret key in the authorization header, JSON content, and an idempotency header. Volanea’s single-message endpoint supports a recipient or up to 50 recipients on one send; for a personal welcome message, use one recipient and one user-specific idempotency key.

There are a few details in this example worth keeping even if your agent writes different-looking code:

  • server-only creates a clear boundary in a Next.js project. It signals that this module must never reach a client bundle.
  • requiredEnv fails clearly during development or deployment if a secret is missing. A quiet fallback can cause a sign-up flow to appear successful while never sending mail.
  • The timeout prevents a stuck outbound request from consuming a serverless invocation indefinitely.
  • Both text and html are supplied. The plain-text version improves resilience for recipients and tools that do not render HTML as expected.
  • escapeHtml protects the HTML message if the user’s display name contains characters such as < or &.
  • The error includes status and provider response text, which gives your logs something actionable without returning that internal detail to an end user.

Always compare the request fields and authentication format with the current API reference and setup guides before deploying. An agent can write the integration quickly; the canonical docs are still the source of truth for current request fields, account permissions, and domain requirements.

Call the module after account creation, not before it

The most important placement rule is simple: create the account first, then attempt the welcome email. Sending before the user record exists can create messages for failed registrations. It can also make retries confusing because the application has no stable user ID to use as the idempotency key.

A simplified sign-up action might look like this:

// src/app/api/signup/route.ts
import { NextResponse } from "next/server";
import { sendWelcomeEmail } from "@/lib/email";
import { createUser } from "@/lib/users";

export async function POST(request: Request) {
  const { email, name, password } = await request.json();

  const user = await createUser({ email, name, password });

  try {
    await sendWelcomeEmail({
      userId: user.id,
      to: user.email,
      name: user.name,
    });
  } catch (error) {
    console.error("Welcome email failed", {
      userId: user.id,
      error: error instanceof Error ? error.message : String(error),
    });

    // The account exists even if email is temporarily unavailable.
    // Queue a retry here if welcome delivery is important to your product.
  }

  return NextResponse.json({ userId: user.id }, { status: 201 });
}

Whether you should wait for the send before returning 201 Created depends on the email’s purpose. A welcome message is usually non-critical: the account can be created, the request can succeed, and a background job can retry email later. A password reset email is different. The user needs the message to proceed, so the UI should surface a generic confirmation state while your backend logs and monitors delivery failures.

For early products, it is reasonable to send inline after user creation if the path is quick and your volume is small. As traffic grows, move non-critical sends into a durable queue or job system. That separates the user-facing transaction from third-party network latency and makes retries easier to control.

Add a test before trusting the generated integration

AI-generated code can look right while pointing at the wrong URL, using an environment variable with a typo, or silently omitting a safety header. A small unit test catches those errors without needing a live API key.

Here is the shape of a Vitest test for the module above:

// src/lib/email.test.ts
import { afterEach, describe, expect, it, vi } from "vitest";
import { sendWelcomeEmail } from "./email";

describe("sendWelcomeEmail", () => {
  afterEach(() => {
    vi.unstubAllEnvs();
    vi.unstubAllGlobals();
  });

  it("sends a server-side welcome email with an idempotency key", async () => {
    vi.stubEnv("VOLANEA_API_KEY", "sk_test_example");
    vi.stubEnv("EMAIL_FROM", "Acme App <hello@updates.example.com>");

    const fetchMock = vi.fn().mockResolvedValue(
      new Response("{}", { status: 202 }),
    );
    vi.stubGlobal("fetch", fetchMock);

    await sendWelcomeEmail({
      userId: "user_123",
      to: "person@example.com",
      name: "Sam",
    });

    expect(fetchMock).toHaveBeenCalledOnce();
    expect(fetchMock).toHaveBeenCalledWith(
      "https://api.volanea.com/v1/send",
      expect.objectContaining({
        method: "POST",
        headers: expect.objectContaining({
          Authorization: "Bearer sk_test_example",
          "Content-Type": "application/json",
          "Idempotency-Key": "welcome:user_123",
        }),
      }),
    );

    const request = fetchMock.mock.calls[0][1];
    expect(JSON.parse(request.body)).toMatchObject({
      from: "Acme App <hello@updates.example.com>",
      to: "person@example.com",
      subject: "Welcome to Acme App",
    });
  });
});

The test does not try to prove mailbox delivery. It proves your application constructs the outbound request you intended. Keep integration testing separate: use a Volanea test key and a mailbox you control to verify the domain, message appearance, and account-level configuration.

Ask Opencode to run the test after it edits the files. Then review the diff yourself. In particular, inspect environment-file changes and search the repository for the API key’s variable name to make sure it appears only in server-side code.

A practical checklist before your first live send

The first live send is a deployment milestone, not merely an API exercise. Work through this list before switching from test credentials to live credentials:

  1. Verify the sender domain. Add the provider-issued DNS records and wait for the domain to show as verified.
  2. Use a real mailbox you control. Check the inbox, spam folder, sender name, reply behavior, text fallback, and links.
  3. Keep live keys in deployment secrets. Remove test keys from local production-like environments when they are no longer needed.
  4. Confirm the event order. Ensure the account, order, or reset request exists before its corresponding email is submitted.
  5. Set an idempotency strategy. Derive a stable key from the business event rather than from the HTTP attempt.
  6. Log a safe correlation value. Log your user ID or event ID, status code, and error class—not the API key or reset token.
  7. Decide how retries work. A background queue is usually better than a browser retry for email side effects.
  8. Check suppressions and bounces. A valid API request should not override an address that has bounced, complained, unsubscribed, or been deliberately blocked.

Volanea maintains a suppression list for addresses that should not receive mail, including bounce, complaint, unsubscribe, and manual-block reasons. That protects sending reputation and prevents an application from repeatedly trying an address that should no longer be mailed.

Welcome emails are easy; password resets need stricter rules

Once welcome email works, it is tempting to use the same pattern everywhere. Reuse the module and sending architecture, but do not reuse the security assumptions.

A password reset message should contain a single-use, short-lived token or URL created by your backend. Never generate that token in the client. Store only a secure hash of the raw token if your authentication model requires database storage, expire it promptly, and invalidate it once used.

Your email module can remain generic:

await sendPasswordResetEmail({
  resetRequestId: resetRequest.id,
  to: user.email,
  resetUrl,
});

The important change is the event identity. The idempotency key should describe the reset request, not merely the user. A user may legitimately request a second password reset after the first link expires. If every reset uses password-reset:user_123, the provider may correctly suppress a genuinely new request as a duplicate.

For account verification, invitations, receipts, and login alerts, follow the same pattern: identify the actual business event, send after that event is committed, and define what a retry means before an outage forces you to decide under pressure.

Do you need an Opencode MCP server for Volanea?

No. You can integrate Volanea with Opencode today by asking the agent to write ordinary server-side HTTP code against the documented REST API. There is no need to claim a native Volanea plugin for the coding workflow.

OpenCode does support Model Context Protocol (MCP) servers. Its configuration can connect local MCP servers over stdio or remote MCP servers over Streamable HTTP, and attached tools become available to the agent. In principle, an email-focused MCP server could expose narrowly scoped tools such as checking a domain’s verification status, listing templates, or creating a test send.

That can be useful later, but it introduces another trust boundary. An MCP tool capable of sending production email is not just documentation access; it can produce external side effects. If you add such a tool, use least-privilege credentials, require approval for sensitive actions, separate test and live environments, and make the tool names clear about whether they send or merely preview.

For most new applications, direct integration is simpler and safer:

  • Use Opencode to inspect the repository and generate the small adapter.
  • Use Volanea’s documented REST endpoint from your backend.
  • Keep configuration in environment variables.
  • Use the provider dashboard and API logs for delivery operations.
  • Add MCP only when repeated operational tasks genuinely justify it.

OpenCode also supports reusable skills stored in project directories such as .opencode/skills/<name>/SKILL.md. A practical team improvement is a project-local transactional-email skill containing your sender conventions, template tone, queue policy, testing commands, and the rule that no email key may enter browser code. That gives future agent sessions consistent constraints without pretending that an external plugin exists.

What to ask the agent to do next

After the first welcome email, avoid building a giant email system in one pass. Add capabilities in the order that reduces risk and creates real product value.

A sensible sequence is:

  1. Welcome email after successful sign-up.
  2. Password reset or verification email with secure, expiring links.
  3. A reusable layout and text fallback shared by product messages.
  4. Background-job delivery for non-critical notifications.
  5. Event logging, alerts, and a reconciliation process for failures.
  6. Stored templates when non-engineers need to safely edit approved content.
  7. Campaign or lifecycle automation only after transactional paths are dependable.

Volanea supports stored templates addressed by templateId, allowing sends to refer to reusable content and variables rather than carrying all markup in every request. That is a useful next step when you have repeated messages or want content revisions to be less tightly coupled to application deployments. For the first email, inline content is often easier to review because the implementation and copy live together.

As sending volume grows, compare plans based on the features you actually need: sending allowance, team workflow, authentication support, event visibility, and the operational effort of your architecture. Review transactional email pricing before assuming that the lowest sticker price is the lowest cost once retries, support, and deliverability work are included.

The second-order benefit: agents make the boundary visible

The lasting value of an Opencode transactional email implementation is not that an agent can write a fetch call. It is that the implementation forces useful boundaries into an AI-built product.

Your app now has a named email module instead of scattered sending logic. It has a documented environment variable rather than a secret hidden in a UI component. It has a test that asserts the outbound request shape. It has a durable event identity for retries. And it has a clear division between account creation—which is your database’s responsibility—and message submission—which is an external delivery operation.

Those boundaries make later changes safer. You can replace welcome copy without touching registration. You can add a queue without changing every calling route. You can test error behavior without sending to real people. And you can ask Opencode to make a focused change because the responsibilities are already legible in the repository.

Start with one message that matters. Make it server-side, authenticated, idempotent, and tested. Then let the rest of your email system grow from a reliable foundation rather than from a one-off prompt.

FAQ

Can Opencode send email directly from my app?

Opencode can generate and modify the code that sends email, but your deployed backend should make the actual Volanea API request. Keep the provider key in server-side environment variables and never ship it to the browser.

Is there a native Volanea plugin for Opencode?

Do not assume one is required or available. OpenCode can work with ordinary REST integrations, and it supports MCP servers for external tools when a team has a verified, appropriately scoped server to connect. A direct backend API call is the simplest starting point.

Should I use SMTP or the REST API for an Opencode-built app?

For a modern app route, serverless function, or worker, a REST call is usually the clearest approach because it uses normal HTTPS requests and structured JSON. SMTP remains useful when your framework or authentication provider already expects SMTP configuration.

How do I stop duplicate welcome emails?

Use an Idempotency-Key based on the stable business event, such as welcome:<user-id>. Reuse that exact key if the same event is retried, and use a different key for a truly new event.

Why did the API accept my email but it did not arrive in the inbox?

Submission, provider processing, and final mailbox placement are separate stages. Check your verified sender domain, recipient address, suppression status, message content, provider events, and spam folder before treating a successful request as a delivery guarantee.