DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two functions decide what to call you, and they disagree on purpose

CogniPrep has two functions whose entire job is to turn whatever we know about a person into a word to put after "Hi". They live in different modules, they are written by the same person, and they make opposite decisions about the same input.

That is not an oversight, and working out why took longer than writing either of them.

The dashboard one guesses

/**
 * Return the user's first name for greetings.
 *
 * Preference order:
 *   1. First token of their stored display name ("Ada Lovelace" -> "Ada")
 *   2. The local part of their email ("ada@x.com" -> "ada"), title-cased
 *   3. A neutral fallback ("there")
 */
export function getFirstName(
  name?: string | null,
  email?: string | null,
  fallback = 'there'
): string {
  const trimmedName = name?.trim();
  if (trimmedName) {
    const first = trimmedName.split(/\s+/)[0];
    if (first) return first;
  }

  const localPart = email?.split('@')[0]?.trim();
  if (localPart) {
    // Strip common separators and title-case the first chunk so "ada.lovelace"
    // and "ada_lovelace" both render as "Ada".
    const cleaned = localPart.split(/[._-]+/)[0];
    if (cleaned) {
      return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
    }
  }

  return fallback;
}
Enter fullscreen mode Exit fullscreen mode

It has exactly one caller: the dashboard page, which is a screen you only reach by signing in.

The email one refuses to

/**
 * Best-effort first name for a greeting. Falls back to "there" when the stored
 * name is missing, an email address, or otherwise unusable - a wrong name reads
 * worse than no name.
 */
export function greetingName(name: string | null | undefined): string {
  const trimmed = (name ?? '').trim();
  if (!trimmed || trimmed.includes('@')) return 'there';
  const first = trimmed.split(/\s+/)[0];
  if (first.length < 2 || first.length > 24) return 'there';
  return first;
}
Enter fullscreen mode Exit fullscreen mode

No email parameter at all, which is the strongest possible way to express "do not use the address as a name". And three rejections the dashboard version does not have:

  • Contains an @. People paste their email into name fields. It happens constantly, and Hi ada@gmail.com, at the top of a branded email is unmistakably a broken mail merge.
  • Shorter than two characters. A stored name of "J" or "A." produces Hi J, which reads like a bug even when it is literally what the user typed.
  • Longer than 24 characters. The long values in a name column are not names. They are job titles, company names, and the occasional sentence.

Why the same product gets two answers

The difference is who reads the output and what a wrong guess costs there.

On the dashboard, the reader is signed in, looking at their own account, one click from the settings page where they can put a real name in. If the greeting says "Hi Hr" because they signed up with hr@company.com, the worst case is mildly funny and entirely self-explanatory: they know what their email address is, because they just used it to log in. Guessing is cheap, and a greeting with a name in it is warmer than a greeting without one.

An email is the opposite situation on every axis. It arrives days later, in an inbox, next to real mail from real people, with no context around it and no settings page attached. The recipient may never have typed a name anywhere. And a wrongly personalised email does a specific kind of damage that an impersonal one does not: it tells the reader this was generated, which makes them read everything else in it as generated too.

So the email side takes the loss. Hi there, is slightly colder and never wrong, and a message that can only ever be slightly cold is better than one that can occasionally be embarrassing.

The asymmetry I did not intend

Writing this up found one. The dashboard function title-cases the email branch and not the name branch:

  • No stored name, address ada.lovelace@example.com, you get "Hi Ada".
  • Stored name ada lovelace, typed in lowercase at sign-up, you get "Hi ada".

The user who gave us less information gets the nicer-looking greeting. It is the right call anyway, though not for the reason it happened: a stored name is what the person chose to call themselves, and title-casing it would mean deciding that danah boyd or bell hooks typed their own name wrong. The derived value has no such claim behind it, because nobody chose it, so cleaning it up is free. The inconsistency is real, and the behaviour stays.

One escape, at the boundary

Both greeting functions return plain text, and the notice email renders both a text part and an HTML part. The escaping happens once, where the HTML is built:

const hello = escapeHtml(greetingName(name));
Enter fullscreen mode Exit fullscreen mode

The text part calls greetingName with no escaping at all, because escaping is a property of the destination format and not of the value.

escapeHtml is itself a single definition now, and the reason it is single is in its own doc comment:

escapeHtml here is the ONLY definition for lib/email. It used to be copied into ticket-emails, support-emails and lifecycle-emails, and the lifecycle copy silently left ' unescaped, so the same name rendered two different ways depending on which builder sent it. Add nothing local; import from here.

A name with an apostrophe in it, O'Neill, rendered correctly in a support reply and as raw characters in a lifecycle email, because one of four copies of a five-line function was missing one .replace(). Not a security hole in this case, just a visibly different product depending on which code path sent the mail, which is the usual way duplicated escaping fails.

There is deliberately a second HTML escaper elsewhere in the codebase, in the API validation module, which also escapes /. That one guards request payloads rather than email markup. Two escapers with different rules for different destinations is fine. Four escapers that are all meant to be the same and are not is the problem.

The mail these greetings go out on is answered by the same small team that writes the product, which the about page says in as many words, and the help page is the web form on the other end of that inbox. If you ever get a Hi there, from us, that is the code working as designed rather than a database with a hole in it.

Top comments (0)