vSMTP
website documentation discord
Rustc Version 1.62.1 docs License GPLv3
CI coverage dependency status
Latest Release Crates.io Docker Pulls

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.

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

crates.io

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

StageSMTP stateContext available
connectBefore HELO/EHLO commandConnection related information.
authenticateAfter AUTH commandConnection related information.
heloAfter HELO/EHLO commandHELO string.
mailAfter MAIL FROM commandSender address.
rcptAfter each RCPT TO commandList of recipients.
preqBefore queuing1The entire mail.
postqAfter queuing2The entire mail.
deliveryBefore deliveringThe entire mail.

Available stages in order of evaluation

1

Preq stage triggers after the end of receiving data from the client, just before the server answers back with a 250 code.

2

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, mail, rcpt and preq stages rules are run before an email is enqueued. postq and delivery 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.

Sub-domain Hierarchy

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.

TypeDescriptionSyntaxComments
ip4IPv4 addressx.y.z.tDecimal values.
ip6IPv6 addressa:b:c:d:e:f:g:hHex values.
rg4IPv4 networkx.y.z.t/rgDecimal values.
rg6IPv6 prefixa:b:c:d:e:f:g:h/rgHex values.
addressEmail addressidentifier@fqdnString.
identifierLocal part of an addressuserString.
fqdnFully qualified domain namemy.domain.comString.
regexRegular expressionPERL regular expression.
fileA file of objectsUnix fileSee file section.
codea custom smtp codeSee 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 scaleExpression
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

levelnote
errorvSMTP encountered an issue, you should try to fix it or open an issue on vSMTP’s repository.
warnSomething unexpected append, vSMTP will still run but you should look into it.
infoGeneral 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 Google and CloudFlare privacy statement for important information about what they track.

Options

DNS Options can be set using the config.server.dns.options object.

ParametervalueDescriptionDefault value
timeoutintegerSpecify the timeout for a request.5 seconds.
attemptsintegerusize Number of retries after lookup failure before giving up.2 attempts.
rotatetrue/falseRotate through the resource records in the response.No rotation.
validatetrue/falseUse DNSSec to validate the request.False.
ip_strategyenum1The ip_strategy for the Resolver to use when lookup Ipv4 or Ipv6 addresses.IPv4 then IPv6.
cache_sizeintegerCache size is in number of records.32 records.
num_concurrent_reqsintegerNumber of concurrent requests per query.2 concurrent requests.
preserve_intermediatestrue/falsePreserve all intermediate records in the lookup response, such as CNAME records.True.

DNS parameters

1

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 an import 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 an import 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:

NameDescription
ArgsArguments to pass to the function.
ReturnWhat result does the function return.
Effective smtp stageStage of the rule engine where this function can be called from.
NoteAdditional comments for the function.
ExamplesvSL examples using the function.
ErrorsErrors 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
Tell the rule engine to force accept the incoming transaction. This means that all rules following the one `faccept` is called will be ignored.

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
Tell the rule engine to accept the incoming transaction for the current stage. This means that all rules following the one `accept` is called in the current stage will be ignored.
  • 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
Tell the rule engine that a rule succeeded. Following rules in the current stage will be executed.

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
Stop rules evaluation and send an error code to the client.
  • 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
Skip all rules until the email is received and place the email in a quarantine queue. The email will never be sent to the recipients and will stop being processed after the `PreQ` stage.
  • queue - the relative path to the queue where the email will be quarantined as a string. This path will be concatenated to the config.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
Check if two statuses are equal.

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
Check if two statuses are not equal.

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
Convert a status to a string. Enables string interpolation.

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
Convert a status to a debug string Enables string interpolation.

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)
Log information to stdout in `nodaemon` mode or to a file.
  • 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
Produce a serialized JSON representation of the mail context.

fn client_address

fn client_address() -> String
Get the address of the client.

All of them.

  • string - the client’s address with the ip:port format.
#{
  connect: [
    action "log client address" || {
      log("info", `new client: ${ctx::client_address()}`);
    },
  ],
}

fn client_ip

fn client_ip() -> String
Get the ip address of the client.

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
Get the ip port of the client.

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
Get the full server address.

All of them.

  • string - the server’s address with the ip:port format.
#{
  connect: [
    action "log server address" || {
      log("info", `server: ${ctx::server_address()}`);
    },
  ],
}

fn server_ip

fn server_ip() -> String
Get the server's ip.

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
Get the server's port.

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
Get a the timestamp of the client's connection time.

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
Get the name of the server.

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
Has the connection been secured under the encryption protocol SSL/TLS.

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
Get the value of the `HELO/EHLO` command sent by the client.

helo and onwards.

  • string - the value of the HELO/EHLO command.
#{
    helo: [
       action "log info" || log("info", `helo/ehlo value: ${ctx::helo()}`),
    ]
}

fn mail_from

fn mail_from() -> SharedObject
Get the value of the `MAIL FROM` command sent by the client.

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
Get the list of recipients received by the client.

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
Get the value of the current `RCPT TO` command sent by the client.

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
Get the time of reception of the email.

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
Get the unique id of the received message.

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) -> ()
Rewrite the sender received from the `MAIL FROM` command.
  • 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) -> ()
Replace a recipient received by a `RCPT TO` command.
  • old_addr - the recipient to replace.
  • new_addr - the new address to use when replacing old_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) -> ()
Add a new recipient to the envelop. Note that this does not add the recipient to the `To` header. Use `msg::add_rcpt` for that.
  • 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) -> ()
Alias for `envelop::add_rcpt`.

fn rm_rcpt

fn rm_rcpt(addr: SharedObject) -> ()
fn rm_rcpt(addr: String) -> ()
Remove a recipient from the envelop. Note that this does not remove the recipient from the `To` header. Use `msg::rm_rcpt` for that.
  • 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
Generate the `.eml` representation of the message.

fn has_header

fn has_header(header: SharedObject) -> bool
fn has_header(header: String) -> bool
Checks if the message contains a specific header.
  • 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
Count the number of headers with the given name.
  • 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
Get a specific header from the incoming message.
  • 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
Get a list of all headers.
  • 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
Get a list of all headers of a specific name with it's name and value separated by a column.
  • 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) -> ()
Add a new header **at the end** of the header list in the message.
  • 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) -> ()
Add a new header on top all other headers in the message.
  • 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) -> ()
Replace an existing header value by a new value, or append a new header to the message.
  • 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) -> ()
Replace an existing header name by a new value.
  • 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
Get a copy of the whole email as a 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
Remove an existing header from the message.
  • 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) -> ()
Change the sender's address in the `From` header of the message.
  • 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) -> ()
Replace a recipient by an other in the `To` header of the message.
  • old_addr - the recipient to replace.
  • new_addr - the new address to use when replacing old_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) -> ()
Add a recipient to the `To` header of the message.
  • addr - the recipient address to add to the To 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) -> ()
Remove a recipient from the `To` header of the message.
  • addr - the recipient to remove to the To 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
Process the SASL authentication mechanism.

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
Check if the client is authenticated.

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
Get authentication credentials from the client.

authenticate only.

  • Credentials - the credentials of the client.
#{
    authenticate: [
       action "log auth" || log("info", `${auth::credentials()}`),
    ]
}

get type

fn get type(credentials: Credentials) -> String
Get the type of the `auth` property of the connection.

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
Get the `authid` property of the connection. Can only be use on 'Verify' authentication typed credentials.

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
Get the `authpass` property of the connection. Can only be use on 'Verify' authentication typed credentials.

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
Get the `anonymous_token` property of the connection. Can only be use on 'AnonymousToken' authentication typed credentials.

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
Check spf record following the Sender Policy Framework (RFC 7208). see
  • 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
WARNING: Low level API, use `spf::check` instead if you do not need to peek inside the spf result data.

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 the result, mechanism and problem 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
Has the `ctx()` a DKIM signature verification result ?

fn result

fn result() -> Map
Return the DKIM signature verification result in the `ctx()` or an error if no result is found.

fn store

fn store(result: Map) -> ()
Store the result produced by the DKIM signature verification in the `ctx()`.
* The `status` field is missing in the DKIM verification results.

fn get_private_keys

fn get_private_keys(sdid: String) -> Array
Get the list of DKIM private keys associated with this sdid

get sdid

fn get sdid(signature: Signature) -> String
return the `sdid` property of the [`backend::Signature`]

get auid

fn get auid(signature: Signature) -> String
return the `auid` property of the [`backend::Signature`]

fn verify

fn verify() -> Map
Operate the hashing of the `message`'s headers and body, and compare the result with the `signature` and `key` data.
// 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) -> ()
Produce a `DKIM-Signature` header.
  • selector - the DNS selector to expose the public key & for the verifier
  • private_key - the private key to sign the mail, associated with the public key in the selector._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
Apply the DMARC policy to the mail.

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
Performs a dual-stack DNS lookup for the given hostname.
  • 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
Performs a reverse lookup for the given IP.
  • 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) -> ()
Set the delivery method to forwarding for a single recipient. After all rules are evaluated, forwarding will be used to deliver the email to the recipient.
  • 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) -> ()
Set the delivery method to forwarding for all recipients. After all rules are evaluated, forwarding will be used to deliver the email.
  • 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) -> ()
Set the delivery method to deliver for a single recipient. After all rules are evaluated, the email will be sent to the recipient using the domain of its address.
  • 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() -> ()
Set the delivery method to deliver for all recipients. After all rules are evaluated, the email will be sent to all recipients using the domain of their respective address.

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) -> ()
Set the delivery method to mbox for a recipient. After all rules are evaluated, the email will be stored locally in the mail box of the recipient if it exists on the server.
  • 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() -> ()
Set the delivery method to mbox for all recipients. After all rules are evaluated, the email will be stored locally in the mail box of all recipients if they exists on the server.

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) -> ()
Set the delivery method to maildir for a recipient. After all rules are evaluated, the email will be stored locally in the `~/Maildir/new/` folder of the recipient's user if it exists on the server.
  • rcpt - the recipient to apply the method to.

All of them.

```ignore #{ delivery: [ action "setup maildir" || transport::maildir("john.doe@example.com"), ] } ```
#{
  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() -> ()
Set the delivery method to maildir for all recipients. After all rules are evaluated, the email will be stored locally in each `~/Maildir/new` folder of they respective recipient if they exists on the server.

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) -> ()
Export the current raw message to a file as an `eml` file. The message id of the email is used to name the file.
  • 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) -> ()
Write the content of the current email with it's metadata in a json file. The message id of the email is used to name the file.
  • 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
Get the current time.
  • 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
Get the current date.
  • 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
Get the root domain (the registrable part)

foo.bar.example.com => example.com


fn env

fn env(variable: String) -> ?
fn env(variable: SharedObject) -> ?
Fetch an environment variable from the current process.
  • 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
Return a relay access denied code.
#{
    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
Return a DKIM Failure code. (RFC 6376) DKIM signature not found.
#{
    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
Return a DKIM Failure code. (RFC 6376) No acceptable DKIM signature found.
#{
    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
Return a DKIM Failure code. (RFC 6376) No valid author matched DKIM signature found.
#{
    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
Return a SPF Failure code. (RFC 7208) Validation failed.
#{
    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
Return a SPF Failure code. (RFC 7208) Validation error.
#{
    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
Return a reverse DNS Failure code.
#{
    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
Return a multiple authentication failures code.
#{
    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
Return a Null MX cod. (RFC 7505) The sender address has a null MX record.
#{
    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
Return a Null MX cod. (RFC 7505) The recipient address has a null MX record.
#{
    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
Return a greylisting code ()
#{
    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
Multiple destination domains per transaction is unsupported code.
#{
    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
Multiple destination domains per transaction is unsupported code.
#{
    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
Return an ip range over "192.168.0.0/16".
#{
    rcpt: [
        rule "anti relay" || { if ctx::client_ip() in net::rg_192() { state::next() } else { state::deny() } }
    ]
}

fn rg_172

fn rg_172() -> SharedObject
Return an ip range over "172.16.0.0/12".
#{
    rcpt: [
        rule "anti relay" || { if ctx::client_ip() in net::rg_172() { state::next() } else { state::deny() } }
    ]
}

fn rg_10

fn rg_10() -> SharedObject
Return an ip range over "10.0.0.0/8".
#{
    rcpt: [
        rule "anti relay" || { if ctx::client_ip() in net::rg_10() { state::next() } else { state::deny() } }
    ]
}

fn non_routable

fn non_routable() -> Array
Return a list of non routable networks (net_192, net_172, and net_10).

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
Build an ip4 address. (a.b.c.d)

get domain

fn get domain(addr: VSLObject) -> VSLObject
Get the domain of an email address.
  • 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
Operator `==` for `SharedObject` and `&str`

get domains

fn get domains(container: Array) -> Array
Get all domains of the recipient list.
  • 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
Convert a `SharedObject` to a `String`

fn to_debug

fn to_debug(this: VSLObject) -> String
Convert a `SharedObject` to a debug string

fn regex

fn regex(regex: String) -> VSLObject
a regex (^[a-z0-9.]+@foo.com$)

fn contains

fn contains(this: SharedObject, other: SharedObject) -> bool
fn contains(this: SharedObject, s: String) -> bool
fn contains(map: Map, object: SharedObject) -> bool
Operator `contains`

fn identifier

fn identifier(identifier: String) -> VSLObject
a user identifier.

fn code

fn code(code: int, text: String) -> VSLObject
fn code(code: int, enhanced: String, text: String) -> VSLObject
A SMTP code with the code and message as parameter.
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
Operator `==` for `SharedObject` and `&str`

fn fqdn

fn fqdn(domain: String) -> VSLObject
a valid fully qualified domain name (foo.com)

get domain

fn get domain(addr: VSLObject) -> VSLObject
Get the domain of an email address.
  • 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
A SMTP code with the code and message as parameter.
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
Build an ip4 address. (a.b.c.d)

fn rg6

fn rg6(range: String) -> VSLObject
an ip v6 range. (x:x:x:x:x:x:x:x/range)

get domains

fn get domains(container: Array) -> Array
Get all domains of the recipient list.
  • 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
Convert a `SharedObject` to a debug string

fn to_string

fn to_string(this: VSLObject) -> String
Convert a `SharedObject` to a `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
Create a new command executor.
  • 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
Execute the given command.

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
Connect to a third party software that accepts SMTP transactions. This module is used with the `delegate` keyword.
  • parameters - a map of the following parameters:
    • delegator - a map of the following parameters.
      • address - the address to connect to the third-party software
      • timeout - 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
Open a pool of connections to a MySQL database.
  • 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 database.
  • 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
Open a pool of connections to a Memcached server.
  • 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) -> ()
Flush all cache on the server immediately

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) -> ?
Get something from the server.
  • 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) -> ?
Get a value from the server with its cas_id and its expiration seconds.
  • 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) -> ?
Gets multiple value from mutliple key from the server.
  • 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) -> ?
Gets multiple value from mutliple key from the server with their cas_id, expiration seconds, key and value.
  • 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) -> ()
Set a value with its associate key into the server with expiration seconds.
  • key - The key you want to allocate with the value
  • value - The value you want to store
  • duration - 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
Compare and swap a key with the associate value into memcached server with expiration seconds.
  • key - The key you want to swap
  • value - The value you want to store
  • expiration - The duration time you want the value to remain in cache
  • cas_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) -> ()
Add a key with associate value into memcached server with expiration seconds.
  • key - The key you want to allocate with the value
  • value - The value you want to store
  • duration - 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) -> ()
Replace a key with associate value into memcached server with expiration seconds.
  • key - The key you want to replace with the value
  • value - The value you want to store
  • duration - 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: ?) -> ()
Append value to the key.
  • key - The key you want to append with the value
  • value - 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: ?) -> ()
Prepend value to the key.
  • key - The key you want to prepend with the value
  • value - 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
Delete value of the specified key.
  • 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) -> ()
Increment value of the specified key.
  • key - The key you want the value to be incremented
  • value - 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) -> ()
Decrement value of the specified key.
  • key - The key you want the value to be decremented
  • value - 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) -> ()
Set a new expiration time for a exist key.
  • key - The key you want to change the expiration time
  • duration - 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
Only for debugging purposes, get all server's statistics in a formatted 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
Construct a ldap connection pool pointing to the given ldap server.
  • 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
Search the ldap server for entries.
  • 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 be base, one or sub.
  • 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 - If result is set to “ok”, contains an array of the following map:
    • dn - The entry DN.
    • attrs - The entry attributes that were searched.
  • error - If result 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
Compare the value(s) of the attribute attr within an entry named by dn with the value val.
  • 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 the testsaslauthd program under the hood, itself calling the saslauthd daemon. Make sure to install the Cyrus sasl binary package for the targeted distribution and configure the saslauthd daemon with MECHANISM="shadow" in /etc/default/saslauthd.

See the auth::is_authenticated and auth::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.

ResultDescription
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.
neutralThe ADMD has explicitly stated that it is not asserting whether the IP address is authorized.
passThe client is authorized to inject mail with the given identity.
failThe client is not authorized to use the domain in the given identity.
softfailThe host is probably not authorized but the ADMD has not published a stronger policy.
temperrorA transient (generally DNS) error while performing the check.
permerrorThe domain’s published records (DNS) could not be correctly interpreted.
policyAdded 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:

  1. 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;
  1. 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.

CodeX.7.23
TextSPF validation failed
Basic status code550
DescriptionA message completed an SPF check that produced a “fail” result
Used in place of5.7.1, as described in Section 8.4 of RFC 7208.
CodeX.7.24
TextSPF validation error
Basic status code451/550
DescriptionEvaluation of SPF relative to an arriving message resulted in an error.
Used in place of4.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.

CodeX.7.25
TextReverse DNS validation failed
Basic status code550
DescriptionAn SMTP client’s IP address failed a reverse DNS validation check, contrary to local policy requirements.
Used in place ofn/a
CodeX.7.26
TextMultiple authentication checks failed
Basic status code500
DescriptionA message failed more than one message authentication check, contrary to local policy requirements. The particular mechanisms that failed are not specified.
Used in place ofn/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.

ResultDescription
noneThe message was not signed.
passThe message was signed, the signature or signatures were acceptable to the ADMD, and the signature(s) passed verification tests.
failThe message was signed and the signature or signatures were acceptable to the ADMD, but they failed the verification test(s).
policyThe message was signed, but some aspect of the signature or signatures was not acceptable to the ADMD.
neutralThe 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.
temperrorThe 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.
permerrorThe 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.

CodeX.7.20
TextNo passing DKIM signature found
Basic status code550
DescriptionA message did not contain any passing DKIM signatures.
CodeX.7.21
TextNo acceptable DKIM signature found
Basic status code550
DescriptionA message contains one or more passing DKIM signatures, but none are acceptable.
CodeX.7.22
TextNo valid author-matched DKIM signature found.
Basic status code550
DescriptionA 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.

CodeX.7.25
TextReverse DNS validation failed
Basic status code550
DescriptionAn SMTP client’s IP address failed a reverse DNS validation check, contrary to local policy requirements.
Used in place ofn/a
CodeX.7.26
TextMultiple authentication checks failed
Basic status code500
DescriptionA message failed more than one message authentication check, contrary to local policy requirements. The particular mechanisms that failed are not specified.
Used in place ofn/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.

CodeX.1.10
TextRecipient address has null MX
Basic status code556
DescriptionThe associated address is marked as invalid using a null MX.
CodeX.7.27
TextSender address has null MX
Basic status code550
DescriptionThe 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.

Queues System

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.