Password reset email rejected: invalid from domain and unverified DKIM

A reset send refused for its sender address is a DNS problem, not a code problem. How to read the error, check SPF/DKIM/DMARC, and preflight in Node 22.

A transactional API that refuses your password-reset send with a complaint about the from address is telling you one thing: it will not sign mail for a domain it hasn’t authenticated. The HTTP status varies by vendor — some answer 400, some 403, some 422 — which is why searching the number gets you nowhere useful. On Infrai the two answers are VENDOR_NOT_CONFIGURED when the domain exists but isn’t verified, and PRO_REQUIRED when the account isn’t entitled to custom sender domains at all.

Both are recoverable in an afternoon, and neither is fixed in your application code. The work happens at your DNS host.

Three faults hiding behind one rejection

Reset mail is the worst place to discover this, because a locked-out user retries, generates more sends, and files a support ticket that reads like an outage. Sort the fault first.

What you seeLikely causeWhere the fix lives
Error names the sender domain, mentions “not verified”DNS records never published, or published on the wrong hostYour DNS zone
Verified yesterday, failing todayRecords edited, TTL expired, or registrar moved the zoneYour DNS zone
Error mentions plan or entitlementAccount tier doesn’t include custom sender domainsBilling, not DNS
Mail sends but lands in spamSPF/DKIM pass, DMARC alignment failsDMARC policy record

That last row is the one people miss. A message can be accepted by the API, delivered by the receiving MTA, and still be filed in spam because the From: header domain doesn’t align with the signing domain. Gmail’s bulk-sender rules have enforced alignment since 2024, and reset mail from a misaligned domain gets quietly filtered rather than bounced — so your logs show success and your user still can’t get in.

Ask the key which domains it can send from

Before touching DNS, find out what the account already knows about. This route takes no path parameter, so it’s also the fastest proof that your key and base URL are right.

curl -s https://api.infrai.cc/v1/email/domain/list \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
{
  "ok": true,
  "data": {
    "records": [
      {
        "domain": "example.com",
        "domain_id": "dom_Tt26LnNxF0eqNNdmHxGN2Ryk",
        "status": "pending_dns",
        "warm_up_state": "not_started",
        "daily_limit_current": 50000,
        "daily_limit_target": 500000
      }
    ]
  }
}

status: "pending_dns" is the whole diagnosis. The domain is registered with the provider, the records aren’t live yet, and every send from it will be refused until they are.

The records, and the four checks that must all go green

POST /v1/email/domain/verify is idempotent — call it to get the record set, publish them, then call it again to re-run the checks.

curl -s -X POST https://api.infrai.cc/v1/email/domain/verify \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
  -H "Content-Type: application/json" \
  -d '{"domain": "example.com"}'

On a standard account that same call answers HTTP 402, and it’s worth knowing what that looks like so you don’t spend an afternoon debugging DNS you never needed to touch:

{
  "ok": false,
  "error": {
    "code": "PRO_REQUIRED",
    "http_status": 402,
    "message": "custom sender domains are Pro-only; standard accounts have 0 custom sender domains",
    "retryable": false
  }
}

That’s a real limitation of the free tier rather than a soft nudge: custom sender domains need a paid plan. Until then you send from the shared @send.infrai.cc sender, which is fine for a staging environment and wrong for production reset mail, because the domain in the From: header won’t be yours.

Once the plan is right and the records are published, the per-check view is the one to watch. Each of SPF, DKIM, DMARC and the tracking CNAME is reported separately:

curl -s https://api.infrai.cc/v1/email/domain/get/example.com \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
{
  "ok": true,
  "data": {
    "verification": {
      "domain": "example.com",
      "status": "verified",
      "dns_records": [
        { "type": "TXT", "name": "example.com", "value": "v=spf1 include:_spf.infrai.cc ~all", "purpose": "spf", "ttl_recommended": 3600 },
        { "type": "TXT", "name": "cf._domainkey.example.com", "value": "v=DKIM1;k=rsa;p=MIIBIj...AB", "purpose": "dkim", "ttl_recommended": 3600 },
        { "type": "TXT", "name": "_dmarc.example.com", "value": "v=DMARC1;p=none;rua=mailto:dmarc@infrai.cc", "purpose": "dmarc", "ttl_recommended": 3600 }
      ],
      "checks": {
        "spf_dns": "verified",
        "dkim_dns": "verified",
        "tracking_cname": "verified",
        "dmarc_dns": "verified",
        "mail_loopback": "verified"
      }
    }
  }
}

A partial green — spf_dns: "verified" next to dkim_dns: "pending" — narrows the problem to one record. In practice dkim_dns is the laggard, for two reasons that have nothing to do with the API.

Why DKIM specifically fails

The DKIM record is a TXT entry at <selector>._domainkey.<your-domain>, and two registrar behaviours break it. Some control panels append the zone name automatically, so pasting the full cf._domainkey.example.com produces cf._domainkey.example.com.example.com — a record that resolves to nothing. Others silently split long TXT values, and an RSA public key is longer than the 255-character limit for a single string, so the key has to be published as adjacent quoted chunks that the resolver rejoins. Check the published value with dig before you blame anyone’s API, and give it the recommended 3600 seconds to propagate — negative caching means a wrong record can outlive its correction by the length of the old TTL.

A Node 22 preflight that won’t send blind

The pattern worth keeping is a gate in front of the send. It costs one free call and turns a user-visible failure into a log line.

// reset-preflight.mjs — Node 22, no dependencies
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };

async function senderIsReady(domain) {
  const res = await fetch(`${API}/v1/email/domain/get/${encodeURIComponent(domain)}`, { headers });
  const payload = await res.json();
  if (!res.ok || payload.ok === false) {
    const err = payload.error ?? { code: `HTTP_${res.status}` };
    return { ready: false, reason: err.code, detail: err.message ?? "" };
  }
  const v = payload.data.verification;
  const failing = Object.entries(v.checks ?? {}).filter(([, state]) => state !== "verified");
  if (v.status !== "verified" || failing.length) {
    return { ready: false, reason: "DNS_INCOMPLETE", detail: failing.map(([k]) => k).join(",") };
  }
  return { ready: true };
}

export async function sendReset({ to, domain, resetUrl }) {
  const gate = await senderIsReady(domain);
  if (!gate.ready) {
    console.error(`sender ${domain} not ready: ${gate.reason} ${gate.detail}`);
    throw new Error(`SENDER_NOT_READY:${gate.reason}`);
  }
  const res = await fetch(`${API}/v1/email/send`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      to,
      from: `security@${domain}`,
      subject: "Reset your password",
      html: `<p>Use this link within 30 minutes: <a href="${resetUrl}">reset</a></p>`,
    }),
  });
  const payload = await res.json();
  if (!res.ok || payload.ok === false) throw new Error(payload.error?.code ?? `HTTP_${res.status}`);
  return payload.data.message_id;
}

const id = await sendReset({
  to: process.argv[2] ?? "dana@example.com",
  domain: process.argv[3] ?? "example.com",
  resetUrl: "https://app.example.com/reset?t=abc123",
});
console.log(id);

One caveat on error handling here. Validation failures on the send route currently surface with a 503-class body rather than a 4xx, and the retryable flag on them isn’t a promise that retrying helps — an address that isn’t a valid address will never become one. Branch on error.code and on the message text, not on the status number alone.

If you’re not on Infrai

The shape of this problem is identical everywhere, and the vendor docs are good. Brevo publishes a domain-authentication troubleshooting page that walks the same DKIM/DMARC checks with their record names; Postmark and Mailgun both expose a per-record verification view in their dashboards, and Resend surfaces the DNS state on the domain page. If your product only sends mail and you already have one of those set up, stick with it — swapping providers to fix a TXT record is the wrong trade-off.

Infrai’s argument isn’t the send itself. It’s that the reset email, the rate-limit counter in front of it, the token you stored, and the error you captured when it failed all sit behind one key and one bill, so the second question after “did it send” doesn’t need a second vendor.

References

Browse more email developer guides