The Emails My App Swore It Sent
Tobi's email logs in Firestore said every daily digest went out. Resend had never seen a single one. The bug was a domain verification assumption, but the real story is how my own logging hid the failure for weeks.
I’ve been building Tobi, a shared household maintenance app. Everyone in a home shares one view of what needs upkeep, like air filters and appliance servicing, along with a log of what was done and when, so the knowledge of how to care for the house doesn’t live in just one person’s head. Part of that is email: a daily digest of what’s due, and a monthly report of what got done.
At some point I noticed I wasn’t receiving either one. Not bouncing, not in spam. Just not arriving.
My email logs in Firestore said every digest and report went out on schedule. The Resend dashboard showed nothing: no sends, no failures. One of those two had to be lying, and it turned out to be mine.
My email logs recorded intentions, not outcomes
The email code in my Firebase functions looked roughly like this:
await resend.emails.send({
from: "Tobi <digest@mail.example.com>",
to: user.email,
subject: "Your daily digest",
html,
});
await db.collection("emailLogs").add({
userId: user.id,
type: "dailyDigest",
sentAt: FieldValue.serverTimestamp(),
});
The problem is in the first call. Resend’s Node SDK doesn’t throw when a send fails. It returns a { data, error } object instead. If you await it and never look at error, a failed send and a successful send are indistinguishable. The function sails right past the failure and writes the “sent” log entry either way.
So my “email log” wasn’t logging that an email was sent. It was logging that the code reached the line that tries to send an email. Those are very different claims, and for weeks I couldn’t tell them apart, because nothing anywhere recorded the difference.
Getting eyes on the actual error
The fix for the visibility problem came first, because until I could see the real error, I was just guessing. I wired PostHog error tracking into the Firebase functions, so exceptions and captured errors show up somewhere I actually look, instead of dying quietly inside a Cloud Functions invocation:
const { data, error } = await resend.emails.send({
from: "Tobi <digest@mail.example.com>",
to: user.email,
subject: "Your daily digest",
html,
});
if (error) {
posthog.captureException(new Error(`Resend: ${error.message}`), user.id, {
emailType: "dailyDigest",
});
}
The next morning’s digest run produced an error in PostHog immediately, and it was not subtle: the sending domain wasn’t verified. Resend was rejecting every send with a validation error and pointing me at the domains page. The emails weren’t failing to deliver. They were being refused at the front door, which is exactly why the Resend dashboard showed nothing.
The bug: subdomains aren’t verified for free
Here’s the part that stung. I knew my domain was verified in Resend. I’d done it. I could see it sitting there with a green check.
But I wasn’t sending from that domain. I was sending from a subdomain of it, and I’d assumed that verifying the root domain covered its subdomains, because an AI assistant told me so while I was setting this up. It does not. Resend treats each sending domain as its own thing: if you send from mail.example.com, then mail.example.com needs to be added and verified, regardless of what example.com has.
This is documented. Straight from Resend’s domain docs:
Each individual subdomain must be added and verified independently, and they will each be listed separately in your Dashboard.
I just never checked, because the answer I’d been given was confident, plausible, and matched what I wanted to hear. Add the subdomain in Resend, add its DNS records, wait for verification, and the digests started arriving.
Fixing the log so it can’t lie again
The domain fix got the emails flowing, but the log was the deeper problem, so I fixed that too. The email log now records the outcome of the send, not the attempt:
const { data, error } = await resend.emails.send({ ... });
await db.collection("emailLogs").add({
userId: user.id,
type: "dailyDigest",
status: error ? "failed" : "sent",
resendId: data?.id ?? null,
error: error?.message ?? null,
createdAt: FieldValue.serverTimestamp(),
});
Now a failed send is a visible, queryable fact. If Resend rejects something, the log says failed and why, PostHog raises the error, and the Resend ID on successful sends means I can trace any individual email end to end. The two sources of truth can’t silently disagree anymore, because my side finally records the truth.
What I’m taking away
Put errors where you’ll actually see them.
The Resend error existed the whole time. It was in the SDK’s return value on every single invocation. But a Cloud Function returning an unread error object might as well be silent. Errors need to land somewhere you routinely look, which for me is now PostHog.
Verify what AI tells you, especially when it’s convenient.
“Subdomains inherit the root domain’s verification” was a confident answer that saved me a setup step, and it was wrong. The Resend docs would have taken two minutes to check. Confidence is not a source.
An audit log has to record outcomes, not attempts.
A log entry written before you know whether the operation succeeded is a statement of intent wearing a receipt’s clothing. Log the result, success or failure, with the error. Otherwise the log will actively mislead you exactly when you need it most.
None of these are new lessons. That’s sort of the point. I knew all three in the abstract, and it still took a missing digest, a lying log, and a wrong assumption stacked on top of each other before any of them became real.