> ## Documentation Index
> Fetch the complete documentation index at: https://ahasend.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# How to Build Email Features with Windsurf

> Ground Windsurf's Cascade agent in the AhaSend Node.js SDK, then capture the transactional email integration as a Workflow you can replay on demand.

Brief Cascade well and it will wire up email correctly, then you brief it again from scratch on the next project. Windsurf's Rules and Workflows files turn that briefing into something you replay instead.

## Prerequisites

Cascade writes and runs code, but it can't verify your domain or create your credentials. Do these in the [AhaSend dashboard](https://dash.ahasend.com) first.

<Steps>
  <Step title="Verify a Sending Domain">
    Add the SPF, DKIM, and DMARC records from the [domain setup guide](/docs/domains), then confirm the domain is verified before sending.
  </Step>

  <Step title="Create an API Key">
    Follow the [API credentials guide](/docs/send-api/credentials) and use a domain scope such as `messages:send:{example.com}` instead of `messages:send:all`: if it leaks, the damage is limited to one domain's outbound mail. Domain-specific [scopes](/docs/api-reference/scopes) are written with the curly braces around the domain. Grab your **account ID** too.
  </Step>

  <Step title="Put Both in Your Environment">
    For local agent-run tests, create a dedicated, domain-scoped credential in **Sandbox** mode. Set its `AHASEND_API_KEY`, `AHASEND_ACCOUNT_ID`, and `AHASEND_DELIVERY_MODE=sandbox` in `.env`, and keep that file out of git. Configure live credentials in your deployment's secret manager rather than giving them to an agent-run test.
  </Step>
</Steps>

## Step 1: Install the SDK Before You Prompt

Do this yourself. An agent that finds the dependency already in `package.json` has far less room to improvise a different one.

<CodeGroup>
  ```bash npm theme={null}
  npm install @ahasend/sdk
  ```

  ```bash pnpm theme={null}
  pnpm add @ahasend/sdk
  ```

  ```bash yarn theme={null}
  yarn add @ahasend/sdk
  ```

  ```bash bun theme={null}
  bun add @ahasend/sdk
  ```
</CodeGroup>

## Step 2: Give Cascade the Ground Truth with a Rules File

Give Cascade an authoritative project Rule instead of relying on generated code to reconstruct the integration from general context. Cascade can also search documentation, but a Rule keeps the critical constraints available inside the repository.

Workspace Rules are Markdown files with an activation mode, discovered under `.devin/rules/` in your workspace — the preferred location in current Windsurf builds, with the older `.windsurf/rules/` still read as a fallback. Create a model-decision rule so Cascade loads the full instructions when its description matches an email task:

```markdown .devin/rules/ahasend-email.md theme={null}
---
trigger: model_decision
description: Apply the project's AhaSend transactional email and webhook safety rules
---

# AhaSend email

When this project sends email, use the official AhaSend Node.js SDK.

- Package: `@ahasend/sdk` on npm. Do not invent a
  package name and do not hand-roll fetch calls to the REST API.
- Server-side only. Never import the SDK or expose credentials in browser
  code. The constructor's browser check is defense in depth; it cannot
  undo a credential that a bundler has already exposed.
- Never build a route whose caller picks the recipients, subject, or body.
  Derive the recipient from the authenticated session or a server-side
  record, require authentication and authorization on anything that
  triggers mail, validate the input, bound the request body size, and
  rate-limit per account. An unauthenticated send endpoint is an open
  mail relay on your verified domain.
- Construct the client ONCE at module scope, never per request:
  `new AhaSendClient({ apiKey, accountId })`.
- Send with `client.messages.send({ from, recipients, subject,
  html_content, text_content })`. Body fields are snake_case. `from` is
  `{ email, name? }`; `recipients` is an array of `{ email, name? }`,
  1 to 100 entries, and each recipient gets a separate message.
- The response is multi-status (HTTP 202). `result.data` has one entry per
  recipient, and a recipient can come back `status: "error"` with a null
  `id` while the promise resolves. Check every entry, not just the first.
- Errors are typed. Catch `AhaSendAPIError` and `AhaSendRateLimitError`
  from `@ahasend/sdk`, and match on `err.status` or `err.code`, never on
  message text.
- Verify webhooks with `WebhookVerifier` from `@ahasend/sdk/webhooks`.
  Signature checks need the RAW request body. `parse()` and `verify()`
  are async: `await verifier.parse(headers, rawBody)`. Use a framework
  adapter when available and cap the raw body read with the adapter's
  `maxBodyBytes`. Atomically deduplicate `webhook-id` with the durable
  work or outbox record before returning success.
- Read `AHASEND_API_KEY`, `AHASEND_ACCOUNT_ID`, and the fail-closed
  `AHASEND_DELIVERY_MODE` (`sandbox` or `live`) from the environment.
  Never hardcode, print, or commit credentials.
- Derive idempotency keys from a stable server-side business-event ID.
  Prefix the key with delivery mode so sandbox and live requests cannot
  collide. Reuse a key only for the exact same request payload.
- Mock the API boundary by default. Run a real sandbox request only with
  explicit approval and a dedicated Sandbox-mode credential.
```

To start from a draft, point Cascade at AhaSend's machine-readable doc index at `https://ahasend.com/docs/llms.txt`, ask it to write the rule from those pages, then check the result against the list above.

## Step 3: Capture the Integration as a Workflow

A Workflow is a Markdown file under `.windsurf/workflows/` with a short frontmatter description and a series of steps: a saved prompt-plus-procedure that Cascade can replay instead of you re-explaining the task each time.

Workflows are manual and specific to Cascade. Open a Cascade session and invoke this one with `/ahasend-email`; another agent mode will not run it, and Cascade will not select it automatically.

```markdown .windsurf/workflows/ahasend-email.md theme={null}
---
description: Add a transactional email send via the AhaSend SDK
---

# Add AhaSend email

1. If `lib/ahasend.ts` does not exist, create it: import `AhaSendClient`
   from `@ahasend/sdk` and export one client built from
   `AHASEND_API_KEY` and `AHASEND_ACCOUNT_ID`. Validate those variables
   and `AHASEND_DELIVERY_MODE` (`sandbox` or `live`) at startup. Module
   scope only.
2. In the target handler, import that client and call
   `client.messages.send({ from, recipients, subject, html_content,
   text_content, sandbox }, { idempotencyKey })`. Build a mode-prefixed
   idempotency key from a stable server-side business-event ID. Body
   fields are snake_case.
3. Filter `result.data` for entries with `status === "error"` and log
   how many recipients were rejected. Never read only `result.data[0]`.
4. Wrap the call in a try/catch for `AhaSendAPIError` and log
   only `err.status`, `err.code`, and `err.requestId`, never the whole
   error object or its message.
5. Await the send before completing the handler. If it should happen
   after the response, enqueue a durable job and send from its worker;
   do not start an unobserved promise.
6. Mock the AhaSend boundary in automated tests. Only with explicit user
   approval, run one integration send using a dedicated Sandbox-mode
   credential and confirm no recipient result has `status === "error"`.
```

Adding email to a new screen is now one invocation, and because the recipe encodes the same facts as your Rules file, every replay lands the integration the same way.

## Step 4: Prompt Cascade with Specifics

Whether you're invoking the Workflow or asking directly, name the file and the behavior:

* Weak: "add email to my app"
* Better: "In the signup handler, after the user is created, send a welcome email through AhaSend using `/ahasend-email`. Reuse the client from `lib/ahasend.ts`, use the server-side signup event ID for idempotency, and await the send. If response latency matters, enqueue a durable job and send from its worker."

Here is the code the Workflow is grounded against:

```ts lib/ahasend.ts theme={null}
import { AhaSendClient } from "@ahasend/sdk";

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required environment variable: ${name}`);
  return value;
}

export const ahasend = new AhaSendClient({
  apiKey: requireEnv("AHASEND_API_KEY"),
  accountId: requireEnv("AHASEND_ACCOUNT_ID"),
});

const mode = requireEnv("AHASEND_DELIVERY_MODE");
if (mode !== "sandbox" && mode !== "live") {
  throw new Error("AHASEND_DELIVERY_MODE must be sandbox or live");
}

export const deliveryMode = mode;
```

```ts routes/signup.ts theme={null}
import { AhaSendAPIError } from "@ahasend/sdk";
import { ahasend, deliveryMode } from "../lib/ahasend.js";

type SignupEvent = {
  id: string;
  email: string;
  name: string;
};

export async function sendWelcome(signupEvent: SignupEvent) {
  try {
    const result = await ahasend.messages.send(
      {
        from: { email: "hello@yourdomain.com", name: "Your App" },
        recipients: [{ email: signupEvent.email, name: signupEvent.name }],
        subject: "Welcome aboard",
        text_content: "Thanks for signing up.",
        html_content: "<p>Thanks for signing up.</p>",
        sandbox: deliveryMode === "sandbox",
      },
      {
        idempotencyKey: `${deliveryMode}-welcome-${signupEvent.id}`,
      },
    );

    const rejected = result.data.filter((entry) => entry.status === "error").length;
    const accepted = result.data.length - rejected;
    if (rejected > 0) {
      console.warn("AhaSend rejected recipients", { accepted, rejected });
      throw new Error("The welcome email was not accepted");
    }

    return { accepted, rejected };
  } catch (error) {
    if (error instanceof AhaSendAPIError) {
      console.error("AhaSend request failed", {
        status: error.status,
        code: error.code,
        requestId: error.requestId,
      });
      throw new Error("The welcome email request failed");
    }
    throw error;
  }
}
```

## Step 5: Test in Sandbox Mode

Cascade can run terminal commands, but a production-capable API key remains production-capable even when one request sets `sandbox: true`. Prefer mocked tests. If you explicitly approve a live integration check, use a dedicated domain-scoped credential configured in Sandbox mode. [Sandbox mode](/docs/send-api/sandbox) validates and processes the message, triggers relevant configured webhooks, consumes no email credits, and never delivers it.

```ts theme={null}
const result = await ahasend.messages.send({
  from: { email: "hello@yourdomain.com", name: "Your App" },
  recipients: [{ email: "test@example.com", name: "Ada" }],
  subject: "Welcome aboard",
  text_content: "Thanks for signing up.",
  sandbox: true,
  sandbox_result: "bounce", // rehearse the failure path
});
```

With explicit approval, ask Cascade to send once with the dedicated Sandbox-mode credential and confirm no recipient result has `status === "error"`. Swap `sandbox_result` to `"deliver"`, `"bounce"`, `"defer"`, `"fail"`, or `"suppress"` to trigger the applicable configured webhook event. A webhook test also needs a configured endpoint that AhaSend can reach. Keep delivery fail-closed through `AHASEND_DELIVERY_MODE`; do not infer live delivery from a generic environment name.

## Step 6: Read the Diff

Confirm these before you commit:

* **The real package.** The import must be `@ahasend/sdk`. If you see another package name, or raw `fetch` calls in a plain Node service, a guess slipped past the rule.
* **No open relay.** If Cascade added an HTTP route that sends, check that the recipient comes from the authenticated session or a server-side record rather than the request body, that the route is authenticated and rate-limited, and that the body size is bounded. A handler taking `to`, `subject`, and `html` from an unauthenticated caller is a spam relay on your verified domain.
* **Every recipient checked.** A promise that resolves means the request was accepted, not that every address was. Code reading `result.data[0]` and moving on will silently miss a suppressed recipient in a batch.
* **The key comes from the environment.** An agent editing several files can drift a secret into the wrong one. Confirm the key is read from `process.env` and never written to anything git tracks.

<Warning>
  If Cascade scaffolds frontend code, make sure the key never lands in a client-exposed variable (`NEXT_PUBLIC_`, `VITE_`, `PUBLIC_`, `NUXT_PUBLIC_`). Anything with those prefixes ships to the browser.
</Warning>

## Going Further

* **Write a second workflow for the failure path**: one that walks `sandbox_result` through `bounce` and `suppress`, so rehearsing a rejection is a slash command rather than a memory exercise.
* **Templating and webhooks**: per-recipient `substitutions` cover personalised batches, and `@ahasend/sdk/webhooks` verifies delivery-event signatures. Add both to your rules file so Cascade reaches for them unprompted.
* Same grounding trick, different file: [Cursor](/docs/guides/cursor) reads `.cursor/rules/`, [Claude](/docs/guides/claude) reads `CLAUDE.md`, and [GitHub Copilot](/docs/guides/github-copilot) reads `.github/copilot-instructions.md`.
* For the finished shape of what Cascade is building, see [Express](/docs/guides/express), [Fastify](/docs/guides/fastify), or [SvelteKit](/docs/guides/sveltekit).

## Troubleshooting

<AccordionGroup>
  <Accordion title="Cascade ignores the Rules file">
    Confirm the file is under `.devin/rules/` in the current workspace and has valid `trigger` frontmatter. With `model_decision`, Cascade sees the description first and loads the rule when it decides that description is relevant. Make that description more specific, or change the trigger to `always_on` if the full rule must be included on every request.
  </Accordion>

  <Accordion title="The Workflow runs but skips the sandbox check">
    Workflow steps are instructions, not a script. Make the last step an explicit, checkable assertion ("confirm no entry in result.data has status error before reporting done") rather than a vague "test it".
  </Accordion>

  <Accordion title="The generated code throws AhaSendConfigurationError on startup">
    If Cascade put the client in frontend code, move the send to a server-side route. If it is already server-side, verify the required environment variables and account ID. The browser check is defense in depth, not a substitute for keeping credentials out of client bundles.
  </Accordion>

  <Accordion title="400 error mentioning the from address">
    The `from` address must belong to a verified sending domain on your account. Check domain status in the dashboard, and see [the quickstart](/docs/quickstart) if you haven't added one yet.
  </Accordion>
</AccordionGroup>

Every endpoint the SDK exposes is in the [API reference](/docs/api-reference).
