Implementare la funzionalità "post by email" in un blog con Node.js e PostgreSQL

La funzionalità "post by email" permette agli autori di un blog di pubblicare articoli inviando una semplice email a un indirizzo dedicato. È un meccanismo comodo: non richiede l'accesso a un pannello di amministrazione, funziona da qualsiasi client di posta e si presta bene alla scrittura da dispositivi mobili. In questo articolo costruiamo l'intera funzionalità con Node.js e PostgreSQL, partendo da un approccio autonomo basato su IMAP polling, senza dipendere da servizi esterni.

Le due strategie per ricevere le email

Prima di scrivere codice è necessario decidere come le email arrivano all'applicazione. Esistono due strade principali, con compromessi diversi.

La prima è l'IMAP polling: l'applicazione si connette periodicamente a una casella di posta e legge i nuovi messaggi non letti. Questo approccio funziona ovunque, anche quando il server si trova dietro un NAT, e non richiede di esporre porte verso l'esterno. È completamente autonomo e si sposa bene con un'infrastruttura self-hosted, ad esempio un server di posta gestito in proprio.

La seconda è il webhook inbound: un servizio esterno riceve l'email e inoltra il contenuto all'applicazione tramite una richiesta HTTP POST. Questo metodo è più reattivo, perché elimina il ritardo del polling, ma introduce una dipendenza da un fornitore terzo.

In questo articolo seguiamo l'approccio IMAP polling, perché è indipendente e si integra naturalmente con una casella di posta che si controlla direttamente.

Il problema della sicurezza

La criticità centrale del "post by email" è semplice da enunciare: chiunque conosca l'indirizzo dedicato può tentare di pubblicare. Una difesa efficace si costruisce a più livelli, e ciascuno copre una debolezza che gli altri non coprono.

Il primo livello è la whitelist dei mittenti: l'applicazione accetta email solo da indirizzi corrispondenti ad autori registrati. Da sola, però, questa barriera protegge poco: il campo From di un'email è facilmente falsificabile, e l'indirizzo di un autore è spesso pubblico, magari proprio sul blog.

Il secondo livello è il token segreto incluso nell'indirizzo destinatario. L'email non si invia a blog@dominio ma a blog+token@dominio, dove il token è una stringa casuale associata a uno specifico autore. Il token è la barriera più importante, perché è un segreto che un attaccante non può indovinare anche conoscendo l'indirizzo del mittente. Per questo motivo va considerato obbligatorio.

Il terzo livello è la verifica SPF e DKIM: questi meccanismi permettono di controllare che il messaggio sia stato autenticato dal server di posta che lo ha ricevuto, confermando che il campo From non sia stato contraffatto.

La combinazione dei tre livelli è ciò che rende la funzionalità sicura nella pratica. Il token da solo non basta, perché se trapelasse chiunque potrebbe usarlo. Il mittente da solo non basta, perché è falsificabile. Insieme, e con SPF e DKIM a confermare l'autenticità del mittente, un attacco diventa molto difficile.

Lo schema del database

Il modello di dati prevede tre tabelle. La tabella authors contiene gli autori abilitati alla pubblicazione via email, ciascuno con il proprio token. La tabella posts contiene gli articoli. La tabella processed_emails garantisce l'idempotenza, registrando ogni email già elaborata in modo da non creare due volte lo stesso post.

-- Schema per la funzionalità "post by email".

CREATE TABLE IF NOT EXISTS authors (
    id              SERIAL PRIMARY KEY,
    name            VARCHAR(120) NOT NULL,
    email           VARCHAR(255) NOT NULL UNIQUE,
    -- token segreto incluso nell'indirizzo: blog+<token>@dominio
    email_token     VARCHAR(64)  NOT NULL UNIQUE,
    -- true = i post via email vengono pubblicati subito
    autopublish     BOOLEAN      NOT NULL DEFAULT TRUE,
    is_active       BOOLEAN      NOT NULL DEFAULT TRUE,
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS posts (
    id              SERIAL PRIMARY KEY,
    author_id       INTEGER      NOT NULL REFERENCES authors(id),
    title           VARCHAR(255) NOT NULL,
    slug            VARCHAR(280) NOT NULL UNIQUE,
    body            TEXT         NOT NULL,
    cover_image     VARCHAR(500),
    status          VARCHAR(20)  NOT NULL DEFAULT 'draft'
                    CHECK (status IN ('draft', 'published')),
    source          VARCHAR(20)  NOT NULL DEFAULT 'web'
                    CHECK (source IN ('web', 'email')),
    published_at    TIMESTAMPTZ,
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

-- Idempotenza: ogni email ha un Message-ID univoco.
-- Serve a non creare due volte lo stesso post se il poller
-- viene riavviato o l'email non viene marcata come letta in tempo.
CREATE TABLE IF NOT EXISTS processed_emails (
    message_id      VARCHAR(998) PRIMARY KEY,
    post_id         INTEGER REFERENCES posts(id),
    status          VARCHAR(20) NOT NULL,
    reason          TEXT,
    processed_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status, published_at DESC);

La colonna email_token è dichiarata NOT NULL e UNIQUE: ogni autore deve avere un token, e nessun token può essere condiviso tra autori diversi. La tabella processed_emails è il dettaglio che viene spesso trascurato. Senza di essa, qualsiasi anomalia nel ciclo di polling, come un riavvio dell'applicazione, rischia di duplicare i post. Il Message-ID dell'email è la chiave naturale di idempotenza, perché identifica univocamente ogni messaggio.

La generazione del token in PostgreSQL

Il token deve essere generato in modo sicuro, con sufficiente entropia da non essere indovinabile. Una scelta naturale è la funzione gen_random_bytes, ma è importante sapere che essa non appartiene al nucleo di PostgreSQL: vive nell'estensione pgcrypto, che non è abilitata di default. Tentare di usarla senza l'estensione produce un errore che segnala una funzione inesistente.

La soluzione consiste nell'abilitare l'estensione una sola volta per database.

CREATE EXTENSION IF NOT EXISTS pgcrypto;

Dopo questo passaggio è possibile inserire un autore generando il token direttamente in SQL.

INSERT INTO authors (name, email, email_token)
VALUES (
    'Author Name',
    'author@blog.com',
    encode(gen_random_bytes(8), 'hex')
);

Se non si dispone dei permessi per creare estensioni, ad esempio su un database gestito, si può ricorrere a gen_random_uuid, che invece fa parte del nucleo di PostgreSQL. Rimuovendo i trattini dall'UUID si ottiene un token esadecimale di trentadue caratteri, con entropia anche superiore.

INSERT INTO authors (name, email, email_token)
VALUES (
    'Author Name',
    'author@blog.com',
    replace(gen_random_uuid()::text, '-', '')
);

Una volta inserito l'autore, si recupera il token per costruire l'indirizzo a cui inviare le email.

SELECT email_token FROM authors WHERE email = 'author@blog.com';

La configurazione dell'applicazione

L'applicazione legge i propri parametri da variabili d'ambiente. Un piccolo modulo di configurazione centralizza la lettura e fallisce immediatamente se manca un valore obbligatorio.

import 'dotenv/config';

function required(name) {
    const value = process.env[name];
    if (!value) {
        throw new Error(`Variabile d'ambiente mancante: ${name}`);
    }
    return value;
}

export const config = {
    db: {
        connectionString: required('DATABASE_URL'),
    },
    imap: {
        host: required('IMAP_HOST'),
        port: parseInt(process.env.IMAP_PORT || '993', 10),
        secure: process.env.IMAP_SECURE !== 'false',
        auth: {
            user: required('IMAP_USER'),
            pass: required('IMAP_PASS'),
        },
    },
    // intervallo di polling in millisecondi
    pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS || '60000', 10),
    // cartella dove salvare gli allegati immagine
    uploadsDir: process.env.UPLOADS_DIR || './uploads',
    // base URL pubblico per costruire i path delle immagini
    publicBaseUrl: process.env.PUBLIC_BASE_URL || 'http://localhost:3000',
    // dimensione massima accettata per allegato (5 MB)
    maxAttachmentBytes: 5 * 1024 * 1024,
};

Il pool di connessioni e le transazioni

Per dialogare con PostgreSQL si utilizza la libreria pg. Un helper dedicato incapsula la gestione delle transazioni, perché la creazione del post e la registrazione dell'email elaborata devono avvenire in modo atomico.

import pg from 'pg';
import { config } from './config.js';

export const pool = new pg.Pool(config.db);

pool.on('error', (err) => {
    console.error('[db] errore inatteso sul pool', err);
});

export async function query(text, params) {
    return pool.query(text, params);
}

// Helper per transazioni: la creazione del post + il log
// dell'email devono essere atomici.
export async function withTransaction(fn) {
    const client = await pool.connect();
    try {
        await client.query('BEGIN');
        const result = await fn(client);
        await client.query('COMMIT');
        return result;
    } catch (err) {
        await client.query('ROLLBACK');
        throw err;
    } finally {
        client.release();
    }
}

La validazione dell'email

Il modulo di validazione decide se un'email può generare un post. La strategia applica tutti e tre i livelli di sicurezza in sequenza: estrae il token dall'indirizzo destinatario, verifica che mittente e token corrispondano allo stesso autore attivo, e infine controlla gli header di autenticazione.

Il punto centrale è la query che cerca l'autore. Essa filtra contemporaneamente su email e su email_token: un'email viene accettata solo se entrambi corrispondono alla stessa riga della tabella. Questo è ciò che rende il token una barriera reale, e non un semplice ornamento dell'indirizzo.

Un dettaglio merita attenzione nella verifica degli header. La libreria mailparser non restituisce sempre una stringa quando si legge un header. Se un header compare più volte nel messaggio, e l'header Authentication-Results è proprio uno di quelli che si ripetono perché ogni server di posta attraversato ne aggiunge uno, il valore restituito è un array. In altri casi può essere un oggetto strutturato. Per questo è necessario normalizzare il valore in una stringa prima di analizzarlo, altrimenti una chiamata diretta a un metodo come toLowerCase fallisce su un array.

import { query } from './db.js';

/**
 * Estrae il token dall'indirizzo destinatario.
 * Accetta formati come: blog+a8f3k2x9@dominio
 * Restituisce 'a8f3k2x9' oppure null.
 */
function extractToken(recipients) {
    for (const addr of recipients) {
        const match = addr.match(/\+([a-zA-Z0-9]+)@/);
        if (match) return match[1];
    }
    return null;
}

/**
 * Normalizza l'indirizzo mittente: minuscolo, senza spazi.
 */
function normalizeEmail(raw) {
    return (raw || '').trim().toLowerCase();
}

/**
 * Verifica gli header di autenticazione aggiunti dal server di posta.
 * Postfix aggiunge un header "Authentication-Results" che contiene
 * esiti come "spf=pass" e "dkim=pass".
 */
function checkAuthentication(headers) {
    const raw = headers.get('authentication-results');
    if (!raw) {
        // Header assente: non possiamo verificare. A seconda della
        // tolleranza al rischio si puo scegliere di restituire false.
        return { ok: true, note: 'header authentication-results assente' };
    }

    // mailparser puo restituire stringa, array (header ripetuto piu
    // volte) o oggetto. Normalizziamo tutto in un'unica stringa.
    let text;
    if (Array.isArray(raw)) {
        text = raw.join(' ');
    } else if (typeof raw === 'object') {
        text = JSON.stringify(raw);
    } else {
        text = String(raw);
    }
    text = text.toLowerCase();

    const spfPass = text.includes('spf=pass');
    const dkimPass = text.includes('dkim=pass');
    return {
        ok: spfPass || dkimPass,
        note: `spf=${spfPass} dkim=${dkimPass}`,
    };
}

/**
 * Valida un'email parsata. Restituisce { author } se valida,
 * oppure { error } con la motivazione del rifiuto.
 */
export async function validateEmail(parsed) {
    // 1. Mittente nella whitelist?
    const fromAddr = normalizeEmail(parsed.from?.value?.[0]?.address);
    if (!fromAddr) {
        return { error: 'mittente assente' };
    }

    // 2. Token presente nei destinatari?
    const recipients = (parsed.to?.value || []).map((v) => v.address || '');
    const token = extractToken(recipients);
    if (!token) {
        return { error: 'token mancante nell\'indirizzo destinatario' };
    }

    // 3. Mittente e token corrispondono allo stesso autore attivo?
    const { rows } = await query(
        `SELECT id, name, email, autopublish
           FROM authors
          WHERE email = $1 AND email_token = $2 AND is_active = TRUE`,
        [fromAddr, token]
    );
    if (rows.length === 0) {
        return { error: `nessun autore attivo per ${fromAddr} con quel token` };
    }

    // 4. Verifica SPF/DKIM.
    const auth = checkAuthentication(parsed.headers);
    if (!auth.ok) {
        return { error: `autenticazione email fallita (${auth.note})` };
    }

    return { author: rows[0] };
}

La verifica congiunta di mittente e token sulla stessa riga è il cuore del modello di sicurezza. Il token, essendo obbligatorio, garantisce che un'email priva del suffisso corretto venga respinta prima ancora di interrogare la tabella degli autori.

La generazione dello slug

Ogni post necessita di uno slug univoco per l'URL. La funzione che lo genera parte dal titolo, lo trasforma in una forma adatta a un URL e, in caso di collisione con uno slug già esistente, aggiunge un suffisso numerico progressivo. La funzione riceve un client di transazione per restare coerente con l'inserimento del post.

import slugify from 'slugify';

/**
 * Genera uno slug univoco. Se "il-mio-titolo" esiste gia,
 * prova "il-mio-titolo-2", "-3", ecc.
 * Riceve un client di transazione per restare consistente.
 */
export async function generateUniqueSlug(client, title) {
    const base = slugify(title, { lower: true, strict: true }).slice(0, 270);
    let candidate = base;
    let counter = 1;

    while (true) {
        const { rowCount } = await client.query(
            'SELECT 1 FROM posts WHERE slug = $1',
            [candidate]
        );
        if (rowCount === 0) return candidate;
        counter += 1;
        candidate = `${base}-${counter}`;
    }
}

La creazione del post

Il modulo che crea il post traduce l'email in un record della tabella posts. L'oggetto del messaggio diventa il titolo, il corpo del messaggio diventa il testo dell'articolo, e il primo allegato immagine diventa l'immagine di copertina. Tutta l'operazione avviene in una transazione, così che il post e la registrazione dell'email vengano salvati insieme oppure non vengano salvati affatto.

Un accorgimento difensivo riguarda il campo autopublish. Se per qualsiasi motivo l'oggetto autore non lo riportasse, l'uso dell'optional chaining e del nullish coalescing evita un errore di tipo e fa ricadere il comportamento sulla pubblicazione immediata, che è quella attesa.

import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import { config } from './config.js';
import { withTransaction } from './db.js';
import { generateUniqueSlug } from './slug.js';

/**
 * Salva un allegato immagine su disco e restituisce l'URL pubblico.
 */
async function saveAttachment(attachment) {
    if (!attachment.contentType?.startsWith('image/')) {
        return null;
    }
    if (attachment.size > config.maxAttachmentBytes) {
        console.warn(`[post] allegato ignorato, troppo grande: ${attachment.filename}`);
        return null;
    }

    await fs.mkdir(config.uploadsDir, { recursive: true });

    // Nome file univoco per evitare collisioni.
    const ext = path.extname(attachment.filename || '') || '.jpg';
    const safeName = crypto.randomBytes(12).toString('hex') + ext;
    const destPath = path.join(config.uploadsDir, safeName);

    await fs.writeFile(destPath, attachment.content);

    return `${config.publicBaseUrl}/uploads/${safeName}`;
}

/**
 * Determina il corpo del post. Preferisce il testo semplice;
 * se assente, ricade sull'HTML.
 */
function extractBody(parsed) {
    if (parsed.text && parsed.text.trim()) {
        return parsed.text.trim();
    }
    if (parsed.html) {
        return parsed.html;
    }
    return '';
}

/**
 * Crea un post a partire da un'email validata.
 * Tutto avviene in transazione: post + log dell'email insieme.
 */
export async function createPostFromEmail(parsed, author, messageId) {
    const title = (parsed.subject || '').trim();
    if (!title) {
        throw new Error('subject vuoto: impossibile creare il post senza titolo');
    }

    const body = extractBody(parsed);
    if (!body) {
        throw new Error('corpo dell\'email vuoto');
    }

    // Salva gli allegati immagine; la prima diventa la copertina.
    const imageUrls = [];
    for (const att of parsed.attachments || []) {
        const url = await saveAttachment(att);
        if (url) imageUrls.push(url);
    }
    const coverImage = imageUrls[0] || null;

    // Se per qualche motivo il campo manca, default a 'published'
    // (comportamento atteso: pubblicazione immediata).
    const status = (author?.autopublish ?? true) ? 'published' : 'draft';
    const publishedAt = status === 'published' ? new Date() : null;

    return withTransaction(async (client) => {
        const slug = await generateUniqueSlug(client, title);

        const { rows } = await client.query(
            `INSERT INTO posts
                (author_id, title, slug, body, cover_image,
                 status, source, published_at)
             VALUES ($1, $2, $3, $4, $5, $6, 'email', $7)
             RETURNING id, slug, status`,
            [author.id, title, slug, body, coverImage, status, publishedAt]
        );
        const post = rows[0];

        await client.query(
            `INSERT INTO processed_emails (message_id, post_id, status)
             VALUES ($1, $2, 'created')`,
            [messageId, post.id]
        );

        return post;
    });
}

Il poller IMAP

Il cuore della funzionalità è il poller, che si connette alla casella IMAP, legge le email non lette e le elabora. Per la connessione si usa la libreria imapflow, che offre un'API moderna basata su Promise e una gestione robusta della riconnessione. Per trasformare il messaggio MIME grezzo in un oggetto strutturato si usa mailparser.

Due scelte di progettazione rendono il poller affidabile. La prima: un messaggio viene marcato come letto soltanto dopo che l'elaborazione è andata a buon fine, così che un eventuale errore lasci l'email disponibile per un nuovo tentativo. La seconda: una variabile di stato impedisce che due cicli di polling si sovrappongano se uno richiede più tempo dell'intervallo configurato.

import { ImapFlow } from 'imapflow';
import { simpleParser } from 'mailparser';
import { config } from './config.js';
import { query } from './db.js';
import { validateEmail } from './emailValidator.js';
import { createPostFromEmail } from './postCreator.js';

/**
 * Controlla se un'email e gia stata processata, usando il Message-ID.
 */
async function alreadyProcessed(messageId) {
    const { rowCount } = await query(
        'SELECT 1 FROM processed_emails WHERE message_id = $1',
        [messageId]
    );
    return rowCount > 0;
}

/**
 * Registra il rifiuto di un'email nel log.
 */
async function logRejection(messageId, reason) {
    await query(
        `INSERT INTO processed_emails (message_id, status, reason)
         VALUES ($1, 'rejected', $2)
         ON CONFLICT (message_id) DO NOTHING`,
        [messageId, reason]
    );
}

/**
 * Processa un singolo messaggio gia parsato.
 */
async function processMessage(parsed) {
    // Il Message-ID e la chiave di idempotenza. Se assente,
    // ne generiamo uno deterministico da mittente + subject + data.
    const messageId =
        parsed.messageId ||
        `synthetic-${parsed.from?.text}-${parsed.subject}-${parsed.date}`;

    if (await alreadyProcessed(messageId)) {
        console.log(`[poller] gia processata, salto: ${messageId}`);
        return;
    }

    const { author, error } = await validateEmail(parsed);
    if (error) {
        console.warn(`[poller] email rifiutata (${messageId}): ${error}`);
        await logRejection(messageId, error);
        return;
    }

    const post = await createPostFromEmail(parsed, author, messageId);
    console.log(
        `[poller] post creato #${post.id} "${post.slug}" ` +
        `(${post.status}) da ${author.email}`
    );
}

/**
 * Esegue un ciclo di polling: connette, legge le email non lette,
 * le processa e le marca come lette.
 */
async function pollOnce() {
    const client = new ImapFlow({
        host: config.imap.host,
        port: config.imap.port,
        secure: config.imap.secure,
        auth: config.imap.auth,
        logger: false,
    });

    await client.connect();
    let lock;
    try {
        lock = await client.getMailboxLock('INBOX');

        // Cerca solo i messaggi non letti.
        const uids = await client.search({ seen: false });
        if (uids.length === 0) {
            return;
        }
        console.log(`[poller] ${uids.length} nuove email da processare`);

        for (const uid of uids) {
            // Scarica il sorgente grezzo del messaggio.
            const { content } = await client.download(uid, undefined, {
                uid: true,
            });
            const parsed = await simpleParser(content);

            try {
                await processMessage(parsed);
            } catch (err) {
                console.error(`[poller] errore processando uid ${uid}:`, err);
                // Non marchiamo come letta: verra ritentata.
                continue;
            }

            // Successo (post creato o rifiuto loggato): marca come letta.
            await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
        }
    } finally {
        if (lock) lock.release();
        await client.logout();
    }
}

/**
 * Avvia il ciclo di polling periodico. Non si sovrappone:
 * aspetta che un ciclo finisca prima di pianificare il successivo.
 */
export function startPoller() {
    let running = false;

    async function tick() {
        if (running) return;
        running = true;
        try {
            await pollOnce();
        } catch (err) {
            console.error('[poller] ciclo fallito:', err);
        } finally {
            running = false;
        }
    }

    console.log(`[poller] avviato, intervallo ${config.pollIntervalMs}ms`);
    tick(); // primo ciclo immediato
    return setInterval(tick, config.pollIntervalMs);
}

L'entrypoint dell'applicazione

L'entrypoint avvia il poller e gestisce lo spegnimento pulito. Alla ricezione di un segnale di terminazione, ferma il ciclo di polling e chiude il pool di connessioni prima di uscire.

import { startPoller } from './mailPoller.js';
import { pool } from './db.js';

const interval = startPoller();

// Spegnimento pulito: ferma il polling e chiudi il pool.
async function shutdown(signal) {
    console.log(`\n[app] ricevuto ${signal}, arresto in corso...`);
    clearInterval(interval);
    await pool.end();
    process.exit(0);
}

process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));

console.log('[app] post-by-email avviato');

La configurazione del server di posta

Poiché il token è obbligatorio, l'indirizzo destinatario assume sempre la forma blog+token@dominio. Questo formato sfrutta il sub-addressing, una funzionalità per cui ogni email inviata a un indirizzo con suffisso dopo il segno più viene consegnata nella stessa casella priva del suffisso. Il suffisso non crea un nuovo account: non occorre quindi configurare alcuna casella aggiuntiva sul server di posta. Il suffisso rimane visibile nell'header To del messaggio ed è esattamente ciò che il codice legge per estrarre il token.

Affinché questo funzioni, Postfix deve avere il separatore di sub-address attivo. Il parametro relativo è recipient_delimiter. Su molte installazioni è già impostato sul segno più. Per verificarlo si interroga la configurazione dal server.

postconf recipient_delimiter

Se il valore è vuoto, lo si imposta e si ricarica il servizio.

postconf -e 'recipient_delimiter = +'
systemctl reload postfix

Un buon modo per verificare che tutto funzioni, prima ancora di provare il codice, consiste nell'inviare una email di prova all'indirizzo con un suffisso qualsiasi e controllare che arrivi nella casella principale.

Considerazioni finali

La funzionalità "post by email" si presta a diverse estensioni. Si possono interpretare comandi nell'oggetto del messaggio, ad esempio un prefisso che imposta lo stato di bozza o che assegna delle etichette. Si può inviare un'email di conferma all'autore con il link al post creato. Si può convertire il corpo del messaggio da Markdown a HTML. Sul fronte della sicurezza, un limite di frequenza per autore impedisce che una casella compromessa generi un numero eccessivo di articoli: con la pubblicazione immediata, è la rete di protezione più sensata da aggiungere.

L'aspetto più importante da ricordare riguarda però la sicurezza di base. La validazione del solo mittente protegge poco, perché il campo che identifica il mittente è facilmente falsificabile. Il token segreto nell'indirizzo è la barriera che non può essere aggirata anche conoscendo l'indirizzo dell'autore, e per questo va trattato come obbligatorio. La verifica SPF e DKIM completa il quadro, confermando che il mittente dichiarato sia autentico. È la combinazione di queste difese, e non una sola di esse, a rendere la funzionalità sicura nella pratica.