Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 20 additions & 12 deletions docs/how-to/set-up-production.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,28 +401,36 @@ manage Turnstile.

### Resend

Resend shows the DNS records for each domain. Set `email.dnsProfile` in `platform.json` to match:
`forge` for the `rsend` and `send` CNAMEs, or `ses` for the older `send` MX and SPF records. An
omitted profile means `ses`, so existing manifests keep their current checks.

1. Sign in at <https://resend.com/login>. If you have no account, sign up at
<https://resend.com/signup> with your @lasvegasfortransit.org address, and ask a maintainer to
invite you to the LVBT team.
2. On the Domains page, click "Add Domain", type the sending domain (lvwwd.org sends from
`lvwwd.org`), choose the region "North Virginia (us-east-1)", and click "Add". The region must
match `email.region` in `platform.json`.
3. On the domain's page, click "Sign in to Cloudflare" and approve the request. It adds every DNS
record for you.
4. To add the records by hand instead, open the zone's DNS records page in the Cloudflare dashboard
and add each with TTL "Auto" and Proxy status "DNS only": an MX record named `send` with the mail
server `feedback-smtp.us-east-1.amazonses.com` and priority 10; a TXT record named `send` with
the content `v=spf1 include:amazonses.com ~all`; and a TXT record named `resend._domainkey` with
the long `p=` value Resend shows. For a subdomain such as `notify.lasvegasfortransit.org`, add
the subdomain to each name, as in `send.notify`.
5. Add the DMARC record Resend recommends: a TXT record named `_dmarc` with the content
3. If the domain's page offers "Sign in to Cloudflare", use it to add the DNS records, then check
each record against the values Resend shows. Otherwise add them by hand in the next steps.
4. If Resend shows the Forge layout and you are adding its records by hand, open the zone's DNS
records page in Cloudflare. Add CNAME `rsend` pointing to `rsend.forge.rmta.net` and CNAME `send`
pointing to `send.forge.rmta.net`. Set both to DNS only with TTL Auto. Add TXT
`resend._domainkey` with the exact `p=` value shown for this domain in Resend. A Forge domain
does not use the older SES MX or SPF records.
5. If Resend shows the older SES layout instead, add MX `send` pointing to
`feedback-smtp.us-east-1.amazonses.com` with priority 10, TXT `send` containing
`v=spf1 include:amazonses.com ~all`, and TXT `resend._domainkey` with the exact `p=` value Resend
shows. For a sending subdomain such as `notify.lasvegasfortransit.org`, add the subdomain to each
DNS name, as in `send.notify`.
6. Add the DMARC record Resend recommends: a TXT record named `_dmarc` with the content
`v=DMARC1; p=none;`.
6. Click "Verify DNS Records" and wait until the domain's status says "Verified". It usually takes a
7. Click "Verify DNS Records" and wait until the domain's status says "Verified". It usually takes a
few minutes; DNS can take up to 72 hours.
7. Open <https://resend.com/api-keys> and click "Create API Key". Name it after the Worker, such as
8. Open <https://resend.com/api-keys> and click "Create API Key". Name it after the Worker, such as
`lvwwd.org Worker`, choose the permission "Sending access", choose the verified domain, and click
"Add".
8. Copy the key, which starts with `re_` and is shown only once, and paste it when setup asks for
9. Copy the key, which starts with `re_` and is shown only once, and paste it when setup asks for
`RESEND_API_KEY`.

### Cloudflare Web Analytics
Expand Down
10 changes: 7 additions & 3 deletions docs/reference/platform-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,16 @@ those steps.
| -------------- | -------- | -------------------------------------------------------------------------------------- |
| `domain` | yes | The domain in the From address. |
| `provider` | yes | `resend`. The provider decides which DNS records are checked. |
| `dnsProfile` | no | `ses` (the default) for legacy MX/SPF records, or `forge` for Resend's CNAME records. |
| `region` | no | The provider's sending region. Defaults to `us-east-1`. |
| `apiKeySecret` | no | The Worker secret that carries the provider's API key. It must be listed in `secrets`. |

For Resend, the check looks up the `send` MX and SPF records and the `resend._domainkey` DKIM
record, which production needs, and the `_dmarc` record, which it recommends. It uses public DNS, so
it needs no credential.
Set `dnsProfile` to match the records shown on the domain's Resend page. The default `ses` profile
checks the legacy `send` MX and SPF records. The `forge` profile checks `rsend` and `send` CNAMEs
pointing to `rsend.forge.rmta.net` and `send.forge.rmta.net`. Both profiles require the
`resend._domainkey` DKIM TXT record and recommend `_dmarc` TXT. The check uses public DNS and needs
no credential. A passing DNS check does not confirm Resend has marked the domain Verified or that a
message can be delivered; check the domain in Resend and send a test message before launch.

## `secrets`

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/platform.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@
"enum": ["resend"],
"description": "The sending service. It decides which DNS records are checked."
},
"dnsProfile": {
"enum": ["ses", "forge"],
"description": "The DNS layout Resend shows for this domain. Defaults to the legacy SES MX/SPF layout; choose forge for Resend's rsend and send CNAME records."
},
"region": {
"type": "string",
"pattern": "^[a-z]{2}-[a-z]+-[0-9]$",
Expand Down
83 changes: 57 additions & 26 deletions packages/cli/src/lib/platform/guides.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,51 @@ export function emailRecords(email) {
const region = email.region ?? 'us-east-1';
const domain = email.domain;
const mailFrom = `feedback-smtp.${region}.amazonses.com`;
const dkimAndDmarc = [
{
key: 'dkim',
type: 'TXT',
name: `resend._domainkey.${domain}`,
purpose: 'signs every message (DKIM)',
expected: 'TXT resend._domainkey → the p=… value Resend shows',
matches: (data) => dnsText(data).startsWith('p='),
level: 'required',
},
{
key: 'dmarc',
type: 'TXT',
name: `_dmarc.${domain}`,
purpose: 'tells inboxes what to do with mail that fails the checks (DMARC)',
expected: 'TXT _dmarc → "v=DMARC1; p=none;"',
matches: (data) => dnsText(data).startsWith('v=DMARC1'),
level: 'recommended',
},
];
if (email.dnsProfile === 'forge') {
const cnameMatches = (target) => (data) =>
data.trim().replace(/\.$/, '').toLowerCase() === target;
return [
{
key: 'rsend',
type: 'CNAME',
name: `rsend.${domain}`,
purpose: 'connects this sending domain to Resend',
expected: 'CNAME rsend → rsend.forge.rmta.net',
matches: cnameMatches('rsend.forge.rmta.net'),
level: 'required',
},
{
key: 'send',
type: 'CNAME',
name: `send.${domain}`,
purpose: 'routes the return path through Resend',
expected: 'CNAME send → send.forge.rmta.net',
matches: cnameMatches('send.forge.rmta.net'),
level: 'required',
},
...dkimAndDmarc,
];
}
return [
{
key: 'mx',
Expand All @@ -87,42 +132,28 @@ export function emailRecords(email) {
dnsText(data).startsWith('v=spf1') && dnsText(data).includes('include:amazonses.com'),
level: 'required',
},
{
key: 'dkim',
type: 'TXT',
name: `resend._domainkey.${domain}`,
purpose: 'signs every message (DKIM)',
expected: 'TXT resend._domainkey → the p=… value Resend shows',
matches: (data) => dnsText(data).startsWith('p='),
level: 'required',
},
{
key: 'dmarc',
type: 'TXT',
name: `_dmarc.${domain}`,
purpose: 'tells inboxes what to do with mail that fails the checks (DMARC)',
expected: 'TXT _dmarc → "v=DMARC1; p=none;"',
matches: (data) => dnsText(data).startsWith('v=DMARC1'),
level: 'recommended',
},
...dkimAndDmarc,
];
}

export function resendDomainGuide(email, cloudflare) {
const region = email.region ?? 'us-east-1';
const zone = cloudflare.zone.name;
const [mx, spf, dkim, dmarc] = emailRecords(email).map((record) => ({
...record,
short: relativeName(record.name, zone),
}));
const records = Object.fromEntries(
emailRecords(email).map((record) => [record.key, relativeName(record.name, zone)]),
);
const forge = email.dnsProfile === 'forge';
const manualRecords = forge
? `To add them by hand, open https://dash.cloudflare.com/${cloudflare.accountId}/${zone}/dns/records. Add these CNAME records with TTL "Auto" and Proxy status "DNS only": type CNAME, name ${records.rsend}, target rsend.forge.rmta.net; type CNAME, name ${records.send}, target send.forge.rmta.net. Add a third record: type TXT, name ${records.dkim}, content the long p=… value shown on this domain's page in Resend.`
: `To add them by hand instead, open https://dash.cloudflare.com/${cloudflare.accountId}/${zone}/dns/records and add these three, each with TTL "Auto" and Proxy status "DNS only": type MX, name ${records.mx}, mail server feedback-smtp.${region}.amazonses.com, priority 10; type TXT, name ${records.spf}, content v=spf1 include:amazonses.com ~all; type TXT, name ${records.dkim}, content the long p=… value Resend shows for it.`;
return {
url: 'https://resend.com/domains',
steps: [
'Sign in to Resend at https://resend.com/login. If you have no account, sign up at https://resend.com/signup with your @lasvegasfortransit.org address, then ask a maintainer to invite you to the LVBT team. Everything below belongs in that team, never in a personal one.',
`On the Domains page, if ${email.domain} is listed, click it and go to the next step. Otherwise click "Add Domain", type ${email.domain}, choose the region ${REGIONS[region] ?? region}, and click "Add". Keep that region: platform.json and the DNS records both name it.`,
`The easiest way to add the DNS records is the "Sign in to Cloudflare" button on the domain's page in Resend. Approve the request in the Cloudflare window, and it adds every record for you.`,
`To add them by hand instead, open https://dash.cloudflare.com/${cloudflare.accountId}/${zone}/dns/records and add these three, each with TTL "Auto" and Proxy status "DNS only": type MX, name ${mx.short}, mail server feedback-smtp.${region}.amazonses.com, priority 10; type TXT, name ${spf.short}, content v=spf1 include:amazonses.com ~all; type TXT, name ${dkim.short}, content the long p=… value Resend shows for it.`,
`Add the DMARC record too, which Resend recommends: type TXT, name ${dmarc.short}, content v=DMARC1; p=none;.`,
`On the Domains page, if ${email.domain} is listed, click it and go to the next step. Otherwise click "Add Domain", type ${email.domain}, choose the region ${REGIONS[region] ?? region}, and click "Add". Keep that region: platform.json names it${forge ? '.' : ' and the DNS records do too.'}`,
`If Resend offers a "Sign in to Cloudflare" button on the domain's page, you can use it to add the DNS records. Approve the request in the Cloudflare window, then check the records it added against the values Resend shows.`,
manualRecords,
`Add the DMARC record too, which Resend recommends: type TXT, name ${records.dmarc}, content v=DMARC1; p=none;.`,
'Back in Resend, click "Verify DNS Records". Wait until the domain\'s status says "Verified", usually within a few minutes (DNS can take up to 72 hours). Then run this command again.',
],
};
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/platform/services.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export function dnsResolver(request = fetch) {
);
if (!response.ok) throw new Error(`DNS lookup answered ${response.status}`);
const payload = await response.json();
const code = { MX: 15, TXT: 16 }[type];
const code = { CNAME: 5, MX: 15, TXT: 16 }[type];
return (payload.Answer ?? [])
.filter((answer) => code === undefined || answer.type === code)
.map((answer) => answer.data);
Expand Down
22 changes: 22 additions & 0 deletions tests/platform-dns.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { dnsResolver } from '../packages/cli/src/lib/platform/services.mjs';

test('DNS lookup keeps only CNAME answers for Forge records', async () => {
const requests = [];
const resolve = dnsResolver(async (url) => {
requests.push(new URL(url));
return {
ok: true,
json: async () => ({
Answer: [
{ type: 5, data: 'send.forge.rmta.net.' },
{ type: 1, data: '192.0.2.1' },
],
}),
};
});
assert.deepEqual(await resolve('send.example.org', 'CNAME'), ['send.forge.rmta.net.']);
assert.equal(requests[0].searchParams.get('type'), 'CNAME');
});
31 changes: 31 additions & 0 deletions tests/platform-guides.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import test from 'node:test';

import {
accessAppGuide,
emailRecords,
googleGroupGuide,
googleWorkspaceGuide,
hostnameParts,
Expand Down Expand Up @@ -76,6 +77,36 @@ test('email records are named relative to the zone, including for a sending subd
assert.ok(steps.includes('us-east-1'));
});

test('the Forge profile requires its two CNAMEs and DKIM but not legacy SES records', () => {
const email = { domain: 'example.org', provider: 'resend', dnsProfile: 'forge' };
const records = emailRecords(email);
assert.deepEqual(
records.map(({ key, type, name, level }) => ({ key, type, name, level })),
[
{ key: 'rsend', type: 'CNAME', name: 'rsend.example.org', level: 'required' },
{ key: 'send', type: 'CNAME', name: 'send.example.org', level: 'required' },
{ key: 'dkim', type: 'TXT', name: 'resend._domainkey.example.org', level: 'required' },
{ key: 'dmarc', type: 'TXT', name: '_dmarc.example.org', level: 'recommended' },
],
);
assert.equal(records[0].matches('RSEND.FORGE.RMTA.NET.'), true);
assert.equal(records[0].matches('send.forge.rmta.net.'), false);
assert.equal(records[1].matches('send.forge.rmta.net.'), true);
});

test('the Forge guide names the observed Resend records without SES instructions', () => {
const steps = everyStep(
resendDomainGuide(
{ domain: 'notify.example.org', provider: 'resend', dnsProfile: 'forge' },
sampleManifest().cloudflare,
),
);
assert.match(steps, /CNAME, name rsend\.notify, target rsend\.forge\.rmta\.net/);
assert.match(steps, /CNAME, name send\.notify, target send\.forge\.rmta\.net/);
assert.match(steps, /TXT, name resend\._domainkey\.notify/);
assert.doesNotMatch(steps, /amazonses|type MX|include:amazonses/);
});

test('Cloudflare config guides use typed worker bindings while Wrangler guides use JSON vars', () => {
const manifest = sampleManifest();
const widget = manifest.turnstile[0];
Expand Down
13 changes: 13 additions & 0 deletions tests/platform-manifest.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ test('a cf project can name its canonical config without a Wrangler config', ()
);
});

test('email DNS profiles accept Forge explicitly and reject unknown layouts', () => {
assert.deepEqual(
errorsAfter((manifest) => {
manifest.email[0].dnsProfile = 'forge';
}),
[],
);
const errors = errorsAfter((manifest) => {
manifest.email[0].dnsProfile = 'unknown';
});
assert.ok(errors.some((error) => error.includes('dnsProfile')));
});

test('a cf project manifest rejects a config filename cf cannot discover', () => {
const errors = errorsAfter((manifest) => {
manifest.cloudflare.cloudflareConfig = 'production.config.ts';
Expand Down
32 changes: 32 additions & 0 deletions tests/platform-plan.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,38 @@ test('missing sending records block production; a missing DMARC record only warn
assert.equal(noDmarc.ready, true);
});

test('Forge records make email ready and a missing return-path CNAME blocks it', () => {
const forgeDns = {
'rsend.example.org CNAME': known(['rsend.forge.rmta.net.']),
'send.example.org CNAME': known(['send.forge.rmta.net.']),
'resend._domainkey.example.org TXT': known(['"p=MIGfMA0GCSqGSIb3"']),
'_dmarc.example.org TXT': known(['"v=DMARC1; p=none;"']),
};
const forge = (change = () => undefined) =>
planAfter(
(state) => {
state.dns = { ...forgeDns };
change(state);
},
(manifest) => {
manifest.email[0].dnsProfile = 'forge';
},
);
const ready = forge();
assert.equal(ready.byId('email:example.org:rsend').status, 'ok');
assert.equal(ready.byId('email:example.org:send').status, 'ok');
assert.equal(ready.ready, true);
assert.equal(ready.byId('email:example.org:mx'), undefined);

const noReturnPath = forge((state) => (state.dns['send.example.org CNAME'] = known([])));
assert.equal(noReturnPath.byId('email:example.org:send').status, 'missing');
assert.equal(noReturnPath.ready, false);

const noDmarc = forge((state) => (state.dns['_dmarc.example.org TXT'] = known([])));
assert.equal(noDmarc.ready, true);
assert.equal(noDmarc.byId('email:example.org:dmarc').level, 'recommended');
});

test('a forbidden secret on the production Worker fails the check and can be deleted', () => {
const plan = planAfter((state) => state.worker.value.secrets.push('PREVIEW_ADMIN_KEY'));
const forbidden = plan.byId('forbidden:PREVIEW_ADMIN_KEY:worker');
Expand Down
Loading