Resend
Our transactional email vendor across the portfolio.
There is a bundled resend skill in this Claude installation and a resend MCP
server — both are authoritative over this file for API detail.
Docs: https://resend.com/docs
Environment
RESEND_API_KEY= # SECRET
RESEND_FROM= # "Ekinoxis <alerts@ekinoxis.xyz>"
RESEND_FROM_EMAIL= # same thing, older name — pick one per repo
RESEND_DRY_RUN=true # local: log instead of send
RESEND_DRY_RUN came out of a scheduled scanner and is worth copying everywhere. That
scanner emails on every detected opportunity; without a dry-run flag, a local debugging
session sends a few hundred real emails and burns the domain's reputation.
if (process.env.RESEND_DRY_RUN === "true") {
console.log("[resend:dry-run]", { to, subject });
return { id: "dry-run" };
}
Sending
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY!);
const { data, error } = await resend.emails.send({
from: process.env.RESEND_FROM!,
to: [user.email],
subject: "Oportunidad detectada — spread 4.2%",
react: <OpportunityEmail opportunity={opp} />, // or: html / text
headers: { "X-Entity-Ref-ID": opportunity.id }, // idempotency hint
});
if (error) {
// Resend returns { data: null, error } — it does NOT throw.
console.error("resend failed", error);
}
Resend returns errors, it does not throw. Code that only wraps the call in
try/catch will report success on every failure. Always destructure and check error.
Batch (up to 100 per call):
await resend.batch.send([{ from, to, subject, html }, /* … */]);
Domain setup
Verify the sending domain in the dashboard, then add the DNS records it gives you: SPF, DKIM, and a DMARC record. All three. Sending from an unverified domain works only to your own address; sending from a verified domain without DMARC lands in spam at Gmail and Outlook.
Use a subdomain for transactional mail (mail.ekinoxis.xyz) so a reputation problem
never touches the root domain's deliverability.
Templates
React Email components live alongside the app and are type-checked with the data they render — better than HTML strings:
export function OpportunityEmail({ opportunity }: { opportunity: Opportunity }) {
return (
<Html><Body>
<Heading>Spread {opportunity.spread}%</Heading>
<Text>{opportunity.marketA} vs {opportunity.marketB}</Text>
<Button href={opportunity.url}>Ver en Polymarket</Button>
</Body></Html>
);
}
Always provide a plain-text fallback — some clients, and most spam filters, want one.
From a cron
A scheduled scan that emails on a hit. Two rules:
- Deduplicate before sending. Store what was already alerted in Supabase and check before send, or a stuck condition emails on every tick.
- Rate-limit. Resend's default is 2 requests/second. A loop over 200 recipients needs
batch.sendor a delay, not 200 concurrent calls.
Gotchas
- Errors are returned, not thrown. Check
error. - Unverified domain silently limits delivery to your own address.
- Missing DMARC → spam folder at the big providers.
- 2 req/s default rate limit.
- No dry-run flag in local dev = real emails from your laptop.
tois an array. A bare string works in some SDK versions and not others; always pass an array.