<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Galactic</title>
    <description>The latest articles on DEV Community by Galactic (@galactic_outreach).</description>
    <link>https://dev.to/galactic_outreach</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4150695%2Fb7d1d0ae-19e8-4ebb-bcd8-f3b8d09f24b9.png</url>
      <title>DEV Community: Galactic</title>
      <link>https://dev.to/galactic_outreach</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/galactic_outreach"/>
    <language>en</language>
    <item>
      <title>Our send log counted every email twice because of "Z" vs. "+00:00"</title>
      <dc:creator>Galactic</dc:creator>
      <pubDate>Fri, 06 Nov 2026 20:45:54 +0000</pubDate>
      <link>https://dev.to/galactic_outreach/our-send-log-counted-every-email-twice-because-of-z-vs-0000-2e5l</link>
      <guid>https://dev.to/galactic_outreach/our-send-log-counted-every-email-twice-because-of-z-vs-0000-2e5l</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two copies of the same campaign
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;But it means every campaign exists twice, and the two copies have to agree. Before every run, a step called &lt;em&gt;hydration&lt;/em&gt; rebuilds the mailer's files from Postgres:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The queue and the batch are overwritten&lt;/strong&gt;, even when the database copy is empty. If a lead was erased in the dashboard, a stale file must not still email them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The send log and the do-not-contact list are merged.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;The mailer writes a send's time with &lt;code&gt;toISOString()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-10-08T09:14:02.511Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres hands the same instant back as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-10-08 09:14:02.511+00:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The do-not-contact list had the same problem in a different form. The file keeps a reason next to each address (&lt;code&gt;someone@example.com  # bounced&lt;/code&gt;), while the table stores just the address. Two different lines, so every entry appeared twice.&lt;/p&gt;

&lt;p&gt;One campaign's screen said &lt;strong&gt;"52 sent, 14 suppressed."&lt;/strong&gt; The real figures were 26 and 7.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it mattered more than a wrong number
&lt;/h2&gt;

&lt;p&gt;The mailer's daily cap and its email-provider quota check both work by &lt;strong&gt;counting rows in the send log&lt;/strong&gt;. With every row doubled, a campaign allowed 50 emails a day would stop at 25 and report that it had hit its limit.&lt;/p&gt;

&lt;p&gt;Nothing errored or crashed. Every check passed, because every check was reading a file that was internally consistent and wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sentKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sentAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sentAt&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sb"&gt;`${Number.isFinite(ms) ? ms : at}|${String(email ?? "").trim().toLowerCase()}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A send is identified by its instant in milliseconds plus the address, lowercased. &lt;code&gt;Z&lt;/code&gt; and &lt;code&gt;+00:00&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Both functions are pure and tested directly, without a database.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;If you merge two stores, define what "the same row" means in exactly one place.&lt;/strong&gt; The copy back to Postgres already compared instants. The merge didn't. Two definitions of the same rule will drift apart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Union is safe" is only true if equality is right.&lt;/strong&gt; A union can't lose data, but it can duplicate it, and anything that counts rows inherits the duplicates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bugs that look like a wrong number often act like wrong behaviour.&lt;/strong&gt; The doubled count looked cosmetic until we noticed the cap was counting it too.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Galactic Outreach is at &lt;a href="https://galacticoutreach.com" rel="noopener noreferrer"&gt;galacticoutreach.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>javascript</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
