CodeKits.shopNew developer offer

Email multiple recipients without exposing addresses

Choose between individual delivery and Bcc, validate a deliberately small recipient format, and make address visibility obvious before sending.

By Jonathan · 4 min read

Published

Private recipients stay outside the visible To and Cc headers
In this article

Putting a customer list in To exposes the list to every recipient. Bcc can hide addresses from other recipients, but a safer design for newsletters is often one separately addressed message per subscriber. Start by deciding whether people are intentionally sharing a conversation or receiving private copies.

This tutorial demonstrates a constrained address-entry contract and the composition boundary. It uses Node.js and Nodemailer integration excerpts, with fictional example.com addresses only. It does not send messages. Production delivery also needs consent, suppression handling, rate limits and provider-specific policies.

Make visibility a deliberate choice

For a small private announcement, send to a fixed organisational address and put the private audience in Bcc. For personalised messages or per-subscriber unsubscribe links, create one message per recipient. For a project discussion where everyone should see who is involved, use To and Cc and show that explicitly in the composer.

Bcc hides recipients from ordinary message headers; it does not hide them from the sending service, receiving infrastructure or authorised administrators. A recipient can still forward a message. Do not describe Bcc as end-to-end privacy.

Prefer structured input to clever splitting

A comma can be part of a quoted display name, so text.split(',') is not a general email parser. Nodemailer supports structured address objects, which avoid hand-built quoting. This example intentionally accepts one simple ASCII mailbox per line, with no names or quoted local parts. Label that limitation in the interface.

recipients.mjs
export function recipientsFromLines(text) {
  if (typeof text !== "string" || text.length > 20_000)
    throw new Error("Recipient input is too large");
  const result = new Map();
  for (const line of text.split(/\r?\n/)) {
    const address = line.trim();
    if (!address) continue;
    if (address.length > 254 || !/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(address))
      throw new Error("Use one simple email address per line");
    const [local, domain] = address.split("@");
    if (local.length > 64 || local.startsWith(".") || local.endsWith(".") ||
        local.includes("..") || domain.split(".").some(label =>
          !label || label.length > 63 || label.startsWith("-") || label.endsWith("-")))
      throw new Error("Invalid address shape");
    const normal = `${local}@${domain.toLowerCase()}`;
    result.set(normal, { address: normal });
    if (result.size > 100) throw new Error("Too many recipients");
  }
  if (!result.size) throw new Error("Add a recipient");
  return [...result.values()];
}

Only the domain is lowercased. Local-part casing can be significant, so do not silently merge all case variants or remove dots and plus tags. If your product chooses a stronger canonicalisation rule, document it and accept that it changes identity semantics. This validator accepts a supported subset, not every address allowed by email standards; it cannot establish deliverability.

Compose without exposing the list

private-announcement.mjs
const message = {
  from: "Updates <[email protected]>",
  to: "Updates <[email protected]>",
  bcc: recipientsFromLines(recipientInput),
  subject: "Project update",
  text: "A short demonstration message.",
};

Nodemailer's message configuration distinguishes visible To/Cc fields from Bcc. Never copy the audience into the subject, HTML body, attachment names or a visible “recipient summary”. Do not preserve Bcc headers in an outbound SMTP message through custom raw-message handling.

Test the actual transport boundary

Test blank input, duplicates, domain case variants, malformed labels, too many lines and a pasted display-name list. The last should fail with an instruction to use one address per line rather than guessing.

Use local MIME generation for composition tests, but note that stream transport retains Bcc for inspection. That output is not proof that SMTP will leak Bcc. Test the real SMTP path against a local capture server or controlled test mailboxes before launch, inspecting received raw headers and envelope recipients separately.

Test retries per recipient. An accepted address should not be resent just because another recipient was rejected. Use the email attempt state model to preserve that distinction.

Troubleshooting and next steps

If private addresses appear in received headers, inspect custom transport options, raw MIME and accidental To/Cc use. If replies reach the whole audience, check whether the original message intentionally used shared recipients. Make the visibility choice unmistakable before the send button.

For a composer with safe audience selection and suppression checks, explore CodeKits branded email systems.