DEV Community

Cover image for Build an anti-spam, double opt-in Email form with Python
Rutkat
Rutkat

Posted on • Edited on

Build an anti-spam, double opt-in Email form with Python

So you want to build an app and get many users?

We all do and if you're a beginner you need to take the following email sign-up features into consideration.

  1. Filtering input for a valid email address
  2. Double opt-in sign-up
  3. Bot/spam prevention

A solid email sign-up form is essential for web apps, newsletters, freebie downloads, invites to private groups, and lead generation. It allows you to collect valid email address and establish personal communication without relying on social media or other platforms. Do respect the email data and abide your region's anti-spam regulations such as the can-spam act.

Let's not rely on using 3rd party services such as Auth0, Facebook, or Google to have access to your app and services which can shut your down at any given time. Keep your app data yours!

Starting off, you should have some experience in Python because we're going to use the Flask framework with a MySQL database. This is going to be more fun (maybe) than using Wordpress, the most popular CMS. You would have to pay for some Wordpress plugin to have the same capability as a free Flask extension. I've build previously built on Wordpress (PHP) and prefer Python Flask for web apps even though Wordpress is very capable of making web apps.

We're going to use existing Python modules which simplify cleaning user input, generating a verification link, and communicating with the database.

Each code snippet will be explained and include some comments in the code. In case you haven't build user-registration or know of the inner workings, I will describe the details for you and then you can see the final code at the end (don't skip ahead).

Here is a summary of the features we will implement as stated in the first paragraph:

  1. A valid email address can be checked by parsing the input string from the user using a regular expression or a Flask extension. We won't allow random text nor SQL injection type of hacks.

  2. The double opt-in method requires the recipient to give permission for you to email them by receiving a validation link to their inbox. This is mainly used to prevent someone else from using your email address. This also prevents test users who just sign-up and abandon their accounts.

  3. Bot prevention can be done with a hidden field that is not shown to the user but is commonly auto-filled by bots crawling for vulnerable sign-up forms but it is not as reliable is a "captcha" from a 3rd party service.

Let's code it out!

Create a working directory:

mkdir signup
cd signup
Enter fullscreen mode Exit fullscreen mode

Create your Python environment using python3 -m venv signup or conda create -n double-opt-contact python3. I prefer conda and if you want to learn more you can read my Python environments article.

Install the following dependencies:
pip flask flask-mail secure SQLAlchemy Flask-WTF Flask-SQLAlchemy mysql-connector-python bleach

Alternatively, you can have the same dependencies listed in a requirements.txt file and run pip install -r requirements.txt

Create app.py file with the following dependencies included:

from flask import Flask, render_template, request, url_for, redirect, flash
from flask_mail import Mail, Message
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.sql import func
from itsdangerous import URLSafeTimedSerializer, SignatureExpired
import secrets
import bleach
Enter fullscreen mode Exit fullscreen mode

Initialize the app object with default template folder location:

app = Flask(__name__, template_folder='templates')
Enter fullscreen mode Exit fullscreen mode

Enter your own server configuration data using these lines:

# Flask configurations
secret = secrets.token_urlsafe(32)
app.secret_key = secret
app.config['SECRET_KEY'] = secret # auto-generated secret key

# SQLAlchemy configurations
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+mysqlconnector://admin:user@localhost/tablename'

# Email configurations
app.config['MAIL_SERVER'] = 'smtp.example.com'
app.config['MAIL_PORT'] = 465 #check your port
app.config['MAIL_USERNAME'] = 'your_email@example.com'
app.config['MAIL_PASSWORD'] = 'your_password'
app.config['MAIL_USE_TLS'] = True
app.config['MAIL_USE_SSL'] = False

db = SQLAlchemy(app)
mail = Mail(app)
sserialzer = URLSafeTimedSerializer(app.config['SECRET_KEY']) #set secret to the serliazer
Enter fullscreen mode Exit fullscreen mode

Ultimately, you should have your config info in a .env file.

We will need a MySQL database to store users which can be created manually or by Python code. As part of the learning process, you can enter the following code using the command-line or using Python's with app.app_context() db_create_all() method.

The validated field is for a token string which allows the double opt-in technique.

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(120) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    validated BOOLEAN DEFAULT FALSE
);
Enter fullscreen mode Exit fullscreen mode

The next section uses SQLAlchemy's ORM structure to query the database for you. Take note that the class name should match your database table name otherwise you'll get an error. The db.model represents your table settings which include the column name, it's type, length, key and null value:

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(120), unique=True, nullable=False)
    created_at = db.Column(db.DateTime, server_default=db.func.now())
    validated = db.Column(db.Boolean, default=False)
Enter fullscreen mode Exit fullscreen mode

If you haven't manually created the MySQL database table already, you can do it with this Flask code directly after the class User code block:

# Create the database table
with app.app_context():
    db.create_all()
Enter fullscreen mode Exit fullscreen mode

For brevity of this tutorial, we're skipping the index page or what you would want to call your app homepage and just show the sign-up page using Python's decorator function for the page route, but first we need an html template for the front end to render. The braces {} allow for python code inside the html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Email Sign Up</title>
</head>
<body>
    <h1>Sign Up</h1>
    <form action="{{ url_for('signup') }}" method="POST">
        <input type="email" name="email" placeholder="Enter your email" required>
        <input type="submit" value="Sign Up">
    </form>
    {% with messages = get_flashed_messages(with_categories=true) %}
      {% if messages %}
        <ul>
          {% for category, message in messages %}
            <li>{{ message }}</li>
          {% endfor %}
        </ul>
      {% endif %}
    {% endwith %}
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Now we enter the back-end code which are 2 pages/routes (index, signup), the email message, and confirmation. The signup page includes the GET/POST methods which allow the form to be submitted. The bleach object is Python extension that cleans the input from the user to ensure security and mitigating malicious scripts. Then the sserializer generates a one-time token to email the verification link.

@app.route('/')
def index():
    return '<h1>Index page</h1>'

@app.route('/signup', methods=['GET', 'POST'])
def signup():
    if request.method == 'POST':
        email = bleach.clean(request.form.get('email'))

        # Insert user into the database
        new_user = User(email=email)
        try:
            db.session.add(new_user)
            db.session.commit()
        except Exception as e:
            print(f"Error occurred saving to db: {e}")

        # Send confirmation email
        token = sserialzer.dumps(email, salt='email-confirm')
        msg = Message('Confirm your Email', sender='your_email@example.com', recipients=[email])
        link = url_for('confirm_email', token=token, _external=True)
        msg.body = f'Your link is {link}'
        try:
            mail.send(msg)
        except Exception as e:
            print(f"Error occurred sending message: {e}")
            flash("Error occurred sending message!")
            return render_template('signup.html')  
        flash('A confirmation email has been sent to your email address.', 'success')
        return redirect(url_for('index'))
    return render_template('signup.html')
Enter fullscreen mode Exit fullscreen mode

Before adding the html sign-up form, let's complete the backend by adding the route for validating the double opt-in feature. This route uses the s variable we created earlier which generates the time-sensitive, secret token. See the docs for details
The max age is the seconds before the link expires so in this case, the user has 20 minutes to confirm their email address.

@app.route('/confirm_email/<token>')
def confirm_email(token):
    try:
        email = sserialzer.loads(token, salt='email-confirm', max_age=1200)  # Token expires after 1 hour
    except SignatureExpired:
        return '<h1>The token is expired!</h1>'

    # Update field in database
    user = User.query.filter_by(email=email).first_or_404()
    user.validated = True
    db.session.commit()

    return '<h1>Email address confirmed!</h1>'

Enter fullscreen mode Exit fullscreen mode

Now for the ubiquitous main statement which tells Python to execute the script if the file is being executed directly (as opposed to an imported module):

if __name__ == '__main__':
    app.run()
Enter fullscreen mode Exit fullscreen mode

Your code should be working from this point when you run the flask command with debugging enabled. This will allow you to see any errors in the command line as well as the browser window:

flask --app app.py --debug run 
Enter fullscreen mode Exit fullscreen mode

Open your browser to the domain shown in the command-line (localhost) and the index page should render. Try to submit the form using a valid email address to receive the verification link. Once you get the link which should look like http://localhost:5000/confirm_email/InRvbUByYXRldG91cmd1aWRlcy5jb20i.ZteEvQ.7o1_L0uM9Wl8uii7KhJdiWAH, you can follow it and get the email address validated using the validator route shown here:

@app.route('/confirm_email/<token>')
def confirm_email(token):
    try:
        email = sserializer.loads(token, salt='email-confirm', max_age=1200)  # Token expires after 1 hour
    except SignatureExpired:
        return '<h1>Oops, the token expired!</h1>'

    # Update field in database
    user = Users.query.filter_by(email=email).first_or_404()
    user.validated = True
    try:
        db.session.commit()
    except Exception as e:
        print(f"Error occurred saving to db: {e}")

    return '<h1>Email address confirmed!</h1>'
Enter fullscreen mode Exit fullscreen mode

This route accepts the token string previously sent to you and checks it to see if it matches the corresponding database entry. If it does, it updates the validated field to True and you can rest knowing your sign-up form wasn't abandoned.

This is an important step all successful businesses use in their registration systems and now you have it to. But wait, what if we get bots attacks submitting random email addresses without validating them. Then you will have a dirty database filled with useless entries. Let's prevent that!

To prevent bot attacks or at least mitigate the advanced once you can build your own time-consuming solution, include a IP limiter which requires an in-memory database such as redis or you can use a 3rd party service such as Google's captcha or hCaptcha.

In our tutorial, we will add hcaptcha's free plan. At the time of this writing, google's captcha isn't free and hcaptcha is. To have this functioning for your site, you need to register with them to get API key from hcaptcha.

We need new requirements so install them:
pip install flask-hcaptcha requests

Requests is needed to send the email address to hcaptcha for validation. Get the key and integrate hcaptcha's javascript file with your html signup form. Add the file to the head of your html page and your site key to your form:

<head>
   ...
   <script src="https://hcaptcha.com/1/api.js" async defer></script>
</head>
<body>
   ...
   <form action="{{ url_for('signup') }}" method="POST">
       <input type="email" name="email" placeholder="Enter your email" required>
       <input type="submit" value="Sign Up">
       <div class="h-captcha" data-sitekey="b62gbcc-5cg2-41b2-cd5a-de95dd1eg61h" data-size="compact"></div>
   </form>
Enter fullscreen mode Exit fullscreen mode

The site key in this code is an example, you will need your own from the free plan. This site key validates your form and inspects the site visitor with a comprehensive list of spam bots known by hcaptcha.

Next modify your app.py file to include the hcaptcha's secret key (not the site key) in the app.config object and post the response to hcaptcha prior to saving to your own database.

  app.config['HCAPTCHA_SECRET_KEY'] = 'your-secret-hcaptcha-key'
  ...
@app.route("/signup", methods=['GET', 'POST'])
def signup():
    if request.method == 'POST':
        email = bleach.clean(request.form.get('email'))
        hcaptcha_response = request.form.get('h-captcha-response')
        # Verify hCaptcha response
        payload = {
            'secret': app.config['HCAPTCHA_SECRET_KEY'],
            'response': hcaptcha_response
        }
        try:
            response = requests.post('https://hcaptcha.com/siteverify', data=payload, timeout=10)
            result = response.json()
        except requests.exceptions.RequestException as e:
            print(f"Request failed: {e}")

        if not result.get('success'):
            flash('CAPTCHA validation failed, please try again.', 'danger')
         ...
        # Insert user into the database
        new_user = Users(email=email)
Enter fullscreen mode Exit fullscreen mode

Once this is done, you will have the hcaptcha icon showing in your sign-up form and it should be enabled preventing any spam. Now you have a more robust form for your new app.

In case you encountered any errors or you have a typo in the code you can check the completed code on my github.com

Follow and like for more!

Top comments (0)