Introduction
Welcome to vBook, the vSMTP reference guide. It serves as vSMTP’s primary documentation and tutorial resource.
⚠️ This manual documents vSMTP v2.x
vSMTP
vSMTP is a secured, faster and greener Mail Transfer Agent (MTA).
It is a more efficient, more secure and more ergonomic product than the competition. It is up to 10 times faster than its competitors and significantly reduces the need for IT resources.
It is written in Rust. Compared to solutions developed in other programming languages (usually C or C++), Rust provides a memory safety model, guaranteeing no segmentation fault or race conditions. It is the latest generation language best suited to system programming, network services, and embedded systems.
vSMTP development goes through a full cycle of testing. Unit and integration tests allow more comprehensive coverage of safety tests, one covering faults of the other, and vice versa (static view and runtime view).
License
The core of vSMTP is released under the GPLv3 license. It is provided as usual without any warranty. Please refer to the vSMTP LICENSE for further information.
This book is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. For further details please refer to the LICENSE file.
Contributing
The vSMTP and vBook projects need you! Please see the CONTRIBUTING.md file for more information.
Features
vSMTP is a Mail Transfer Agent (MTA) and a Mail Submission Agent (MSA). It is not intended to be a Mail User Agent (MUA) nor a Mail Delivery Agent (MDA).
vSMTP can store messages on disk using Mbox or Maildir formats. To retrieve emails from a remote client it is necessary to install a MDA server that can handle POP and/or IMAP protocols.
Roadmap
Take a look at the milestones for vSMTP to get an overview of what will be added in future releases.
Follow the development, plannings and announcements for incoming features on the official discord server.
Available features
Networking
The core of vSMTP uses high performance asynchronous connections to supports heavy workloads.
- It can listen and serve on multiple addresses.
- Supports IPv4 and IPv6 formats.
- Handle one or multiple emails per connections.
- Is compliant with Internet Message Format and Simple Mail Transfer Protocol RFCs.
- TLS 1.3 support.
- Exposes complete DNS configurations (thanks to Benjamin Fry’s Trust-DNS crate).
Filtering
vSMTP exposes a complete filtering system. In addition to the standard analysis of the SMTP envelope, vSMTP provides on the fly interactions with headers of the email. Users can generate complex routing and filtering scenarios through a simple and intuitive scripting language.
- vSMTP scripting language allowing administrators to define complex filtering rules.
- Before and after queueing filtering.
- Interaction at each SMTP state (CONNECT, HELO/EHLO, MAIL, RCPT, DATA).
Interacting with the body of the email (MIME) is planned for future releases.
Extensions
vSMTP is a modular and highly customizable product supporting plugins developed in the Rust programing language.
Some plugins are already available.
- CSV file databases.
- MySQL databases.
- System commands execution.
- Third-party software integration via delegation using the SMTP protocol.
Other plugins like LDAP, NoSQL databases, in-memory caches, Compliancy with Postfix SMTP access policy delegation and Unix/IP socket calls are being planned for future releases.
Delivery
vSMTP is able to deliver emails remotely or locally.
- SMTP remote delivery - using Lettre.
- SMTP forwarding.
- Mbox and Maildir formats for local delivery.
Email authentication mechanisms
vSMTP secures the email traffic by implementing the following mechanisms.
- Message submission RFCs.
- SPF support.
- DKIM signer and verifier.
- DMARC verifier. (reporting is not natively supported)
- Null MX RFC.
DANE, ARC and BIMI RFCs are planned for future releases.
Installation
vSMTP is a stand-alone application with few kernel interactions, it may run on any system with slight modifications. Many installation methods are available:
- by downloading a package suitable for your distribution (Linux/BSD).
- by using Rust’s Cargo tool.
- by deploying a Docker container.
If your system is not supported or if these installation method are not suited for your usage, you can contact us by opening an issue on github or by joining the official discord server.
It is possible to download and build the project from source, see the dedicated chapter.
Requirements
Physical requirements
The current release has been tested and deployed on x86/64 and ARMv7 architectures.
Operating systems
vSMTP is tested and deployed on the latests Ubuntu Server LTS version, but vSMTP should be compatible with any recent Linux distributions.
FreeBSD 13.x is supported using the latest port branch which includes Rust 1.60.
Microsoft Windows Server is not supported.
Linux
Debian binary packages .deb
can be downloaded from the release section of the vSMTP github.
sudo apt install vsmtp
Fedora and RedHat packages are planned for future releases. (check the issue tracker)
Rust Cargo
vSMTP is published on https://crates.io. It can be installed using cargo.
cargo install vsmtp
Docker
A docker image for vSMTP is available to download.
docker pull viridit/vsmtp
Concepts
Configuration
vSMTP is a server that does not use a traditional configuration language to configure itself: it uses .vsl
files (vSMTP Scripting Language) that are scripts based on the Rhai programming language with additional functions and syntax on top of it.
Filtering
SMTP filtering is performed by a rule engine, which uses .vsl
scripts to filter any incoming connection, email, user, etc.
Its syntax is similar to a configuration format, but with programmatic capabilities. It is based on two filtering primitives: rules
and actions
.
Please refer to the dedicated chapter guide on vSL for further details.
Service configuration
The vsmtp.service
file contains all the mandatory information to start the vSMTP service on Linux server using systemd as the system and service manager.
Configuring vSMTP
vSMTP, by default, stores it’s configuration in the /etc/vsmtp
directory. Lets look at how files are organized.
Overview
A typical vSMTP configuration looks like the following.
/etc/vsmtp
┣ vsmtp.vsl
┣ filter.vsl
┣ conf.d/
┃ ┣ config.vsl
┃ ┣ interfaces.vsl
┃ ┗ app.vsl
┣ domain-available/
┃ ┗ example.com/
┃ ┣ config.vsl
┃ ┣ incoming.vsl
┃ ┣ outgoing.vsl
┃ ┗ internal.vsl
┣ domain-enabled/
┃ ┗ example.com -> /etc/vsmtp/domain-available/example.com
┣ objects/
┃ ┗ net.vsl
┣ services/
┃ ┗ mysql-service.vsl
┗ plugins/
┗ vsmtp-plugin-mysql.so -> /usr/lib/vsmtp/libvsmtp-plugin-mysql-1.0.0.so
typical vSMTP configuration placed in the `/etc/vsmtp` directory
Lets break it down step by step, by building a configuration from scratch.
Root configuration
By default, the following files are created in the /etc/vsmtp
directory once vSMTP is installed.
/etc/vsmtp
┣ vsmtp.vsl
┣ conf.d/
┃ ┗ config.vsl
┣ domain-available/
┣ domain-enabled/
┣ objects/
┣ services/
┗ plugins/
Default file structure in `/etc/vsmtp`
vsmtp.vsl
is the mandatory entrypoint for vSMTP. It’s content SHOULD NOT be modified, since it can change between versions.
conf.d/config.vsl
, on the other hand, is a mandatory configuration file that contains the root configuration for vSMTP.
This file must at least contain the following statement:
fn on_config(config) {
return config;
}
An empty root configuration file in `/etc/vsmtp/conf.d/config.vsl`
vSMTP calls the on_config
function once starting up. Modify the config
object to change the configuration. The config
object MUST be returned at the end of the function.
The Configuration reference lists all fields that can be changed in the
config
object.
For example:
fn on_config(config) {
// Change the name of the server.
config.server.name = "example.com";
// Specify addresses that the server will listen to.
config.server.interfaces = #{
addr: ["192.168.1.254:25"],
addr_submission: ["192.168.1.254:587"],
addr_submissions: ["192.168.1.254:465"],
};
// Specify a user and group that will run vsmtp.
// If those fields are not set, vsmtp will try to use,
// by default, the `vsmtp` user and `vsmtp` group.
config.server.system = #{
user: "debian-1",
group: "mail",
};
// Change filter rules locations.
config.app.vsl.filter_path = "/etc/vsmtp/filter.vsl";
config.app.vsl.domain_dir = "/etc/vsmtp/domain-enabled";
return config;
}
Configuring vSMTP by changing the `config` object
It is recommended that you use absolute paths in your configuration files. Relative paths will be relative to the execution path of vSMTP.
Splitting configuration in modules
Rhai exposes a module API, making it possible to split the configuration by theme.
For example, it is possible to split the above configuration this way:
/etc/vsmtp
┣ vsmtp.vsl
┣ conf.d/
┃ ┣ config.vsl
+┃ ┣ interfaces.vsl
+┃ ┗ app.vsl
┣ domain-available/
┣ domain-enabled/
┣ objects/
┣ services/
┗ plugins/
Adding modules to split the configuration
Let’s define the addresses that the server will listen to.
export const interfaces = #{
addr: ["192.168.1.254:25"],
addr_submission: ["192.168.1.254:587"],
addr_submissions: ["192.168.1.254:465"],
};
`/etc/vsmtp/conf.d/interfaces.vsl`
Let’s also write our filtering scripts locations.
// Filter by domain.
export const domain_dir = "/etc/vsmtp/domain-enabled";
// Global filter.
export const filter_path = "/etc/vsmtp/filter.vsl";
`/etc/vsmtp/conf.d/app.vsl`
Those modules can then be imported in config.vsl
, resulting in a more cleaner configuration file.
import "conf.d/interfaces" as i;
import "conf.d/app" as app;
fn on_config(config) {
config.server.name = "example.com";
config.server.interfaces = i::interfaces;
config.server.system = #{
user: "debian-1",
group: "mail",
};
config.app.vsl.domain_dir = app::domain_dir;
config.app.vsl.filter_path = app::filter_path;
return config;
}
`/etc/vsmtp/conf.d/config.vsl`
Filtering
It is possible to filter emails using .vsl
files for incoming emails and specific domains. vSL
is the vSMTP scripting language
, a superset of Rhai which is used to filter emails, modify their contents, send them to a specific target, etc.
The vSL chapter explains in detail what is possible to do with
.vsl
scripts.
Root Filter
The root filter.vsl
script is used to filter incoming transaction at the connect
, helo
and authenticate
stages of an SMTP transaction. (Check out the Transaction Context chapter for more details)
/etc/vsmtp
┣ vsmtp.vsl
+┣ filter.vsl
┣ conf.d/
┃ ┣ config.vsl
┃ ┣ interfaces.vsl
┃ ┗ app.vsl
┣ domain-available/
┣ domain-enabled/
┣ objects/
┣ services/
┗ plugins/
Adding the root filter script
{
connect: [
rule "deny all" || state::deny(),
]
}
Content of the `filter.vsl` file. Denies every transaction by default.
fn on_config(config) {
config.app.vsl.filter_path = "/etc/vsmtp/filter.vsl";
return config;
}
Specifying the path to the filter in the configuration
If this script is not present, vSMTP will deny all incoming transactions by default.
Domains
It is also possible to filter emails per domain.
┣ vsmtp.vsl
┣ filter.vsl
┣ conf.d/
┃ ┣ config.vsl
┃ ┣ interfaces.vsl
┃ ┗ app.vsl
┣ domain-available/
+┃ ┗ example.com/
+┃ ┣ incoming.vsl
+┃ ┣ outgoing.vsl
+┃ ┗ internal.vsl
┣ domain-enabled/
┣ objects/
┣ services/
┗ plugins/
Adding filtering scripts for the `example.com` domain under the `domain-available` directory
The configuration in conf.d/config.vsl
must be updated like so:
fn on_config(config) {
config.app.vsl.domain_dir = "/etc/vsmtp/domain-enabled";
return config;
}
Specifying filtering rules directory for domains in the configuration
In the above configuration, vSMTP has been setup to pickup filtering rules in the domain-enabled
directory, not domain-available
. Let’s use symbolic links to make our scripts available for vSMTP inside the domain-available/example.com
directory.
/etc/vsmtp
┣ vsmtp.vsl
┣ filter.vsl
┣ conf.d/
┃ ┣ config.vsl
┃ ┣ interfaces.vsl
┃ ┗ app.vsl
┣ domain-available/
┃ ┗ example.com/
┃ ┣ incoming.vsl
┃ ┣ outgoing.vsl
┃ ┗ internal.vsl
┣ domain-enabled/
+┃ ┗ example.com -> /etc/vsmtp/domain-available/example.com
┣ objects/
┣ services/
┗ plugins/
Using symlinks to enable filtering for the `example.com` domain
This directory structure is standard. The goal here is to disable / enable domain specific filtering by simply removing / adding symbolic links while keeping the configuration intact.
The server will pickup the scripts defined in the domain-enabled/example.com
directory and run them following the conditions defined in the Transaction Context chapter.
Domain specific configuration
It is possible to add a specific configuration for each domain.
/etc/vsmtp
┣ vsmtp.vsl
┣ filter.vsl
┣ conf.d/
┃ ┣ config.vsl
┃ ┣ interfaces.vsl
┃ ┗ app.vsl
┣ domain-available/
┃ ┗ example.com
+┃ ┣ config.vsl
┃ ┣ incoming.vsl
┃ ┣ outgoing.vsl
┃ ┗ internal.vsl
┣ domain-enabled/
┃ ┗ example.com -> /etc/vsmtp/domain-available/example.com
┣ objects/
┣ services/
┗ plugins/
Adding specific configuration for a domain
Although it is not mandatory to add a config.vsl
script under a domain directory, if it is created, it must contain, at least, the following statement:
fn on_domain_config(config) {
config
}
An empty domain specific configuration
Like the root config.vsl
file, this script contains a function used to configure the domain, in this case called on_domain_config
. It is possible to configure TLS, DKIM and DNS for each domain.
fn on_domain_config(config) {
config.tls = #{
protocol_version: ["TLSv1.2", "TLSv1.3"],
certificate: "/etc/vsmtp/certs/fullchain.pem",
private_key: "/etc/vsmtp/certs/privkey.pem",
};
config.dkim = #{
private_key: "/etc/vsmtp/certs/example.dkim.key",
};
config.server.dns.type = "system";
config
}
Changing TLS, DKIM and DNS parameters for a specific domain
If this script is not present in a domain directory, configuration from the root
config.vsl
script is used instead.
Objects
Objects are variables that can be re-used accros filtering scripts. They are placed in the objects/
directory.
/etc/vsmtp
┣ vsmtp.vsl
┣ filter.vsl
┣ conf.d/
┃ ┣ config.vsl
┃ ┣ interfaces.vsl
┃ ┗ app.vsl
┣ domain-available/
┃ ┗ example.com
┃ ┣ config.vsl
┃ ┣ incoming.vsl
┃ ┣ outgoing.vsl
┃ ┗ internal.vsl
┣ domain-enabled/
┃ ┗ example.com -> /etc/vsmtp/domain-available/example.com
+┣ objects/
+┃ ┗ net.vsl
┣ services/
┗ plugins/
Adding objects
Objects are declared using simple functions in a .vsl
file.
export const localhost = ip4("127.0.0.1");
export const my_address = address("john.doe@example.com");
// ...
Declaring an ip and email address using objects. /etc/vsmtp/objects/net.vsl
Then, they can be imported and used in rules.
import "objects/net" as net;
#{
connect: [
rule "trust localhost" || {
if ctx::client_ip() == net::localhost {
state::accept()
} else {
state::next()
}
}
]
}
import and use objects in rules to filter client ips
For more informations on objects and their usage, check out the Objects reference
Plugins
Plugins are dynamic libraries (.so
) that can be imported by .vsl
scripts.
They are bridges between third-party software (MySQL databases, an antivirus, redis databases, etc.) that uses vSL
to interface with vSMTP and leverage filtering.
Plugins are placed in the /usr/lib/vsmtp/
directory, and referenced in the configuration using symbolic links.
/etc/vsmtp
┣ vsmtp.vsl
┣ filter.vsl
┣ conf.d/
┃ ┣ config.vsl
┃ ┣ interfaces.vsl
┃ ┗ app.vsl
┣ domain-available/
┃ ┗ example.com
┃ ┣ config.vsl
┃ ┣ incoming.vsl
┃ ┣ outgoing.vsl
┃ ┗ internal.vsl
┣ domain-enabled/
┃ ┗ example.com -> /etc/vsmtp/domain-available/example.com
┣ objects/
┃ ┗ net.vsl
┣ services/
┗ plugins/
+ ┗ vsmtp-plugin-mysql.so -> /usr/lib/vsmtp/libvsmtp-plugin-mysql-1.0.0.so
Adding plugins and associated services
They are then imported in “services”, .vsl
scripts that configure and use plugins features.
Check out the Plugin chapter for more information.
Services
Services are a standard way to configure and use plugins enabled in the plugins
directory.
/etc/vsmtp
┣ vsmtp.vsl
┣ filter.vsl
┣ conf.d/
┃ ┣ config.vsl
┃ ┣ interfaces.vsl
┃ ┗ app.vsl
┣ domain-available/
┃ ┗ example.com
┃ ┣ config.vsl
┃ ┣ incoming.vsl
┃ ┣ outgoing.vsl
┃ ┗ internal.vsl
┣ domain-enabled/
┃ ┗ example.com -> /etc/vsmtp/domain-available/example.com
┣ objects/
┃ ┗ net.vsl
┣ services/
+┃ ┗ mysql-service.vsl
┗ plugins/
┗ vsmtp-plugin-mysql.so -> /usr/lib/vsmtp/libvsmtp-plugin-mysql-1.0.0.so
Declared as .vsl
scripts, they expose variables or functions that uses plugins interfaces.
For example, a MySQL plugin is available to download, we can use it’s interface inside of a service script called mysql-service.vsl
.
import "plugins/vsmtp-plugin-mysql" as mysql;
// Let's establish a connexion to a MySQL database.
export const database = mysql::connect(...);
/etc/vsmtp/services/mysql-service.vsl
Check out the MySQL plugin tutorial for more details.
Running vSMTP
Now that vSMTP is configured, here are a few options to launch an instance of the server.
Daemon
If you installed vSMTP via the debian packages, a vsmtp.service
file has been registered into your system.
Using systemctl
, you can start your vsmtp instance with:
systemctl start vsmtp
Source code
If you want to build vSMTP from it’s source code, see the dedicated chapter to download and build the source code.
Then you can run the following command:
./target/release/vsmtp -c /etc/vsmtp/vsmtp.vsl --no-daemon --stdout
This commands run vSMTP in the foreground without creating the daemon and printing all logs to stdout
. Remove or add vSMTP options as you see fit. (you can type ./target/release/vsmtp --help
to get a list of available options)
Testing vSMTP
To test if your instance works properly, emulate a transaction with your favorite software.
Here is an example with curl with a vSMTP instance listening on localhost and port 10025.
curl -vv -k --url 'smtp://localhost:10025' \
--mail-from 'john.doe@example.com' --mail-rcpt 'jenny.doe@example.com' \
# Provide an email to curl.
--upload-file /tmp/mail.txt
Filtering
By default, vSMTP denies all SMTP transactions. The following chapters explains how to accept connections and filter messages using sets of rules.
vSL - the vSMTP Scripting Language
vSL is a lightweight scripting language dedicated to email filtering. It is based on the fully featured Rhai scripting language. To make the most out of vSL, it is recommended that you read the documentation of Rhai.
vSL, on top of Rhai, adds:
- Functions used to query vSMTP on the current transaction’s data.
- Constructors used to create objects like regex, fqdn and addresses for filtering.
- Services, objects that helps vSMTP to interact with third party software.
- A special
rule
syntax to filter emails.
Syntax highlighting is available for Microsoft VSCode IDE, using the Rhai extension.
Rules and Actions
Rules and actions are the entry point to filter emails.
Syntax
.vsl
files in the rules directory accepts a special syntax: the rule
and action
keywords.
Rule
A rule
is used to change the transaction state. It can accept and deny a transaction or simply proceed to the next rule using rule state functions. A rule
is the main primitive for filtering.
<rule> ::= "rule" <rule-name> "||" <expr>
<rule-name> ::= <string>
<expr> ::= <rhai-expr> ; any valid Rhai expression. Must return a "state".
A BNF representation of a rule
// `state::deny()` is a function that return the `Deny` state.
// Thus, this rule denies any incoming transaction.
rule "deny all transactions" || state::deny(),
// Rhai expressions can be declared using the above inline syntax,
// or using code blocks, like bellow.
rule "check client ip" || {
if ctx::client_ip() == "192.168.1.254" {
return state::faccept();
} else {
return state::next();
}
},
Declaring rules
As shown in the above example, a rule MUST return a “state” (accept, deny, next, etc). Once the rule is executed and a state returned, vSMTP uses it to change the transaction state.
Rule engine state and effects are listed in the rule state reference.
Action
An action
is used to execute arbitrary code (logging, saving an email on disk, etc) without changing the transaction state.
<action> ::= "action" <rule-name> "||" <expr>
<action-name> ::= <string>
<expr> ::= <rhai-expr> ; any valid Rhai expression.
BNF representation of an action
// Write the email as json to a "backup" directory.
action "dump to disk" || fs::dump("backup"),
action "log incoming transaction" || {
// Logging to /var/log/vsmtp.
log("info", `new transaction: ${ctx::helo()} from ${ctx::client_ip()}`);
}
Declaring actions
Stages
vSMTP interacts with the SMTP transaction at all stages defined in the SMTP protocol. At each step, vSL updates a context containing transaction and mail data that can be queried in rules and actions.
vSMTP stages
Stage | SMTP state | Context available |
---|---|---|
connect | Before HELO/EHLO command | Connection related information. |
authenticate | After AUTH command | Connection related information. |
helo | After HELO/EHLO command | HELO string. |
After MAIL FROM command | Sender address. | |
rcpt | After each RCPT TO command | List of recipients. |
preq | Before queuing1 | The entire mail. |
postq | After queuing2 | The entire mail. |
delivery | Before delivering | The entire mail. |
Available stages in order of evaluation
Preq stage triggers after the end of receiving data from the client, just before the server answers back with a 250 code.
Postq stage triggers after the preq stage, when the connection closes and the SMTP code is sent to the client.
Syntax
Stages are declared in .vsl
files using the following syntax:
#{
connect: [
// rules, actions, delegations ...
],
mail: [
// rules, actions, delegations ...
],
postq: [
// rules, actions, delegations ...
],
// other stages ...
}
Declaring stages
Stages do not need to be declared in the previous given order, but it is a good practice as it makes rules easier to read.
Rules
Rules are combined with stages in .vsl
files.
#{
connect: [
// This rule is executed once a new client connects to the server.
rule "check client ip" || {
if ctx::client_ip() == "192.168.1.254" {
state::faccept()
} else {
state::next()
}
}
],
mail: [
// This action is executed once the server receive the "MAIL FROM" command.
action "log incoming transaction" || {
// Logging to /var/log/vsmtp.
log("info", `new transaction from ${ctx::mail_from()} at ${ctx::client_ip()}`);
}
],
}
Combining stages and rules
Using stages, rules can be run at specific SMTP state, enabling precise email filtering.
Stages that are not defined are omitted, but must appear only once if used.
#{ connect: [], // invalid, 'connect' must only appear once! connect: [], }
Trailing rules
For security purpose, a trailing rule should be added at the end of a stage.
#{
connect: [
// This rule is executed once a new client connects to the server.
rule "check client ip" || {
if ctx::client_ip() == "192.168.1.254" {
state::accept()
} else {
state::next()
}
}
// If the client ip is not known, the connection is denied.
rule "trailing" || state::deny(),
],
}
Adding a trailing rule
In a stage, rules are executed from top to bottom. In the above example, if the client ip does not equal the 192.168.1.254 ip, the rule engine jumps to the “trailing” rule, denying the transaction instantly.
Like firewall rules, the best practice is to deny “everything” and only accept authorized and known clients (like the example above).
Before queueing vs. after queueing
TL;DR
connect
,authenticate
,helo
,rcpt
andpreq
stages rules are run before an email is enqueued.postq
anddelivery
stages rules are run after an email is enqueued and the connection with the client is closed.
vSMTP can process emails before the incoming SMTP mail transfer completes and thus rejects inappropriate mails by sending an SMTP error code and closing the connection. This is possible by creating rules under the connect
, authenticate
, helo
, mail
, rcpt
and preq
stages.
The advantages of an early detection of unwanted mails are:
- The responsibility is on the remote SMTP client side.
- It consumes less CPU and disk resources.
- The system is more reactive.
However, as the SMTP transfer must to be completed within a deadline, heavy workload may cause a system to fail to respond in time.
Therefore, it is possible to handle an email “offline” when specifying rules under the postq
and delivery
stages.
Rules under those stages are run after the client received a 250 Ok
code, after vSMTP received the complete email.
At this point, the rule engine is not able to send codes to the client even if the client sends multiple emails (each email is treated as a single entity by the rule engine), thus the rest of the rule engine behavior is considered “offline”.
Context variables
As described above, depending on the stage, vSL exposes data that can be queried in rules. Check out the Mail Context reference for more details.
Transaction context
As described in the Configuring vSMTP
chapter, domains handled by the configuration of vSMTP have three filtering entry-points: incoming
, outgoing
and internal
.
/etc/vsmtp
┣ vsmtp.vsl
┣ filter.vsl
┣ conf.d/
┃ ┗ config.vsl
┣ domain-available/
+ ┃ ┗ example.com
+ ┃ ┣ incoming.vsl
+ ┃ ┣ outgoing.vsl
+ ┃ ┗ internal.vsl
+ ┗ domain-enabled/
+ ┗ example.com -> ...
Adding filtering scripts for the `example.com` domain
Here is a diagram of which entry-points are executed following the transaction pipeline.
Rules execution order following the transaction context
Root Filter ⬜
The root filter.vsl
script is used to filter incoming transaction at the connect
, helo
and authenticate
stages. The rules contained in those stages are applied to ALL incoming transactions.
This script also run rules under the mail
stage when an incoming sender domain is not handled by the configuration.
Finally, if the sender’s domain is not handled by the configuration, and that the domain of recipients is not as well, rules defined in the rcpt
stage contained in the root filter.vsl
are also called. By default, the transaction should be denied at this stage since it probably is a relay tentative.
#{
rcpt: [
rule "deny relay" || state::deny(),
]
}
anti-relaying using rules in `filter.vsl`
If this file is not present in the rule directory, it will deny all transactions by default.
Incoming 🟨
The incoming.vsl
script is run if the sender domain is not handled by the configuration, but domains from recipients are.
MAIL FROM: <john.doe@unknown.com> # We don't have a `unknown.com` folder, this is an incoming message.
RCPT TO: <foo@example.com> # `example.com` is handled, we run `example.com/incoming.vsl`.
RCPT TO: <bar@example.com> # Same as above.
Transaction example
If any recipient domain in this context is not handled by the configuration, then the root filter.vsl
script is called.
MAIL FROM: <john.doe@unknown.com> # We don't have a `unknown.com` folder, this is an incoming message.
RCPT TO: <foo@example.com> # `example.com` is handled, we run `example.com/incoming.vsl`.
RCPT TO: <bar@anonymous.com> # We don't have a `unknown.com` folder, the root `filter.vsl` is used.
Transaction example
A client should not mix up multiple recipient domains when sending a message to the server. This is why the root filter.vsl
script is called when this happens. Once again, if incoming.vsl
is not defined, the transaction will be denied by default.
Outgoing 🟪
The outgoing.vsl
script is run if the sender domain is handled by the configuration, but recipients are not.
MAIL FROM: <john.doe@example.com> # `example.com` exists, this is an outgoing message.
RCPT TO: <foo@anonymous.com> # We don't have a `anonymous.com` folder, `outgoing.vsl` is used.
RCPT TO: <bar@anonymous.com> # Same as above.
Transaction example
Internal 🟩
The internal.vsl
script is run if the sender and recipients domains are handled by the configuration and the exact same.
MAIL FROM: <john.doe@example.com> # `example.com` exists, we don't know yet about the recipient, so this is an outgoing message for now.
RCPT TO: <foo@example.com> # The domain is the same as the sender, `internal.vsl` is used, it becomes an internal message now.
RCPT TO: <bar@example.com> # Same as above.
Transaction example
Sub domains
Root domain rules are used when a sub domain does not have any rules configured.
For example, if the configuration has rules setup for the example.com
domain, but does not for the dev.example.com
, if any transaction has the dev.example.com
sub-domain, rules for example.com
will be used.
Objects
Objects are used to create reusable configuration variables declared using Rhai functions.
Type | Description | Syntax | Comments |
---|---|---|---|
ip4 | IPv4 address | x.y.z.t | Decimal values. |
ip6 | IPv6 address | a:b:c:d:e:f:g:h | Hex values. |
rg4 | IPv4 network | x.y.z.t/rg | Decimal values. |
rg6 | IPv6 prefix | a:b:c:d:e:f:g:h/rg | Hex values. |
address | Email address | identifier@fqdn | String. |
identifier | Local part of an address | user | String. |
fqdn | Fully qualified domain name | my.domain.com | String. |
regex | Regular expression | PERL regular expression. | |
file | A file of objects | Unix file | See file section. |
code | a custom smtp code | See code section. |
All available objects types
Creating objects
Objects can be created via associated functions:
const my_ipv4 = ip4("127.0.0.1");
const my_address = address("john.doe@example.com");
// ...
Declaring objects in scripts
See the Object reference to get an extensive list of objects constructors.
Modules
It is recommended to declare objects inside .vsl
files and importing them via the import
Rhai directive in rule files.
See the Rhai modules documentation for more details.
// Object are accessible trough `import` when declared with the `export` keyword.
export const localhost = ip4("127.0.0.1");
An object file, `objects/network.vsl`
import "objects/network" as network;
#{
connect: [
rule "force accept localhost" || {
if ctx::client_ip() == network::localhost {
state::faccept();
} else {
state::next()
}
}
]
}
A rule in `filter.vsl`
Objects should be stored inside the objects
directory of /etc/vsmtp
if they are used into multiple rules sets.
/etc/vsmtp
┣ vsmtp.vsl
┣ filter.vsl
┣ conf.d/
┃ ┗ config.vsl
┣ domain-available/
┃ ┗ example.com/
┃ ┗ ...
┣ domain-enabled/
┃ ┗ example.com -> ...
┗ objects/
+ ┗ network.vsl
Placing objects files in `/etc/vsmtp/objects/`
However, if objects are used in only a specific rule set, they should be stored directly in a separate file among the rule set.
/etc/vsmtp
┣ vsmtp.vsl
┣ filter.vsl
┣ conf.d/
┃ ┗ config.vsl
┣ domain-available/
┃ ┗ example.com/
+ ┃ ┣ network-objects.vsl
┃ ┣ incoming.vsl
┃ ┣ outgoing.vsl
┃ ┗ internal.vsl
┣ domain-enabled/
┃ ┗ example.com -> ...
┗ objects/
- ┗ network.vsl
Placing objects relative to a domain, renaming it to network-objects.vsl to make it clear that it is an object file
Grouping objects
Objects can be grouped using Rhai Arrays.
const authorized_users = [
address("admin@example.com"),
address("foo@example.com"),
address("bar@example.com"),
];
An array of email address
When used with check operators (
==
,!=
,in
etc …), the whole array will be tested. The test stops when one element of the group matches, or nothing matches.
Different types of objects can be grouped together.
Pre-defined objects
vSL already exposes some objects. Check out the Variable reference to get more details.
Delegation
Alongside the rule
and actions
keyword, vSL exposes another keyword for filtering: delegate
.
<delegation> ::= "delegate" <delegation-service> <delegation-name> "||" <expr>
<delegation-service> ::= <smtp-service> ; a valid smtp service.
<delegation-name> ::= <string>
<expr> ::= <rhai-expr> ; any valid Rhai expression. Must return a "status".
BNF representation of a delegate directive
The delegate
directive uses a smtp
plugin to delegate an email to a third party software:
export const third_party = smtp::connect(#{
// Send the email to "127.0.0.1:10026" using the SMTP protocol.
delegator: #{
address: "127.0.0.1:10026",
timeout: "60s",
},
// Get back the results on "127.0.0.1:10024".
receiver: "127.0.0.1:10024",
});
Declaring a `smtp` service in `/etc/vsmtp/services/smtp.vsl`
import "services/smtp" as srv;
#{
postq: [
delegate srv::third_party "delegate to third party" || {
log("info", "delegation results are in!");
// ...
return state::next();
}
]
}
Declaring a delegation rule with the previously declared smtp service
The delegate
directives first send the email to the given address, and wait for the results on the receiver
address.
The body of a delegate
directive is executed once the email as been received back from the third party software.
A delegation directive MUST return a status, exactly like a rule
.
The delegate
keyword can only be used from the postq
stage and onwards.
Rule engine status and effects are listed in the API, in the rule state reference.
Time
Some objects and functions require argument of time. Here is a list of available time scales formats.
Time scale | Expression |
---|---|
nanoseconds | “nanos” / “nsec” / “ns” |
microseconds | “usec” / “us” |
milliseconds | “millis” / “msec” / “ms” |
seconds | “seconds” / “second” / “secs” / “sec” / “s” |
minutes | “minutes” / “minute” / “min” / “mins” / “m” |
hours | “hours” / “hour” / “hr” / “hrs” / “h” |
days | “days” / “day” / “d” |
weeks | “weeks” / “week” / “w” |
months | “months” / “month” / “M” |
years | “years” / “year” / “y” |
Time scales and their expressions
For reference, the humantime crate is used to parse time.
const my_command = cmd::build(#{
// for the `cmd` service, a timeout for the command can be specified.
// Use the different time scales above to specify the time.
timeout: "10s",
// timeout: "200usec",
// timeout: "1minute",
// timeout: "10000nsec",
// ...
});
Declaring a service that requires a time value
Settings
The following chapters explain how to configure vSMTP.
Logging
Multiple log backends are available for vSMTP.
vSMTP default logs
Default vSMTP log system. By default, it writes logs in the /var/log/vsmtp/vsmtp.log
file.
fn on_config(config) {
// Change the location of the logs.
config.server.logs.filename = "./tmp/system/vsmtp.log";
// Set global logging level to "info".
//
// The configuration for the level here is an array because vSMTP
// will support log levels for specific modules of the server in future releases.
config.server.logs.level = [ "info" ];
config
}
Configuring logs in the root configuration file
Application logs
Application logs are written using the log(level, message)
function in filtering scripts.
The default output location is of application logs is the /var/log/vsmtp/app.log
file. It can be changed in the root configuration.
fn on_config(config) {
config.app.logs.filename = "./tmp/system/app.log";
config
}
Change the location of the application logs.
System logs
vSMTP also supports system logs through the following services.
Journald
vSMTP will send server logs to the journald daemon.
fn on_config(config) {
config.server.logs.system = #{
level: "info",
backend: "journald",
};
config
}
Configure journald for vSMTP
Syslogd
vSMTP will send logs to the syslog daemon using the mail
facility.
fn on_config(config) {
config.server.logs.system = #{
level: "info",
backend: "syslogd",
// Format used by the logger.
// See https://www.rfc-editor.org/rfc/rfc3164 and https://www.rfc-editor.org/rfc/rfc5424
// for more details.
format: "3164",
// Writing syslogs on disk using a unix socket.
socket: #{ type: "unix", path: "/dev/log" },
// It is possible to use:
// `socket: #{ type: "tcp", server: "127.0.0.1:601" }`
//
// or
// `socket: #{ type: "udp", server: "127.0.0.1:514", local: "127.0.0.1:0" }`
//
// note: address can be ipv4 / ipv6
};
config
}
Configure syslogs for vSMTP
Levels
level | note |
---|---|
error | vSMTP encountered an issue, you should try to fix it or open an issue on vSMTP’s repository. |
warn | Something unexpected append, vSMTP will still run but you should look into it. |
info | General informations on what the server is doing. |
Available log levels
Debug and Trace levels are, for the time being, not available in the production version of vSMTP.
Domain Name System configuration
vSMTP can handle complex DNS situations. A default configuration can be provided on the root configuration of vSMTP and specific dns configurations can be setup on specific domains.
Root DNS parameters are stored in the config.server.dns
map.
Please refer to vSMTP configuration reference and Trust-DNS repository for detailed information.
Resolver
The default behavior of the root resolver is defined by the operating system /etc/resolv.conf
file. Alternative configurations such as Google
or CloudFlare
Public DNS may be applied using the type
field in the server.dns
table.
fn on_config(config) {
config.server.dns.type = "system" | "google" | "cloudflare";
config
}
Selecting a DNS type
Please see
CloudFlare
privacy statement for important information about what they track.
Options
DNS Options can be set using the config.server.dns.options
object.
Parameter | value | Description | Default value |
---|---|---|---|
timeout | integer | Specify the timeout for a request. | 5 seconds. |
attempts | integer | usize Number of retries after lookup failure before giving up. | 2 attempts. |
rotate | true/false | Rotate through the resource records in the response. | No rotation. |
validate | true/false | Use DNSSec to validate the request. | False. |
ip_strategy | enum1 | The ip_strategy for the Resolver to use when lookup Ipv4 or Ipv6 addresses. | IPv4 then IPv6. |
cache_size | integer | Cache size is in number of records. | 32 records. |
num_concurrent_reqs | integer | Number of concurrent requests per query. | 2 concurrent requests. |
preserve_intermediates | true/false | Preserve all intermediate records in the lookup response, such as CNAME records. | True. |
DNS parameters
Ipv4Only, Ipv6Only, Ipv4AndIpv6, Ipv6thenIpv4, Ipv4thenIpv6
fn on_config(config) {
config.server.dns.type = "cloudflare";
config.server.dns.options = #{
timeout: "5s",
cache_size: 500,
ip_strategy: "Ipv6thenIpv4",
validate: true,
};
config
}
A Resolver configuration example
Domain specific resolver
It is possible to configure a DNS per domain. Under the desired domain folder in config.vsl
, add a on_domain_config
callback and configure the dns here.
fn on_domain_config(config) {
config.dns.type = "cloudflare";
config.dns.options = #{
timeout: "5s",
cache_size: 500,
ip_strategy: "Ipv6thenIpv4",
validate: true,
};
config
}
A configuration for a specific domain, i.e. `/etc/vsmtp/domain-enabled/example.com/config.vsl`
Plugins
vSMTP feature set can be extended using plugins.
Plugins are dynamic libraries (.so
files on Linux) that exposes vSL
interfaces.
Recommandation
Plugin directory
Plugins are stored in the /etc/vsmtp/plugins
directory.
/etc/vsmtp
┣ vsmtp.vsl
┃ conf.d/
┃ ┗ ...
+ ┗ plugins
+ ┣ vsmtp-plugin-mysql-1.0.0.so
+ ┗ ...
To make things cleaner with Linux’s file system, it is recommended that plugins are stored in the /usr/lib/vsmtp
directory, and symbolic links are used to link those libraries to the plugins
directory.
/etc/vsmtp
┣ vsmtp.vsl
┃ conf.d/
┃ ┗ ...
+ ┗ plugins
+ ┣ vsmtp-plugin-mysql.so -> /usr/lib/vsmtp/libvsmtp-plugin-mysql-1.0.0.so
+ ┗ ...
Plugins are named using the libvsmtp-plugin-<name>-<vsmtp-version>.so
nomenclature, with <name>
begin the name of the plugin, and <vsmtp-version>
the associated vSMTP version. Plugins must have the same version as the current vSMTP version to work correctly.
ln -s /usr/lib/vsmtp/libvsmtp-plugin-mysql-1.0.0.so /etc/vsmtp/plugins/vsmtp-plugin-mysql.so
Services directory
Some plugins create Rhai objects that use system resources like sockets or file descriptors. Constructing an instance of those objects can be costly.
Thus, it is HIGHLY recommended that objects created by plugins are declared inside .vsl
files stored in the /etc/vsmtp/services
directory. This way objects are initialized only once when vSMTP starts.
Here is an example:
/etc/vsmtp
┣ vsmtp.vsl
┣ filter.vsl
┣ conf.d/
┃ ┗ config.vsl
┣ domain-available/
┃ ┗ example.com/
┃ ┗ ...
┣ domain-enabled/
┃ ┗ example.com -> ...
+ ┗ services/
+ ┗ command.vsl
Let’s define a command service that runs the echo
command.
// Do not forget to use the `export` keyword when declaring
// the object to make it accessible trough `import`.
const echo = cmd::build(#{
command: "echo",
args: [ "-n", "executing a command from vSMTP!" ],
});
Creating a new command object in services/command.vsl
Check out the Command reference to get examples for the command plugin.
import "services/command" as command;
#{
connect: [
action "use echo" || command::echo.run(),
]
}
Using the object in rules using Rhai's import statement
Command
The command plugin executes Unix shell commands directly in vSL
.
This is a native plugin, thus using the
cmd
function does not require animport
statement.
Using the plugin
This plugin exposes a single constructor function.
export const command = cmd::build(#{
// Time allowed to execute the command.
// Aborted if the timeout value is reached.
timeout: "60s",
// The user to execute the command with. (optional)
user: "user",
// The group to execute the command with. (optional)
group: "group",
// Name of the command to execute.
command: "...",
// Array of arguments to execute the command with.
args: ["--arg1", "--arg2", "--arg3"],
});
See the Time chapter for more information on available time scale formats for the
timeout
field.
Example
export const echo = cmd::build(#{
timeout: "10s",
command: "echo",
args: ["-e", "'Hello World. \c This is vSMTP.'"],
});
// run the command.
// the command executed will be:
// echo -e 'Hello World. \c This is vSMTP.'
echo.run();
// run the command with custom arguments (based one are replaced).
// echo -n 'Hello World.'
echo.run([ "-n", "'Hello World.'" ]);
SMTP
The SMTP plugin enables vSMTP to interact with a third party software using the SMTP protocol.
Paired with the delegate
directive, an incoming email can be delegated to another service via the SMTP protocol.
This is a native plugin, thus using the
smtp
function does not require animport
statement.
Using the plugin
This plugin exposes a single constructor function.
export const clamsmtpd = smtp::connect(#{
delegator: #{
// The service address to delegate to.
address: "127.0.0.1:10026",
// The time allowed between each message before timeout.
timeout: "2s",
},
// The address where vsmtp will gather the results of the delegation.
// The third party software should be configured to send the email back at this address.
receiver: "127.0.0.1:10024",
});
Example
The SMTP plugin must be paired with a delegation
directive.
export const clamsmtpd = smtp::connect(#{
delegator: #{
address: "127.0.0.1:10026",
timeout: "2s",
},
receiver: "127.0.0.1:10024",
});
import "service/smtp" as srv;
{
postq: [
// this will delegate the email using the `clamsmtpd` service.
delegate srv::clamsmtpd "delegate antivirus processing" || {
// this is executed after the delegation results have been
// received on port 10024.
// ...
}
]
}
Check the Delegation chapter to get more information on the
delegate
directive.
CSV
The csv plugin can open, read and write to csv databases.
Install
Install libvsmtp_plugin_csv.so
in /usr/lib/vsmtp
, then use a symbolic link in the configuration.
ln -s /usr/lib/vsmtp/libvsmtp_plugin_csv.so /etc/vsmtp/plugins/libvsmtp_plugin_csv.so
Using the plugin
import "plugins/libvsmtp_plugin_csv" as db;
export const user_accounts = db::csv(#{
// The path to the csv database.
connector: "/db/user_accounts.csv",
// The access mode of the database. Can be:
// `O_RDONLY`, `O_WRONLY` or `O_RDWR`.
access: "O_RDONLY",
// The refresh mode of the database.
// Can be "always" (database is always refreshed once queried)
// or "no" (database is readonly and never refreshed).
//
// WARNING: using the "always" option can make vsmtp really slow,
// because it has to pull the whole database in memory every
// time it is queried. Use it only with a small database.
refresh: "always",
// The delimiter character used in the csv file.
delimiter: ",",
});
Here is a rule using the csv service.
import "services/databases" as db;
#{
mail: [
rule "is sender in database ?" || {
// query the database.
// Let's assume that the `ctx::mail_from()` value is `john.doe@example.com`.
// `db::user_accounts.get` return an array representing the selected row with
// the key passed as parameter.
let user = db::user_accounts.get(ctx::mail_from().local_part);
// The record returned is an array `["john.doe", "john.doe@example.com"]`.
if user != [] {
// We can select columns by index.
log("info", `A trusted client just connected: user=${user[0]}, email=${user[1]}`)
}
state::next()
}
]
}
MySQL
The mysql plugin can query mysql databases.
Install
Install libvsmtp_plugin_mysql.so
in /usr/lib/vsmtp
, then use a symbolic link in the configuration.
ln -s /usr/lib/vsmtp/libvsmtp_plugin_mysql.so /etc/vsmtp/plugins/libvsmtp_plugin_mysql.so
Using the plugin
import "plugins/libvsmtp_plugin_mysql" as mysql;
export const database = mysql::connect(#{
// the url to connect to the database.
url: "mysql://localhost/",
// the time allowed to the database to send a
// response to the vSMTP. (optional, 30s by default)
timeout: "30s",
// the number of connections to open on the database. (optional, 4 by default)
connections: 4,
});
/etc/vsmtp/services/mysql.vsl
Using Rhai arrays and maps, vSL can easily fetch and update data from a mysql database.
import "service/mysql" as srv;
#{
connect: [
rule "query mysql database" || {
// Query the database.
let records = srv::database.query("SELECT * FROM my_table");
log("info", "mysql records");
for record in records {
log("info", ` -> ${record}`);
}
}
]
}
Check out the greylist tutorial for a full example of a greylist database using mysql.
Configuration Parameters
config.version_requirement
Version of vSMTP to use, should not be changed.
fn on_config(config) {
config.version_requirement = "1.0.0";
config
}
config.path
Path to the vsmtp.vsl
, default to /etc/vsmtp/vsmtp.vsl
.
fn on_config(config) {
config.path = "/etc/vsmtp/vsmtp.vsl";
config
}
config.server
Configuration variables for the core of vSMTP.
config.server.name
Name of the server. Used in return codes. Defaults to the hostname.
fn on_config(config) {
config.server.name = "example.com";
config
}
config.server.client_count_max
Maximum number of clients that can connect at the same time. Defaults to 16.
fn on_config(config) {
// Accept at maximum 100 clients at the same time.
config.server.client_count_max = 100;
// No limits.
config.server.client_count_max = -1;
config
}
config.server.message_size_limit
Maximum authorized size for an email. Defaults to 10MB.
fn on_config(config) {
// Max size is 20MB.
config.server.message_size_limit = 20000000;
config
}
config.server.interfaces
Address served by vSMTP
. Either ipv4 or ipv6.
fn on_config(config) {
config.server.interfaces = #{
addr: ["127.0.0.1:25", "127.0.0.1:10025"],
addr_submission: ["127.0.0.1:587"],
addr_submissions: ["127.0.0.1:465"],
};
config
}
config.server.system
System configuration for the server.
If config.server.system.user
and config.server.system.group
are not set in the configuration, vSMTP will try to use, by default, the vsmtp
user and vsmtp
group to run the server.
fn on_config(config) {
config.server.system = #{
user: "vsmtp",
group: "mail",
// User used when writing emails to disk using Maildir or Mbox.
group_local: "mail",
// Number of threads per vSMTP process.
thread_pool: #{
receiver: 6,
processing: 6,
delivery: 6,
};
};
config
}
config.server.logs
Log configuration for the server.
fn on_config(config) {
config.server.logs = #{
filename: "/var/log/vsmtp/vsmtp.log",
level: ["info"],
};
config
}
config.server.logs.system
Type of system logs to use.
An example using syslogd.
fn on_config(config) {
config.server.logs.system = #{
level: "info",
backend: "syslogd",
// Format used by the logger.
// See https://www.rfc-editor.org/rfc/rfc3164 and https://www.rfc-editor.org/rfc/rfc5424
// for more details.
format: "3164",
// Writing syslogs on disk using a unix socket.
socket: #{ type: "unix", path: "/dev/log" },
// It is possible to use:
// `socket: #{ type: "tcp", server: "127.0.0.1:601" }`
//
// or
// `socket: #{ type: "udp", server: "127.0.0.1:514", local: "127.0.0.1:0" }`
//
// note: address can be ipv4 / ipv6
};
config
}
An example using journald.
fn on_config(config) {
config.server.logs.system = #{
level: "info",
backend: "journald",
};
config
}
config.server.queues
Configuration of mail queues of vSMTP.
fn on_config(config) {
// The root directory for the queuer system.
config.server.queues.dirpath = "/var/spool/vsmtp";
// Size of the channel queue communicating the mails from the `receiver` pool to the `processing` pool.
config.server.queues.working.channel_size = 32;
config.server.queues.delivery = #{
// Size of the channel queue communicating the mails from the `processing` pool to the `delivery` pool.
channel_size: 32,
// Maximum number of attempt to deliver the mail before being considered dead.
deferred_retry_max: 100,
// The mail in the `deferred` are resend in a clock with this period.
deferred_retry_period: "5m",
};
config
}
config.server.tls
TLS configuration for vSMTP.
fn on_config(config) {
config.server.tls = #{
// Ignore the client’s ciphersuite order.
// Instead, choose the top ciphersuite in the server list which is supported by the client.
preempt_cipherlist: false,
// Timeout for the TLS handshake. Sends a timeout message to the client once reached.
handshake_timeout: "200ms",
protocol_version: "TLSv1.3",
cipher_suite: "TLS13_AES_256_GCM_SHA384",
}
config
}
config.server.smtp
SMTP protocol configuration for receivers of vSMTP.
fn on_config(config) {
config.server.smtp = #{
auth: #{
// Some mechanisms are considered unsecure under non-TLS connections.
// If `false`, the server will allow to use them even on clair connections.
enable_dangerous_mechanism_in_clair: false,
// List of mechanisms supported by the server.
mechanisms: ["Plain", "Login", "CramMd5"],
// If the AUTH exchange is canceled, the server will not consider the connection as closing,
// increasing the number of attempt failed, until `attempt_count_max`, producing an error.
attempt_count_max: 3,
},
error: #{
// The delay used between each response, after `soft_count` errors.
// Unused if `soft_count` is `-1`.
delay: "5s",
// The maximum number of errors before the client is disconnected.
// `-1` to disable
hard_count: 20,
// The maximum number of errors before the client is delay between each response.
// `-1` to disable
soft_count: 10,
},
// Maximum number of recipients per email.
rcpt_count_max: 1000,
// Timeout configuration for each SMTP command.
timeout_client: #{
connect: "5m",
data: "5m",
helo: "5m",
mail_from: "5m",
rcpt_to: "5m",
},
},
config
}
config.server.dns
Configure the internal DNS of vSMTP.
fn on_config(config) {
// Using the resolver of the system (/etc/resolv.conf).
config.server.dns = #{
"type": "system",
}
// Options available for the google, cloudflare and custom dns configurations.
const options = #{
// Specify the timeout for a request. Defaults to 5 seconds
timeout: "5s",
// Number of retries after lookup failure before giving up. Defaults to 2
attempts: 2,
// Rotate through the resource records in the response (if there is more than one for a given name)
rotate: false,
// Use DNSSec to validate the request
dnssec: true,
// The ip_strategy for the Resolver to use when lookup Ipv4 or Ipv6 addresses
ip_strategy: "Ipv4Only" | "Ipv6Only" | "Ipv4AndIpv6" | "Ipv6thenIpv4" | "Ipv4thenIpv6",
// Cache size is in number of records (some records can be large)
cache_size: 32,
// Check /ect/hosts file before dns requery (only works for unix like OS)
use_hosts_file: false,
// Number of concurrent requests per query
// Where more than one nameserver is configured, this configures the resolver to send queries
// to a number of servers in parallel. Defaults to 2; 0 or 1 will execute requests serially.
num_concurrent_reqs: 2,
};
// Using the google DNS resolver.
config.server.dns = #{
"type": "google",
options,
}
// Using the google DNS resolver.
config.server.dns = #{
"type": "cloudflare",
options;
}
// Using a custom DNS resolver.
config.server.dns = #{
"type": "custom",
config: #{
// base search domain.
domain: "example.com",
// search domains.
search: [],
},
options
}
config
}
config.app
Configuration variables for the applicative side of vSMTP.
fn on_config(config) {
config.app = #{
// Path where custom quarantine queues will be stored.
"dirpath": "/var/spool/vsmtp/app",
"logs": #{
// path to the log file generated by calling the `log` function
// in `.vsl` scripts.
"filename": "/var/log/vsmtp/app.log",
},
"vsl": #{
// Path to the domain specific filtering directory.
"domain_dir": "/etc/vsmtp/domain-enabled",
// Path to the root filter script.
"filter_path": "/etc/vsmtp/filter.vsl",
},
};
config
}
vSL’s API
vsl
, on top of the Rhai language, exposes it’s own api in the form of functions and objects.
The following files are auto generated documentation for each of vSL’s modules.
Standard functions & operators
The following modules lists:
- All standard functions available for email filtering. (referred by the
fn
keyword) - Operators. (referred by the
op
keyword) - Objects getters. (referred by the
get
keyword) - Objects setters. (referred by the
set
keyword)
Documentation for each function is written using markdown, and is split between sections
:
Name | Description |
---|---|
Args | Arguments to pass to the function. |
Return | What result does the function return. |
Effective smtp stage | Stage of the rule engine where this function can be called from. |
Note | Additional comments for the function. |
Examples | vSL examples using the function. |
Errors | Errors that can happen during the execution of the function. WARNING: this means that the function can throw an exception. Exceptions stops the evaluation of the rule engine and return a deny code. To handle exceptions, checkout the try catch statement in Rhai. |
Available documentation sections and their purpose
global::state
Functions used to interact with the rule engine.
Use states
in rules
to deny, accept, or quarantine emails.
fn
faccept
fn faccept() -> Status
fn faccept(code: SharedObject) -> Status
fn faccept(code: String) -> Status
Use this return status when you are sure that the incoming client can be trusted.
- code - A customized code as a string or code object. (default: “250 Ok”)
- The object passed as parameter was not a code object.
- The string passed as parameter failed to be parsed into a valid code.
all of them.
#{
connect: [
// Here we imagine that "192.168.1.10" is a trusted source, so we can force accept
// any other rules that don't need to be run.
rule "check for trusted source" || if ctx::client_ip() == "192.168.1.10" { faccept() } else { state::next() },
],
// The following rules will not be evaluated if `ctx::client_ip() == "192.168.1.10"` is true.
mail: [
rule "another rule" || {
// ... doing stuff
}
],
}
#{
mail: [
rule "send a custom code with a code object" || {
faccept(code(220, "Ok"))
}
],
}
#{
mail: [
rule "send a custom code with a string" || {
faccept("220 Ok")
}
],
}
fn
accept
fn accept() -> Status
fn accept(code: SharedObject) -> Status
fn accept(code: String) -> Status
- code - A customized code as a string or code object. (default: “250 Ok”)
- The object passed as parameter was not a code object.
- The string passed as parameter failed to be parsed into a valid code.
all of them.
#{
connect: [
// "ignored checks" will be ignored because the previous rule returned accept.
rule "accept" || state::accept(),
action "ignore checks" || print("this will be ignored because the previous rule used state::accept()."),
],
mail: [
// rule evaluation is resumed in the next stage.
rule "resume rules" || print("evaluation resumed!");
]
}
#{
mail: [
rule "send a custom code with a code object" || {
accept(code(220, "Ok"))
}
],
}
#{
mail: [
rule "send a custom code with a string" || {
accept("220 Ok")
}
],
}
fn
next
fn next() -> Status
all of them.
#{
connect: [
// once "go to the next rule" is evaluated, the rule engine execute "another rule".
rule "go to the next rule" || state::next(),
action "another rule" || print("checking stuff ..."),
],
}
fn
deny
fn deny() -> Status
fn deny(code: String) -> Status
fn deny(code: SharedObject) -> Status
- code - A customized code as a string or code object. (default: “554 permanent problems with the remote server”)
- The object passed as parameter was not a code object.
- The string passed as parameter failed to be parsed into a valid code.
all of them.
#{
rcpt: [
rule "check for satan" || {
// The client is denied if a recipient's domain matches satan.org,
// this is a blacklist, sort-of.
if ctx::rcpt().domain == "satan.org" {
state::deny()
} else {
state::next()
}
},
],
}
#{
mail: [
rule "send a custom code with a code object" || {
deny(code(421, "Service not available"))
}
],
}
#{
mail: [
rule "send a custom code with a string" || {
deny("450 mailbox unavailable")
}
],
}
fn
quarantine
fn quarantine(queue: String) -> Status
queue
- the relative path to the queue where the email will be quarantined as a string. This path will be concatenated to theconfig.app.dirpath
field in your root configuration.
all of them.
import "services" as svc;
#{
postq: [
delegate svc::clamsmtpd "check email for virus" || {
// the email is placed in quarantined if a virus is detected by
// a service.
if has_header("X-Virus-Infected") {
state::quarantine("virus_queue")
} else {
state::next()
}
}
],
}
op
==
op ==(status_1: Status, status_2: Status) -> bool
all of them.
#{
connect: [
action "check status equality" || {
deny() == deny(); // returns true.
faccept() == next(); // returns false.
}
],
}
op
!=
op !=(status_1: Status, status_2: Status) -> bool
all of them.
#{
connect: [
action "check status not equal" || {
deny() != deny(); // returns false.
faccept() != next(); // returns true.
}
],
}
fn
to_string
fn to_string(status: Status) -> String
all of them.
#{
connect: [
rule "status to string" || {
let status = next();
// `.to_string` is called automatically here.
log("info", `converting my status to a string: ${status}`);
status
}
],
}
fn
to_debug
fn to_debug(status: Status) -> String
all of them.
#{
connect: [
rule "status to string" || {
let status = next();
log("info", `converting my status to a string: ${status.to_debug()}`);
status
}
],
}
global::logging
Logging mechanisms.
fn
log
fn log(level: SharedObject, message: String)
fn log(level: String, message: SharedObject)
fn log(level: SharedObject, message: SharedObject)
fn log(level: String, message: String)
level
- the level of the message, can be “trace”, “debug”, “info”, “warn” or “error”.message
- the message to log.
All of them.
#{
connect: [
action "log on connection (str/str)" || {
log("info", `[${date()}/${time()}] client=${ctx::client_ip()}`);
},
action "log on connection (str/obj)" || {
log("error", identifier("Hello world!"));
},
action "log on connection (obj/obj)" || {
const level = "trace";
const message = "connection established";
log(identifier(level), identifier(message));
},
action "log on connection (obj/str)" || {
const level = "warn";
log(identifier(level), "I love vsl!");
},
],
}
global::ctx
Inspect the transaction context.
fn
to_string
fn to_string(context: Context) -> String
fn
client_address
fn client_address() -> String
All of them.
string
- the client’s address with theip:port
format.
#{
connect: [
action "log client address" || {
log("info", `new client: ${ctx::client_address()}`);
},
],
}
fn
client_ip
fn client_ip() -> String
All of them.
string
- the client’s ip address.
#{
connect: [
action "log client ip" || {
log("info", `new client: ${ctx::client_ip()}`);
},
],
}
fn
client_port
fn client_port() -> int
All of them.
int
- the client’s port.
#{
connect: [
action "log client address" || {
log("info", `new client: ${ctx::client_ip()}:${ctx::client_port()}`);
},
],
}
fn
server_address
fn server_address() -> String
All of them.
string
- the server’s address with theip:port
format.
#{
connect: [
action "log server address" || {
log("info", `server: ${ctx::server_address()}`);
},
],
}
fn
server_ip
fn server_ip() -> String
All of them.
string
- the server’s ip.
#{
connect: [
action "log server ip" || {
log("info", `server: ${ctx::server_ip()}`);
},
],
}
fn
server_port
fn server_port() -> int
All of them.
string
- the server’s port.
#{
connect: [
action "log server address" || {
log("info", `server: ${ctx::server_ip()}:${ctx::server_port()}`);
},
],
}
fn
connection_timestamp
fn connection_timestamp() -> OffsetDateTime
All of them.
timestamp
- the connection timestamp of the client.
#{
connect: [
action "log client" || {
log("info", `new client connected at ${ctx::connection_timestamp()}`);
},
],
}
fn
server_name
fn server_name() -> String
All of them.
string
- the name of the server.
#{
connect: [
action "log server" || {
log("info", `server name: ${ctx::server_name()}`);
},
],
}
fn
is_secured
fn is_secured() -> bool
all of them.
- bool -
true
if the connection is secured,false
otherwise.
#{
connect: [
action "log ssl/tls" || {
log("info", `The client is ${if ctx::is_secured() { "secured" } else { "unsecured!!!" }}`)
}
],
}
fn
helo
fn helo() -> String
helo
and onwards.
string
- the value of theHELO/EHLO
command.
#{
helo: [
action "log info" || log("info", `helo/ehlo value: ${ctx::helo()}`),
]
}
fn
mail_from
fn mail_from() -> SharedObject
mail
and onwards.
address
- the sender address.
#{
helo: [
action "log info" || log("info", `received sender: ${ctx::mail_from()}`),
]
}
fn
rcpt_list
fn rcpt_list() -> Array
rcpt
and onwards. Note that you will not have all recipients received
all at once in the rcpt
stage. It is better to use this function
in the later stages.
Array of addresses
- the list containing all recipients.
#{
preq: [
action "log recipients" || log("info", `recipients: ${ctx::rcpt_list()}`),
]
}
fn
rcpt
fn rcpt() -> SharedObject
rcpt
and onwards. Please note that ctx::rcpt()
will always return
the last recipient received in stages after the rcpt
stage. Therefore,
this functions is best used in the rcpt
stage.
address
- the address of the received recipient.
#{
rcpt: [
action "log recipients" || log("info", `new recipient: ${ctx::rcpt()}`),
]
}
fn
mail_timestamp
fn mail_timestamp() -> OffsetDateTime
preq
and onwards.
string
- the timestamp.
#{
preq: [
action "receiving the email" || log("info", `time of reception: ${ctx::mail_timestamp()}`),
]
}
fn
message_id
fn message_id() -> String
preq
and onwards.
string
- the message id.
#{
preq: [
action "message received" || log("info", `message id: ${ctx::message_id()}`),
]
}
global::envelop
Functions to inspect and mutate the SMTP envelop.
fn
rw_mail_from
fn rw_mail_from(new_addr: SharedObject) -> ()
fn rw_mail_from(new_addr: String) -> ()
new_addr
- the new string sender address to set.
mail
and onwards.
#{
preq: [
action "rewrite envelop 1" || envelop::rw_mail_from("unknown@example.com"),
// You can use vsl addresses too.
action "rewrite envelop 2" || envelop::rw_mail_from(address("john.doe@example.com")),
]
}
fn
rw_rcpt
fn rw_rcpt(old_addr: String, new_addr: SharedObject) -> ()
fn rw_rcpt(old_addr: SharedObject, new_addr: SharedObject) -> ()
fn rw_rcpt(old_addr: String, new_addr: String) -> ()
fn rw_rcpt(old_addr: SharedObject, new_addr: String) -> ()
old_addr
- the recipient to replace.new_addr
- the new address to use when replacingold_addr
.
rcpt
and onwards.
#{
preq: [
// You can use strings or addresses as parameters.
action "rewrite envelop 1" || envelop::rw_rcpt("john.doe@example.com", "john.main@example.com"),
action "rewrite envelop 2" || envelop::rw_rcpt(address("john.doe@example.com"), "john.main@example.com"),
action "rewrite envelop 3" || envelop::rw_rcpt("john.doe@example.com", address("john.main@example.com")),
action "rewrite envelop 4" || envelop::rw_rcpt(address("john.doe@example.com"), address("john.main@example.com")),
]
}
fn
add_rcpt
fn add_rcpt(new_addr: SharedObject) -> ()
fn add_rcpt(new_addr: String) -> ()
rcpt
- the new recipient to add.
All of them.
#{
connect: [
// always deliver a copy of the message to "john.doe@example.com".
action "rewrite envelop 1" || envelop::add_rcpt("john.doe@example.com"),
action "rewrite envelop 2" || envelop::add_rcpt(address("john.doe@example.com")),
]
}
fn
bcc
fn bcc(new_addr: SharedObject) -> ()
fn bcc(new_addr: String) -> ()
fn
rm_rcpt
fn rm_rcpt(addr: SharedObject) -> ()
fn rm_rcpt(addr: String) -> ()
rcpt
- the recipient to remove.
All of them.
#{
preq: [
// never deliver to "john.doe@example.com".
action "rewrite envelop 1" || envelop::rm_rcpt("john.doe@example.com"),
action "rewrite envelop 2" || envelop::rm_rcpt(address("john.doe@example.com")),
]
}
global::msg
Inspect incoming messages.
fn
to_string
fn to_string(message: Message) -> String
fn
has_header
fn has_header(header: SharedObject) -> bool
fn has_header(header: String) -> bool
header
- the name of the header to search.
All of them, although it is most useful in the preq
stage because the
email is received at this point.
// Message example.
"X-My-Header: foo\r\n",
"Subject: Unit test are cool\r\n",
"\r\n",
"Hello world!\r\n",
#{
preq: [
rule "check if header exists" || {
if msg::has_header("X-My-Header") && msg::has_header(identifier("Subject")) {
state::accept();
} else {
state::deny();
}
}
]
}
fn
count_header
fn count_header(header: String) -> int
fn count_header(header: SharedObject) -> int
header
- the name of the header to count.
number
- the number headers with the same name.
All of them, although it is most useful in the preq
stage because this
is when the email body is received.
"X-My-Header: foo\r\n",
"X-My-Header: bar\r\n",
"X-My-Header: baz\r\n",
"Subject: Unit test are cool\r\n",
"\r\n",
"Hello world!\r\n",
#{
preq: [
rule "count_header" || {
state::accept(`250 count is ${msg::count_header("X-My-Header")} and ${msg::count_header(identifier("Subject"))}`);
}
]
}
fn
get_header
fn get_header(header: SharedObject) -> String
fn get_header(header: String) -> String
header
- the name of the header to get.
string
- the header value, or an empty string if the header was not found.
All of them, although it is most useful in the preq
stage because this
is when the email body is received.
X-My-Header: 250 foo
Subject: Unit test are cool
Hello world!
; // .eml ends here
let rules = r#"
#{
preq: [
rule "get_header" || {
if msg::get_header("X-My-Header") != "250 foo"
|| msg::get_header(identifier("Subject")) != "Unit test are cool" {
state::deny();
} else {
state::accept(`${msg::get_header("X-My-Header")} ${msg::get_header(identifier("Subject"))}`);
}
}
]
}
fn
get_all_headers
fn get_all_headers() -> Array
fn get_all_headers(name: String) -> Array
fn get_all_headers(name: SharedObject) -> Array
header
- the name of the header to search. (optional, if not set, returns every header)
array
- all of the headers found in the message.
All of them, although it is most useful in the preq
stage because this
is when the email body is received.
X-My-Header: 250 foo
Subject: Unit test are cool
Hello world!
; // .eml ends here
#{
preq: [
rule "display headers" || {
log("info", `all headers: ${msg::get_all_headers()}`);
log("info", `all "Return-Path" headers: ${msg::get_all_headers("Return-Path")}`);
}
]
}
fn
get_header_untouched
fn get_header_untouched(name: String) -> Array
header
- the name of the header to search.
array
- all header values, or an empty array if the header was not found.
All of them, although it is most useful in the preq
stage because this
is when the email body is received.
X-My-Header: 250 foo
Subject: Unit test are cool
Hello world!
; // .eml ends here
#{
postq: [
action "display return path" || {
// Will display "Return-Path: value".
log("info", msg::get_header_untouched("Return-Path"));
}
],
}
fn
append_header
fn append_header(header: String, value: SharedObject) -> ()
fn append_header(header: String, value: String) -> ()
header
- the name of the header to append.value
- the value of the header to append.
All of them. Even though the email is not received at the current stage,
vsmtp stores new headers and will add them on top of the ones received once
the preq
stage is reached.
"X-My-Header: 250 foo\r\n",
"Subject: Unit test are cool\r\n",
"\r\n",
"Hello world!\r\n",
#{
preq: [
rule "append_header" || {
msg::append_header("X-My-Header-2", "bar");
msg::append_header("X-My-Header-3", identifier("baz"));
}
]
}
fn
prepend_header
fn prepend_header(header: String, value: String) -> ()
fn prepend_header(header: String, value: SharedObject) -> ()
header
- the name of the header to prepend.value
- the value of the header to prepend.
All of them. Even though the email is not received at the current stage,
vsmtp stores new headers and will add them on top of the ones received once
the preq
stage is reached.
"X-My-Header: 250 foo\r\n",
"Subject: Unit test are cool\r\n",
"\r\n",
"Hello world!\r\n",
#{
preq: [
rule "prepend_header" || {
msg::prepend_header("X-My-Header-2", "bar");
msg::prepend_header("X-My-Header-3", identifier("baz"));
}
]
}
fn
set_header
fn set_header(header: String, value: String) -> ()
fn set_header(header: String, value: SharedObject) -> ()
header
- the name of the header to set or add.value
- the value of the header to set or add.
All of them. Even though the email is not received at the current stage,
vsmtp stores new headers and will add them on top to the ones received once
the preq
stage is reached.
Be aware that if you want to set a header value from the original message,
you must use set_header
in the preq
stage and onwards.
"Subject: The initial header value\r\n",
"\r\n",
"Hello world!\r\n",
#{
preq: [
rule "set_header" || {
msg::set_header("Subject", "The header value has been updated");
msg::set_header("Subject", identifier("The header value has been updated again"));
state::accept(`250 ${msg::get_header("Subject")}`);
}
]
}
fn
rename_header
fn rename_header(old: String, new: SharedObject) -> ()
fn rename_header(old: SharedObject, new: SharedObject) -> ()
fn rename_header(old: String, new: String) -> ()
fn rename_header(old: SharedObject, new: String) -> ()
old
- the name of the header to rename.new
- the new new of the header.
All of them, although it is most useful in the preq
stage because this
is when the email body is received.
"Subject: The initial header value\r\n",
"\r\n",
"Hello world!\r\n",
#{
preq: [
rule "rename_header" || {
msg::rename_header("Subject", "bob");
if msg::has_header("Subject") { return state::deny(); }
msg::rename_header("bob", identifier("Subject"));
if msg::has_header("bob") { return state::deny(); }
msg::rename_header(identifier("Subject"), "foo");
if msg::has_header("Subject") { return state::deny(); }
msg::rename_header(identifier("foo"), identifier("Subject"));
if msg::has_header("foo") { return state::deny(); }
state::accept(`250 ${msg::get_header("Subject")}`);
}
]
}
fn
mail
fn mail() -> String
preq
and onwards.
#{
postq: [
action "display email content" || log("trace", `email content: ${msg::mail()}`),
]
}
fn
rm_header
fn rm_header(header: String) -> bool
fn rm_header(header: SharedObject) -> bool
header
- the name of the header to remove.
- a boolean value, true if a header has been removed, false otherwise.
All of them, although it is most useful in the preq
stage because this
is when the email body is received.
"Subject: The initial header value\r\n",
"\r\n",
"Hello world!\r\n",
#{
preq: [
rule "remove_header" || {
msg::rm_header("Subject");
if msg::has_header("Subject") { return state::deny(); }
msg::prepend_header("Subject-2", "Rust is good");
msg::rm_header(identifier("Subject-2"));
msg::prepend_header("Subject-3", "Rust is good !!!!!");
state::accept(`250 ${msg::get_header("Subject-3")}`);
}
]
}
fn
rw_mail_from
fn rw_mail_from(new_addr: SharedObject) -> ()
fn rw_mail_from(new_addr: String) -> ()
new_addr
- the new sender address to set.
preq
and onwards.
#{
preq: [
action "replace sender" || msg::rw_mail_from("john.server@example.com"),
]
}
fn
rw_rcpt
fn rw_rcpt(old_addr: SharedObject, new_addr: SharedObject) -> ()
fn rw_rcpt(old_addr: String, new_addr: String) -> ()
fn rw_rcpt(old_addr: SharedObject, new_addr: String) -> ()
fn rw_rcpt(old_addr: String, new_addr: SharedObject) -> ()
old_addr
- the recipient to replace.new_addr
- the new address to use when replacingold_addr
.
preq
and onwards.
#{
preq: [
action "rewrite recipient" || msg::rw_rcpt("john.doe@example.com", "john-mta@example.com"),
]
}
fn
add_rcpt
fn add_rcpt(new_addr: String) -> ()
fn add_rcpt(new_addr: SharedObject) -> ()
addr
- the recipient address to add to theTo
header.
preq
and onwards.
#{
preq: [
action "update recipients" || msg::add_rcpt("john.doe@example.com"),
]
}
fn
rm_rcpt
fn rm_rcpt(addr: SharedObject) -> ()
fn rm_rcpt(addr: String) -> ()
addr
- the recipient to remove to theTo
header.
preq
and onwards.
#{
preq: [
action "update recipients" || msg::rm_rcpt("john.doe@example.com"),
]
}
global::auth
Authentication mechanisms and credential manipulation.
fn
unix_users
fn unix_users() -> Status
The current implementation support “PLAIN” mechanism, and will call the
testsaslauthd
program to check the credentials.
The credentials will be verified depending on the mode of saslauthd
.
A native implementation will be provided in the future.
fn
is_authenticated
fn is_authenticated() -> bool
authenticate
stage only.
bool
-true
if the client succeeded to authenticate itself,false
otherwise.
#{
authenticate: [
action "log info" || log("info", `client authenticated: ${auth::is_authenticated()}`),
]
}
fn
credentials
fn credentials() -> Credentials
authenticate
only.
Credentials
- the credentials of the client.
#{
authenticate: [
action "log auth" || log("info", `${auth::credentials()}`),
]
}
get
type
fn get type(credentials: Credentials) -> String
authenticate
only.
String
- the credentials type.
#{
authenticate: [
action "log auth type" || {
let credentials = auth::credentials();
// Logs here will output 'Verify' or 'AnonymousToken'.
// depending on the authentication type.
log("info", `credentials type: ${credentials.type}`);
},
]
}
get
authid
fn get authid(credentials: Credentials) -> String
authenticate
only.
String
- the authentication id.
#{
authenticate: [
action "log auth id" || {
let credentials = auth::credentials();
log("info", `credentials id: ${credentials.authid}`);
},
]
}
get
authpass
fn get authpass(credentials: Credentials) -> String
authenticate
only.
String
- the authentication password.
#{
authenticate: [
action "log auth pass" || {
let credentials = auth::credentials();
log("info", `credentials pass: ${credentials.authpass}`);
},
]
}
get
anonymous_token
fn get anonymous_token(credentials: Credentials) -> String
authenticate
only.
String
- the token.
#{
authenticate: [
action "log auth token" || {
let credentials = auth::credentials();
log("info", `credentials token: ${credentials.anonymous_token}`);
},
]
}
global::spf
Implementation of the Sender Policy Framework (SPF), described by RFC 4408. (https://www.ietf.org/rfc/rfc4408.txt)
fn
check
fn check() -> Status
fn check(params: Map) -> Status
- a map composed of the following parameters:
header
- The header(s) where the spf results will be written. Can be “spf”, “auth”, “both” or “none”. (default: “both”)policy
- Degrees of flexibility when getting spf results. Can be “strict” or “soft”. (default: “strict”) A “soft” policy will let softfail pass while a “strict” policy will return a deny if the results are not “pass”.
deny(code550_7_23 | code550_7_24)
- an error occurred during lookup. (returned even when a softfail is received using the “strict” policy)next()
- the operation succeeded.
mail
and onwards.
- The
header
argument is not valid. - The
policy
argument is not valid.
spf::check
only checks for the sender’s identity, not the helo
value.
mail: [
rule "check spf" || spf::check(),
]
}
mail: [
// if this check succeed, it wil return `next`.
// if it fails, it might return `deny` with a custom code
// (X.7.24 or X.7.25 for example)
//
// if you want to use the return status, just put the spf::check
// function on the last line of your rule.
rule "check spf 1" || {
log("debug", `running sender policy framework on ${ctx::mail_from()} identity ...`);
spf::check(#{ header: "spf", policy: "soft" })
},
// policy is set to "strict" by default.
rule "check spf 2" || spf::check(#{ header: "both" }),
],
}
fn
check_raw
fn check_raw() -> Map
Check spf record following the Sender Policy Framework (RFC 7208). see https://datatracker.ietf.org/doc/html/rfc7208
map
- the result of the spf check, contains theresult
,mechanism
andproblem
keys.
mail
and onwards.
spf::check
only checks for the sender’s identity, not the helo
value.
#{
mail: [
rule "check spf relay" || {
const spf = spf::check_raw();
log("info", `spf results: ${spf.result}, mechanism: ${spf.mechanism}, problem: ${spf.problem}`)
},
]
}
global::dkim
Generate and verify DKIM signatures. Implementation of RFC 6376. (https://www.rfc-editor.org/rfc/rfc6376.html)
fn
has_result
fn has_result() -> bool
fn
result
fn result() -> Map
fn
store
fn store(result: Map) -> ()
fn
get_private_keys
fn get_private_keys(sdid: String) -> Array
get
sdid
fn get sdid(signature: Signature) -> String
get
auid
fn get auid(signature: Signature) -> String
fn
verify
fn verify() -> Map
// The message received.
let msg = r#"
Received: from github.com (hubbernetes-node-54a15d2.ash1-iad.github.net [10.56.202.84])
by smtp.github.com (Postfix) with ESMTPA id 19FB45E0B6B
for <mlala@negabit.com>; Wed, 26 Oct 2022 14:30:51 -0700 (PDT)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=github.com;
s=pf2014; t=1666819851;
bh=7gTTczemS/Aahap1SpEnunm4pAPNuUIg7fUzwEx0QUA=;
h=Date:From:To:Subject:From;
b=eAufMk7uj4R+bO5Nr4DymffdGdbrJNza1+eykatgZED6tBBcMidkMiLSnP8FyVCS9
/GSlXME6/YffAXg4JEBr2lN3PuLIf94S86U3VckuoQQQe1LPtHlnGW5ZwJgi6DjrzT
klht/6Pn1w3a2jdNSDccWhk5qlSOQX9JKnE7UD58=
Date: Wed, 26 Oct 2022 14:30:51 -0700
From: Mathieu Lala <noreply@github.com>
To: mlala@negabit.com
Message-ID: <viridIT/vSMTP/push/refs/heads/test/rule-engine/000000-c6459a@github.com>
Subject: [viridIT/vSMTP] c6459a: test: add test on message
Mime-Version: 1.0
Content-Type: text/plain;
charset=UTF-8
Content-Transfer-Encoding: 7bit
Approved: =?UTF-8?Q?hello_there_=F0=9F=91=8B?=
X-GitHub-Recipient-Address: mlala@negabit.com
X-Auto-Response-Suppress: All
Branch: refs/heads/test/rule-engine
Home: https://github.com/viridIT/vSMTP
Commit: c6459a4946395ba90182ce7181bdbc327994c038
https://github.com/viridIT/vSMTP/commit/c6459a4946395ba90182ce7181bdbc327994c038
Author: Mathieu Lala <m.lala@viridit.com>
Date: 2022-10-26 (Wed, 26 Oct 2022)
Changed paths:
M src/vsmtp/vsmtp-rule-engine/src/api/message.rs
M src/vsmtp/vsmtp-rule-engine/src/lib.rs
M src/vsmtp/vsmtp-test/src/vsl.rs
Log Message:
-----------
test: add test on message
"#;
#{
preq: [
rule "verify dkim" || {
dkim::verify();
// The dkim header should indicate a pass.
if !msg::get_header("Authentication-Results").contains("dkim=pass") {
return state::deny();
}
// the result of dkim verification is cached, so this call will
// not recompute the signature and recreate a header.
dkim::verify();
// FIXME: should be one.
if msg::count_header("Authentication-Results") != 2 {
return state::deny();
}
state::accept()
}
]
}
Changing the header Subject
will result in a dkim verification failure.
// The message received.
let msg = r#"
Received: from github.com (hubbernetes-node-54a15d2.ash1-iad.github.net [10.56.202.84])
by smtp.github.com (Postfix) with ESMTPA id 19FB45E0B6B
for <mlala@negabit.com>; Wed, 26 Oct 2022 14:30:51 -0700 (PDT)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=github.com;
s=pf2014; t=1666819851;
bh=7gTTczemS/Aahap1SpEnunm4pAPNuUIg7fUzwEx0QUA=;
h=Date:From:To:Subject:From;
b=eAufMk7uj4R+bO5Nr4DymffdGdbrJNza1+eykatgZED6tBBcMidkMiLSnP8FyVCS9
/GSlXME6/YffAXg4JEBr2lN3PuLIf94S86U3VckuoQQQe1LPtHlnGW5ZwJgi6DjrzT
klht/6Pn1w3a2jdNSDccWhk5qlSOQX9JKnE7UD58=
Date: Wed, 26 Oct 2022 14:30:51 -0700
From: Mathieu Lala <noreply@github.com>
To: mlala@negabit.com
Message-ID: <viridIT/vSMTP/push/refs/heads/test/rule-engine/000000-c6459a@github.com>
Subject: Changing the header produce an invalid dkim verification
Mime-Version: 1.0
Content-Type: text/plain;
charset=UTF-8
Content-Transfer-Encoding: 7bit
Approved: =?UTF-8?Q?hello_there_=F0=9F=91=8B?=
X-GitHub-Recipient-Address: mlala@negabit.com
X-Auto-Response-Suppress: All
Branch: refs/heads/test/rule-engine
Home: https://github.com/viridIT/vSMTP
Commit: c6459a4946395ba90182ce7181bdbc327994c038
https://github.com/viridIT/vSMTP/commit/c6459a4946395ba90182ce7181bdbc327994c038
Author: Mathieu Lala <m.lala@viridit.com>
Date: 2022-10-26 (Wed, 26 Oct 2022)
Changed paths:
M src/vsmtp/vsmtp-rule-engine/src/api/message.rs
M src/vsmtp/vsmtp-rule-engine/src/lib.rs
M src/vsmtp/vsmtp-test/src/vsl.rs
Log Message:
-----------
test: add test on message
"#;
preq: [
rule "verify dkim" || {
dkim::verify();
if !msg::get_header("Authentication-Results").contains("dkim=fail") {
return state::deny();
}
state::accept();
}
]
}
fn
sign
fn sign(params: Map) -> ()
selector
- the DNS selector to expose the public key & for the verifierprivate_key
- the private key to sign the mail, associated with the public key in theselector._domainkey.sdid
DNS record.headers_field
- list of headers to sign.canonicalization
- the canonicalization algorithm to use. (ex: “simple/relaxed”)
preq
and onwards.
preq: [
action "sign dkim" || {
for private_key in dkim::get_private_keys("testserver.com") {
dkim::sign(#{
// default: server_name()
sdid: "testserver.com",
// mandatory
selector: "2022-09",
// mandatory
private_key: private_key,
// default: ["From", "To", "Date", "Subject", "From"]
headers: ["From", "To", "Date", "Subject", "From"],
// default: "simple/relaxed"
canonicalization: "simple/relaxed"
});
}
},
]
}
global::dmarc
Domain-based message authentication, reporting and conformance implementation specified by RFC 7489. (https://www.rfc-editor.org/rfc/rfc7489)
fn
check
fn check() -> Status
preq
and onwards.
#{
preq: [
rule "check dmarc" || { dmarc::check() },
]
}
global::dns
Functions used to query the DNS.
fn
lookup
fn lookup(name: SharedObject) -> Array
fn lookup(name: String) -> Array
host
- A valid hostname to search.
array
- an array of IPs. The array is empty if no IPs were found for the host.
All of them.
- Root resolver was not found.
- Lookup failed.
#{
preq: [
action "lookup recipients" || {
let domain = "gmail.com";
let ips = dns::lookup(domain);
print(`ips found for ${domain}`);
for ip in ips { print(`- ${ip}`); }
},
],
}
fn
rlookup
fn rlookup(name: String) -> Array
fn rlookup(name: SharedObject) -> Array
ip
- The IP to query.
array
- an array of FQDNs. The array is empty if nothing was found.
All of them.
- Failed to convert the
ip
parameter from a string into an IP. - Reverse lookup failed.
#{
connect: [
rule "rlookup" || {
state::accept(`250 client ip: ${"127.0.0.1"} -> ${dns::rlookup("127.0.0.1")}`);
}
],
}
global::transport
Functions to configure delivery methods of emails.
fn
forward
fn forward(rcpt: String, forward: SharedObject) -> ()
fn forward(rcpt: SharedObject, forward: SharedObject) -> ()
fn forward(rcpt: String, forward: String) -> ()
fn forward(rcpt: SharedObject, forward: String) -> ()
rcpt
- the recipient to apply the method to.target
- the target to forward the email to.
All of them.
#{
rcpt: [
action "forward (str/str)" || {
envelop::add_rcpt("my.address@foo.com");
transport::forward("my.address@foo.com", "127.0.0.1");
},
action "forward (obj/str)" || {
let rcpt = address("my.address@bar.com");
envelop::add_rcpt(rcpt);
transport::forward(rcpt, "127.0.0.2");
},
action "forward (str/obj)" || {
let target = ip6("::1");
envelop::add_rcpt("my.address@baz.com");
transport::forward("my.address@baz.com", target);
},
action "forward (obj/obj)" || {
let rcpt = address("my.address@boz.com");
envelop::add_rcpt(rcpt);
transport::forward(rcpt, ip4("127.0.0.4"));
},
],
}
#
#
#
Or with url:
#{
rcpt: [
action "set forward" || {
let user = "root@domain.tld";
let pass = "xxxxxx";
let host = "smtp.domain.tld";
let port = 25;
transport::forward_all(`smtp://${user}:${pass}@${host}:${port}?tls=opportunistic`);
},
]
}
#
fn
forward_all
fn forward_all(forward: String) -> ()
fn forward_all(forward: SharedObject) -> ()
target
- the target to forward the email to.
All of them.
#{
rcpt: [
action "forward_all" || {
envelop::add_rcpt("my.address@foo.com");
envelop::add_rcpt("my.address@bar.com");
transport::forward_all("127.0.0.1");
},
action "forward_all (obj)" || {
envelop::add_rcpt("my.address@foo2.com");
envelop::add_rcpt("my.address@bar2.com");
transport::forward_all(ip4("127.0.0.1"));
},
],
}
fn
deliver
fn deliver(rcpt: String) -> ()
fn deliver(rcpt: SharedObject) -> ()
rcpt
- the recipient to apply the method to.
All of them.
#{
rcpt: [
action "deliver (str/str)" || {
envelop::add_rcpt("my.address@foo.com");
transport::deliver("my.address@foo.com");
},
action "deliver (obj/str)" || {
let rcpt = address("my.address@bar.com");
envelop::add_rcpt(rcpt);
transport::deliver(rcpt);
},
action "deliver (str/obj)" || {
let target = ip6("::1");
envelop::add_rcpt("my.address@baz.com");
transport::deliver("my.address@baz.com");
},
action "deliver (obj/obj)" || {
let rcpt = address("my.address@boz.com");
envelop::add_rcpt(rcpt);
transport::deliver(rcpt);
},
],
}
fn
deliver_all
fn deliver_all() -> ()
All of them.
#{
delivery: [
action "setup delivery" || transport::deliver_all(),
]
}
#{
rcpt: [
action "deliver_all" || {
envelop::add_rcpt("my.address@foo.com");
envelop::add_rcpt("my.address@bar.com");
transport::deliver_all();
},
],
}
fn
mbox
fn mbox(rcpt: String) -> ()
fn mbox(rcpt: SharedObject) -> ()
rcpt
- the recipient to apply the method to.
All of them.
#{
delivery: [
action "setup mbox" || transport::mbox("john.doe@example.com"),
]
}
#{
rcpt: [
action "setup mbox" || {
const doe = address("doe@example.com");
envelop::add_rcpt(doe);
envelop::add_rcpt("a@example.com");
transport::mbox(doe);
transport::mbox("a@example.com");
},
],
}
fn
mbox_all
fn mbox_all() -> ()
All of them.
#{
delivery: [
action "setup mbox" || transport::mbox_all(),
]
}
#{
rcpt: [
action "setup mbox" || {
const doe = address("doe@example.com");
envelop::add_rcpt(doe);
envelop::add_rcpt("a@example.com");
transport::mbox_all();
},
],
}
fn
maildir
fn maildir(rcpt: SharedObject) -> ()
fn maildir(rcpt: String) -> ()
rcpt
- the recipient to apply the method to.
All of them.
#{
rcpt: [
action "setup maildir" || {
const doe = address("doe@example.com");
envelop::add_rcpt(doe);
envelop::add_rcpt("a@example.com");
transport::maildir(doe);
transport::maildir("a@example.com");
},
],
}
fn
maildir_all
fn maildir_all() -> ()
All of them.
#{
delivery: [
action "setup maildir" || transport::maildir_all(),
]
}
#{
rcpt: [
action "setup maildir" || {
const doe = address("doe@example.com");
envelop::add_rcpt(doe);
envelop::add_rcpt("a@example.com");
transport::maildir_all();
},
],
}
global::fs
APIs to interact with the file system.
fn
write
fn write(dir: String) -> ()
dir
- the directory where to store the email. Relative to the application path.
preq
and onwards.
#{
preq: [
action "write to file" || fs::write("archives"),
]
}
fn
dump
fn dump(dir: String) -> ()
dir
- the directory where to store the email. Relative to the application path.
preq
and onwards.
#{
preq: [
action "write to file" || fs::dump("metadata"),
]
}
global::time
Utilities to get the current time and date.
fn
now
fn now() -> String
string
- the current time.
All of them.
#{
preq: [
action "append info header" || {
msg::append_header("X-VSMTP", `email received by ${utils::hostname()} the ${time::date()} at ${time::now()}.`);
}
]
}
fn
date
fn date() -> String
string
- the current date.
All of them.
#{
preq: [
action "append info header" || {
msg::append_header("X-VSMTP", `email received by ${utils::hostname()} the ${time::date()}.`);
}
]
}
global::utils
Utility functions to interact with the system.
fn
get_root_domain
fn get_root_domain(domain: SharedObject) -> String
fn get_root_domain(domain: String) -> String
foo.bar.example.com
=> example.com
fn
env
fn env(variable: String) -> ?
fn env(variable: SharedObject) -> ?
variable
- the variable to fetch.
string
- the value of the fetched variable.()
- when the variable is not set, when the variable contains the sign character (=) or the NUL character, or that the variable does not contain valid Unicode.
#{
connect: [
rule "get env variable" || {
// get the HOME environment variable.
let home = utils::env("HOME");
// "VSMTP=ENV" is malformed, this will return the unit type '()'.
let invalid = utils::env("VSMTP=ENV");
// ...
}
],
}
global::code
Predefined codes for SMTP responses.
fn
c554_7_1
fn c554_7_1() -> SharedObject
#{
mail: [
// Will send "554 5.7.1 Relay access denied" to the client.
rule "anti relay" || { state::deny(code::c554_7_1()) }
]
}
fn
c550_7_20
fn c550_7_20() -> SharedObject
#{
mail: [
// Will send "550 5.7.20 No passing DKIM signature found" to the client.
rule "deny with code" || { state::deny(code::c550_7_20()) }
]
}
fn
c550_7_21
fn c550_7_21() -> SharedObject
#{
mail: [
// Will send "550 5.7.21 No acceptable DKIM signature found" to the client.
rule "deny with code" || { state::deny(code::c550_7_21()) }
]
}
fn
c550_7_22
fn c550_7_22() -> SharedObject
#{
mail: [
// Will send "550 5.7.22 No valid author-matched DKIM signature found" to the client.
rule "deny with code" || { state::deny(code::c550_7_22()) }
]
}
fn
c550_7_23
fn c550_7_23() -> SharedObject
#{
mail: [
// Will send "550 5.7.23 SPF validation failed" to the client.
rule "deny with code" || { state::deny(code::c550_7_23()) }
]
}
fn
c550_7_24
fn c550_7_24() -> SharedObject
#{
mail: [
// Will send "550 5.7.24 SPF validation error" to the client.
rule "deny with code" || { state::deny(code::c550_7_24()) }
]
}
fn
c550_7_25
fn c550_7_25() -> SharedObject
#{
mail: [
// Will send "550 5.7.25 Reverse DNS validation failed" to the client.
rule "deny with code" || { state::deny(code::c550_7_25()) }
]
}
fn
c500_7_26
fn c500_7_26() -> SharedObject
#{
mail: [
// Will send "500 5.7.26 Multiple authentication checks failed" to the client.
rule "deny with code" || { state::deny(code::c500_7_26()) }
]
}
fn
c550_7_27
fn c550_7_27() -> SharedObject
#{
mail: [
// Will send "550 5.7.27 Sender address has null MX" to the client.
rule "deny with code" || { state::deny(code::c550_7_27()) }
]
}
fn
c556_1_10
fn c556_1_10() -> SharedObject
#{
mail: [
// Will send "556 5.1.10 Recipient address has null MX" to the client.
rule "deny with code" || { state::deny(code::c556_1_10()) }
]
}
fn
c451_7_1
fn c451_7_1() -> SharedObject
#{
mail: [
// Will send "451 4.7.1 Sender is not authorized. Please try again." to the client.
rule "deny with code" || { state::deny(code::c451_7_1()) }
]
}
fn
c451_3_0
fn c451_3_0() -> SharedObject
#{
mail: [
// Will send "451 4.3.0 Multiple destination domains per transaction is unsupported. Please try again." to the client.
rule "deny with code" || { state::deny(code::c451_3_0()) }
]
}
fn
c550_1_1
fn c550_1_1() -> SharedObject
#{
mail: [
// Will send "550 5.1.1 No passing DKIM signature found" to the client.
rule "deny with code" || { state::deny(code::c550_1_1()) }
]
}
global::net
Predefined network ip ranges.
fn
rg_192
fn rg_192() -> SharedObject
#{
rcpt: [
rule "anti relay" || { if ctx::client_ip() in net::rg_192() { state::next() } else { state::deny() } }
]
}
fn
rg_172
fn rg_172() -> SharedObject
#{
rcpt: [
rule "anti relay" || { if ctx::client_ip() in net::rg_172() { state::next() } else { state::deny() } }
]
}
fn
rg_10
fn rg_10() -> SharedObject
#{
rcpt: [
rule "anti relay" || { if ctx::client_ip() in net::rg_10() { state::next() } else { state::deny() } }
]
}
fn
non_routable
fn non_routable() -> Array
global::obj
vSL objects declaration functions. vSL objects utility methods. vSL objects Eq method between each other and other types.
fn
ip4
fn ip4(ip: String) -> VSLObject
get
domain
fn get domain(addr: VSLObject) -> VSLObject
address
- the address to extract the domain from.
All of them.
#{
mail: [
// You can also use the `get_domain(ctx::mail_from())` syntax.
action "display sender's domain" || {
log("info", `received a message from domain ${ctx::mail_from().domain}.`);
}
],
}
op
==
op ==(this: SharedObject, s: String) -> bool
op ==(this: SharedObject, other: SharedObject) -> bool
op ==(this: String, other: SharedObject) -> bool
get
domains
fn get domains(container: Array) -> Array
rcpt_list
- the recipient list.
mail
and onwards.
#{
mail: [
action "display recipients domains" || {
print("list of recipients domains:");
// You can also use the `get_domains(ctx::rcpt_list())` syntax.
for domain in ctx::rcpt_list().domains {
print(`- ${domain}`);
}
}
],
}
fn
to_string
fn to_string(this: VSLObject) -> String
fn
to_debug
fn to_debug(this: VSLObject) -> String
fn
regex
fn regex(regex: String) -> VSLObject
fn
contains
fn contains(this: SharedObject, other: SharedObject) -> bool
fn contains(this: SharedObject, s: String) -> bool
fn contains(map: Map, object: SharedObject) -> bool
fn
identifier
fn identifier(identifier: String) -> VSLObject
fn
code
fn code(code: int, text: String) -> VSLObject
fn code(code: int, enhanced: String, text: String) -> VSLObject
let code = code(250, "Ok");
let enhanced = code(451, "5.7.3", "STARTTLS is required to send mail");
op
==
op ==(this: SharedObject, s: String) -> bool
op ==(this: SharedObject, other: SharedObject) -> bool
op ==(this: String, other: SharedObject) -> bool
fn
fqdn
fn fqdn(domain: String) -> VSLObject
get
domain
fn get domain(addr: VSLObject) -> VSLObject
address
- the address to extract the domain from.
All of them.
#{
mail: [
// You can also use the `get_domain(ctx::mail_from())` syntax.
action "display sender's domain" || {
log("info", `received a message from domain ${ctx::mail_from().domain}.`);
}
],
}
fn
code
fn code(code: int, text: String) -> VSLObject
fn code(code: int, enhanced: String, text: String) -> VSLObject
let code = code(250, "Ok");
let enhanced = code(451, "5.7.3", "STARTTLS is required to send mail");
fn
ip4
fn ip4(ip: String) -> VSLObject
fn
rg6
fn rg6(range: String) -> VSLObject
get
domains
fn get domains(container: Array) -> Array
rcpt_list
- the recipient list.
mail
and onwards.
#{
mail: [
action "display recipients domains" || {
print("list of recipients domains:");
// You can also use the `get_domains(ctx::rcpt_list())` syntax.
for domain in ctx::rcpt_list().domains {
print(`- ${domain}`);
}
}
],
}
fn
to_debug
fn to_debug(this: VSLObject) -> String
fn
to_string
fn to_string(this: VSLObject) -> String
Variables
The following modules lists all variables available by default to configure vSMTP or use in filtering.
app
#![allow(unused)] fn main() { let app = #{ "dirpath": "/var/spool/vsmtp/app", "logs": #{ "filename": "/var/log/vsmtp/app.log", }, "vsl": #{ "domain_dir": (), "filter_path": (), }, } }
server
#![allow(unused)] fn main() { let server = #{ "client_count_max": 16, "dns": #{ "type": "system", }, "interfaces": #{ "addr": [ "127.0.0.1:25", ], "addr_submission": [ "127.0.0.1:587", ], "addr_submissions": [ "127.0.0.1:465", ], }, "logs": #{ "filename": "/var/log/vsmtp/vsmtp.log", "level": [ "warn", ], "system": (), }, "message_size_limit": 10000000, "name": "deadass", "queues": #{ "delivery": #{ "channel_size": 32, "deferred_retry_max": 100, "deferred_retry_period": "5m", }, "dirpath": "/var/spool/vsmtp", "working": #{ "channel_size": 32, }, }, "smtp": #{ "auth": (), "codes": #{ "AlreadyUnderTls": "554 5.5.1 Error: TLS already active\r\n", "AuthClientCanceled": "501 Authentication canceled by client\r\n", "AuthClientMustNotStart": "501 5.7.0 Client must not start with this mechanism\r\n", "AuthErrorDecode64": "501 5.5.2 Invalid, not base64\r\n", "AuthInvalidCredentials": "535 5.7.8 Authentication credentials invalid\r\n", "AuthMechNotSupported": "504 5.5.4 Mechanism is not supported\r\n", "AuthMechanismMustBeEncrypted": "538 5.7.11 Encryption required for requested authentication mechanism\r\n", "AuthSucceeded": "235 2.7.0 Authentication succeeded\r\n", "AuthTempError": "454 4.7.0 Temporary authentication failure\r\n", "BadSequence": "503 Bad sequence of commands\r\n", "Closing": "221 Service closing transmission channel\r\n", "ConnectionMaxReached": "554 Cannot process connection, closing\r\n", "DataStart": "354 Start mail input; end with <CRLF>.<CRLF>\r\n", "Denied": "554 permanent problems with the remote server\r\n", "Failure": "451 Requested action aborted: local error in processing\r\n", "Greetings": "220 {name} Service ready\r\n", "Helo": "250 Ok\r\n", "Help": "214 joining us https://viridit.com/support\r\n", "MessageSizeExceeded": "552 4.3.1 Message size exceeds fixed maximum message size\r\n", "Ok": "250 Ok\r\n", "ParameterUnimplemented": "504 Command parameter not implemented\r\n", "SyntaxErrorParams": "501 Syntax error in parameters or arguments\r\n", "Timeout": "451 Timeout - closing connection\r\n", "TlsGoAhead": "220 TLS go ahead\r\n", "TlsNotAvailable": "454 TLS not available due to temporary reason\r\n", "TooManyError": "451 Too many errors from the client\r\n", "TooManyRecipients": "452 Requested action not taken: too many recipients\r\n", "Unimplemented": "502 Command not implemented\r\n", "UnrecognizedCommand": "500 Syntax error command unrecognized\r\n", }, "error": #{ "delay": "5s", "hard_count": 20, "soft_count": 10, }, "rcpt_count_max": 1000, "timeout_client": #{ "connect": "5m", "data": "5m", "helo": "5m", "mail_from": "5m", "rcpt_to": "5m", }, }, "system": #{ "group": "vsmtp", "group_local": (), "thread_pool": #{ "delivery": 6, "processing": 6, "receiver": 6, }, "user": "vsmtp", }, "tls": (), "virtual": #{}, } }
Plugins
The following modules lists all functions, operators and variables exposed by vSMTP plugins.
global::cmd
This module exposes the cmd
function, allowing vSMTP to execute system commands.
fn
build
fn build(parameters: Map) -> Cmd
parameters
- a map of the following parameters:command
- the command to execute.timeout
- a duration after which the command subprocess will be killed.args
- an array of parameters passed to the executed program. (optional)user
- a user to run the command with. (optional)group
- a group to run the command with. (optional)
A service used to execute the a command.
- The service failed to parse the command parameters.
export const echo = cmd::build(#{
command: "echo",
args: ["-e", "'Hello World. \c This is vSMTP.'"],
timeout: "10s",
});
fn
run
fn run(cmd: Cmd) -> Map
fn run(cmd: Cmd, args: Array) -> Map
The command output.
- The service failed to execute the command.
const echo = cmd::build(#{
command: "echo",
args: ["-e", "'Hello World. \c This is vSMTP.'"],
timeout: "10s",
});
// the command executed will be:
// echo -e 'Hello World. \c This is vSMTP.'
echo.run();
// run the command with custom arguments (based one are replaced).
// echo -n 'Hello World.'
echo.run([ "-n", "'Hello World.'" ]);
global::smtp
fn
connect
fn connect(parameters: Map) -> Smtp
parameters
- a map of the following parameters:delegator
- a map of the following parameters.address
- the address to connect to the third-party softwaretimeout
- timeout between each SMTP commands. (optional, default: 30s)
receiver
- the socket to get back the result from.
A service used to delegate a message.
- The service failed to parse the command parameters.
- The service failed to connect to the
delegator
address.
// declared in /etc/vsmtp/services/smtp.vsl
export const clamsmtpd = smtp::connect(#{
delegator: #{
// The service address to delegate to.
address: "127.0.0.1:10026",
// The time allowed between each message before timeout.
timeout: "2s",
},
// The address where vsmtp will gather the results of the delegation.
// The third party software should be configured to send the email back at this address.
receiver: "127.0.0.1:10024",
});
The service is then used in a rule file using the following syntax:
import "service/smtp" as srv;
#{
postq: [
// this will delegate the email using the `clamsmtpd` service.
delegate srv::clamsmtpd "delegate antivirus processing" || {
// this is executed after the delegation results have been
// received on port 10024.
}
]
}
global::mysql
This plugin exposes methods to open a pool of connexions to a mysql database using Rhai.
fn
connect
fn connect(parameters: Map) -> MySQL
parameters
- a map of the following parameters:url
- a string url to connect to the database.timeout
- time allowed between each query to the database. (default: 30s)connections
- Number of connections to open to the database. (default: 4)
A service used to query the database pointed by the url
parameter.
- The service failed to connect to the database.
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_mysql" as mysql;
export const database = mysql::connect(#{
// Connect to a database on the system with the 'greylist-manager' user and 'my-password' password.
url: "mysql://localhost/?user=greylist-manager&password=my-password",
timeout: "1m",
connections: 1,
});
fn
query
fn query(database: MySQL, query: SharedObject) -> Array
fn query(database: MySQL, query: String) -> Array
query
- The query to execute.
A list of records.
Build a service in services/database.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_mysql" as mysql;
export const database = mysql::connect(#{
// Connect to a database on the system with the 'greylist-manager' user and 'my-password' password.
url: "mysql://localhost/?user=greylist-manager&password=my-password",
timeout: "1m",
connections: 1,
});
Query the database during filtering.
import "services/database" as srv;
#{
connect: [
action "get records from my database" || {
// For the sake of this example, we assume that there is a populated
// table called 'my_table' in the database.
const records = srv::database.query("SELECT * FROM my_table");
// `records` is an array, we can run a for loop and print all records.
log("info", "fetching mysql records ...");
for record in records {
log("info", ` -> ${record}`);
}
}
],
}
global::memcached
This plugin exposes methods to open a pool of connexions to a memached server using Rhai.
fn
connect
fn connect(parameters: Map) -> Cache
parameters
- a map of the following parameters:url
- a string url to connect to the server.timeout
- time allowed between each interaction with the server. (default: 30s)connections
- Number of connections to open to the server. (default: 4)
A service used to access the memcached server pointed by the url
parameter.
- The service failed to connect to the server.
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const cache = cache::connect(#{
// Connect to a server on the port 11211 with a timeout.
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
fn
flush
fn flush(cache: Cache) -> ()
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Flush all cache during filtering.
import "services/cache" as srv;
#{
connect: [
action "flush the cache" || {
srv::cache.flush();
}
],
}
fn
get
fn get(cache: Cache, key: String) -> ?
key
- The key you want to get the value from
A rhai::Dynamic with the value inside
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Get the value wanted during filtering.
import "services/cache" as srv;
#{
connect: [
action "get value from my memcached server" || {
// For the sake of this example, we assume that there is a "client_ip" as a key and "0.0.0.0" as its value.
const client_ip = srv::cache.get("client_ip");
log("info", `ip of my client is: ${client_ip}`);
}
],
}
fn
get_with_cas
fn get_with_cas(cache: Cache, key: String) -> ?
key
- The key you want to get the value from
A rhai::Dynamic with the values inside. Keys are “value”, “expiration”, “cas_id”. If the key doesn’t exist, returns 0
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Get the value wanted during filtering.
import "services/cache" as srv;
#{
connect: [
action "get value from my memcached server" || {
// For the sake of this example, we assume that there is a "client_ip" as a key and "0.0.0.0" as its value.
const cas_id = srv::cache.get_with_cas("client_ip").cas_id;
log("info", `id is: ${cas_id}`);
}
],
}
fn
gets
fn gets(cache: Cache, keys: Array) -> ?
keys
- The keys you want to get the values from
A rhai::Map<String, rhai::Dynamic> with the values inside
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Gets all the values wanted during filtering.
import "services/cache" as srv;
#{
connect: [
action "get value from my memcached server" || {
// For the sake of this example, we assume that there is a server filled with multiple values
const client_ips = srv::cache.gets(["client1_ip", "client2_ip", "client3_ip"]);
log("info", `client 1: ${client_ips["client1_ip"]}`);
log("info", `client 2: ${client_ips["client2_ip"]}`);
log("info", `client 3: ${client_ips["client3_ip"]}`);
}
],
}
fn
gets_with_cas
fn gets_with_cas(cache: Cache, keys: Array) -> ?
keys
- The keys you want to get the values from
A rhai::Arrayrhai::Map with the values cas_id, expiration, key and value inside
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Gets all the values wanted during filtering.
import "services/cache" as srv;
#{
connect: [
action "get value from my memcached server" || {
// For the sake of this example, we assume that there is a server filled with multiple values
const entries = srv::memcached.gets_with_cas(["client1_ip", "client2_ip", "client3_ip"]);
const cas_id = entries.find(|v| v.key == "client1_ip").cas_id;
log("info", `id is: ${cas_id}`);
}
],
}
fn
set
fn set(cache: Cache, key: String, value: ?, duration: int) -> ()
key
- The key you want to allocate with the valuevalue
- The value you want to storeduration
- The duration time you want the value to remain in cache
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Set a value during filtering.
import "services/cache" as srv;
#{
connect: [
action "set value into my memcached server" || {
srv::cache.set("client_ip", "0.0.0.0", 0);
const client_ip = srv::cache.get("client_ip");
log("info", `ip of my client is: ${client_ip}`);
}
],
}
fn
cas
fn cas(cache: Cache, key: String, value: ?, expiration: int, cas_id: int) -> bool
key
- The key you want to swapvalue
- The value you want to storeexpiration
- The duration time you want the value to remain in cachecas_id
- The id which is obtained from a previous call to gets
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Compare and swap a value during filtering
import "services/cache" as srv;
#{
connect: [
action "cas a key in the server" || {
srv::cache.set("foo", "bar", 0);
let result = srv::cache.get_with_cas("foo");
srv::cache.cas("foo", "bar2", 0, result.cas_id);
}
],
}
fn
add
fn add(cache: Cache, key: String, value: ?, duration: int) -> ()
key
- The key you want to allocate with the valuevalue
- The value you want to storeduration
- The duration time you want the value to remain in cache
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Add a value during filtering.
import "services/cache" as srv;
#{
connect: [
action "add value into my memcached server" || {
// Will get an error if the key already exists
srv::cache.add("client_ip", "0.0.0.0", 0);
const client_ip = srv::cache.get("client_ip");
log("info", `ip of my client is: ${client_ip}`);
}
],
}
fn
replace
fn replace(cache: Cache, key: String, value: ?, duration: int) -> ()
key
- The key you want to replace with the valuevalue
- The value you want to storeduration
- The duration time you want the value to remain in cache
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Replace a value during filtering.
import "services/cache" as srv;
#{
connect: [
action "replace value into my memcached server" || {
srv::cache.set("client_ip", "0.0.0.0", 0);
// Will get an error if the key doesn't exist
srv::cache.replace("client_ip", "255.255.255.255", 0);
const client_ip = srv::cache.get("client_ip");
log("info", `ip of my client is: ${client_ip}`);
}
],
}
fn
append
fn append(cache: Cache, key: String, value: ?) -> ()
key
- The key you want to append with the valuevalue
- The value you want to append
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Append a value during filtering.
import "services/cache" as srv;
#{
connect: [
action "append value into my memcached server" || {
srv::cache.set("client_ip", "0.0.", 0);
// Will get an error if the key doesn't exist
srv::cache.append("client_ip", "0.0");
const client_ip = srv::cache.get("client_ip");
log("info", `ip of my client is: ${client_ip}`);
}
],
}
fn
prepend
fn prepend(cache: Cache, key: String, value: ?) -> ()
key
- The key you want to prepend with the valuevalue
- The value you want to prepend
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Prepend a value during filtering.
import "services/cache" as srv;
#{
connect: [
action "prepend value into my memcached server" || {
srv::cache.set("client_ip", ".0.0", 0);
// Will get an error if the key doesn't exist
srv::cache.prepend("client_ip", "0.0");
const client_ip = srv::cache.get("client_ip");
log("info", `ip of my client is: ${client_ip}`);
}
],
}
fn
delete
fn delete(cache: Cache, key: String) -> bool
key
- The key you want the value to be deleted
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Delete a value during filtering.
import "services/cache" as srv;
#{
connect: [
action "delete value into my memcached server" || {
srv::cache.set("client_ip", "0.0.0.0", 0);
srv::cache.delete("client_ip");
// Will return nothing
const client_ip = srv::cache.get("client_ip");
log("info", `ip of my client is: ${client_ip}`);
}
],
}
fn
increment
fn increment(cache: Cache, key: String, value: int) -> ()
key
- The key you want the value to be incrementedvalue
- Amount of the increment
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Increment a value during filtering.
import "services/cache" as srv;
#{
connect: [
action "increment value into my memcached server" || {
srv::cache.set("nb_of_client", 1, 0);
srv::cache.increment("nb_of_client", 21);
const nb_of_client = srv::cache.get("nb_of_client");
log("info", `nb of client is: ${nb_of_client}`);
}
],
}
fn
decrement
fn decrement(cache: Cache, key: String, value: int) -> ()
key
- The key you want the value to be decrementedvalue
- Amount of the Decrement
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Decrement a value during filtering.
import "services/cache" as srv;
#{
connect: [
action "decrement value into my memcached server" || {
srv::cache.set("nb_of_client", 21, 0);
srv::cache.decrement("nb_of_client", 1);
const nb_of_client = srv::cache.get("nb_of_client");
log("info", `nb of client is: ${nb_of_client}`);
}
],
}
fn
touch
fn touch(cache: Cache, key: String, duration: int) -> ()
key
- The key you want to change the expiration timeduration
- Amount of expiration time
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Change an expiration time during filtering.
import "services/cache" as srv;
#{
connect: [
action "change expiration time of a value into my memcached server" || {
srv::cache.set("nb_of_client", 21, 5000);
srv::cache.touch("nb_of_client", 0);
const nb_of_client = srv::cache.get("nb_of_client");
log("info", `nb of client is: ${nb_of_client}`);
}
],
}
fn
stats
fn stats(cache: Cache) -> String
A formatted string
Build a service in services/cache.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_memcached" as cache;
export const srv = cache::connect(#{
url: "memcache://localhost:11211",
timeout: "10s",
connections: 1,
});
Display the server statistics during filtering.
import "services/cache" as srv;
#{
connect: [
action "show statistics of my memcached server" || {
const stats = srv::cache.stats();
log("info", stats);
}
],
}
global::ldap
fn
connect
fn connect(parameters: Map) -> Ldap
parameters
- A map with following parameters:url
- A string url to connect to the database.timeout
- Time allowed between each query to the database. (default: 30s)connections
- Number of connections to open to the database. (default: 4)bind
- A map of parameters to execute a simple bind operation: (optional, default: no bind)dn
- The DN used to bind.pw
- The password used to bind.
tls
- A map with the following parameters: (optional, default: no tls)starttls
-true
to use starttls when connecting to the server. (optional, default: false)cafile
- Root certificate path to use when connecting. (optional) If this parameter is not used, the client will load root certificates found in the platform’s native certificate store instead. Be careful since loading native certificates, on some platforms, involves loading and parsing a ~300KB disk file.
A service used to query the server pointed by the url
parameter.
- The service failed to connect to the server.
- The service failed to load root certificates.
It is recommended to create a ldap service in it’s own module.
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_ldap" as ldap;
export const directory = ldap::connect(#{
url: "ldap://ds.example.com:1389 ",
});
fn
search
fn search(database: Ldap, base: String, scope: String, filter: String, attrs: Array) -> Map
base
- The search base, which is the starting point in the DIT for the operation.scope
- The scope, which bounds the number of entries which the operation will consider Can either bebase
,one
orsub
.filter
- An expression computed for all candidate entries, selecting those for which it evaluates to true.attrs
- The list of attributes to retrieve from the matching entries.
A list of entries (as maps) containing the queried attributes for each entry.
result
- Can be “ok” or “error”.entries
- Ifresult
is set to “ok”, contains an array of the following map:dn
- The entry DN.attrs
- The entry attributes that were searched.
error
- Ifresult
is set to “error”, contains a string with the error.
- The connection timed out.
- The scope string is invalid.
Build a service in services/ds.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_ldap" as ldap;
export const directory = ldap::connect(#{
url: "ldap://ds.example.com:389 ",
timeout: "1m",
connections: 10,
});
Search the DS during filtering.
import "services/ds" as srv;
#{
rcpt: [
rule "check recipient in DS" || {
let address = rcpt();
let user = recipient.local_part();
const results = srv::directory.search(
"ou=People,dc=example,dc=com",
// Search the whole tree.
"sub",
// Match on the user id and address.
`(|(uid=${user})(mail=${address}))`
// Get all attributes from the entries.
["*"]
);
// ...
}
],
}
</div>
</div>
</br>
<div markdown="span" style='box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2); padding: 15px; border-radius: 5px;'>
<h2 class="func-name"> <code>fn</code> compare </h2>
```rust,ignore
fn compare(database: Ldap, dn: String, attr: String, val: String) -> bool
dn
- name of the entry.attr
- The attribute use to compare the value.val
- expected value of the attribute.
True, if the attribute matches, false otherwise.
Build a service in services/ds.vsl
;
// Import the plugin stored in the `plugins` directory.
import "plugins/libvsmtp_plugin_ldap" as ldap;
export const directory = ldap::connect(#{
url: "ldap://ds.example.com:389 ",
timeout: "1m",
connections: 10,
});
Compare an entry attribute during filtering.
import "services/ds" as srv;
#{
rcpt: [
rule "check recipient in DS" || {
let address = rcpt();
let user = recipient.local_part();
if srv::directory.compare(
// Find the user in our directory.
`uid=${user},ou=People,dc=example,dc=org`,
// Compare the "address" attribute.
"address",
// Check if the given recipient address is the same as
// the one registered in the directory.
address.to_string(),
) {
log("info", `${user} email address is registered in the directory.`);
} else {
log("warn", `${user}'s email address does not match the one registered in the directory.`);
}
}
],
}
</div>
</div>
</br>
vsmtp
vsmtp
is the binary executed by the vsmtp.service
daemon. It is usually run as a daemon but it can be used with commands to manage the configuration.
$ vsmtp --help
vsmtp 1.0.0
Team viridIT <https://viridit.com/>
Next-gen MTA. Secured, Faster and Greener
USAGE:
vsmtp [OPTIONS] [SUBCOMMAND]
OPTIONS:
-c, --config <CONFIG> Path of the vSMTP configuration file ("vsl" format)
-h, --help Print help information
-n, --no-daemon Do not run the program as a daemon
-t, --timeout <TIMEOUT> Make the server stop after a delay (human readable format)
-V, --version Print version information
SUBCOMMANDS:
config-diff Show the difference between the loaded config and the default one
config-show Show the loaded config (as serialized json format)
help Print this message or the help of the given subcommand(s)
Managing configuration
Loaded configurations can be checked using the config-diff
and config-show
commands.
$ sudo vsmtp -c /etc/vsmtp/vsmtp.vsl config-show
Loading configuration at path='/etc/vsmtp/vsmtp.vsl'
Loaded configuration: {
"server": {
"domain": "testserver.com",
"addr": "0.0.0.0:25",
"addr_submission": "0.0.0.0:587",
"addr_submissions": "0.0.0.0:465",
"thread_count": 10
},
"log": {
"file": "/var/log/vsmtp/vsmtp.log",
"level": {
"receiver": "INFO",
"rules": "WARN",
"default": "WARN",
"resolver": "WARN"
}
},
"reply_codes": {
"Code214": "214 my custom help message\r\n",
"Code220": "220 testserver.com ESMTP Service ready\r\n",
... // etc.
}
}
$ sudo vsmtp -c /etc/vsmtp/vsmtp.vsl config-diff
Loading configuration at path='/etc/vsmtp/vsmtp.vsl'
{
"server": {
"domain": "testserver.com",
"addr": "0.0.0.0:25",
"addr_submission": "0.0.0.0:587",
"addr_submissions": "0.0.0.0:465",
- "thread_count": 2 // DEFAULT configuration
+ "thread_count": 10 // CURRENT configuration
},
"log": {
- "file": "./trash/log.log", // DEFAULT configuration
+ "file": "/var/log/vsmtp/vsmtp.log", // CURRENT configuration
"level": {
- "default": "OFF"
+ "resolver": "WARN",
+ "default": "WARN", // etc.
+ "rules": "WARN",
+ "receiver": "INFO"
}
},
"reply_codes": {
- "Code214": "214 joining us https://viridit.com/support\r\n",
- "Code220": "220 testserver.com Service ready\r\n",
+ "Code214": "214 my custom help message\r\n",
+ "Code220": "220 testserver.com ESMTP Service ready\r\n",
... // etc.
}
}
vqueue
vqueue
is a cli utility that is used to inspect and manage vSMTP’s queues.
Managing queues
Internal and user defined queues can be managed using the vqueue
command with root privileges.
The vqueue show
subcommand displays a summary of vSMTP queues in a Postfix qshape way. The -c
option allows vqueue to parse queues and quarantines defined in the configuration.
WORKING is at '/var/spool/vsmtp/working' : <EMPTY>
DELIVER is at '/var/spool/vsmtp/deliver' :
T 5 10 20 40 80 160 320 640 1280 1280+
TOTAL 4 0 0 0 0 0 0 0 0 4 0
example1.com 1 0 0 0 0 0 0 0 0 1 0
example2.com 1 0 0 0 0 0 0 0 0 1 0
example3.com 1 0 0 0 0 0 0 0 0 1 0
example4.com 1 0 0 0 0 0 0 0 0 1 0
DEFERRED is at '/var/spool/vsmtp/deferred' : <EMPTY>
DEAD is at '/var/spool/vsmtp/dead' : <EMPTY>
Managing messages
Like queues, messages are also managed using the vqueue
command.
vqueue msg <msg-id> show [json | eml]
: Print the content of a message.vqueue msg <msg-id> move <queue>
: Move a message to a queue.vqueue msg <msg-id> remove
: Remove a message from disk.
Doe’s family: Step-by-step tutorial
In this tutorial we suppose that Doe’s family (John, Jane and their children Jimmy and Jenny) wants to self-host their mailboxes.
Context
They use a router/firewall to route and secure their home network. They also setup a small server in the DMZ to manage the family website, emails, etc.
{ Internet ISP } --- Firewall --- { DMZ }
|
{ Internal Network }
Does family network setup
They also rented a domain name doe-family.com
. They decided to prefix their mailboxes with the common standard “first_name.last_name” like jenny.doe@doe-family.com
Network configuration
vSMTP is installed on an Ubuntu server 20.04 virtual instance hosted by the DMZ server.
{ Internet ISP } --- Firewall --- { DMZ : MTA }
|
{ Internal Network }
- Public IP : 80.80.80.80
- MTA IP : 192.168.1.254/32 - fqdn : mta.doe-family.com
- Internal Network : 192.168.0.0/24
Network setup
# Pseudo code - depends on your FW
#
# Allow SMTP and IMAP from Internet to MTA
Public IP > MTA : TCP/SMTP 25, TCP/SMTP 465, TCP/SMTP 587
# Allow viewing family emails using IMAP/ssl from the Internet
Public IP > MTA : IMAP/ssl 993
# Outgoing SMTP traffic
Internal NET > MTA : TCP/SMTP 25, TCP/SMTP 465, TCP/SMTP 587
MTA > {Internet} : TCP/SMTP 25, TCP/SMTP 465
# DNS traffic from MTA
MTA > {Internet} : UDP/DNS 53, TCP/DNS 53
Firewall rules
MX preference = 1, mail exchanger = vsmtp.doe-family.com
DNS setup
John generated a certificate through the Let’s Encrypt Certificate Authority for vSMTP server.
sudo certbot certonly --manual --preferred-challenges=dns \
--agree-tos -d mta.doe-family.com
Creating the certificate
Basic configuration
vSMTP Configuration
Let’s build a vSMTP configuration step by step.
When installing vSMTP, the package manager creates the following basic configuration.
/etc/vsmtp
+┣ vsmtp.vsl
+┗ conf.d/
+ ┗ config.vsl
vSMTP default configuration
Configure vSMTP
Modify the /etc/vsmtp/conf.d/config.vsl
file with this configuration:
fn on_config(config) {
// Name of the server.
config.server.name = "doe-family.com";
// addresses that the server will listen to.
// (change `192.168.1.254` for the desired address)
config.server.interfaces = #{
addr: ["192.168.1.254:25"],
addr_submission: ["192.168.1.254:587"],
addr_submissions: ["192.168.1.254:465"],
};
config
}
Configuring vSMTP
For complex configurations, it is recommended to split the file into Rhai modules.
To get an exhaustive list of parameters that can be changed in the configuration, see the Configuration Reference chapter.
The server can now listen and serve SMTP connections.
Filtering objects
Let’s define all the required objects for John Doe’s MTA. Those objects are used to configure vSMTP and simplify filtering rules.
Create the /etc/vsmtp/objects/family.vsl
file with following objects:
// Doe's family domain name.
export const domain = fqdn("doe-family.com");
// Mailboxes.
export const john = address("john.doe@doe-family.com");
export const jane = address("jane.doe@doe-family.com");
export const jimmy = address("jimmy.doe@doe-family.com");
export const jenny = address("jenny.doe@doe-family.com");
export const addresses = [john, jane, jimmy, jenny];
// Paths for quarantines.
export const virus_queue = "virus";
export const untrusted_queue = "untrusted";
// A user blacklist file
export const blacklist = file("domain-available/example.com/blacklist.txt", "fqdn");
Objects that will be used during filtering
See the Object chapter for more information.
Blacklist
Define a blacklist file at /etc/vsmtp/domain-available/example.com/blacklist.txt
with the following contents:
domain-spam.com
spam-domain.org
domain-spammers.com
foobar-spam-pro.org
Blacklist content
Listen and serve
The file structure of /etc/vsmtp
should now look like this.
/etc/vsmtp/
┣ vsmtp.vsl
┣ conf.d/
┃ ┗ config.vsl
┣ domain-available/
+┃ ┗ example.com/
+┃ ┗ blacklist.txt
+┗ objects/
+ ┗ family.vsl
Adding objects and the blacklist to the configuration directory
If no interface is specified, the server listens on localhost on port 25, 465 and 587. Remote connections are therefore refused.
$> sudo systemd restart vsmtp
$> telnet 192.168.1.254:25
# 220 doe-family.com Service ready
# 554 permanent problems with the remote server
Test by opening a connexion to the server
Filtering
By default, the server will deny any SMTP transaction. We have to define filtering rules to accept connections and filter messages.
In this chapter, we will get a glimpse of vSMTP’s filtering system. To create filtering rules, we recommend checking out the vSL reference, focussing on the following chapters:
For this example, we will configure the following rules:
- Messages from blacklisted domain will be rejected.
- As Jenny is 11 years old, Jane wants her address to be added as a blind carbon copy of messages destined to her daughter.
- Messages sent to the family must be delivered in Mailbox format.
Configuration
Let’s first add our filters in the /etc/vsmtp/conf.d/config.vsl
script.
fn on_config(config) {
// Name of the server.
config.server.name = "doe-family.com";
// addresses that the server will listen to.
// (change `192.168.1.254` for the desired address)
config.server.interfaces = #{
addr: ["192.168.1.254:25"],
addr_submission: ["192.168.1.254:587"],
addr_submissions: ["192.168.1.254:465"],
};
+ // Root filter.
+ config.app.vsl.filter_path = "/etc/vsmtp/filter.vsl";
+ // Domain specific filters.
+ config.app.vsl.domain_dir = "/etc/vsmtp/domain-enabled";
config
}
Root Filter
Let’s define the root filter for incoming emails.
/etc/vsmtp/
┣ vsmtp.vsl
+┣ filter.vsl
┣ conf.d/
┃ ┣ config.vsl
┃ ┗ *.vsl
┗ objects/
┗ family.vsl
Adding the root filtering script
The filter.vsl
script is responsible for handling clients that just connected to vSMTP.
Add anti-relaying
Let’s setup anti-relaying by adding the following rule. (See the Root Filter section in the Transaction Context chapter for more details)
#{
rcpt: [
rule "anti relaying" || state::deny(),
]
}
/etc/vsmtp/filter.vsl
Use the blacklist
We can add the blacklist we defined in the Blacklist section to filter out sender domains that we do not trust.
// Importing objects that we defined in the last chapter.
+import "objects/family" as family;
#{
+ mail: [
+ rule "do not deliver untrusted domains" || {
+ if ctx::mail_from().domain in family::blacklist {
+ state::quarantine(family::untrusted_queue)
+ } else {
+ state::next()
+ }
+ },
+ ],
rcpt: [
rule "anti relaying" || state::deny(),
]
}
/etc/vsmtp/filter.vsl
The do not deliver untrusted domains
rule will save any email from senders found in the blacklist in a quarantine folder named “untrusted” and will not deliver the email.
Filtering for doe-family.com
Let’s create filtering rules for the doe-family.com
domain.
/etc/vsmtp
┣ vsmtp.vsl
┣ filter.vsl
┣ conf.d/
┃ ┣ config.vsl
┃ ┗ *.vsl
+ ┣ domain-available/
+ ┃ ┗ doe-family.com/
+ ┃ ┣ incoming.vsl
+ ┃ ┣ outgoing.vsl
+ ┃ ┗ internal.vsl
+ ┣ domain-enabled/
┗ objects/
┗ family.vsl
adding filtering scripts for the `doe-family.com` domain
Since we specified in the configuration that the domain-enabled
directory was our domain filtering directory, we need to create a symbolic link to domain-available/doe-family.com
to enable filtering for doe-family.com
.
/etc/vsmtp
┣ vsmtp.vsl
┣ filter.vsl
┣ conf.d/
┃ ┣ config.vsl
┃ ┗ *.vsl
┣ domain-available/
┃ ┗ doe-family.com/
┃ ┣ incoming.vsl
┃ ┣ outgoing.vsl
┃ ┗ internal.vsl
┣ domain-enabled/
+ ┃ ┗ example.com -> /etc/vsmtp/domain-available/doe-family.com
┗ objects/
┗ family.vsl
Enabling the `example.com` domain filtering
vSMTP will pickup incoming.vsl
, outgoing.vsl
and internal.vsl
scripts under a folder with a fully qualified domain name. Those rules will be run following vSMTP’s transaction logic. Let’s define rules for each cases.
Incoming messages
The doe-family.com/incoming.vsl
script is run when the sender of the domain is not doe-family.com
and that recipients domains are doe-family.com
.
Thus, when this script is run, all recipients are guaranteed to have the doe-family.com
domain. We can then deliver emails locally using the Mailbox protocol.
import "objects/family" as family;
#{
delivery: [
action "setup delivery" || {
for rcpt in ctx::rcpt_list() {
// Deliver locally using Mailbox if the recipient is from Doe's family.
if rcpt in family::addresses { transport::mailbox(rcpt) }
}
}
],
}
doe-family.com/incoming.vsl
Jane wants a blind copy of her Jenny’s messages. Let’s create a Rhai function that does exactly that.
/etc/vsmtp
┣ vsmtp.vsl
┣ filter.vsl
┣ conf.d/
┃ ┣ config.vsl
┃ ┗ *.vsl
┣ domain-available/
┃ ┗ doe-family.com/
+┃ ┣ bcc.vsl
┃ ┣ incoming.vsl
┃ ┣ outgoing.vsl
┃ ┗ internal.vsl
┣ domain-enabled/
┃ ┗ example.com -> ...
┗ objects/
┗ family.vsl
adding a new script to the domain
import "objects/family" as family;
fn bcc_jenny() {
// add Jane as a blind carbon copy if the current recipient is Jenny.
if ctx::rcpt() == family::jenny {
bcc(family::jane)
}
}
doe-family.com/bcc.vsl
Now, let’s plug this function to our filtering rules by importing the bcc.vsl
script.
+ import "domain-available/doe-family.com/bcc" as bcc;
import "objects/family" as family;
#{
+ rcpt: [
+ action "bcc jenny" || bcc::bcc_jenny(),
+ ],
delivery: [
action "setup delivery" || {
for rcpt in ctx::rcpt_list() {
// Deliver locally using Mailbox if the recipient is from Doe's family.
if rcpt in family::family_addr { transport::mailbox(rcpt) }
}
}
],
}
doe-family.com/incoming.vsl
With Rhai modules and functions, it becomes easy to reuse code across different rules.
Outgoing messages
doe-family.com/outgoing.vsl
is run when the sender of the domain is doe-family.com
and that recipients domains are not doe-family.com
.
Here, a member of Doe’s family is sending an email to someone else. We just have to verify that the sender is legitimate by asking the client to authenticate itself to vSMTP. If the authentication fails, this probably means that a spammer tried to use our server as a relay. The auth::unix_user()
function automatically denies the transaction if the authentication failed.
#{
authenticate: [
rule "sasl authentication" || auth::unix_user(),
],
mail: [
rule "deny unauthenticated" || {
if auth::is_authenticated() {
state::next()
} else {
state::deny(code(530, "5.7.0", "Authentication required\r\n"))
}
}
]
}
/etc/vsmtp/domain-available/doe-family.com/outgoing.vsl
⚠️ The
auth::unix_user
function uses thetestsaslauthd
program under the hood, itself calling thesaslauthd
daemon. Make sure to install the Cyrus sasl binary package for the targeted distribution and configure thesaslauthd
daemon withMECHANISM="shadow"
in/etc/default/saslauthd
.
See the
auth::is_authenticated
andauth::unix_user
reference for more details.
Internal messages
doe-family.com/internal.vsl
is run when the sender and recipients domains are both doe-family.com
.
Since we already authenticated clients in outgoing.vsl
, we simply have to setup delivery.
// let's reuse our bcc code to add Jane as a blind carbon copy.
import "domain-available/doe-family.com/bcc" as bcc;
#{
rcpt: [
action "bcc jenny" || bcc::bcc_jenny(),
],
delivery: [
// Deliver all recipients locally.
action "setup delivery" || transport::mailbox_all(),
],
}
/etc/vsmtp/domain-available/doe-family.com/internal.vsl
Now that all filtering rules are set for the doe-family.com
domain, let’s restart the server to apply all rules.
sudo systemd restart vsmtp
SSL/TLS
ℹ️ To use SMTPS, you will need a valid TLS certificate and a private key for your server.
vSMTP support X.509 certificates and RSA/PKCS8/EC keys stored in
.pem
files.
TLS can be initiated right after connect on the address submissions,
or with the STARTTLS
mechanism.
fn on_config(config) {
// Add root TLS settings.
config.server.tls = #{
preempt_cipherlist: false,
handshake_timeout: "1000ms",
protocol_version: ["TLSv1.2", "TLSv1.3"],
};
config
}
Adding tls configuration to `/etc/vsmtp/conf.d/config.vsl`
Policy
Rules can then be added to filter out unsecure transactions.
#{
mail: [
rule "deny unencrypted" || {
// It is possible to customize the policy to whitelist some ip for example.
if ctx::is_secured() {
state::next()
} else {
state::deny(code(451, "5.7.3", "Must issue a STARTTLS command first\r\n"))
}
}
]
}
Adding rules to check if the transaction is secured in `/etc/vsmtp/filter.vsl`
See the
ctx::is_secured
reference for more details.
Certificate / SNI
You can host multiple domains on the same server. The certificate resolution of the server is based on the SNI extension.
By default, SNI is required. Meaning both these commands will produce an error.
openssl s_client -starttls smtp -crlf -connect 192.168.1.254:25
openssl s_client -crlf -connect 192.168.1.254:465
To support TLS for a virtual server, add those lines to your configuration.
fn on_domain_config(config) {
config.tls = #{
certificate: "/etc/vsmtp/certs/fullchain.pem",
private_key: "/etc/vsmtp/certs/privkey.pem",
};
config
}
`/etc/vsmtp/domain-available/example.com/config.vsl`
You can then test the connection with the following command:
openssl s_client -starttls smtp -crlf -connect 192.168.1.254:25 -servername example.com
openssl s_client -crlf -connect 192.168.1.254:465 -servername example.com
Default domain
You can specify the certificate and the private key to use by default when no SNI is provided.
fn on_config(config) {
// ...
config.server.tls = #{
// ...
certificate: "/etc/vsmtp/certs/fullchain.pem",
private_key: "/etc/vsmtp/certs/privkey.pem",
};
config
}
Set a default domain `/etc/vsmtp/conf.d/config.vsl`
These command will now work.
openssl s_client -starttls smtp -crlf -connect 192.168.1.254:25
openssl s_client -crlf -connect 192.168.1.254:465
Filtering with the Sender Policy Framework
SPF is an authentication standard used to link a domain name and an email address. it allows email clients to verify that incoming email from a domain comes from a host authorized by the administrator of this domain.
Add a DNS TXT record for SPF
A new DNS record is added into the doe-family.com
DNS zone. It declares that only the server specified in the MX record is allowed to send messages on behalf of Doe’s family.
doe-family.com. TXT "v=spf1 +mx -all"
TODO: add commands
For incoming messages, SPF is configured to check that the sending host is authorized to use the doe-family.com
according to published SPF policy. Rules are executed at the mail
stage.
Edit the /etc/vsmtp/domain-enabled/filter.vsl
file and add the following rule.
#{
mail: [
rule "check spf" || spf::check("both", "soft"),
]
}
Preventing spams using SPF
See the
spf::check
reference for more details.
Filtering with DKIM
DKIM
is an open standard for email authentication used to check the integrity of the content of an email. A signature header is added to the messages. This signature is secured by a key pair (private/public).
We will configure these rules:
- If the sender is an account from Doe’s family, add DKIM signature to the message.
- If the recipient is an account from Doe’s family, verify DKIM signatures.
Add a DKIM record
A new DNS record is added into the doe-family.com
DNS zone. It declares the public key usable to verify the messages.
TODO: add an example.
Configure vSMTP for DKIM
Add the following line to the on_config
function body in the root configuration.
fn on_config(config) {
config.server.dkim.private_key = ["/path/to/private-key"];
config
}
`/etc/vsmtp/conf.d/config.vsl`
Add signatures
The dkim_sign
function is used to sign the email. Use it in the postq
stage like so:
#{
// ... previous rules ...
postq: [
action "sign dkim" || {
// Iterate over all the private keys defined for the server 'doe-family.com'
for key in dkim::get_private_keys("doe-family.com") {
dkim::sign(
// Selector of the DNS record.
"2022-09",
// The private key associated with the public key in `{selector}._domainkey.{sdid}`
// Or `2022-09._domainkey.doe-family.com.` in that case
key,
// Headers to sign with.
["From", "To", "Date", "Subject", "From"],
// Canonicalization algorithm to use.
"simple/relaxed"
);
}
}
],
}
/etc/vsmtp/domain-available/doe-family.com/outgoing.vsl
See the
dkim::sign
reference for more details.
Verify signatures
#{
// ... previous rules ...
postq: [
rule "verify DKIM signatures" || {
if dkim::verify().status == "pass" {
state::accept()
} else {
state::deny()
}
}
],
}
/etc/vsmtp/domain-available/doe-family.com/incoming.vsl
See the
dkim::verify
reference for more details.
Adding an antivirus
Malware remains a scourge. As John is aware of security issues, he decides to add a layer of antivirus directly on the MTA. He installed ClamAV which comes with the clamsmtpd antivirus daemon.
vSMTP support security delegation via the SMTP protocol using .vsl
scripts. In the following tutorial, we are going to setup a delegation workflow that looks like the following.
{ Incoming msg } ---> vSMTP server ---> { Delivered msg }
| ^
| |
clamsmtpd socket | | vSMTP socket
(delegator) | | (receiver)
127.0.0.1:10026 | | 127.0.0.1:10025
| |
v |
{ clamsmtpd daemon } <-> { ClamAV }
Delegating emails to clamav and getting back the results
ClamAV setup
The following example assumes that the clamsmtpd
service is loaded and started with the following configuration:
# The address to send scanned mail to.
# This option is required unless TransparentProxy is enabled
OutAddress: 10025
# Address to listen on (defaults to all local addresses on port 10025)
Listen: 127.0.0.1:10026
# Tells clamav to forward the email to vsmtp
# event thought it found a virus. (it drops the email by default)
Action: pass
clamav configuration at `/etc/clamsmtpd.conf`
sudo systemctl start clamsmtp
sudo systemctl start clamav-daemon
Starting clamav
SMTP Service
Let’s create a smtp
service in the /etc/vsmtp/services/smtp.vsl
script to send incoming emails to clamsmtpd and receive them back on a specific address.
export const clamsmtpd = smtp::connect(#{
delegator: #{
address: "127.0.0.1:10026",
timeout: "60s",
},
receiver: "127.0.0.1:10025",
});
Declaring a SMTP service
The receiver’s socket must be enabled in the root config.
fn on_config(config) {
config.server.interfaces = #{
addr: [
// Receiver for clients.
"192.168.1.254:25",
// Receiver for delegation results from clamav.
"127.0.0.1:10025"
],
};
config
}
Update the root configuration with a receiver for clamav
Filtering
Create the antivirus passthrough using the delegate
keyword in the /etc/vsmtp/domain-available/doe-family.com/incoming.vsl
script.
import "objects/family" as family;
import "services/smtp" as srv;
{
postq: [
delegate srv::clamsmtpd "check email for virus" || {
// this is executed once the delegation result are received.
log("debug", "email analyzed by clamsmtpd.");
// ClamAV inserts the "X-Virus-Infected" header if it found a virus.
if msg::has_header("X-Virus-Infected") {
state::quarantine(family::virus_queue)
} else {
state::next()
}
}
],
}
Moving infected emails in the `virus_queue` quarantine queue.
Compromised emails are quarantined in the virus_queue
folder.
Check out the Delegation chapter for more details.
Greylist
Greylisting is one method to defend a SMTP server against spam.
The goal is to temporarily reject emails from a sender that is not yet in registered in a database, then accepting it back if the sender retries.
To build a greylist, create a database and a vSMTP service. For this tutorial, we will use the mysql database.
The database
Install MySQL
Please follow this great tutorial to install MySQL.
This setup has been tested on Ubuntu 22.04, check out the MySQL website for other systems.
# Install mysql.
$ sudo apt update
$ sudo apt install mysql-server
$ sudo systemctl start mysql.service
# Login as root.
$ sudo mysql
# Replace auth_socket authentication by a simple password.
mysql> ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'your-password';
mysql> exit
# Update root password & remove unnecessary stuff.
$ sudo mysql_secure_installation
# Connect as root with the password.
mysql -u root -p
# Reset auth to auth_socket, this way you can connect with `sudo mysql`
mysql> ALTER USER 'root'@'localhost' IDENTIFIED WITH auth_socket;
Setting up MySQL
Setup a user
To manage your database, you should create a new user with restricted privileges instead of relying on root.
# Here we use localhost as our host but you could also setup your database on another server.
mysql> CREATE USER 'greylist-manager'@'localhost' IDENTIFIED BY 'your-password';
Creating a user named 'greylist-manager'
Setup the database and a table
To create the database, simply put the content of this sql code into a greylist.sql
file.
CREATE DATABASE greylist;
CREATE TABLE greylist.sender(
address varchar(500) NOT null primary key,
user varchar(500) NOT null,
domain varchar(500) NOT null
);
Template for the greylist database
And then run the mysql
command as root to generate the database.
sudo mysql < greylist.sql
Grant necessary privileges to your user on the newly create table.
mysql> GRANT SELECT, INSERT ON greylist.sender TO 'greylist-manager'@'localhost';
The greylist database is now operational.
Setup vSMTP
To setup vSMTP, you first need to create a mysql service, that will enable you to query your database. You can, for example, write it in a services.vsl
file where your main.vsl
is located.
export const greylist = mysql::connect(#{
// Change this url to the url of your database, or keep it like this if the 'greylist-manager' user is setup on localhost.
url: "mysql://localhost/?user=greylist-manager&password=your-password",
});
`services/db.vsl`
The query
function from the mysql
service is used to query a mysql database. Variables are passed to the query using string interpolation.
⚠️ String interpolation can lead to SQL injection if not used properly. Make sure to sanitize your inputs, set only required privileges to the mysql user, and check what kind of data you are injecting.
Create a greylist rule in your root filter.vsl
file.
import "services/db" as db;
#{
// The greylist is effective in the "mail" stage, because the sender
// of the email is received at this stage.
mail: [
rule "greylist" || {
let sender = ctx::mail_from();
// if the sender is not recognized in our database,
// we deny the transaction and write the sender into
// the database.
if db::greylist.query(`SELECT * FROM greylist.sender WHERE address = '${sender}';`) == [] {
// Writing the sender into the database.
db::greylist.query(`
INSERT INTO greylist.sender (user, domain, address)
values ("${sender.local_part}", "${sender.domain}", "${sender}");
`);
// vsl exposes a built-in `greylist` error code.
state::deny(code::c451_7_1())
} else {
// the user is known by the server, the transaction
// can proceed.
state::accept()
}
}
]
}
Declaring a greylist rule
Using the Sender Policy Framework
SPF is an authentication standard used to link a domain name and an email address. it allows email clients to verify that incoming email from a domain comes from a host authorized by the administrator of this domain.
In this tutorial, we will set up SPF for the example.com
domain by:
- Adding DNS TXT records for SPF.
- Add filtering for incoming and outgoing emails using SPF.
Feel free to replace example.com
by your own domain.
Add a DNS TXT record for SPF
A new DNS record is added into the example.com
DNS zone. It declares that only the server specified in the MX record is allowed to send messages on behalf of the domain.
example.com. TXT "v=spf1 +mx -all"
A TXT record with SPF specifications for the `example.com` domain
TODO: add commands
Filtering with SPF
For incoming messages, SPF is configured to check that the sending host is authorized to use the example.com
domain according to published SPF policy. Rules are executed at the mail
stage.
Edit the /etc/vsmtp/filter.vsl
script and add the following rule.
#{
mail: [
rule "check spf" || spf::check("both", "soft"),
]
}
Preventing spams using SPF
See the
spf::check
reference for more details.To check if DKIM is working correctly, check out this site.
Using DKIM
DKIM is an open standard for email authentication used to check the integrity of the content of an email. In this tutorial, we will set up DKIM by:
- Adding DNS TXT records for DKIM.
- Generate keys to encrypt and verify emails.
- Add filtering for incoming and outgoing emails using DKIM.
We will use the example.com
domain for this example, but feel free to replace it by your own domain.
Configure the DNS
A new DNS record is added into the example.com
DNS zone. This record declares the public key usable to verify the messages. (See the What is DKIM
chapter for more details)
TODO: add command line example.
Generate Keys
TODO: add commands using lets encrypt.
vSMTP root configuration
The path to private keys for DKIM can be specified in the /etc/vsmtp/conf.d/config.vsl
script:
fn on_config(config) {
config.server.dkim.private_key = ["/path/to/private-key-1", "/path/to/private-key-2", ...];
config
}
Configuring DKIM keys
It is also possible to configure keys per domain.
fn on_domain_config(config) {
config.dkim.private_key = ["/path/to/private-key-1", "/path/to/private-key-2", ...];
config
}
Configuring DKIM keys for a specific domain (f.e. example.com)
If a key cannot be found for a specific domain, the root dkim keys are used instead.
Add signatures
Sign an email using the dkim::sign
function for outgoing emails.
#{
postq: [
action "sign dkim" || {
// Iterate over all the private keys defined for the server 'example.com'
for key in dkim::get_private_keys("example.com") {
dkim::sign(
// Selector of the DNS record.
"2022-09",
// The private key associated with the public key in `{selector}._domainkey.{sdid}`
// Or `2022-09._domainkey.example.com.` in that case
key,
// Headers to sign with.
["From", "To", "Date", "Subject", "From"],
// Canonicalization algorithm to use.
"simple/relaxed"
);
}
}
],
}
/etc/vsmtp/domain-available/example.com/outgoing.vsl
Verify signatures
Verify DKIM signatures of incoming emails by calling the dkim::verify
function.
#{
// ... previous rules ...
postq: [
rule "verify DKIM signatures" || {
if dkim::verify().status == "pass" {
state::accept()
} else {
state::deny()
}
}
],
}
/etc/vsmtp/domain-available/example.com/incoming.vsl
To check if DKIM is working correctly, check out this site.
Using DMARC
DMARC (Domain-based Message Authentication, Reporting and Conformance) allows email administrators to prevent hackers from impersonating their organization. This type of attack is also called “spoofing” because the message appears to come from the spoofed organization or domain.
This document specifies the vSMTP implementation of the Domain-based Message Authentication, Reporting and Conformance (DMARC) protocol described in RFC 7489.
<!> DMARC reporting and DMARC feedback system are not implemented.
In order to counter spoofing attacks, DMARC uses SPF and DKIM protocols.
When a message appears to come from an organization, but does not pass authentication checks or does not meet the authentication criteria, DMARC policy tells mail servers what action to perform.
The DMARC implementation of vSMTP support the Mail Receiver
part, and apply the policy specified in the DNS record.
We will setup DMARC in this tutorial by:
- Setting up SPF.
- Setting up DKIM.
- Adding filtering using DMARC.
We will use the example.com
domain for this example, but feel free to replace it by your own domain.
Setup SPF
See the Using SPF
tutorial.
Setup DKIM
See the Using SPF
tutorial.
Filtering with DMARC
Add this rule to the domain-available/example.com/incoming.vsl
script.
#{
preq: [
rule "check dmarc" || dmarc::check(),
]
}
Filter emails using DMARC
This rule will conducts SPF and DKIM authentication checks by passing the necessary data to their respective modules. The results of these are passed to the DMARC module along with the Author’s domain. The DMARC module attempts to retrieve a policy from the DNS for that domain. If a policy is found, it is combined with the Author’s domain and the SPF and DKIM results to produce a DMARC policy result (“pass” or “fail”).
Trouble shooting
This section is
Under construction
.
No logs available (daemon mode)
Sometimes, logs do not get initialized fast enough in daemon mode, leading to vSMTP not starting on error.
To fix this, use the server with the --no-daemon
option, it will log error messages on stderr.
$ sudo systemctl start vsmtp
$ sudo systemctl status vsmtp
# this prints a failed start status.
$ vsmtp -c /path/to/config.vsl --no-daemon
# this will print errors on stderr.
Running vsmtp with the `--no-daemon` flag to redirect logs to stderr
Mail Agent
This chapter describes Mail Agents and their purpose in the email ecosystem.
+-----+ +-----+ +------------+
| MUA |-->| MSA |-->| Border MTA |
+-----+ +-----+ +------------+
|
|
V
+----------+
| Internet |
+----------+
|
|
V
+------------------+ +------------+
| Intermediate MTA |<--| Border MTA |
+------------------+ +------------+
|
|
V
+-----+ +-----+
| MDA |-->| MUA |
+-----+ +-----+
Flow of emails in the mail ecosystem
MUA (Mail User Agent)
Client application that allows receiving and sending emails. It can be a desktop application such as Microsoft Outlook/Thunderbird/… or web-based such as Gmail/Hotmail/… (the latter is also called Webmail).
MSA (Mail Submission Agent)
The MSA is responsible for the incoming traffic in the mail system.
A server program that receives mail from an MUA, checks for any errors, and transfers it (via SMTP) to a MTA.
MTA (Mail Transfer Agent)
The MTA is responsible to deliver the mails to the recipients’s mail exchange, using the DNS logics.
A server application that receives mail from the MSA, or from another MTA. It will find (through name servers and the DNS) the MX record from the recipient domain’s DNS zone in order to know how to transfer the mail. It then transfers the mail (with SMTP) to another MTA (which is known as SMTP relaying) or, if the recipient’s server has been reached, to the MDA.
- A “border MTA” is an MTA that acts as a gateway between the general Internet and the users within an organizational boundary.
- A “delivery MTA” (or Mail Delivery Agent or MDA) is an MTA that actually enacts delivery of a message to a user’s inbox or other final delivery.
- An “intermediate MTA” is any MTA that is not a delivery MTA and is also not the first MTA to handle the message.
MDA (Mail Delivery Agent)
A server program that receives mail from the server’s MTA, and stores it into the mailbox. MDA is also known as LDA (Local Delivery Agent).
An example is Dovecot, which is mainly a POP3 and IMAP server allowing an MUA to retrieve mail, but also includes an MDA which takes mail from an MTA and delivers it to the server’s mailbox.
Email Authentication Mechanisms
Prevent scammers from usurping an identity by using its domain name is a must nowadays. This is where the SPF, DKIM and DMARC authentication protocols come into play.
This chapter described how to define vSMTP as a legitimate sender and receiver.
SPF and DKIM : what’s that all about ?
Sender Policy Framework (SPF) specifies the authorized senders in a given domain. It thus allows email clients to check that incoming email from a domain comes from a host authorized by the administrator of this domain.
SPF is an authentication standard that links a domain name and an email address.
The DomainKeys Identified Mail (DKIM) protocol allows the sender to sign its message with its domain name. The DKIM protocol is used to attest that the domain name has not been usurped, and that the message has not been altered during its transmission.
DKIM is an authentication protocol that links a domain name to a message.
The simultaneous use of this two protocols is one of the most effective way to prevent phishers and other fraudsters from impersonating a legitimate sender using its domain name.
The implementation of these protocols improves email deliverability, since senders are better identified by the ISPs (Internet Service Providers) and email clients of your recipients. You then optimize your chances that your emails will
These two protocols are now widely used. A message sent without an SPF and/or DKIM signature is viewed with suspicion by the various email analysis tools and may be flagged as a “spam”.
SPF and DKIM : limitations
SPF has limits. If a message is forwarded, the verification may not be performed, since the address of the sender of the forwarded message is not necessarily included in the list of addresses agreed by the SPF evaluation.
DKIM also has limits. The DKIM signature does not prevent the sender from being considered a spammer if good emailing practices are not applied.
Moreover, SPF and DKIM do not specify the action to apply in case of verification failure. The DMARC protocol comes in, tells the recipient’s server how it should act if the sender’s authentication processes fail.
DMARC standard
Domain-based Message Authentication, Reporting and Conformance, or DMARC, is an authentication standard complementary to SPF and DKIM intended to fight more efficiently against phishing and other spamming practices.
DMARC allows domain holders to tell ISPs and email clients what to do when a signed message from their domain is not formally identified by a SPF or a DKIM mechanism.
ARC (extension to DMARC)
Parts of legitimate messages (subject tags, footers, etc.) can be altered due to forwarding (mailing list, virus scanner, etc.) and thus no longer succeed commonly accepted authentication checks. It can happen when an Internet domain publishes a strict DMARC policy.
ARC captures and conveys authentication results and allows the final recipient to check the authentication status of the message when it arrives at the first ARC-aware MTA.
BIMI
Brand Indicators for Message Identification (BIMI) is a future standard that makes it easier to identify the sender of an email. BIMI coordinates e-mail publishers and domain name owners to allow the latter to display their logos directly at the level of their customers’ e-mail boxes, i.e. next to the name of the issuer. BIMI requires DMARC.
For brands, BIMI is an opportunity to protect their identities by e-mail (fight against “phishing”) and to increase the reach and visibility of their logos. Because these logos are only included in DMARC-authenticated emails, consumers’ trust in their brands is also enhanced.
What is SPF ?
This document describes the vSMTP implementation of the Sender Policy Framework (SPF) protocol. SPF specifications are available in the RFC 7208 document.
SPF is an authentication standard used to link a domain name and an email address. it allows email clients to verify that incoming email from a domain comes from a host authorized by the administrator of this domain.
The SPF framework allows the ADministrative Management Domains (ADMDs) to explicitly authorize hosts to send email. The authorization list is published in the DNS records of the sender’s domain.
DNS records
The type of a SPF record is TXT. There should be only one SPF record per domain. In case of multiple SPF records, an error may be raised.
Here is a basic SPF record example: “only servers in the range 123.123.123.0/24 and MTA (MX) are authorized to send emails from my domain example.com. All other senders are considered unauthorized.”
example.com. TXT "v=spf1 +mx ip4:123.123.123.0/24 -all"
There is also a tilde version: ~all. It warns that other senders are not allowed, but must still be accepted. This “Soft Fail” statement was first introduced for testing purposes, but is now used by various hosting providers.
Use of wildcard records for publishing is discouraged.
Please refer to RFC 7208 for further details.
vSMTP implementation
HELO/EHLO Identity
The RFC 7208 does not enforce a HELO/EHLO verification.
“It is RECOMMENDED that SPF verifiers not only check the “MAIL FROM” identity but also separately check the “HELO” identity […] Additionally, since SPF records published for “HELO” identities refer to a single host, when available, they are a very reliable source of host authorization status. Checking “HELO” before “MAIL FROM” is the RECOMMENDED sequence if both are checked.“
The RFC 5321 tends to normalize the HELO/EHLO arguments to represent the fully qualified domain name of the SMTP client. However the vSMTP SPF verifier is prepared for the identity to be an IP address literal or simply be malformed.
“SPF check can only be performed when the “HELO” string is a valid, multi-label domain name.“
MAIL FROM identity
According to the RFC, “MAIL FROM” check occurs when :
“SPF verifiers MUST check the “MAIL FROM” identity if a “HELO” check either has not been performed or has not reached a definitive policy result.“
Note that RFC5321 allows the reverse-path to be null. In this case, the RFC 7208 defines the “MAIL FROM” identity as the local-part “postmaster” and the “HELO” identity.
Location of checks
As defined by the RFC :
“The authorization check SHOULD be performed during the processing of the SMTP transaction that receives the mail. This reduces the complexity of determining the correct IP address to use as an input to check_host() and allows errors to be returned directly to the sending MTA by way of SMTP replies.”
vSMTP allows the use of the SPF framework only at the “MAIL FROM” stage.
Results of Evaluation
The vSMTP SPF verifier implements results semantically equivalent to the RFC.
Result | Description |
---|---|
none | (a) no syntactically valid DNS domain name was extracted from the SMTP session that could be used as the one to be or (b) no SPF records were retrieved from the DNS. |
neutral | The ADMD has explicitly stated that it is not asserting whether the IP address is authorized. |
pass | The client is authorized to inject mail with the given identity. |
fail | The client is not authorized to use the domain in the given identity. |
softfail | The host is probably not authorized but the ADMD has not published a stronger policy. |
temperror | A transient (generally DNS) error while performing the check. |
permerror | The domain’s published records (DNS) could not be correctly interpreted. |
policy | Added by RFC 8601, Section 2.4 - indicate that some local policy mechanism was applied that augments or even replaces (i.e., overrides) the result returned by the authentication mechanism. The property and value in this case identify the local policy that was applied and the result it returned. |
Results headers
Results should be recorded in the message header. Two options are available according to the RFC:
- The “Received-SPF” header
Received-SPF: pass (mybox.example.org: domain of myname@example.com
designates 192.0.2.1 as permitted sender)
receiver=mybox.example.org; client-ip=192.0.2.1;
envelope-from="myname@example.com"; helo=foo.example.com;
- The “Authentication-Results” header described in RFC 8601 : Message Header Field for Indicating Message Authentication Status.
Authentication-Results: example.com; spf=pass smtp.mailfrom=example.net
SPF failure codes
The RFC 7372 “Email Auth Status Codes” introduces new status codes for reporting the DKIM and SPF mechanisms.
Code | X.7.23 |
---|---|
Text | SPF validation failed |
Basic status code | 550 |
Description | A message completed an SPF check that produced a “fail” result |
Used in place of | 5.7.1, as described in Section 8.4 of RFC 7208. |
Code | X.7.24 |
---|---|
Text | SPF validation error |
Basic status code | 451/550 |
Description | Evaluation of SPF relative to an arriving message resulted in an error. |
Used in place of | 4.4.3 or 5.5.2, as described in Sections 8.6 and 8.7 of RFC 7208. |
The following error codes can also be sent by the SPF framework.
Code | X.7.25 |
---|---|
Text | Reverse DNS validation failed |
Basic status code | 550 |
Description | An SMTP client’s IP address failed a reverse DNS validation check, contrary to local policy requirements. |
Used in place of | n/a |
Code | X.7.26 |
---|---|
Text | Multiple authentication checks failed |
Basic status code | 500 |
Description | A message failed more than one message authentication check, contrary to local policy requirements. The particular mechanisms that failed are not specified. |
Used in place of | n/a |
What is DKIM ?
The vSMTP implementation of the DomainKeys Identified Mail Signatures (DKIM) protocol is described in RFC 6376.
DKIM is an open standard for email authentication used to check the integrity of the content. A signature header is added to the messages. This signature is secured by a key pair (private/public).
DKIM signatures work like a watermark. Therefore, they survive forwarding, which is not the case for SPF.
Assertion of responsibility is validated through a cryptographic signature and by querying the Signer’s domain to retrieve the appropriate public key.
DNS records
DKIM relies on a DNS TXT record inserted into the sender domain’s DNS zone. The record contains the public key for the DKIM settings. The private key held by the sending mail server can be verified against the public key by the receiving mail server.
Unlike SPF record, several DKIM records can be linked to a domain. A domain can have several public keys if it has several mail servers (each mail server has its own private key that matches only one public key). The recipient’s mail server uses an attribute called selector of the DKIM signature to find the correct public key in the sender’s DNS zone.
A DKIM record must follow the syntax : [selector]._domainkey.[domain].
and the query on may only result in one TXT type record maximum.
Here is an example of a DKIM record:
mail._domainkey.example.com. 86400 IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG...
DKIM keys are not meant to be changed frequently. A high time-to-live (TTL) value of 86400 seconds or more is not uncommon.
For detailed information about fields in a DKIM record please check the RFC 6376.
vSMTP implementation
vSMTP can act as DKIM signer
or verifier
.
Results of Evaluation
The vSMTP DKIM verifier implements results semantically equivalent to the RFC.
Result | Description |
---|---|
none | The message was not signed. |
pass | The message was signed, the signature or signatures were acceptable to the ADMD, and the signature(s) passed verification tests. |
fail | The message was signed and the signature or signatures were acceptable to the ADMD, but they failed the verification test(s). |
policy | The message was signed, but some aspect of the signature or signatures was not acceptable to the ADMD. |
neutral | The message was signed, but the signature or signatures contained syntax errors or were not otherwise able to be processed. This result is also used for other failures not covered elsewhere in this list. |
temperror | The message could not be verified due to some error that is likely transient in nature, such as a temporary inability to retrieve a public key. A later attempt may produce a final result. |
permerror | The message could not be verified due to some error that is unrecoverable, such as a required header field being absent. A later attempt is unlikely to produce a final result. |
Results headers
Results should be recorded in the message header before any existing DKIM-Signature or preexisting authentication status header fields in the header field block.
There’s no specific DKIM header. The Authentication-Results
header described in RFC 8601 should be used.
Authentication-Results: example.com;
dkim=pass (good signature) header.d=example.com
Received: from mail-router.example.com
(mail-router.example.com [192.0.2.1])
by auth-checker.example.com (8.11.6/8.11.6)
with ESMTP id i7PK0sH7021929;
Fri, Feb 15 2002 17:19:22 -0800
DKIM-Signature: v=1; a=rsa-sha256; s=gatsby; d=example.com;
t=1188964191; c=simple/simple; h=From:Date:To:Subject:
Message-Id:Authentication-Results;
bh=sEuZGD/pSr7ANysbY3jtdaQ3Xv9xPQtS0m70;
b=EToRSuvUfQVP3Bkz ... rTB0t0gYnBVCM=
DKIM failure codes
The RFC 7372 “Email Auth Status Codes” introduces new status codes to report the DKIM and SPF mechanisms results.
Code | X.7.20 |
---|---|
Text | No passing DKIM signature found |
Basic status code | 550 |
Description | A message did not contain any passing DKIM signatures. |
Code | X.7.21 |
---|---|
Text | No acceptable DKIM signature found |
Basic status code | 550 |
Description | A message contains one or more passing DKIM signatures, but none are acceptable. |
Code | X.7.22 |
---|---|
Text | No valid author-matched DKIM signature found. |
Basic status code | 550 |
Description | A message contains one or more passing DKIM signatures, but none are acceptable because none have an identifier(s) that matches the author address(es) found in the From header field. |
The following error codes can also be sent by the DKIM framework.
Code | X.7.25 |
---|---|
Text | Reverse DNS validation failed |
Basic status code | 550 |
Description | An SMTP client’s IP address failed a reverse DNS validation check, contrary to local policy requirements. |
Used in place of | n/a |
Code | X.7.26 |
---|---|
Text | Multiple authentication checks failed |
Basic status code | 500 |
Description | A message failed more than one message authentication check, contrary to local policy requirements. The particular mechanisms that failed are not specified. |
Used in place of | n/a |
Null MX record
Section 5 of RFC5321 covers the sequence to identify a server that accepts email for a domain. The SMTP client first looks up a DNS MX RR, and if is not found, falls back to a DNS A or AAAA request. If a CNAME record is found, the resulting name is processed as if it were the initial name.
This mechanism suffers from two major drawbacks.
- A DNS overload because of an email service semantic.
- If there are no SMTP listeners at the A/AAAA addresses, message delivery will be attempted repeatedly many times before the sending Mail Transfer Agent (MTA) gives up, implying:
- a delay notification to the sender in the case of misdirected mail,
- a waste of IT resources at the sender.
The “Null MX” protocol solves these issues. It is used to state that mail services are disabled in a given domain. The protocol is described in RFC 7505.
When a specific DNS record (a null MX) is effectively defined in the DNS zone of a given domain, all mail delivery attempts fail immediately.
DNS record
The null MX
RR is an ordinary MX record with an RDATA section consisting of preference number 0 and a “.” as the exchange domain, to denote that there exists no mail exchanger.
nomail.example.com. 86400 IN MX 0 "."
A domain that advertises a null MX must not advertise any other MX RR.
Sender with Null MX records
As Null MX is primarily intended for domains that do not send or receive any mail, vSMTP default behavior rejects mail that has an invalid return address.
Mail systems should not publish a null MX record for domains that they use in MAIL FROM
(RFC5321) or From
(RFC5322) directives.
Return codes
The RFC 7505 defines two specific return codes.
Code | X.1.10 |
---|---|
Text | Recipient address has null MX |
Basic status code | 556 |
Description | The associated address is marked as invalid using a null MX. |
Code | X.7.27 |
---|---|
Text | Sender address has null MX |
Basic status code | 550 |
Description | The associated sender address has a null MX, and the SMTP receiver is configured to reject mail from such sender (e.g., because it could not return a DSN). |
Dealing with Null MX records
Currently, MX records are automatically checked on delivery. If the destination server provides a NULL MX record, the message is immediately moved into the dead queue.
In future release, it will be possible to configure rules to check for null MX records using filtering and decide what to do with the email.
Installing vSMTP from source
Download the source code
All versions of the software are available on the release page.
It is possible to get the latest release using git. (via the main
branch)
git clone -b main --single-branch https://github.com/viridIT/vSMTP.git
Or get the latest unstable version of vSMTP. (via the develop
branch)
git clone -b develop --single-branch https://github.com/viridIT/vSMTP.git
Linux installation
The installation described below was performed on an Ubuntu Server 20.04. Slight changes may be needed for other distributions.
Install Rust language
See the official website https://www.rust-lang.org/tools/install
Check dependencies
vSMTP requires the libc
libraries and the GCC compiler/linker.
On a Debian system, they are contained in the build-essential
package.
sudo apt update
sudo apt install pkg-config build-essential sasl2-bin
Install vSMTP's dependencies
vSMTP compilation
cargo
(Rust package manager) downloads all required dependencies and compile the source code in conformance to the current environment.
$> cargo build --workspace --release
[...]
$> cargo run -- --help
vsmtp 2.1.0
Team viridIT <https://viridit.com/>
Next-gen MTA. Secured, Faster and Greener
USAGE:
vsmtp [OPTIONS] [SUBCOMMAND]
OPTIONS:
-c, --config <CONFIG> Path of the vSMTP configuration file ("vsl" format)
-h, --help Print help information
-n, --no-daemon Do not run the program as a daemon
-t, --timeout <TIMEOUT> Make the server stop after a delay (human readable format)
-V, --version Print version information
SUBCOMMANDS:
config-diff Show the difference between the loaded config and the default one
config-show Show the loaded config (as serialized json format)
help Print this message or the help of the given subcommand(s)
Building and running the source code using `cargo`
By default Rust/Cargo use static linking to compile. All libraries required are compiled into the executable, allowing vSMTP to be a standalone application.
Configure the Operating System for vSMTP
For security purpose, vSMTP should run using a dedicated account with minimum privileges.
$ sudo adduser --system --shell /usr/sbin/nologin --no-create-home \
--uid 9999 --group --disabled-password --disabled-login vsmtp
Create a user to run vSMTP
Adding system user 'vsmtp' (UID 9999) ...
Adding new group 'vsmtp' (GID 9999) ...
Adding new user 'vsmtp' (UID 9999) with group 'vsmtp' ...
Not creating home directory '/home/vsmtp'.
vSMTP binaries and config files should be located in:
┣ /usr/sbin/ : binaries
┣ /etc/vsmtp/
┃ ┣ vsmtp.vsl : default configuration file
┃ ┗ rules/ : rules
┣ /var/spool/vsmtp/ : internal queues
┣ /var/log/
┃ ┣ vsmtp.log/ : internal logs and trace
┃ ┗ app.log : application logs
┗ /home/<user>/Maildir : local IMAP delivery
These default locations may be changed in the /etc/vsmtp/vsmtp.vsl
configuration file which is called by the service startup /etc/systemd/system/vsmtp.service
file.
sudo mkdir /etc/vsmtp /etc/vsmtp/rules /etc/vsmtp/certs /var/log/vsmtp /var/spool/vsmtp
sudo cp ./target/release/vsmtp /usr/sbin/
sudo cp ./target/release/vqueue /usr/sbin/
Create vSMTP directories
A minimal vsmtp.vsl configuration file that matches vsmtp version (i.e. 2.1.0) must be created.
cat > /etc/vsmtp/vsmtp.vsl <<EOF
import "conf.d/config.vsl" as cfg;
let config = cfg::config;
config.version_requirement = ">=2.1.0";
EOF
Create a minimal configuration file
sudo chown -R vsmtp:vsmtp /var/log/vsmtp /etc/vsmtp/* /var/spool/vsmtp
Grant rights to vSMTP files and directories
If required, add private key and certificate to /etc/vsmtp/certs
and grant reading rights to the vsmtp user.
Configuring the MTA service
Check and disable current MTA
Check if a mail transfer agent service is running and disable it.
$ sudo ss -ltpn | grep ":25"
tcp 0 0 127.0.0.1:25 127.0.0.1:* LISTEN 39423/master
$ sudo systemctl status postfix
● postfix.service - Postfix Mail Transport Agent
Loaded: loaded (/lib/systemd/system/postfix.service; enabled; vendor preset: enabled)
Active: active (exited) since Fri 2021-12-10 11:10:58 CET; 5min ago
Process: 39426 ExecStart=/bin/true (code=exited, status=0/SUCCESS)
Main PID: 39426 (code=exited, status=0/SUCCESS)
$ sudo systemctl stop postfix
$ sudo systemctl disable postfix
Synchronizing state of postfix.service with SysV service script with /lib/systemd/systemd-sysv-install.
Executing: /lib/systemd/systemd-sysv-install disable postfix
Removed /etc/systemd/system/multi-user.target.wants/postfix.service.
Disabling a postfix instance that was running on port 25
☞ | Depending on Linux distributions, use the ss
or netstat -ltpn
commands.
Add vSMTP as a systemd service
Copy the daemon configuration file in /etc/systemd/system
.
sudo cp ./tools/install/deb/vsmtp.service /etc/systemd/system/vsmtp.service
Please note that vSMTP drops privileges at startup. The service type must be set to forking
.
Do not modify this file unless you know what you are doing.
Enable and activate vSMTP service
$ sudo systemctl enable vsmtp.service
Created symlink /etc/systemd/system/multi-user.target.wants/vsmtp.service → /etc/systemd/system/vsmtp.service.
$ sudo systemctl start vsmtp
$ sudo systemctl status vsmtp
● vsmtp.service - vSMTP Mail Transfer Agent
Loaded: loaded (/etc/systemd/system/vsmtp.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2021-12-10 11:32:15 CET; 21s ago
Main PID: 2164 (vsmtp)
Tasks: 25 (limit: 38287)
Memory: 39.9M
CGroup: /system.slice/vsmtp.service
└─2164 /usr/sbin/vsmtp -c /etc/vsmtp/vsmtp.vsl
Check that vSMTP is working properly
$ ss -ltpn | grep vsmtp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 128 127.0.0.1:587 127.0.0.1:* users:(("vsmtp",pid=2127,fd=5))
LISTEN 0 128 127.0.0.1:465 127.0.0.1:* users:(("vsmtp",pid=2127,fd=6))
LISTEN 0 128 127.0.0.1:25 127.0.0.1:* users:(("vsmtp",pid=2127,fd=4))
$ nc -C localhost 25
220 mydomain.com Service ready
451 Timeout - closing connection.
FreeBSD installation
The installation described above was performed on an FreeBSD 13.0 server.
Installing Rust language
Rust port, packages and information can be found on the freshports website. Find more information about packages and port in the FreeBSD handbook.
pkg install lang/rust
Rust 1.60+ package is required. It may be necessary to switch to the latest ports branch. Please refer to the freeBSD wiki.
Dependencies
FreeBSD 13.x includes all required dependencies. Check that sasl is included in the used release. (See Linux dependencies)
vSMTP compilation
$> cargo build --workspace --release
[...]
$> cargo run -- --help
vsmtp 1.4
Team viridIT <https://viridit.com/>
Next-gen MTA. Secured, Faster and Greener
USAGE:
vsmtp [OPTIONS] [SUBCOMMAND]
OPTIONS:
-c, --config <CONFIG> Path of the vSMTP configuration file ("vsl" format)
-h, --help Print help information
-n, --no-daemon Do not run the program as a daemon
-t, --timeout <TIMEOUT> Make the server stop after a delay (human readable format)
-V, --version Print version information
SUBCOMMANDS:
config-diff Show the difference between the loaded config and the default one
config-show Show the loaded config (as serialized json format)
help Print this message or the help of the given subcommand(s)
Configuring the Operating System for vSMTP
Create the directories and change the owner and group.
mkdir /etc/vsmtp /etc/vsmtp/rules /etc/vsmtp/certs /var/log/vsmtp /var/spool/vsmtp
cp ./target/release/vsmtp /usr/sbin/
cp ./target/release/vqueue /usr/sbin/
cp ./examples/config/minimal.vsl /etc/vsmtp/vsmtp.vsl
chown -R vsmtp:vsmtp /var/log/vsmtp /etc/vsmtp/* /var/spool/vsmtp
Create a minimal vsmtp.vsl configuration file that matches vsmtp version (i.e. 1.0.0)
cat > /etc/vsmtp/vsmtp.vsl <<EOF
import "conf.d/config.vsl" as cfg;
let config = cfg::config;
config.version_requirement = ">=1.0.0";
EOF
Grant rights to files and folders.
chmod 555 /usr/sbin/vsmtp
sudo chown -R vsmtp:vsmtp /var/log/vsmtp /etc/vsmtp/* /var/spool/vsmtp
If required, add private key and certificate to /etc/vsmtp/certs
and grant reading rights to the vsmtp user.
Disabling sendmail
Sendmail may have been disabled during FreeBSD installation. If not, add the following lines in the /etc/rc.conf
file and reboot the system.
sendmail_enable="NO"
sendmail_submit_enable="NO"
sendmail_outbound_enable="NO"
sendmail_msp_queue_enable="NO"
Use sockstat command to check that sendmail is disabled.
Add vSMTP user:group
pw groupadd vsmtp -g 999
pw useradd vsmtp -u 999 -d /noexistent -g vsmtp -s /sbin/nologin
chown -R vsmtp:vsmtp /var/log/vsmtp /etc/vsmtp/* /var/spool/vsmtp
Adding a vSMTP as a system service
vSMTP drops privileges at startup. User ACLs are no longer needed.
Please add:
- the flag `vsmtp_enable=“YES” in /etc/rc.conf.
- the vsmtp script in /usr/local/etc/rc.d
cp ./tools/install/freebsd/freebsd-vsmtp.service /usr/local/etc/rc.d/vsmtp
#! /bin/sh
# PROVIDE: vsmtp
# REQUIRE: DAEMON
# KEYWORD: shutdown
#
# Add the following lines to /etc/rc.conf to enable vsmtp:
#
# vsmtp_enable="YES"
. /etc/rc.subr
name="vsmtp"
rcvar="${name}_enable"
load_rc_config $name
: ${vsmtp_enable:=NO}
: ${vsmtp_config:=/etc/vsmtp/vsmtp.vsl}
: ${vsmtp_flags:=--config}
command="/usr/sbin/vsmtp"
command_args="${vsmtp_config}"
run_rc_command "$1"
Starting with a non privileged user
To start with an other mechanism please follow these instructions:
- Grant the rights to the user to bind on ports <1024.
- kernel must be updated to support network ACL.
- Add to these options to the KERNEL file and rebuild it.
options MAC
options MAC_PORTACL
cd /usr/src
make buildkernel KERNCONF=MYKERNEL
make installkernel KERNCONF=MYKERNEL
$ sysctl security.mac
security.mac.portacl.rules:
security.mac.portacl.port_high: 1023
security.mac.portacl.autoport_exempt: 1
security.mac.portacl.suser_exempt: 1
security.mac.portacl.enabled: 1
security.mac.mmap_revocation_via_cow: 0
security.mac.mmap_revocation: 1
security.mac.labeled: 0
security.mac.max_slots: 4
security.mac.version: 4
$ sysctl security.mac.portacl.rules=uid:999:tcp:25,uid:999:tcp:587,uid:999:tcp:465
security.mac.portacl.rules: uid:999:tcp:25, -> uid:999:tcp:25,uid:999:tcp:587,uid:999:tcp:465
$ sysctl security.mac.portacl.rules
security.mac.portacl.rules: uid:999:tcp:25,uid:999:tcp:587,uid:999:tcp:465
$ sysctl net.inet.ip.portrange.reservedlow=0
net.inet.ip.portrange.reservedlow: 0 -> 0
$ sysctl net.inet.ip.portrange.reservedhigh=0
net.inet.ip.portrange.reservedhigh: 1023 -> 0
The user with uid 999 should now be authorized to bind on standard SMTP ports (25, 587, 465).
Queue System
Queues are active in postq
& delivery
stages.
How messages are transferred between queues at the end of a SMTP stage
Create plugins
🚧 This chapter is still incomplete.
vSMTP plugins are Rust crates compiled as dynamic libraries that extends vSMTP base features.
They can be imported directly in filtering scripts.
Requirements
- First, make sure that Rust and Cargo are installed on the system.
- The Rust community as created a cargo command called Cargo generate which is used to fetch Rust project templates from Github. Make sure to install it too.
- Rhai imports a crate called ahash to hash types and function signatures to look them up at runtime. Therefore, plugin functions signatures must be hashed with the exact same seed than the core of vSMTP, so that Rhai can call the right functions. See the
rhai-dylib
crate for more details.
Get the plugin template
A Rhai dylib template is available to easily create plugins.
cargo generate --git https://github.com/ltabis/rhai-dylib-template.git
🤷 Project Name : vsmtp-plugin-awesome
🔧 Destination: /path/vsmtp-plugin-awesome ...
🔧 Generating template ...
🤷 What is the ahash key of program that will load this module ? [default: [1, 2, 3, 4]]: [1, 2, 3, 4]
[1/7] Done: .cargo/config.toml
[2/7] Skipped: .cargo
[3/7] Done: .gitignore
[4/7] Done: Cargo.toml
[5/7] Done: README.md
[6/7] Done: src/lib.rs
[7/7] Skipped: src
🔧 Moving generated files into: `/path/vsmtp-plugin-awesome`...
💡 Initializing a fresh Git repository
✨ Done! New project created /path/vsmtp-plugin-awesome
cargo generate
will prompt for a project name (It is recommended to use the vsmtp-plugin-*
nomenclature to name vSMTP plugins) and a ahash seed. (To get more detail on what are ahash seeds and what they are used for, check out the rhai-dylib
crate)
Overview
#![allow(unused)] fn main() { use rhai::plugin::*; #[export_module] mod vsmtp_plugin_awesome { // Build the module here. } /// Export the vsmtp_plugin_awesome module. #[no_mangle] pub extern "C" fn module_entrypoint() -> rhai::Shared<rhai::Module> { // The seed must be the same as the one used in the program that will // load this module. rhai::config::hashing::set_ahash_seed(Some([1, 2, 3, 4])).unwrap(); rhai::exported_module!(vsmtp_plugin_awesome).into() } }
Generated Rust code using `cargo generate`, in vsmtp-plugin-awesome/src/lib.rs
cargo generate
created a basic Rust project called vsmtp-plugin-awesome
. It contains a module_entrypoint
function that returns a Rhai module. Once this plugin is imported in vSMTP via the Rhai import
statement, the
module_entrypoint
function is called and the returned Rhai module is used to extend .vsl
scripts.
The
module_entrypoint
function must be present in the crate, otherwise vSMTP will not run the plugin.
[package]
name = "vsmtp_plugin_awesome"
version = "0.1.0"
edition = "2021"
authors = ["user <user@example.com>"]
[lib]
crate-type = ["cdylib"]
[dependencies]
rhai = { version = "1.11" }
Generated Cargo.toml file
Has specified in the manifest file generated for the project, the configured crate type is cdylib
.
crate-type = ["cdylib"]
With this option, the crate can be compiled into a dynamic library, and vSMTP can access the plugin via the module_entrypoint
function at runtime.
Acknowledgements
The development of vSMTP is made possible thanks to the following projects:
- Rust: the programming language used to develop vSMTP.
- tokio: the asynchronous runtime and the logging framework.
- rhai: the scripting language used in vSMTP.
- trust-dns: the DNS resolver.
- rustls: the TLS library.
- serde: the serialization framework used in vSMTP.
- rsasl: the SASL framework.
And the contributors who helped to improve vSMTP.