---
title: Use SendHQ with ShipFast | SendHQ Docs
description: "Send ShipFast's sendEmail() and NextAuth magic-link emails through the SendHQ API instead of Resend and SMTP, with a prompt your coding agent can run."
canonical: https://sendhq.cc/docs/shipfast
last-updated: 2026-09-07
updated: 2026-09-07
---
# Use SendHQ with ShipFast

Send ShipFast's sendEmail() and NextAuth magic-link emails through the SendHQ API instead of Resend and SMTP, with a prompt your coding agent can run.

## What changes

ShipFast is a Next.js boilerplate by Marc Lou. According to its [email documentation](https://shipfa.st/docs/features/emails), it sends email in two ways: magic login links go out over SMTP through the NextAuth email provider, and every other email goes through the Resend API with the `sendEmail()` function in ShipFast's `libs/resend.js`, using `RESEND_API_KEY`. This guide moves both to SendHQ.

- **No SMTP relay.** SendHQ is a JSON REST API at `https://sendhq.cc/api/v1`, and there is no official SendHQ SDK. `sendEmail()` becomes a small `fetch` call, and magic links use NextAuth's `sendVerificationRequest` option to call the same function instead of SMTP.
- **Same call sites.** The new `sendEmail()` takes the same arguments, so the rest of the app only changes an import path.
- **Integration trial.** Until a paid plan is active, a workspace can deliver only to the account email or a mailbox-simulator address (`…@simulator.amazonses.com`). Test magic links with your account email first.

ShipFast's source code is not public, so every ShipFast file name and setting on this page comes from ShipFast's public documentation. Check them against your copy. This guide is written by SendHQ; SendHQ is not affiliated with ShipFast.

## Where ShipFast sends email

What ShipFast's documentation describes, and what each part becomes with SendHQ.

| ShipFast (per its docs) | With SendHQ |
| --- | --- |
| `sendEmail()` in `libs/resend.js`, key in `RESEND_API_KEY` | `sendEmail()` in a new `libs/sendhq.js` with the same arguments, key in `SENDHQ_API_KEY` |
| Magic login links over SMTP: NextAuth `EmailProvider` in `libs/next-auth.js` | The same `EmailProvider` with a `sendVerificationRequest` that calls `POST /emails` |
| `resend.fromNoReply` in `config.js` | `sendhq.fromNoReply`: the From for magic links, on a domain verified in SendHQ |
| `resend.supportEmail`, passed as `replyTo` | The same address, sent as `reply_to`. It can also be a SendHQ inbox that forwards to your mailbox |
| Mailgun setup: `libs/mailgun.js`, `EMAIL_SERVER` on `smtp.mailgun.org`, and `app/api/webhook/mailgun/route.js` forwarding received mail to `forwardRepliesTo` | The same `libs/sendhq.js` and `EmailProvider` change. SendHQ inbox forwarding does the job of the webhook route |
| Supabase setup: Supabase sends magic links itself, with custom SMTP recommended for production | Supabase's Send Email hook calling SendHQ. See [Supabase Auth emails](https://sendhq.cc/docs/supabase-auth-emails.md) |

## Before you start

1. Create a SendHQ workspace and an API key under **API keys**. Keys start with `re_` and are shown once.
2. Add it to `.env.local` and to your hosting provider's environment variables. Keep `RESEND_API_KEY` for now so you can roll back.
3. Verify the domain you send from (see [Domains and DNS](https://sendhq.cc/docs/domains.md)). ShipFast's docs suggest a subdomain such as `resend.yourdomain.com` for Resend; you can verify that same subdomain or another one in SendHQ. SendHQ's DKIM records use their own selectors, so they sit next to the existing records. If the name already has an SPF record, add `include:amazonses.com` to it rather than creating a second one.

```shell
SENDHQ_API_KEY=re_your_key_here
```

## 1. Add SendHQ settings to config.js

Add a `sendhq` section to the config object in ShipFast's `config.js`, next to `resend`, and copy your existing values into it. Every From address must be on a domain verified in SendHQ, or the send returns `403`. If your `resend` section has other keys, copy them too.

```javascript
sendhq: {
  // REQUIRED: From for magic login links. Must be on a domain verified in SendHQ.
  fromNoReply: `YourApp <noreply@mail.yourdomain.com>`,
  // REQUIRED: From for every other email sent with sendEmail().
  fromAdmin: `Your Name at YourApp <you@mail.yourdomain.com>`,
  // Shown to customers and passed as replyTo. Leave empty if not needed.
  supportEmail: "support@mail.yourdomain.com",
},
```

In the TypeScript version, add the same keys to the `ConfigProps` interface in `types/config.ts`, as ShipFast's docs show for its Mailgun setup.

## 2. Replace sendEmail()

Create `libs/sendhq.js` (`libs/sendhq.ts` in the TypeScript version). It keeps the `{ to, subject, text, html, replyTo }` arguments that ShipFast's documented `sendEmail()` uses, and adds an optional `from` and `idempotencyKey`. The default From here is `fromAdmin`, the name ShipFast's Mailgun setup uses; use whichever setting your `libs/resend.js` sends from today.

```javascript
import config from "@/config";

if (!process.env.SENDHQ_API_KEY && process.env.NODE_ENV === "development") {
  console.error("SENDHQ_API_KEY is missing from .env.local");
}

/**
 * Sends one email through the SendHQ API (no SDK, plain fetch).
 * Same arguments as ShipFast's sendEmail(), plus optional from and idempotencyKey.
 *
 * @returns {Promise<{id: string}>} The SendHQ email ID ("em_…") and thread details.
 */
export const sendEmail = async ({
  to,
  subject,
  text,
  html,
  replyTo,
  from = config.sendhq.fromAdmin,
  idempotencyKey,
}) => {
  const res = await fetch("https://sendhq.cc/api/v1/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SENDHQ_API_KEY}`,
      "Content-Type": "application/json",
      // One stable key per logical send makes retries safe.
      ...(idempotencyKey && { "Idempotency-Key": idempotencyKey }),
    },
    body: JSON.stringify({
      from,
      to: Array.isArray(to) ? to : [to],
      subject,
      text,
      html,
      ...(replyTo && { reply_to: replyTo }),
    }),
  });
  const body = await res.json().catch(() => ({}));
  if (!res.ok) {
    throw new Error(`SendHQ ${res.status}: ${body.error?.message || res.statusText}`);
  }
  return body; // { id: "em_…", providerMessageId, threadId }
};
```

Then replace `@/libs/resend` with `@/libs/sendhq` and `config.resend` with `config.sendhq` across the codebase. ShipFast's docs describe the same swap for moving to Mailgun. Where a call site already has a unique ID, such as a payment webhook event, pass `idempotencyKey` built from it: retrying the identical request with the same key returns the stored response instead of sending twice.

## 3. Send magic links through SendHQ

According to ShipFast's docs, the email provider is configured in `libs/next-auth.js` (with the auth route in `/app/api/auth/[...nextauth]/route.js`). Replace its SMTP settings with a `sendVerificationRequest` function. NextAuth calls it with the recipient as `identifier` and the sign-in link as `url`; the database adapter, tokens, and sign-in page stay as they are.

```javascript
import { createHash } from "crypto";
import EmailProvider from "next-auth/providers/email";
import config from "@/config";
import { sendEmail } from "@/libs/sendhq";

// In authOptions.providers, in place of the SMTP EmailProvider:
EmailProvider({
  from: config.sendhq.fromNoReply,
  // No server option: SendHQ has no SMTP relay, so this function sends the link.
  async sendVerificationRequest({ identifier, url }) {
    const { host } = new URL(url);
    await sendEmail({
      from: config.sendhq.fromNoReply,
      to: identifier,
      subject: `Sign in to ${host}`,
      text: `Sign in to ${host}\n${url}\n\n`,
      html: `<p>Sign in to <strong>${host}</strong></p><p><a href="${url}">Sign in</a></p><p>If you did not request this email you can safely ignore it.</p>`,
      // A retried request for the same link is sent once.
      idempotencyKey: `magic-link-${createHash("sha256").update(url).digest("hex")}`,
    });
    // sendEmail() throws on any error, so NextAuth reports a failed sign-in.
  },
}),
```

- The `server` option goes away, and with it the SMTP connection string (ShipFast's Mailgun setup names it `EMAIL_SERVER`).
- Keep the `nodemailer` package installed: NextAuth's v4 documentation lists it as a requirement of the Email provider.
- The idempotency key is a hash of the link, so a retried request for the same link is sent once, while every new sign-in request gets its own key.
- The subject and body follow NextAuth's built-in sign-in email. Change the wording or design as you like; keep `url` in both the text and HTML versions.

If your project runs Auth.js v5 instead of NextAuth v4, Auth.js documents an HTTP email provider as a plain object. Pass the same function:

```javascript
providers: [
  {
    id: "http-email",
    name: "Email",
    type: "email",
    maxAge: 60 * 60 * 24, // link valid for 24 hours
    sendVerificationRequest, // the same function as above
  },
],
```

ShipFast's docs note that NextAuth has no built-in rate limit for magic links and recommend protecting `/api/auth/signin/email`. Keep that protection: every magic link uses one SendHQ recipient credit and counts toward your daily sending limit.

## Supabase and Mailgun setups

**Supabase.** ShipFast's docs describe a Supabase variant in which Supabase sends magic links itself and recommend setting up custom SMTP for production. SendHQ has no SMTP relay; use Supabase's Send Email hook with a small Edge Function instead, as described in [Supabase Auth emails](https://sendhq.cc/docs/supabase-auth-emails.md). Step 2 (`sendEmail()`) applies unchanged.

**Mailgun.** If your copy follows ShipFast's Mailgun instructions, the same steps apply with `libs/mailgun.js` and `config.mailgun` in place of the Resend names. The `app/api/webhook/mailgun/route.js` route that forwards received mail can be replaced by SendHQ inbox forwarding (next section). Leave it running until forwarding works.

## Receive replies at your support address

ShipFast's Resend setup passes `supportEmail` as `replyTo`, so replies go to a mailbox you already have. That works the same with SendHQ: `replyTo` becomes `reply_to` and nothing else changes.

To receive those replies in SendHQ as well, create an inbox for the support address and forward it to the mailbox you read:

```shell
# 1. Turn on receiving for a verified domain. The response has the MX record to publish.
curl -X POST https://sendhq.cc/api/v1/domains/dom_123/inbound/setup \
  -H "Authorization: Bearer $SENDHQ_API_KEY"

# 2. Create the support address on the receiving domain from step 1.
curl https://sendhq.cc/api/v1/inboxes \
  -H "Authorization: Bearer $SENDHQ_API_KEY" -H "Content-Type: application/json" \
  -d '{"domain_id":"dom_123","local_part":"support","name":"Support"}'

# 3. Forward a copy of every message to the mailbox you read.
curl -X PUT https://sendhq.cc/api/v1/inboxes/inb_123/forwarding \
  -H "Authorization: Bearer $SENDHQ_API_KEY" -H "Content-Type: application/json" \
  -d '{"forward_to":"you@example.net"}'
```

- Inbound setup needs the domain verified for sending first. If the domain already has MX records that point elsewhere, such as your team mailboxes, SendHQ receives on `inbound.<domain>` instead, and the inbox address uses that name. Set `supportEmail` to the address the inbox gets.
- Forwarding to the account owner's own email starts immediately. Any other address gets a confirmation email first.
- Forwarded copies come from the inbox address with the original sender as Reply-To, so you can answer from your mailbox. Each forwarded copy counts as one recipient. Received messages also stay in SendHQ; see [Inbound email](https://sendhq.cc/docs/inbound.md).

## Check delivery and limits

- **Accepted is not delivered.** `sendEmail()` returns the SendHQ email ID. Look it up with `GET /emails/:id/events` or in the dashboard to see delivery, bounce, and complaint events. To react to them in your app, add a webhook endpoint (see [Webhooks](https://sendhq.cc/docs/webhooks.md)).
- **Suppressions.** SendHQ suppresses addresses that hard-bounce or complain, and later sends to them return `422`. A mistyped address at sign-up then fails fast instead of hurting your domain's reputation.
- **Daily limit.** A new paid workspace starts at 100 recipients a day, and the limit steps up after healthy days. Sends past a limit return `429`. Each message can have at most 100 recipients across `to`, `cc`, and `bcc`.
- **Errors.** `sendEmail()` throws with the HTTP status. Retry only `429`, `502`, and `503`, with the same idempotency key and identical arguments. See [Errors and retries](https://sendhq.cc/docs/errors.md).

## Cutover and rollback

1. Verify the domain in SendHQ and set `SENDHQ_API_KEY` in every environment while Resend keeps sending.
2. Deploy the changes from steps 1 to 3 to a preview environment. Sign in with a magic link sent to your SendHQ account email, and trigger one `sendEmail()` path.
3. Activate a paid plan before real users sign in, then deploy to production.
4. To roll back, revert the import and `EmailProvider` changes; `libs/resend.js` and `RESEND_API_KEY` are still there, and no DNS change is needed while both providers' records stay published.
5. Once everything has run cleanly, remove the `resend` package, `libs/resend.js`, `RESEND_API_KEY`, the SMTP connection string, and the DNS records only Resend used.

## Hand this to your coding agent

Paste this into Claude Code, Cursor, Codex, or another coding agent at the root of your ShipFast project. It is self-contained: it lists what ShipFast's docs describe, asks the agent to confirm each file in your copy, and gives the SendHQ endpoints, fields, and limits so the agent does not have to guess. Review the diff like any other change, and add the DNS records and environment variables yourself.

```text
Switch this ShipFast (Next.js) codebase from Resend to SendHQ for outgoing email: the sendEmail() helper and the NextAuth magic-link sign-in emails. Keep user-visible behaviour identical. Do not send real email, change DNS, or call production APIs while working.

STEP 1: INVENTORY
ShipFast's public documentation (https://shipfa.st/docs/features/emails and https://shipfa.st/docs/features/magic-links) describes the setup below. Confirm every item in this repository instead of assuming it; file names and library versions differ between copies.
- sendEmail() in libs/resend.js (or libs/resend.ts), which reads RESEND_API_KEY. A copy set up for Mailgun has libs/mailgun.js and MAILGUN_API_KEY instead; treat it the same way.
- Magic login links sent over SMTP by the NextAuth EmailProvider in libs/next-auth.js (the auth route is app/api/auth/[...nextauth]/route.js). ShipFast's Mailgun instructions configure it as EmailProvider({ server: process.env.EMAIL_SERVER, from: config.mailgun.fromNoReply }).
- Email settings in the resend section of config.js: fromNoReply (From for magic links), supportEmail (passed as replyTo), and whatever other keys it has. TypeScript copies type them in the ConfigProps interface in types/config.ts.
- A Mailgun copy may have app/api/webhook/mailgun/route.js, which forwards received mail to mailgun.forwardRepliesTo.
- A Supabase copy lets Supabase send magic links itself; there is no NextAuth EmailProvider.
Search for: @/libs/resend, @/libs/mailgun, the resend, mailgun.js and nodemailer packages, sendEmail(, EmailProvider, sendVerificationRequest, EMAIL_SERVER, RESEND_, MAILGUN_, config.resend, config.mailgun, api.resend.com, and the next-auth version in package.json (v4 imports next-auth/providers/email; Auth.js v5 can take a provider object). SendHQ keys also start with re_, so identify Resend usage by package, import path, and env var name, not by key prefix.
List every sendEmail call site with what it sends and the arguments it passes.

STEP 2: SENDHQ API FACTS (use exactly these; do not guess or invent other endpoints)
- Base URL: https://sendhq.cc/api/v1
- Auth: header "Authorization: Bearer <SENDHQ_API_KEY>". Keys start with re_. Server side only, never in browser or mobile code.
- There is no SendHQ SDK, no SMTP relay, and no Resend-compatible endpoint. Call the REST API with the project's existing HTTP client (fetch, axios, requests, httpx, net/http, ...).
- Send one: POST /emails, Content-Type: application/json. Fields:
  from (string, "Name <addr@verified-domain>", required; the domain must be verified in SendHQ)
  to (string or array of strings, required), cc, bcc (string or array). At most 100 addresses across to+cc+bcc.
  subject (string), html (string), text (string). One of html, text, or template is required.
  reply_to (ONE address string, not an array)
  headers (object of string to string; not allowed: From, To, Cc, Bcc, Subject, Date, Message-ID, In-Reply-To, References, Content-Type, MIME-Version)
  message_class ("transactional" default, or "marketing", which adds SendHQ's unsubscribe link and headers unless you supply your own one-click List-Unsubscribe)
  template ({"key": "...", "data": {...}} or {"id": "tmpl_..."}; not combinable with subject/html/text; exactly one to and no cc/bcc)
  draft_id (attachments, see below), reply_to_email_id (thread a reply), labels (array of up to 10 mailbox label names)
  Response 201: {"id": "em_...", "providerMessageId": "...", "threadId": "..."}. Persist id.
- Unknown fields (tags, metadata, categories, custom variables, scheduled times, tracking flags) are ignored. Do not send them.
- Idempotency: header "Idempotency-Key" (max 200 chars), one stable key per logical send. Retrying the identical body with the same key replays the stored response (response header Idempotent-Replayed: true). Same key with a different body returns 409.
- Batch: POST /emails/batch {"emails": [ ...up to 100 send objects ]} returns 201 or 207 with {"data": [{"index", "ok", "id"} or {"index", "ok": false, "error": {"message", "status"}}], "count", "successful", "failed"}.
- Attachments: POST /drafts {"from", "to", "subject"} returns {"id": "dr_..."}; then for each file POST /drafts/{id}/attachments with the raw bytes as the body, header Content-Type: <mime type> and X-Filename: <name>; then POST /emails with "draft_id": "dr_...". Max 10 files and 10 MB per message. No inline/CID images.
- Errors: JSON {"error": {"message", "status", "code"?}}. 400 invalid input, 401 bad key, 402 trial or plan limit, 403 domain not verified or not owned, 404 missing resource, 409 idempotency conflict, 413 attachments too large, 422 recipient suppressed or unsubscribed, 423 this From address is paused, 429 usage or daily limit, 502/503 transient. Retry only 429/502/503, with backoff and the same Idempotency-Key and identical body.
- Webhooks: create with POST /webhooks {"url": "https://...", "events": [...]}; the response has "secret": "whsec_...". Event types: email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.failed, email.received. One JSON event per request: {"type", "created_at", "data"}; data has email_id, from, to, subject, and per type: delivered -> recipients; delivery_delayed -> recipients, delay {type, diagnostic, expiration}; bounced -> recipient, bounce {type "Permanent"|"Transient", sub_type, diagnostic, suppressed}; complained -> recipient, complaint {feedback_type}; failed -> reason; received -> inbox_id, category. No open, click, or unsubscribe events.
  Verify Standard Webhooks signatures on the RAW body: headers webhook-id, webhook-timestamp, webhook-signature ("v1,<base64>", space separated if several). Expected = base64(HMAC-SHA256(key = base64-decode(secret without "whsec_"), message = "{webhook-id}.{webhook-timestamp}.{raw body}")). Constant-time compare, reject timestamps older than 5 minutes, dedupe on webhook-id, return 2xx within 10 seconds.
- Suppressions: SendHQ suppresses permanent bounces and complaints itself (GET /suppressions, GET /blocked-recipients). There is no import endpoint.
- Templates: POST /templates {"name", "key"}; PUT /templates/{id}/draft {"revision", "name", "subjectTemplate", "htmlTemplate", "textTemplate", "variables": [{"key", "type": "text"|"number"|"url"|"boolean", "required"}], "sampleData"}; POST /templates/{id}/render {"data"}; POST /templates/{id}/publish. Only {{variable}} substitution with lowercase_snake_case keys: no conditionals, loops, partials, or nested objects.
- Inbound: POST /domains/{id}/inbound/setup returns the MX record to publish; POST /inboxes {"domain_id", "local_part", "name"} creates an address; an email.received webhook carries data.email_id and data.inbox_id; GET /emails/{id} returns from, to, subject, html, text, and attachments (download with GET /attachments/{id}). Mail is stored, not forwarded to your URL.
- Full contract: https://sendhq.cc/openapi.json and https://sendhq.cc/docs/shipfast

STEP 3: MAPPING FROM SHIPFAST'S RESEND SETUP
- sendEmail({ to, subject, text, html, replyTo }) in libs/resend.js -> a new libs/sendhq.js exporting sendEmail with the same arguments plus optional from and idempotencyKey. It calls POST /emails with from (default: the config value libs/resend.js uses as From today, moved to config.sendhq), to as an array, subject, text, html, and reply_to set from replyTo. It sends the Idempotency-Key header only when idempotencyKey is given, throws an Error with the HTTP status and error.message on any non-2xx response, and returns the parsed body ({"id": "em_...", ...}).
- import { sendEmail } from "@/libs/resend" -> import { sendEmail } from "@/libs/sendhq" at every call site.
- config.resend -> a new config.sendhq section with the same keys and values (copy every key the resend section has). Replace config.resend with config.sendhq across the codebase, and add the section to types/config.ts in TypeScript copies.
- NextAuth v4 EmailProvider({ server, from }) over SMTP -> EmailProvider({ from: config.sendhq.fromNoReply, sendVerificationRequest }) with no server option. sendVerificationRequest({ identifier, url }) calls sendEmail with from config.sendhq.fromNoReply, to identifier, subject "Sign in to <host of url>", a text body and an html body that both contain url, and idempotencyKey "magic-link-" + the sha256 hex digest of url. Let errors throw so NextAuth reports a failed sign-in.
- Auth.js v5, if the project uses it -> a provider object { id: "http-email", name: "Email", type: "email", maxAge: 60 * 60 * 24, sendVerificationRequest } with the same function; update any signIn call that names the email provider.
- RESEND_API_KEY -> SENDHQ_API_KEY. EMAIL_SERVER is no longer read once the custom sendVerificationRequest is in place.
- Mailgun inbound route forwarding to forwardRepliesTo -> SendHQ inbox forwarding, PUT /inboxes/{id}/forwarding, set up by the team. Do not delete the route; report it.
- Direct Resend SDK or api.resend.com calls outside libs/resend.js -> sendEmail from libs/sendhq.js.

STEP 4: IMPLEMENT
- Create libs/sendhq.js (libs/sendhq.ts in a TypeScript copy) with fetch; add no dependency. Read the key from process.env.SENDHQ_API_KEY and use it only on the server.
- Make the replacements in STEP 3. Do not change the database adapter, session settings, Google OAuth, payments, or UI code.
- Keep the nodemailer package installed: the NextAuth v4 Email provider documentation lists it as required for that provider.
- Where a call site already has a unique ID (for example a payment webhook event or checkout session ID), pass a stable idempotencyKey built from it and reuse it on retries.
- Leave libs/resend.js, the resend package, RESEND_API_KEY and EMAIL_SERVER in place but unused, so the team can roll back by reverting the imports. They remove them after cutover.
- Add SENDHQ_API_KEY to .env.example (or the project's env template), setup notes, and deployment config wherever RESEND_API_KEY appears.
- Supabase copies: change sendEmail() only. Report that Supabase Auth emails need Supabase's Send Email hook calling SendHQ, as described at https://sendhq.cc/docs/supabase-auth-emails.

STEP 5: TESTS
- If the project has a test runner, unit-test libs/sendhq with a mocked fetch: the exact URL and method, the Authorization, Content-Type, and Idempotency-Key headers, the JSON body (to as an array, replyTo sent as reply_to, undefined fields left out), the default and an overridden from, and that 401, 403, 422, 429 and 503 responses throw with the status in the message.
- Test sendVerificationRequest: it sends to identifier from config.sendhq.fromNoReply, the url appears in both the text and the html body, the idempotency key is the same for the same url, and a failed send throws.
- If the project has no test runner, do not add one; list the manual checks instead.
- Run the project's lint, type check, and build, and keep them passing.

STEP 6: REPORT
List every file changed, every sendEmail call site and how it was mapped, and anything that could not be mapped (for example Resend attachments, tags, scheduled sends, React email payloads, Supabase Auth emails, the Mailgun inbound route). List the env vars added. Then list what the team must do by hand: verify the sending domain in SendHQ and publish its DNS records, create the API key and set SENDHQ_API_KEY in every environment, test a magic link with the SendHQ account email while the workspace is on the integration trial, and set up an inbox with forwarding if they want replies received in SendHQ.
```

## References

ShipFast and NextAuth details on this page come from these public pages, checked on November 8, 2026. ShipFast's source code is not public; check file names and settings against your copy.

- [ShipFast docs: Emails](https://shipfa.st/docs/features/emails)
- [ShipFast docs: Magic Links](https://shipfa.st/docs/features/magic-links)
- [ShipFast docs: User authentication](https://shipfa.st/docs/tutorials/user-auth)
- [ShipFast docs: Rate limiting (Magic Links)](https://shipfa.st/docs/security/rate-limiting-magic-links)
- [NextAuth.js v4: Email provider](https://next-auth.js.org/providers/email)
- [NextAuth.js v4: Email provider options](https://next-auth.js.org/configuration/providers/email)
- [Auth.js: Custom HTTP email provider](https://authjs.dev/guides/configuring-http-email)
