Validazione completa di un indirizzo email in Node.js: le pratiche consigliate

La validazione di un indirizzo email è uno dei compiti più sottovalutati nello sviluppo di applicazioni Node.js. Molti sviluppatori si limitano a una semplice espressione regolare, ma una validazione robusta richiede diversi livelli di controllo: sintattico, strutturale, del dominio e, in alcuni casi, della casella di posta stessa. In questo articolo vedremo come implementare una validazione completa combinando le migliori pratiche disponibili nell'ecosistema Node.js.

I livelli di validazione

Prima di scrivere codice, è importante capire che la validazione di un indirizzo email può avvenire a diversi livelli di profondità. Ogni livello aggiunge garanzie ma anche complessità e, in alcuni casi, latenza. I livelli principali sono quattro: validazione sintattica tramite espressione regolare o parser conforme a RFC 5322, validazione strutturale con controllo della parte locale e del dominio, verifica dell'esistenza del dominio tramite record DNS (in particolare MX) e infine verifica SMTP della casella di posta.

Nella maggior parte delle applicazioni reali conviene fermarsi al terzo livello, poiché la verifica SMTP è lenta, spesso bloccata dai server di posta e può essere interpretata come tentativo di spam.

Validazione sintattica con espressione regolare

Il primo passo consiste nel verificare che la stringa rispetti la forma generale di un indirizzo email. Esistono espressioni regolari conformi a RFC 5322, ma sono estremamente complesse e difficili da mantenere. Una soluzione pragmatica consiste nell'usare un pattern ragionevolmente permissivo che copre la stragrande maggioranza dei casi reali.

// Espressione regolare pragmatica per la validazione sintattica
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function isSyntacticallyValid(email) {
  if (typeof email !== 'string') {
    return false;
  }
  // Verifica della lunghezza massima consentita da RFC 5321
  if (email.length > 254) {
    return false;
  }
  return emailRegex.test(email);
}

console.log(isSyntacticallyValid('user@example.com')); // true
console.log(isSyntacticallyValid('invalid-email'));    // false

Questa funzione esegue due controlli: verifica che la stringa non superi i 254 caratteri (limite imposto da RFC 5321) e applica l'espressione regolare. È un buon punto di partenza ma non è sufficiente da sola.

Validazione strutturale approfondita

Una validazione più rigorosa richiede di separare la parte locale (prima della chiocciola) dalla parte del dominio e di verificarle singolarmente. La parte locale può contenere al massimo 64 caratteri, mentre ogni etichetta del dominio non può superare i 63 caratteri.

function validateStructure(email) {
  const atIndex = email.lastIndexOf('@');
  if (atIndex === -1) {
    return { valid: false, reason: 'missing-at-sign' };
  }

  const localPart = email.slice(0, atIndex);
  const domainPart = email.slice(atIndex + 1);

  // Controllo della lunghezza della parte locale
  if (localPart.length === 0 || localPart.length > 64) {
    return { valid: false, reason: 'invalid-local-length' };
  }

  // Controllo della presenza di punti consecutivi nella parte locale
  if (localPart.startsWith('.') || localPart.endsWith('.') || localPart.includes('..')) {
    return { valid: false, reason: 'invalid-local-dots' };
  }

  // Controllo della lunghezza del dominio
  if (domainPart.length === 0 || domainPart.length > 253) {
    return { valid: false, reason: 'invalid-domain-length' };
  }

  // Controllo delle etichette del dominio
  const labels = domainPart.split('.');
  if (labels.length < 2) {
    return { valid: false, reason: 'missing-tld' };
  }

  for (const label of labels) {
    if (label.length === 0 || label.length > 63) {
      return { valid: false, reason: 'invalid-label-length' };
    }
    if (label.startsWith('-') || label.endsWith('-')) {
      return { valid: false, reason: 'invalid-label-hyphen' };
    }
  }

  return { valid: true, localPart, domainPart };
}

Questa funzione restituisce un oggetto con l'esito della validazione e, in caso di errore, una ragione leggibile che può essere utile per fornire feedback all'utente o per il logging.

Verifica del dominio tramite record DNS

Dopo aver verificato la struttura, il passo successivo consiste nel controllare che il dominio esista davvero e che sia configurato per ricevere posta. Questo si fa interrogando i record MX del DNS tramite il modulo dns integrato in Node.js.

import { promises as dns } from 'node:dns';

async function hasMxRecords(domain) {
  try {
    const records = await dns.resolveMx(domain);
    return Array.isArray(records) && records.length > 0;
  } catch (error) {
    // Se il dominio non esiste o non ha record MX, la risoluzione fallisce
    if (error.code === 'ENOTFOUND' || error.code === 'ENODATA') {
      return false;
    }
    // Altri errori vengono propagati al chiamante
    throw error;
  }
}

È importante gestire correttamente i codici di errore restituiti dal modulo DNS. I codici ENOTFOUND ed ENODATA indicano rispettivamente che il dominio non esiste o che non ha record MX configurati, e in entrambi i casi l'indirizzo email non è valido. Altri errori, come i timeout di rete, dovrebbero essere propagati al chiamante perché indicano un problema temporaneo e non una reale invalidità dell'indirizzo.

Normalizzazione dell'indirizzo

Prima di salvare un indirizzo email in un database, è buona pratica normalizzarlo per evitare duplicati dovuti a differenze di maiuscole e minuscole. La parte del dominio è sempre case-insensitive, mentre la parte locale è teoricamente case-sensitive ma nella pratica quasi tutti i provider la trattano come case-insensitive.

function normalizeEmail(email) {
  const atIndex = email.lastIndexOf('@');
  if (atIndex === -1) {
    return email.trim().toLowerCase();
  }

  const localPart = email.slice(0, atIndex).trim();
  const domainPart = email.slice(atIndex + 1).trim().toLowerCase();

  return `${localPart.toLowerCase()}@${domainPart}`;
}

console.log(normalizeEmail('  User.Name@Example.COM  '));
// Output: 'user.name@example.com'

Una funzione di validazione completa

Mettiamo insieme tutti i pezzi in una funzione asincrona che esegue la validazione completa in sequenza, fermandosi al primo errore per evitare chiamate di rete inutili.

import { promises as dns } from 'node:dns';

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

async function validateEmail(email) {
  // Primo livello: controllo del tipo e della lunghezza
  if (typeof email !== 'string' || email.length === 0 || email.length > 254) {
    return { valid: false, reason: 'invalid-format' };
  }

  const normalized = email.trim().toLowerCase();

  // Secondo livello: espressione regolare
  if (!emailRegex.test(normalized)) {
    return { valid: false, reason: 'invalid-syntax' };
  }

  // Terzo livello: validazione strutturale
  const structure = validateStructure(normalized);
  if (!structure.valid) {
    return { valid: false, reason: structure.reason };
  }

  // Quarto livello: verifica dei record MX
  try {
    const hasMx = await hasMxRecords(structure.domainPart);
    if (!hasMx) {
      return { valid: false, reason: 'no-mx-records' };
    }
  } catch (error) {
    return { valid: false, reason: 'dns-error', error: error.message };
  }

  return { valid: true, email: normalized };
}

Uso di librerie esterne

Nell'ecosistema npm esistono diverse librerie mature per la validazione di indirizzi email. Le più note sono validator, che offre una funzione isEmail configurabile e conforme a molte varianti di RFC, ed email-validator, più leggera e focalizzata. Per applicazioni in produzione, soprattutto se non si vogliono reinventare soluzioni già collaudate, conviene affidarsi a queste librerie.

import validator from 'validator';

function validateWithLibrary(email) {
  // Opzioni rigorose per la validazione
  const options = {
    allow_utf8_local_part: false,
    require_tld: true,
    allow_ip_domain: false
  };

  return validator.isEmail(email, options);
}

Caching delle risoluzioni DNS

Le interrogazioni DNS possono essere lente e, se l'applicazione valida molti indirizzi con lo stesso dominio, conviene implementare una cache per evitare chiamate ripetute. Una semplice cache in memoria con scadenza temporale è sufficiente nella maggior parte dei casi.

const mxCache = new Map();
const cacheTtl = 1000 * 60 * 60; // Un'ora in millisecondi

async function hasMxRecordsCached(domain) {
  const now = Date.now();
  const cached = mxCache.get(domain);

  if (cached && (now - cached.timestamp) < cacheTtl) {
    return cached.value;
  }

  const value = await hasMxRecords(domain);
  mxCache.set(domain, { value, timestamp: now });
  return value;
}

Considerazioni sulla sicurezza

La validazione di un indirizzo email non è solo una questione di correttezza sintattica ma anche di sicurezza. Gli indirizzi email possono essere veicolo di attacchi come l'header injection nei sistemi di invio, quindi è fondamentale non limitarsi alla validazione ma anche sanitizzare i valori prima di usarli in intestazioni SMTP. Inoltre, esporre un endpoint pubblico di validazione DNS può essere sfruttato per attacchi di enumerazione di domini o come amplificatore di richieste DNS, quindi è opportuno applicare rate limiting.

Conclusioni

Una validazione completa di un indirizzo email in Node.js richiede di combinare più tecniche: controllo sintattico, validazione strutturale, verifica dei record DNS e normalizzazione. Per la maggior parte delle applicazioni è sufficiente fermarsi alla verifica dei record MX, evitando la problematica verifica SMTP diretta. L'uso di librerie esterne come validator può semplificare il lavoro, ma è importante capire cosa accade sotto il cofano per poter diagnosticare eventuali falsi positivi o negativi. Infine, ricorda che nessuna validazione tecnica può sostituire la conferma diretta da parte dell'utente tramite un'email di verifica con link univoco: è l'unico modo per avere la certezza assoluta che l'indirizzo sia reale e appartenga effettivamente a chi lo ha inserito.