In modern web applications, confirming user input with an email verification code is a common way to ensure that the person filling out your form is the real owner of the email address.
Odoo 16, with its flexible controller and template system, makes this process relatively straightforward — whether you’re working on a contact form, a newsletter subscription, or a registration page.
In this guide, we’ll create a public-facing frontend form that sends an email with a One-Time Password (OTP) to the user and verifies it before completing the form submission.
Why Add Email Confirmation to a Form?
While login security is important, frontend forms also face spam, fake entries, and bots. Adding an email confirmation code step:
- Prevents fake submissions.
- Ensures the email address is valid and accessible.
- Reduces spam without relying solely on CAPTCHA.
Step 1: Module Structure
We’ll create a new module named frontend_email_otp.
__manifest__.py
{
'name': 'Frontend Form Email OTP',
'version': '16.0.1.0.0',
'depends': ['website', 'mail'],
'data': [
'data/email_template.xml',
'views/form_templates.xml',
],
'installable': True,
}
Step 2: Email Template
This template will be used to send the OTP code.
data/email_template.xml
<odoo>
<record id="frontend_email_otp_template" model="mail.template">
<field name="name">Frontend Form OTP</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="subject">Your Verification Code</field>
<field name="body_html" type="html">
<p>Hello,</p>
<p>Your verification code is: <strong><t t-esc="ctx['data']['code']"/></strong></p>
<p>This code will expire in 10 minutes.</p>
</field>
<field name="email_to">{{ object.email }}</field>
<field name="auto_delete" eval="True"/>
</record>
</odoo>
Step 3: Controller Logic
We’ll handle two steps:
- User fills in the form with an email.
- System sends an OTP and shows a second form for code verification.
controllers/main.py
from odoo import http
from odoo.http import request
import random, string
from datetime import datetime, timedelta
class FrontendEmailOTP(http.Controller):
_otp_storage = {} # Temporary storage (for demo only, use model in production)
@http.route('/custom/form', type='http', auth='public', website=True)
def custom_form(self, **kwargs):
return request.render('frontend_email_otp.custom_form_template')
@http.route('/custom/form/send_otp', type='http', auth='public', methods=['POST'], website=True, csrf=False)
def send_otp(self, **post):
email = post.get('email')
otp = ''.join(random.choices(string.digits, k=6))
expiry = datetime.now() + timedelta(minutes=10)
self._otp_storage[email] = {'code': otp, 'expiry': expiry}
# Send email
partner = request.env['res.partner'].sudo().create({'name': email, 'email': email, 'verification_code': otp})
template = request.env.ref('frontend_email_otp.frontend_email_otp_template')
if template:
template.sudo().send_mail(partner.id, force_send=True)
return request.render('frontend_email_otp.otp_verification_template', {'email': email})
@http.route('/custom/form/verify_otp', type='http', auth='public', methods=['POST'], website=True, csrf=False)
def verify_otp(self, **post):
email = post.get('email')
otp_input = post.get('otp')
otp_data = self._otp_storage.get(email)
if otp_data and otp_data['code'] == otp_input and datetime.now() <= otp_data['expiry']:
return request.render('frontend_email_otp.success_template', {'email': email})
else:
return request.render('frontend_email_otp.otp_verification_template', {
'email': email,
'error': 'Invalid or expired code'
})
Step 4: Templates
views/form_templates.xml
<odoo>
<!-- Initial Form -->
<template id="custom_form_template" name="Custom Form">
<t t-call="website.layout">
<div class="container mt-5">
<h2>Custom Form</h2>
<form action="/custom/form/send_otp" method="post">
<div class="form-group mb-3">
<label>Email:</label>
<input type="email" name="email" class="form-control" required/>
</div>
<button class="btn btn-primary">Send Verification Code</button>
</form>
</div>
</t>
</template>
<!-- OTP Verification -->
<template id="otp_verification_template" name="OTP Verification">
<t t-call="website.layout">
<div class="container mt-5">
<h2>Enter Verification Code</h2>
<t t-if="error">
<div class="alert alert-danger"><t t-esc="error"/></div>
</t>
<form action="/custom/form/verify_otp" method="post">
<input type="hidden" name="email" t-att-value="email"/>
<div class="form-group mb-3">
<input type="text" name="otp" placeholder="Enter code" class="form-control" required/>
</div>
<button class="btn btn-success">Verify</button>
</form>
</div>
</t>
</template>
<!-- Success Page -->
<template id="success_template" name="Success Page">
<t t-call="website.layout">
<div class="container mt-5">
<h2>Verification Successful!</h2>
<p>Email <b><t t-esc="email"/></b> has been verified.</p>
</div>
</t>
</template>
</odoo>
How It Works
- User opens the form — /custom/form displays a simple email field.

- Email submitted — /custom/form/send_otp:
- Generates a random 6-digit code.
- Stores it temporarily (in _otp_storage here, but a model is better in production).
- Sends the code via Odoo’s mail system.
- User enters the OTP — /custom/form/verify_otp checks:

- If still within 10-minute expiry.
Success page — if valid, the user is confirmed.
Security & Improvements
For production use:
- Replace _otp_storage with a proper model for persistence.
- Add rate limiting to prevent abuse.
- Implement a Resend OTP button.
- Store a hash of the OTP instead of the raw code for better security.
- Use server actions or background jobs for high email volumes.
Conclusion
By leveraging Odoo 16’s website module and mail templates, we’ve created a two-step verification process for any public form. This approach is highly flexible — you can adapt it to:
- Newsletter signups.
- Event registrations.
- User onboarding forms.
With just a small amount of custom code, you can dramatically improve the integrity of your form data and reduce spam submissions.
To read more about How to Send Email From Code in Odoo 17, refer to our blog How to Send Email From Code in Odoo 17