I build Galactic Outreach, a tool that finds local businesses and sends them a small, capped number of cold emails. This post is about a bug where a timestamp written two ways made every campaign think it had sent twice as much as it had. It also stopped every campaign at half its daily volume.
Two copies of the same campaign
The dashboard reads Postgres. The mailer, a Node script that does the actual sending, reads plain files: a CSV queue, a CSV send log and a do-not-contact list. That split is deliberate. The mailer is simple, runs from a CLI, and is easy to test against a temp directory.
But it means every campaign exists twice, and the two copies have to agree. Before every run, a step called hydration rebuilds the mailer's files from Postgres:
- The queue and the batch are overwritten, even when the database copy is empty. If a lead was erased in the dashboard, a stale file must not still email them.
- The send log and the do-not-contact list are merged. Both only ever grow, so the union of the two copies can never lose a contact. That's the safe choice, and it's where the bug was.
The bug
The mailer writes a send's time with toISOString():
2026-10-08T09:14:02.511Z Postgres hands the same instant back as:
2026-10-08 09:14:02.511+00:00 The merge used the whole row as text to decide whether two rows were the same send. They weren't the same text, so every send went into the file twice: once from the file's copy and once from the database's.
The do-not-contact list had the same problem in a different form. The file keeps a reason next to each address (someone@example.com # bounced), while the table stores just the address. Two different lines, so every entry appeared twice.
One campaign's screen said "52 sent, 14 suppressed." The real figures were 26 and 7.
Why it mattered more than a wrong number
The mailer's daily cap and its email-provider quota check both work by counting rows in the send log. With every row doubled, a campaign allowed 50 emails a day would stop at 25 and report that it had hit its limit.
Nothing errored or crashed. Every check passed, because every check was reading a file that was internally consistent and wrong.
The fix
Compare values, not text. One small function produces the key for a send, used both when hydrating the files and when copying sends back to Postgres:
export function sentKey(sentAt: unknown, email: unknown): string { const at = String(sentAt ?? ""); const ms = Date.parse(at); return `${Number.isFinite(ms) ? ms : at}|${String(email ?? "").trim().toLowerCase()}`; } A send is identified by its instant in milliseconds plus the address, lowercased. Z and +00:00 now produce the same key. A time that won't parse keys on its own text, so it isn't merged with every other unparseable row.
The do-not-contact key does what the mailer does when it reads the file: drop the comment, trim, lowercase. Where the file has a reason next to an address, the file's line is kept so the reason survives.
Both functions are pure and tested directly, without a database.
What I took from it
- If you merge two stores, define what "the same row" means in exactly one place. The copy back to Postgres already compared instants. The merge didn't. Two definitions of the same rule will drift apart.
- "Union is safe" is only true if equality is right. A union can't lose data, but it can duplicate it, and anything that counts rows inherits the duplicates.
- Bugs that look like a wrong number often act like wrong behaviour. The doubled count looked cosmetic until we noticed the cap was counting it too.
Galactic Outreach is at galacticoutreach.com.
