disposable email

Disposable: la tua mail “usa e getta” su AWS

11 min

Il 90% dei servizi online prevede un processo di registrazione durante il quale fornire il proprio indirizzo di posta elettronica. Questo andrà poi confermato ricevendo una email provvista di link sul quale cliccare. A questo punto avremo accesso al servizio al quale eravamo interessati ma contemporaneamente daremo il via alla ricezione di newsletter, sondaggi, promozioni, ecc.

Un indirizzo “usa e getta” è utile proprio in queste situazioni: ci consente di portare a termine il processo di registrazione e ci evita di ricevere in futuro email indesiderate, mantenendo “pulita” la nostra mailbox principale, che utilizziamo per lavoro o per scrivere agli amici.

NOVITA’!!

Sei alla ricerca di un servizio email “usa e getta” pronto all’uso, dotato di API fantastiche e integrato con Slack?

my24h.email

Esistono diversi servizi online che offrono la possibilità di creare indirizzi email “usa e getta” ed il più famoso è probabilmente Mailinator. Sono però servizi che poco si prestano ad un utilizzo professionale in ambito aziendale e sono spesso caratterizzati da alcune limitazioni. Il dominio di posta utilizzato, per esempio, è spesso riconosciuto per “usa e getta” e non considerato valido durante il processo di registrazione.

Andremo a realizzare Disposable-mail, un servizio di mail monouso personalizzabile con il proprio dominio internet, utilizzando i building block AWS. Il servizio è totalmente serverless: non sono infatti necessarie istanze EC2 ma solo semplici lambda functions.

Disposable-mail funziona così:

  1. Val sul sito
  2. Scegli un indirizzo email e lo digiti
  3. La mailbox che hai scelto viene immediatamente attivata e puoi iniziare a ricevere email

Se vuoi provarlo lo trovi qui: https://www.my24h.email

Non serve registrarsi o scegliere una password. La mailbox ha validità di un’ora e allo scadere dei 60 minuti tutti i messaggi ricevuti saranno eliminati e la mailbox non sarà più attiva.

Non è possibile inviare email, ma solo riceverle. Gli allegati non vengono visualizzati (ma forse lo saranno nella prossima versione).

Per evitare abusi utilizziamo Google reCAPTCHA nella form di login, in versione hidden.

Requisiti

Per realizzare il tuo servizio di email “usa e getta” sul tuo dominio personale ti serve:

  • un account AWS
  • un dominio internet (meglio un terzo livello) su Route53 o comunque per il quale ti sia possibile configurare la relativa zona DNS
  • una coppia di chiavi (site/private) di Google reCAPTCHA validi per il tuo dominio (reCAPTCHA console)

Tutto qui.

Architettura della soluzione

Come anticipato, Disposable-mail è totalmente serverless e, data la natura di questi building blocks AWS, non ci dovremo preoccupare della loro gestione, la scalabilità è garantita e il costo è “per utilizzo”.

Dal layout si comprende come esistano essenzialmente due workflow: il primo, totalmente automatizzato, di ricezione, verifica, memorizzazione e cancellazione delle email. Il secondo, dato dall’utente che accede all’applicazione React e consulta la propria mailbox. L’elemento comune dei due è rappresentato dal bucket S3 dove vengono memorizzate le email ricevute e le tabelle DynamoDB dove vengono memorizzati i relativi metadati.

Per la ricezione delle email ci affidiamo al servizio Amazon SES (Simple Email Service): ci occuperemo della sua configurazione nel paragrafo successivo. Alcune funzioni lambda si occupano di gestire il flusso delle email. Route53 è utilizzato per ospitare la zona relativa al nostro dominio internet.

La web application è ospitata direttamente in un bucket S3 pubblico e l’endpoint API è gestito dal servizio Amazon API Gateway. Alcune funzioni lambda si occupano di processare le richieste da parte dei client.

Ci affidiamo a CloudWatch per eseguire periodicamente una funzione lambda il cui scopo è disattivare ed eliminare le mailbox scadute.

Primi passi

Il primo passo per la realizzazione della soluzione è la corretta configurazione del servizio Amazon SES. Il template CloudFormation che utilizzeremo per la realizzazione di tutta l’infrastruttura di backend presuppone che il dominio email da noi scelto sia già correttamente configurato e verificato.

Iniziamo la configurazione: dalla console AWS è necessario accedere al servizio SES. Non tutte le Region AWS dispongono di questo servizio: è possibile quindi che si debba utilizzare una Region differente da quella che si utilizza normalmente. L’intera soluzione sarà ospitata, per semplicità, sulla medesima Region.

In Domains viene visualizzato l’elenco di tutti i domini internet per i quali il servizio SES è stato configurato. Dovremo aggiungere il nostro dominio ed effettuare il processo di verifica.

Cliccando su Verify a New Domain andiamo ad aggiungere il nostro dominio specificandone il nome. Come detto in precedenza, meglio utilizzare un dominio di terzo livello perché tutte le mailbox di questo saranno gestite da Disposable-mail.

Una volta aggiunto il dominio dobbiamo occuparci della configurazione delle relativa zona DNS. Si dovranno inserire due record:

  • un record TXT per la verifica da parte di AWS
  • un record MX per abilitare la ricezione delle email

Se la zona DNS è gestita da Route53, tutto quello che dobbiamo fare è cliccare su Use Route 53: la configurazione dei due record sarà automatica. In caso contrario è necessario fare riferimento al proprio provider per modificare la zona come indicato.

Una volta che il dominio sarà stato verificato, saremo in grado di ricevere email tramite Amazon SES.

Backend – Ricezione emails

Come vedremo, il template CloudFormation crea una nuova regola di ricezione email (Rule Set) di tipo “catch-all”, cioè valida per tutti gli indirizzi email del tipo <qualsiasi.cosa>@<dominio.scelto>

Il “Recipient” specificato corrisponde infatti al nostro dominio.

La prima Action si occupa di invocare una funziona lambda e di verificarne il responso. La funzione IncomingMailCheckFunction si occupa di verificare che l’indirizzo di destinazione della mail sia valido e attivo. Indica poi al servizio SES se proseguire (CONTINUE) o interrompere l’esecuzione di ulteriori azioni (STOP_RULE_SET).

La seconda Action, eseguita esclusivamente nel caso in cui la precedente lo abbia consentito, comporta il salvataggio della email nel bucket S3, il cui nome corrisponde a “incoming.disposable.<nome_dominio>” e la notifica dell’avvenuto salvataggio ad un topic SNS “IncomingMailTopic“.

Come avviene la verifica dell’indirizzo email? Una tabella DynamoDB è utilizzata per memorizzare l’elenco degli indirizzi email attivi e la relativa scadenza (TTL). La chiave della tabella è proprio l’indirizzo email (address).

## part of IncomingMailCheckFunction, see Github repository for  
## the complete functions code.

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ['addresses_table_name'])

def address_exists(address):
  exists = False
  response = table.get_item(Key={'address': address})
  if 'Item' in response:
      item = response['Item']
      if item['TTL'] > int(time.time()):
         exists = True

def lambda_handler(event, context):
  for record in event['Records']:
    to_address = record['ses']['mail']['destination'][0]
    if address_exists(to_address):
       return {'disposition': 'CONTINUE'}
    else:
       return {'disposition': 'STOP_RULE_SET'}
    break;

Per completare l’analisi del flusso di ricezione delle email, vediamo la funzione StoreEmailFunction. Questa funzione si occupa di memorizzate i dati della email ricevuta nel database DynamoDB, nella tabella “emails”. La tabella ha come Primary Key la stringa “destination” che corrisponde all’indirizzo email ed una Sort Key che corrisponde invece al “messageId” della email stessa. La funzione lambda riceve le notifiche dal topic SNS.

## part of StoreEmailFunction, see Github repository for  
## the complete functions code.

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ['emails_table_name'])

## Store email details in database
def store_email(email, receipt):
   response = table.put_item(
                  Item= {'destination': email['destination'][0],
                         'messageId': email['messageId'],
                         'timestamp': email['timestamp'],
                         'source': email['source'],
                         'commonHeaders': email['commonHeaders'],
                         'bucketName': receipt['action']['bucketName'],
                         'bucketObjectKey': receipt['action']['objectKey'],
                         'isNew': "true"
                  }
   )

def lambda_handler(event, context):
    message = json.loads(event['Records'][0]['Sns']['Message'])
    store_email(message['mail'], message['receipt'])

Nella notifica SNS è presente il nome del bucket S3 e l’objectKey utilizzato per memorizzarla. Salvando le informazioni nella tabella DynamoDB, sarà possibile successivamente recuperare il contenuto della email per la visualizzazione.

La funzione CleanUpFunction viene invocata periodicamente da CloudWatch al fine di svuotare e disattivare le mailbox scadute. Si occupa inoltre di eliminare i record di sessione anch’essi scaduti.

Viene effettuato uno scan sulle tabelle addresses e sessions verificando il valore dell’attributo TTL.

## part of CleanUpFunction, see Github repository for 
## the complete functions code.

def cleanup():
    ## get expired addresses
    try:
        response = addresses_table.scan(
            FilterExpression = Attr('TTL').lt(int(time.time())),
            ProjectionExpression = "address"
        )
    except ClientError as e:
        logger.error('## DynamoDB Client Exception')
        logger.error(e.response['Error']['Message'])
    else:
        for I in response['Items']:
            find_emails(I['address'])
            delete_address_item(I['address'])

Backend – API

API Gateway viene utilizzato come endpoint REST (regional) per la web application.

Viene utilizzata l’integrazione lambda ed esistono tre funzioni lambda dedicate alla creazione di una nuova mailbox (CreateEmailFunction), alle consultazione dell’elenco delle email presenti (GetEmailsListFunction) e al download del contenuto di una email specifica (GetEmailFileFunction).

La funzione CreateEmailFunction risponde al metodo GET della risorsa /create e prevede due parametri:

  • address – l’indirizzo email da creare
  • captcha – la chiave necessaria per la verifica di reCAPTCHA

Entrambi i parametri vengono validati e nel caso in cui l’indirizzo email non esista, lo stesso viene creato inserendo il relativo record nella tabella addresses. La funzione restituisce nel body un parametro sessionID che sarà utilizzato dal client per le ulteriori chiamate alle API. Gli ID di sessione validi e la relativa scadenza sono memorizzati nella tabella sessions.

## part of CreateEmailFunction, see Github repository for  
## the complete functions code.

import http.client, urllib.parse
import os
import re

valid_domains = os.environ['valid_domains'].split(',')
recaptcha_key = os.environ['recaptcha_key']

## A very simple function to validate email address
def validate_email(address):
    valid = False
    # Do some basic regex validation 
    match = re.match('^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,4})$', address)
    if (match != None):
        domain = address.split("@")[-1]
        if domain in valid_domains:
            valid = True
    return valid

## reCAPTCHA validation function
def validate_recaptcha(token):
    conn = http.client.HTTPSConnection('www.google.com')
    headers = {"Content-type": "application/x-www-form-urlencoded"}
    params = urllib.parse.urlencode({'secret': recaptcha_key, 'response': token}) 
    conn.request('POST', '/recaptcha/api/siteverify', params, headers)
    response = conn.getresponse()
    r = response.read().decode() 
    r = json.loads(r)
    return r['success']

La funzione GetEmailsListFunction risponde al metodo GET della risorsa /{destination} dove il parametro {destination} è l’indirizzo della mailbox. La funzione ritorna l’elenco delle email presenti nella mailbox, dopo aver validato il sessionID fornito nella richiesta (queryString).

L’elenco delle email è ottenuto tramite una Query sulla tabella emails.

## part of GetEmailsListFunction, see Github repository for  
## the complete functions code.

dynamodb = boto3.resource("dynamodb")
emails_table = dynamodb.Table(os.environ['emails_table_name'])
sessions_table = dynamodb.Table(os.environ['sessions_table_name'])

## Get emails list from DB
def get_emails(destination):
    items = None
    try:
        filtering_exp = Key('destination').eq(destination)
        response = emails_table.query(KeyConditionExpression=filtering_exp)        
    except ClientError as e:
        logger.info('## DynamoDB Client Exception')
        logger.info(e.response['Error']['Message'])
    else:
        #Remove private bucket details        
        for i in response['Items']:
            i.pop('bucketObjectKey', None)
            i.pop('bucketName', None)
        items = {'Items' : response['Items'], 'Count' : response['Count']}
    return items

## Verify session
def session_is_valid(sessionid):
    valid = False
    try:
        response = sessions_table.get_item(
            Key={
            'sessionId': sessionid
            }
        )
    except ClientError as e:
        logger.info('## DynamoDB Client Exception')
        logger.info(e.response['Error']['Message'])
    else:
        if 'Item' in response:
            item = response['Item']
            if item['TTL'] > int(time.time()):
                valid = True
    return valid

def lambda_handler(event, context):

    headers = {
        "access-control-allow-headers": 
           "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
        "access-control-allow-methods": 
           "DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT",
        "access-control-allow-origin": "*"
    }
     
    if session_is_valid(event["queryStringParameters"]["sessionid"]):
        destination = event["pathParameters"]["destination"]
        items = get_emails(destination)
        if items != None:
           result = {"statusCode": 200, "body": json.dumps(items), "headers": headers }

    return result    

La funzione GetEmailFileFunction risponde al metodo GET della risorsa /{destination}/{messageId} dove il parametro {destination} è l’indirizzo della mailbox e {messageId} è l’identificativo univoco della mail. Anche questa funzione verifica il sessionID fornito nella richiesta (queryString).

Restituisce il contenuto della mail leggendolo dal bucket S3 e si occupa di segnare come “letta” il relativo record della tabella emails.

## part of GetEmailFileFunction, see Github repository for  
## the complete functions code.

dynamodb = boto3.resource("dynamodb")
emails_table = dynamodb.Table(os.environ['emails_table_name'])
s3 = boto3.client('s3')

## Get emails details, including s3 bucket and s3 bucket object key.
def get_email_file(destination, messageId):
    result = None
    try:
        response = emails_table.get_item(
            Key={
            'destination': destination,
            'messageId' : messageId
            }
        )
    except ClientError as e:
        logger.info('## DynamoDB Client Exception')
        logger.info(e.response['Error']['Message'])
    else:
        if 'Item' in response:
            result = response['Item']
    return result

## Set item ad readed in DynamoDB table
def set_as_readed(destination, messageId):
    try:
        response = emails_table.update_item(
            Key={
            'destination': destination,
            'messageId' : messageId
            },
            UpdateExpression="SET isNew = :updated",                   
            ExpressionAttributeValues={':updated': 'false'}
        )
    except ClientError as e:
        logger.info('## DynamoDB Client Exception')
        logger.info(e.response['Error']['Message'])

def lambda_handler(event, context):

    headers = {
        "access-control-allow-headers": 
           "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
        "access-control-allow-methods": 
           "DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT",
        "access-control-allow-origin": "*"
    }

    email_file = get_email_file(destination, messageId) 
    if email_file != None:
        data = s3.get_object(Bucket=email_file['bucketName'], Key=email_file['bucketObjectKey'])
        contents = data['Body'].read().decode('utf-8')
        headers.update({"content-type": "message/rfc822"})
        result = {
           "statusCode": 200,
            "headers": headers,
            "body":  contents 
        }
        if email_file['isNew'] == 'true':
           set_as_readed(destination, messageId)

    return result

Al fine di abilitare CORS è stato aggiunto un metodo OPTION per ciascuna risorsa gestita da API Gateway. In aggiunta, le funzioni lambda restituiscono degli header specifici.

Il template CloudFormation si occupa inoltre di creare la distribuzione delle API e il relativo stage in modo da ottenere un endpoint valido per la configurazione della nostra web application.

Installazione backend

Una volta terminata la configurazione del servizio SES per il proprio dominio come spiegato nella sezione Primi Passi, utilizzeremo un template di CloudFormation per realizzare l’intera infrastruttura di backend. Il template prevede la creazione del bucket S3, delle tabelle DynamoDB, di tutte le funzioni lambda e delle API. Prevede inoltre la creazione di tutti i ruoli IAM, i permessi e la configurazione dei vari servizi, incluso il topic SNS.

AWSTemplateFormatVersion: 2010-09-09
Description: Disposable Email

Parameters:
    DomainName:
        Description: Domain name to be used
        Type: String
    ReCaptchaPrivateKey:
        Description: ReCaptcha private key to validate mailbox creation requests  
        Type: String
        Default: ""
    AddressesTableName:
        Description: DynamoDB table to store valid addresses
        Type: String
        Default: "disposable_addresses_table"
    EmailsTableName:
        Description: DynamoDB table to store emails 
        Type: String
        Default: "disposable_emails_table"
    SessionsTableName:
        Description: DynamoDB table to store client sessions 
        Type: String
        Default: "disposable_sessions_table"  
    RuleSetName:
        Description: SES RuleSet name to be used 
        Type: String
        Default: "default-rule-set"
    MailboxTTL:
        Description: Life (seconds) of mailbox
        Type: String
        Default: "3600"
    SessionTTL:
        Description: Session expiration in seconds
        Type: String
        Default: "600"
    SourceBucket:
        Description: Lambda functions source S3 Bucket
        Type: String
        Default: "cfvn" 

I sorgenti delle funzioni lambda non sono presenti direttamente nel template ma in file python esterni. Durante la creazione dello stack, i file ZIP delle funzioni lambda devono essere ospitati in un bucket S3. E’ possibile specificare un bucket differente da quello “ufficiale” (specificato come parametro “SourceBucket“) se si intende modificarli prima della creazione dello stack.

Per realizzare l’intera infrastruttura di backend puoi utilizzare la console di CloudFormation oppure AWS CLI:

aws cloudformation create-stack --stack-name <name> --template-url <url> --parameters <parameters> --capabilities CAPABILITY_IAM
DescrizioneEsempio
<name> il nome che vuoi dare allo stackdisposable_stack
<url>url del templatehttps://cfvn.s3-eu-west-1.amazonaws.com/disposable.yml

<parameters> è invece l’elenco dei parametri da fornire al template. I due parametri principali e obbligatori sono DomainName e ReCaptchaPrivateKey.

ParameterKey=DomainName,ParameterValue=<your_domain> 
ParameterKey=ReCaptchaPrivateKey,ParameterValue=<your_private_captcha_key>

Una volta creato la stack, ci interessa conoscere l’endpoint da utilizzare per la configurazione della web application. Questo è fornito da CloudFormation come output. E’ possibile vederlo nella console di CloudFormation oppure direttamente da AWS CLI:

aws cloudformation describe-stacks --stack-name <name> --query Stacks[0].Outputs[0].OutputValue

Frontend

L’applicazione React / Material-UI è molto semplice: si compone di tre semplici componenti: LoginForm per consentire la creazione e l’accesso ad una mailbox, EmailList per la visualizzazione dell’elenco dei messaggi presenti e EmailViewer per la visualizzazione del contenuto di una mail.

Il componente principale App prevede nel proprio stato l’indirizzo email e l’id di sessione, entrambi inizialmente vuoti. Viene costruito il componente LoginForm che gestirà il processo di login.

<LoginForm changeAddress={this.changeAddress.bind(this)} 
           apiEndpoint={APIEndpoint}
           recaptcha_key={ReCaptcha_SiteKey}
           email_domain={email_domain}/>

Il componente LoginForm incorpora il componente reCAPTCHA e si occupa di chiamare l’API /create sull’evento onSubmit della form.

handleSubmit(event) {
        const recaptchaValue = this.recaptchaRef.current.getValue();
        if (recaptchaValue === '') {
           window.location.reload();
        } else { 
            console.log("Captcha value:", recaptchaValue);
        
          fetch(this.props.apiEndpoint + 'create?address=' 
             + encodeURI(this.state.address + this.props.email_domain)
             + '&captcha=' + recaptchaValue)
          .then(r =>  r.json().then(data => ({status: r.status, body: data})))
          .then(r => {
            console.log(r);
            console.log('Response from API: ' + r.body.message);
            if (r.status === 200) {
              this.props.changeAddress(this.state.address + this.props.email_domain, r.body.sessionid);  
            }
        })
        .catch(console.log);
        }
        event.preventDefault();
    }

A login avvenuta, si ottiene un sessionID. Il componente App si occupa di costruire il componente EmailList.

<EmailList address={this.state.address} 
           sessionid={this.state.sessionid}
           changeAddress={this.changeAddress.bind(this)} 
           apiEndpoint={APIEndpoint}/>

Il componente EmailList si occupa di chiamare l’API /{destination} per ottenere l’elenco delle email.

getList(force) {
        fetch(this.props.apiEndpoint + this.state.address +
              '?sessionid=' + encodeURI(this.props.sessionid))
        .then(response => {
            const statusCode = response.status;
            const data = response.json();
            return Promise.all([statusCode, data]);
          })
        .then(res => {
            console.log(res);
            if (res[0] === 400) {
                this.logout();
            } else {      
                res[1].Items.sort(function(a,b){
                    if (a.timestamp > b.timestamp) { return -1 } else { return 1 }
                });         
                if ((this.listIsChanged(res[1].Items) || force)) {
                    this.setState({ emails: res[1].Items });
                    if ((this.state.selectedId === '') && (res[1].Items.length > 0)) {
                        this.setState({ selectedId: res[1].Items[0].messageId });   
                    }
                }
            }
        })
        .catch(console.log)
    }

Lo stesso si occupa inoltre di creare un componente EmailViewer per la visualizzazione del messaggio selezionato.

<EmailViewer address={this.state.address} 
             messageId={this.state.selectedId} 
             sessionid={this.state.sessionid} 
             apiEndpoint={this.props.apiEndpoint}/> 

La visualizzazione del messaggio email è la parte più complessa dell’applicazione client. Ottenuto il contenuto dalla API /{destination}/{messageId} è necessario effettuare il parsing dello stesso.

A tale scopo è utilizzata la libreria emailjs-mime-parser che consente di accedere ai vari oggetti che costituiscono la mail. La funzione getMailContents si occupa di scegliere il body corretto da mostrare, dando precedenza ai contenuti HTML.

Per visualizzare il contenuto HTML si utilizza un “trucco” molto pericoloso:

<div className="body" dangerouslySetInnerHTML={{__html: this.state.messageData}} />

Installazione Frontend

Per realizzare la web application React, una volta clonato il repository, è necessario configurare alcuni parametri che sono presenti all’inizio di App.js:

//  - your APIEndpoint
const APIEndpoint = <your_API_endpoint>;

//  - your ReCaptcha Site Key 
const ReCaptcha_SiteKey = <your_reCAPTCHA_site_key>;
 
//  - your email domain
const email_domain = <your_domain>;

Usa npm start per testare l’applicazione e npm run build per prepare i file da posizionare sul tuo hosting preferito oppure, come ho fatto io, direttamente in un bucket S3 pubblico. Ricordati di abilitare il dominio “localhost” tra quelli consentiti da reCAPTCHA per poter testare localmente la tua applicazione.

Conclusione

In questo repository GitHub trovi sia i sorgenti dell’applicazione React che il template CloudFormation per la realizzazione del backend AWS.

Disposable-mail nasce come esempio di realizzazione di una soluzione cloud native. Ci sono sicuramente molti aspetti da perfezionare e sarebbe necessario renderla più sicura. Si tratta comunque di una buona base di partenza per la realizzazione della propria webmail “usa e getta”.

Ci siamo divertiti? Alla prossima!

Leave a Comment