Send Transactional Email via Resend in a Cloud Function
In one sentence: Call
resend.emails.sendinside a CloudBase Web Cloud Function (HTTP trigger) to deliver transactional email from a verified sender domain; Resend handles SPF/DKIM/DMARC automatically, and the API key never reaches the frontend.Estimated time: 30–60 minutes (DNS verification for your domain may take anywhere from 5 minutes to several hours depending on your DNS provider) | Difficulty: Advanced
Applicable Scenarios
- One-to-one transactional emails: registration confirmation, password reset, order confirmation, billing alerts, or agent completion notifications
- You want to avoid self-hosting SMTP (Postfix, SendGrid self-hosted) while maintaining deliverability and observability
- Your users are primarily overseas, with minimal reliance on domestic Chinese mailboxes; or you are willing to validate deliverability to domestic mailboxes at low volume first
Not applicable:
- Recipients are primarily on QQ Mail / 163 / 126 / Foxmail: overseas sending IPs are occasionally flagged as spam by these providers. Run a low-volume test first, and consider layering in a domestic email service (Tencent Cloud SES, Alibaba Cloud DirectMail) if needed.
- You only need to send internal group notifications or alerts to IM users: see connect-wecom-webhook-cloud-function — email is overkill for that use case.
- Marketing emails or bulk newsletters: Resend supports these, but this recipe covers transactional email only. Bulk sending involves unsubscribe flows and compliance requirements that are out of scope here.
Prerequisites
| Dependency | Version / Requirement |
|---|---|
| Node.js (Cloud Function runtime) | ≥ 18 |
resend SDK | latest |
@cloudbase/cli | latest |
| Cloud Function type | Web Cloud Function (HTTP trigger) — frontend / Mini Program calls it directly |
| Public network egress | Cloud Functions can access the public internet by default; confirm "Network → Public Access" is enabled in the Console |
| Resend account | The free tier has a daily sending limit; check Resend pricing for current limits |
| Verified sender domain | Required (see Step 1 below); without verification, from can only be onboarding@resend.dev, which is for testing only |
Step 1: Sign Up for Resend and Verify Your Sender Domain
Domain verification is the most time-consuming and failure-prone part of the entire flow — do this first.
- Log in to resend.com → Domains in the left sidebar → Add Domain. Enter the domain you want to use as your sender (e.g.
mail.yourdomain.com). Using a subdomain rather than your root domain is recommended to avoid polluting your main domain's email reputation. - Resend will provide three DNS records. Use the exact values shown in the dashboard — never copy sample values from any documentation. The records typically include:
- One
MXrecord (points to Resend's MX server for bounce handling) - One
TXTrecord (SPF: declares that Resend is authorized to send on behalf of your domain) - One
CNAMEorTXTrecord (DKIM: the public key for email signing)
- One
- Add these three records to your domain's DNS provider (Cloudflare, Alibaba Cloud DNS, DNSPod, etc. all work). Pay attention to the host field format — Cloudflare expects just the subdomain prefix, while some providers expect the full hostname.
- Return to the Resend dashboard and click Verify. Status will progress from
not_started→pending→verified. Propagation time ranges from a few minutes to a few hours depending on your DNS provider's TTL and caching. - Once verified, DKIM and SPF are automatically signed by Resend — no action needed in the Cloud Function. If you want to add a DMARC policy as well, add a
_dmarcTXT record, though this is optional.
To skip verification temporarily and just validate the flow: set
fromtoonboarding@resend.dev. This only delivers to the email address bound to your Resend account and cannot be used for real users.
Step 2: Configure RESEND_API_KEY in Cloud Function Environment Variables
Go to the Resend dashboard → API Keys → Create API Key. Set the scope to Sending access and copy the resulting re_xxx... key. This key is only shown once — if you lose it, you must create a new one.
Then go to the CloudBase Console → "Cloud Functions → send-email → Environment Variables" and add:
RESEND_API_KEY: there_xxx...key from aboveRESEND_FROM: e.g.Acme <noreply@mail.yourdomain.com>. This must be an address under a verified domain and must use theName <email>format — Resend rejects bare email addresses in thefromfield.
Never hardcode the key in your source code. Once committed to git, it is effectively exposed. Resend will auto-revoke it, but you will have already hit the problem. See secure-secrets-in-cloud-function for proper secrets management.
Step 3: Write the Cloud Function (Full resend.emails.send Call with Error Handling)
Initialize the project:
mkdir send-email && cd send-email
npm init -y
npm install resend express
Set "main": "index.js" in package.json and add "start": "node index.js" to scripts.
index.js:
const express = require('express');
const { Resend } = require('resend');
const app = express();
app.use(express.json({ limit: '10mb' }));
const RESEND_API_KEY = process.env.RESEND_API_KEY;
const RESEND_FROM = process.env.RESEND_FROM; // e.g. 'Acme <noreply@mail.yourdomain.com>'
if (!RESEND_API_KEY || !RESEND_FROM) {
console.error('Missing RESEND_API_KEY or RESEND_FROM env');
process.exit(1);
}
const resend = new Resend(RESEND_API_KEY);
app.post('/send', async (req, res) => {
const { to, subject, html, text, replyTo, cc, bcc, tags } = req.body || {};
// Validate required fields: to, subject, and at least one of html or text
if (!to || !subject || (!html && !text)) {
return res.status(400).json({ error: 'missing_fields', message: 'to, subject, and html or text are required' });
}
const { data, error } = await resend.emails.send({
from: RESEND_FROM,
to: Array.isArray(to) ? to : [to],
subject,
html,
text, // optional, plain-text fallback
replyTo, // optional, routes replies to a support address
cc, // optional, array
bcc, // optional, array
tags, // optional, [{ name: 'category', value: 'transactional' }] for dashboard filtering
headers: { 'X-Entity-Ref-ID': req.body?.refId || '' }, // optional, pass through your internal reference ID
});
if (error) {
// Resend error structure: { name: 'validation_error' | 'missing_required_field' | ..., message: '...' }
console.error('resend send failed', error);
return res.status(502).json({ error: error.name || 'resend_error', message: error.message });
}
// Success: data = { id: 'uuid' } — this ID is the Resend-side email ID, queryable in the dashboard
res.json({ ok: true, id: data?.id });
});
app.get('/health', (_req, res) => res.json({ ok: true }));
const PORT = process.env.PORT || 9000;
app.listen(PORT, () => {
console.log(`send-email listening on ${PORT}`);
});
A few common pitfalls:
- The SDK expects
toto be an array, but also accepts a single string — passing an array consistently avoids edge cases. - Without the
Name <email>angle-bracket format infrom, Resend returnsvalidation_errorimmediately. idempotencyKeyis not shown in the example, but if your business layer can retry (e.g., failed queue consumers retrying delivery), add it: the same key within 24 hours sends the email only once, preventing users from receiving three identical messages.scheduledAtaccepts an ISO 8601 timestamp (UTC or with timezone offset) for deferred delivery — useful for "send a reminder at 9 AM tomorrow" scenarios.- Use
if (error)for error handling rather thantry/catch— the Resend SDK funnels both network failures and API errors into theerrorfield.try/catchonly catches synchronous SDK errors like missing parameters.
Step 4: Template Strategy — Raw HTML String vs. React Email Components
The simplest approach is to pass an HTML string directly in the html field:
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 480px;">
<h2>Welcome to Acme</h2>
<p>Click the link below to verify your email address:</p>
<a href="${verifyUrl}" style="display: inline-block; padding: 10px 20px; background: #000; color: #fff; text-decoration: none; border-radius: 4px;">Verify Email</a>
</div>
`
This works, but email HTML compatibility is notoriously painful — Outlook ignores flex, Gmail strips half the <style> tags, and dark mode inverts colors unpredictably. For complex templates, React Email is the better option:
npm install @react-email/components @react-email/render
// templates/welcome.jsx
const { Html, Body, Container, Heading, Button } = require('@react-email/components');
function WelcomeEmail({ verifyUrl }) {
return (
<Html>
<Body style={{ fontFamily: 'sans-serif' }}>
<Container>
<Heading>Welcome to Acme</Heading>
<Button href={verifyUrl} style={{ background: '#000', color: '#fff', padding: '10px 20px' }}>
Verify Email
</Button>
</Container>
</Body>
</Html>
);
}
module.exports = WelcomeEmail;
Pass the component via the react field instead of html:
const WelcomeEmail = require('./templates/welcome');
const { data, error } = await resend.emails.send({
from: RESEND_FROM,
to: [userEmail],
subject: 'Verify your Acme account',
react: WelcomeEmail({ verifyUrl }),
});
The Resend SDK internally calls @react-email/render to convert the React component into compatibility-processed HTML — inlining styles, downgrading unsupported CSS, and generating a plain-text fallback. Make sure react and react-dom are listed as dependencies in package.json; React Email will not run without them.
Step 5: Deploy
tcb login
tcb fn deploy send-email --httpFn -e your-env-id
After deployment, go to the Console → "Cloud Functions → send-email" and do the following:
- Confirm Environment Variables
RESEND_API_KEYandRESEND_FROMare set (skip if already done in Step 2). - Confirm Network Configuration has public access enabled.
- Confirm Trigger Type has the HTTP access service enabled and note the URL, e.g.
https://your-env.service.tcloudbase.com/send-email.
Verification
Send a test request from the command line to your own inbox:
curl -X POST 'https://your-env.service.tcloudbase.com/send-email/send' \
-H 'Content-Type: application/json' \
-d '{
"to": "yourself@gmail.com",
"subject": "CloudBase × Resend test",
"html": "<strong>It works!</strong>"
}'
Expected outcomes:
- The function returns
{ "ok": true, "id": "xxxxxxx-uuid" }. - The email arrives in your inbox within 1–10 seconds, sent from the address configured in
RESEND_FROM. - In the Resend dashboard → Logs, the email shows
deliveredstatus (sent→delivered). If it showsbouncedorcomplained, the recipient's server rejected it. - In Gmail, open the email → three-dot menu → Show original. You should see
dkim=passandspf=pass. If either fails, revisit Step 1 and check the DNS records.
Common Errors
| Error | Cause | Fix |
|---|---|---|
validation_error: The 'from' field must be a verified domain | The sender domain has not been verified in Resend | Go back to Step 1, add the DNS records, and wait for the dashboard to show verified. For testing, temporarily use onboarding@resend.dev. |
validation_error: from must be in format 'Name <email>' | RESEND_FROM is set to a bare email like noreply@mail.yourdomain.com | Change it to Acme <noreply@mail.yourdomain.com> — the display name and angle brackets are required. |
restricted_api_key: This API key is restricted to only sending emails | Wrong API key scope (e.g. Domains access selected instead of Sending access) | Create a new key in the dashboard with Sending access scope. |
| Email delivered, but lands in spam on QQ Mail / 163 / 126 | Overseas sending IPs have lower reputation with domestic Chinese providers; newly created domains have no sending history | Tighten DMARC policy (p=none → p=quarantine); warm up the domain with low-volume sends over a few days; guide users to whitelist the sender; for high-volume sends to domestic inboxes, consider switching to Tencent Cloud SES. |
attachment_too_large | Attachment exceeds Resend's per-email size limit (40 MB total) | Use object storage and send a download link instead, or split into multiple emails. Check the Resend attachments docs for the current limit. |
rate_limit_exceeded | Too many sends in a short period; default limit is 2 emails per second (can be increased via the dashboard) | Add a queue on the business side to smooth out spikes; use resend.batch.send for bulk sends (up to 100 per call); contact Resend support to raise your limit. |
invalid_idempotency_key | A retry used the same idempotencyKey as a previous successful send but with different parameters | The same key must always map to the same parameters; use a new key for different emails. |
getaddrinfo EAI_AGAIN api.resend.com | Cloud Function has no public network egress | Enable public access in the Console; if the region restricts outbound traffic, attach an egress NAT. |
| Function times out after 30 seconds | Default function execution timeout is 30 seconds; slow Resend responses or retries can hit this | Console → Function Config → Timeout, increase to 60 seconds; or offload sending to a queue for async processing. |
For the full error code reference, see docs.cloudbase.net error codes and the Resend errors reference.
Related Documentation
- alert-cloud-function-errors-to-wecom — Alert channel comparison: this recipe covers email; enterprise WeChat Webhook covers IM. Use transactional email for user-facing messages, WeChat for internal ops alerts.
- connect-wecom-webhook-cloud-function — For internal group notifications only, enterprise WeChat Webhook is more direct than email.
- secure-secrets-in-cloud-function — Environment variable management for
RESEND_API_KEYand other sensitive values, with local dev vs. production separation. - HTTP Cloud Functions — Quick start for Web Cloud Functions (HTTP trigger).
- Function Environment Variables — Configuring injected variables.