<?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: Seotow</title>
    <description>The latest articles on DEV Community by Seotow (@seotow).</description>
    <link>https://dev.to/seotow</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%2F4239332%2F89d8ce89-d9c0-4b42-965c-d81000b43298.png</url>
      <title>DEV Community: Seotow</title>
      <link>https://dev.to/seotow</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/seotow"/>
    <language>en</language>
    <item>
      <title>Follow-up emails that stop when the client replies: quotes and invoices in n8n</title>
      <dc:creator>Seotow</dc:creator>
      <pubDate>Wed, 11 Nov 2026 15:13:01 +0000</pubDate>
      <link>https://dev.to/seotow/follow-up-emails-that-stop-when-the-client-replies-quotes-and-invoices-in-n8n-4d6n</link>
      <guid>https://dev.to/seotow/follow-up-emails-that-stop-when-the-client-replies-quotes-and-invoices-in-n8n-4d6n</guid>
      <description>&lt;p&gt;Two emails cost freelancers more money than any other: the follow-up on a quote nobody answered, and the reminder about an invoice nobody paid. Both are easy to forget and awkward to write, so they don't get sent.&lt;/p&gt;

&lt;p&gt;I built both in n8n with a Google Sheet and Gmail, and there's no AI model anywhere in it. This post walks through the parts that took the most thought. They apply to any "send a sequence until someone answers" workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sheet is the state
&lt;/h2&gt;

&lt;p&gt;Each quote or invoice is one row. Besides the obvious columns (client, email, amount, date, status), two columns belong to the workflow:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Written by&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LastStage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;workflow&lt;/td&gt;
&lt;td&gt;the last email this row received&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LastSentAt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;workflow&lt;/td&gt;
&lt;td&gt;when it was sent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After every email, an update node writes both back, matching on the row's ID. Run the workflow three times in one morning and nobody gets a second copy, because the next run sees the stage already went out.&lt;/p&gt;

&lt;p&gt;You could keep this state inside n8n instead (workflow static data), but then the sheet and the workflow can disagree the moment someone edits a row by hand. With the state in the sheet, what you see is what the workflow sees, and you can reset a row by clearing two cells.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stages, not schedules
&lt;/h2&gt;

&lt;p&gt;The tempting design is "wait 3 days, send, wait 4 days, send". In n8n that means long-running executions, and it breaks when you add a row that's already two weeks old.&lt;/p&gt;

&lt;p&gt;Instead, one scheduled run each weekday looks at every open row and works out where it should be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;STAGES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'check-in'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'questions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'nudge'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'closing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&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;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;daysSince&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SentDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="no"&gt;STAGES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&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;lastIdx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;STAGES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LastStage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;lastIdx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// already sent this one&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;daysSince&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LastSentAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// don't pile on&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A row only ever gets the latest stage it has reached. Add a quote that went out 16 days ago and it gets the 14-day email, not all three earlier ones in a burst.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop when they write back
&lt;/h2&gt;

&lt;p&gt;This is the part most templates skip, and it's the one clients notice. Nothing reads worse than "just following up!" the morning after someone explained the delay.&lt;/p&gt;

&lt;p&gt;A second workflow polls Gmail once an hour with the query &lt;code&gt;in:inbox -from:me&lt;/code&gt;. For each new message it takes the sender's address and looks for an open row that has already had at least one email:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;mail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bySender&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ClientEmail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'Open'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LastStage&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;mail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// set Status to Paused, note the subject, tell me&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paused rows get nothing until a person sets them back to Open. Matching on the address alone is a real limitation: a reply from a colleague at the same company won't match. I decided that's better than guessing from the domain and pausing the wrong client.&lt;/p&gt;

&lt;p&gt;Polling hourly rather than every few minutes matters on n8n Cloud, where executions are counted. Hourly is at most 24 a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draft mode first
&lt;/h2&gt;

&lt;p&gt;Every workflow starts with a Settings node, and one setting is &lt;code&gt;mode&lt;/code&gt;, which starts as &lt;code&gt;draft&lt;/code&gt;. In draft mode the Gmail node creates drafts instead of sending, but the sheet is still updated. For the first week you open Drafts each morning, read what it wrote, and press send yourself. When the wording sounds like you, you switch to &lt;code&gt;send&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bit me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dates from Google Sheets&lt;/strong&gt; come back as serial numbers (&lt;code&gt;46000&lt;/code&gt;) unless the column is formatted as plain text. The date parser handles both, plus &lt;code&gt;yyyy-mm-dd&lt;/code&gt; and slash dates in either order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTML email in Gmail.&lt;/strong&gt; The Monday summary is an HTML email. A font stack written as &lt;code&gt;font-family:Georgia,"Times New Roman",serif&lt;/code&gt; inside a &lt;code&gt;style="..."&lt;/code&gt; attribute closes the attribute early. Chrome rendered it fine, Gmail silently dropped every style after it. Use single quotes inside inline styles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reminders to clients stay plain text.&lt;/strong&gt; HTML reminders look like a billing system. Plain text from your own address reads like you wrote it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two kits
&lt;/h2&gt;

&lt;p&gt;I packaged both as n8n kits with sample sheets and a setup guide. I'm the seller, so weigh that accordingly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://lovelynhox.gumroad.com/l/quote-chaser" rel="noopener noreferrer"&gt;Quote Chaser&lt;/a&gt;, follow-ups for quotes that went quiet ($19)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://lovelynhox.gumroad.com/l/net30-kit" rel="noopener noreferrer"&gt;Net 30 Kit&lt;/a&gt;, invoice reminders ($19)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://lovelynhox.gumroad.com/l/quote-to-cash" rel="noopener noreferrer"&gt;Both together&lt;/a&gt; ($29)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you'd rather build it yourself, everything above is enough to do it. Questions about the logic are welcome in the comments.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>freelancing</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
