← All notes

Do not trust SMTP with your leads

Every small site eventually needs to send mail. An order confirmation, a contact form notification, a password reset. The usual answer is Gmail SMTP, because it is free and you already have the account.

It works. It is also the part of the stack most likely to quietly stop working, and the way most people wire it up means that when it does, the thing it was carrying is gone.

The setup, briefly

You cannot use your normal Gmail password. Google stopped allowing that for SMTP, and it is the correct decision: handing your actual account password to an application means anything that reads it gets your whole account.

What you use instead is an App Password. To generate one:

  1. Turn on 2-Step Verification on the account. This is required. App Passwords do not exist as an option until it is on.
  2. Google Account, then Security, then 2-Step Verification, then App passwords.
  3. Generate one. You get sixteen characters. Google shows it once.
  4. Put it in an environment variable. Never in the repository.

That is the whole setup. Two environment variables, a user and a password, and nodemailer does the rest:

nodemailer.createTransport({
  service: "gmail",
  auth: {
    user: process.env.GMAIL_USER,
    pass: process.env.GMAIL_APP_PASSWORD,
  },
});

An App Password is scoped to one application and can be revoked on its own, so a leaked one costs you that integration rather than your email.

Worth knowing before you build on it: Gmail has sending limits, and with plain SMTP the From address generally has to be the account itself or a verified alias. If you need to send as a domain you do not control from that account, this is not the tool.

Now the part that matters

Here is a comment from one of our own repositories, on the layer that stores contact form submissions:

The point of this layer is that a lead is safe the moment it arrives. Before this existed, a submission was handed straight to Gmail and if SMTP refused, which it did for weeks, the lead was gone.

Weeks. Not one bad afternoon.

That is the shape of the failure. The form submits, the page says thank you, the visitor believes they have contacted you, and the handler tries to send an email that never arrives. Nothing errors visibly. Nobody is watching the server logs of a marketing site. You find out when you eventually notice the inbox has been quiet longer than seems right, and by then you cannot recover what you missed, because it was never written down anywhere.

Blaming SMTP is missing the point. SMTP will fail sometimes: rate limits, an expired App Password, a Google security block on a new IP, a transient outage. All of that is normal. The mistake is architectural, not operational. Email was the only copy.

Write first, notify second

The fix is one reordering.

before:  form  ->  email  ->  hope
after:   form  ->  database  ->  email about a row that already exists

Store the submission first. Then send the notification. If the email fails, you still have the lead, and you can look at it whenever you notice.

The corollary is that delivery status belongs on the record, as its own field, separate from the record's own state:

delivery       TEXT NOT NULL DEFAULT 'pending'
               CHECK (delivery IN ('pending','sent','failed')),
delivery_error TEXT,

with the rule written down next to it: delivery is about the notification email, not about the lead itself. 'failed' still means we have the lead.

Those are two different questions and conflating them is exactly how the original bug happened. "Did we hear from this person" and "did our notification about them go out" have different answers, and only one of them is a business problem.

Now a failed send is a row you can find, retry, and count. It is visible instead of silent.

Degrade, do not throw

Second rule, same spirit. A missing email configuration should never take down the thing that triggered the email.

if (!mailerConfigured()) {
  console.warn(`Email not sent (credentials not set): "${subject}"`);
  return;
}
try {
  await transporter.sendMail({ ... });
} catch (err) {
  // Email is a side effect of an already-successful payment or admin action.
  // Never let it fail the request that triggered it.
  console.error(`Failed to send "${subject}":`, err);
}

If a customer has paid, the payment succeeded. Throwing because the receipt did not send turns a completed order into a 500 and a confused customer who was charged. The order is real whether or not the email about it went anywhere.

This also means a fresh clone with no credentials runs. Nobody has to configure email to work on the checkout page.

Have a second path

If the mail actually matters, one provider is one point of failure. On our auth service, delivery falls back automatically rather than failing outright, and signup completes even when the verification email does not send, because a user who cannot finish creating an account is a worse outcome than a user who has to request a second email.

That ordering principle keeps showing up: decide which part is the irreversible one, make sure that part is safe, and let the rest be retryable.

The short version

Setting up Gmail SMTP takes twenty minutes and two environment variables.

Building so that its inevitable failure costs you nothing takes one extra database write and a status field. That is the part worth doing carefully, because the failure is silent, and silent failures are only discovered by accident.