Quantcast
Channel: ( f o o b a r . l u )
Viewing all articles
Browse latest Browse all 10

Basic Postfix Config, Backed by PostgreSQL

$
0
0

Following my previous post, I now have a working config with PostgreSQL.

This post is meant to set up a config which is “just enough” to get postfix working with PostgreSQL. There are many tutorials out there, but they all give you a full-blown set-up with spam/virus checking, POP/IMAP access with authentication, webmail, and so on and so on. None of those I have read managed to explain the inner workings of postfix, and just gave you a bunch of copy/pasteable “templates”. This post intentionally leaves out additional features. You can add more support yourself if you want to.

Database administrative stuff (database/user creation, authentication, pg_hba.conf & co) is out of the scope of this document. You should read up on those topics somewhere else if you don’t feel comfortable with it!

The aim of this is to have a postfix installation capable mainly of “aliasing” e-mails. Let’s say I have a domain “example.com” and I want to manage e-mail addresses for that domain. But, it’s a small domain with only a handful of users and I don’t want to store mails locally, just alias them to the users private e-mails addresses.

While the aim is only to alias e-maile, the config explained below is still capable of delivering mail locally (storing them directly to disk), but as there is no set-up to access those mails (POP/IMAP/webmail), it’s only marginally useful. But it gives you a working framework if you want to add these features yourself.

First a bit of postfix basics:

Postfix has “lists” and “maps”. When postfix queries a list the aim is to determine if a value exists in the list or not. It will pass one value (usually the e-mail address), and expect either something (true) or nothing (false) in return. Commonly, if the element exists in the list, the element itself is returned. As a code-example, this might look like this:

def exists(needle, haystack):
    if needle in haystack:
        return needle
    else:
        return None

“Maps” on the other hand work like typical key/value stores. They should return the associated value to a key (again the e-mail address), or nothing. Again, as a code example:

def get_mapped_value(key, mapping):
    if mapping.has_key(key):
        return mapping[key]
    else:
        return None

Now, for our goal there are 4 config values of special interest:

virtual_alias_domains

This is simply a list of domains which are handled as aliases. Each entry is the FQDN of the domain that should be treated as such. If a mail arrives on the server, and the domain of the recipient matches an entry in this list, processing continues with virtual_alias_maps.

Important: No email on one of the domains in this list can be stored on the local disk! They will always be aliased to another e-mail address.

virtual_alias_maps
This maps an e-mail address to another e-mail address. Naturally, the new e-mail address can also land on the same server.
virtual_mailbox_domains

Like virtual_alias_domains, this is a list, but this time for all domains for which e-mails will be stored locally on disk.

Important: No email on one of the domains in this list can be aliased! They will always be stored on disk.

virtual_mailbox_maps

This maps an e-mail address to a folder. If the mapped value ends with a slash, it will be treated as a MAILDIR, if it does not end in a slash, it will be treated as an MBOX file.

The returned folders should preferably be relative folders. They will be relative to the virtual_mailbox_base config value

Postfix supports multiple storage formats for these files (one of which is PostgreSQL). The simplest one is the “hash” format. For lists, you just write down the value twice, for maps, you assign a key to a value. Examples:

main.cf
[...]
virtual_alias_domains = hash:/path/to/vad
virtual_alias_maps = hash:/path/to/vam
[...]
/path/to/vad
domain1.com    domain1.com
domain2.com    domain1.com
/path/to/vam
user@domain1.com     john.doe@example.com
user2@domain2.com    jane.doe@example.com

This simply shows the difference between a list and a map.

To store the values in PostgreSQL, you need to replace hash with pgsql and the file itself contains the connection and query details for postgres:

Warning: The examples below are insecure (using user postgres without password), and only used here as a simple example!

main.cf
[...]
virtual_alias_domains = pgsql:/path/to/vad
virtual_alias_maps = pgsql:/path/to/vam
[...]
/path/to/vad
hosts = 127.0.0.1
user = postgres
password =
dbname = postfix
query = SELECT domain FROM alias_domains WHERE domain='%s';
/path/to/vam
hosts = 127.0.0.1
user = postgres
password =
dbname = postfix
query = SELECT alias FROM aliases WHERE email='%s';

For more details on the postgres config files (f.ex. the %s placeholder), see the official docs.

In my case, the DB schema will be very simple (as mentioned in the beginning of the post). We will have 4 tables, representing the 4 maps/lists mentioned above. We won’t use auto-incrementing integers as keys, because it will make administration and selects a lot easier. If this irks you, you are welcome to modify the schema! Eventually, we will define one stored procedure per list/map. This allows us to add additional logic to the lookups which would otherwise be not possible. Again, given the explanations above, you should be able to change this if you want to.

The following tables will be created:

alias_domain
A list of domains which should be aliased only.
aliases
The e-mail alias map.
local_domain
A list of domains which should be delivered locally.
mailboxes
The map of e-mail -> folder

The following functions will be created:

is_alias_domain(TEXT)
Takes a domain-name as input, and returns either nothing, or the domain-name. Used for the virtual alias domain list.
is_local_domain(TEXT)
Takes a domain-name as input, and returns either nothing, or the domain-name. Used for the virtual mailbox domain list.
get_alias(TEXT)
Takes an e-mail address as input, and returns either nothing, or a replacement e-mail address. Used for the virtual alias map.
get_mailbox_folder(TEXT)
Takes an e-mail address as input, and returns either nothing, or a folder-name. Used for the virtual mailbox map.

With the database in place, the postfix config could look like this:

I repeat again: Don’t use the user “postgres” in trust mode (without password) on a production system! Create a dedicated user, and fill in the appropriate values in the config files below!

/etc/postfix/main.cf
[...]
virtual_alias_domains = pgsql:/etc/postfix/psql-alias-domains.cf
virtual_alias_maps = pgsql:/etc/postfix/psql-alias-maps.cf
virtual_mailbox_domains = pgsql:/etc/postfix/pgsql-local-domains.cf
virtual_mailbox_maps = pgsql:/etc/postfix/pgsql-mailboxes.cf
[...]
/etc/postfix/psql-alias-domains.cf
hosts = 127.0.0.1
user = postgres
password =
dbname = postfix
query = SELECT domain FROM is_alias_domain('%s');
/etc/postfix/psql-alias-maps.cf
hosts = 127.0.0.1
user = postgres
password =
dbname = postfix
query = SELECT alias FROM get_alias('%s');
/etc/postfix/pgsql-local-domains.cf
hosts = 127.0.0.1
user = postgres
password =
dbname = postfix
query = SELECT domain FROM is_local_domain('%s');
/etc/postfix/pgsql-mailboxes.cf
hosts = 127.0.0.1
user = postgres
password =
dbname = postfix
query = SELECT folder FROM get_mailbox_folder('%s');

… and as much as I dislike copy/paste templates, here’s the one for the DB Schema:

Careful: This script contains DROP CASCADE statements! I suggest you run it on a new/empty DB, and preferably in a TX. For example: psql -1 -f script.sql db_name

DROP TABLE IF EXISTS mailboxes CASCADE;
DROP TABLE IF EXISTS local_domain CASCADE;
DROP TABLE IF EXISTS aliases CASCADE;
DROP TABLE IF EXISTS alias_domain CASCADE;

CREATE TABLE alias_domain (
    "domain" text NOT NULL PRIMARY KEY,
    CONSTRAINT alias_domains_domain_check CHECK (("domain" <> ''::text))
);
ALTER TABLE public.alias_domain OWNER TO vmail;


CREATE TABLE aliases (
    "name" text NOT NULL,
    "domain" text NOT NULL REFERENCES alias_domain ON UPDATE CASCADE ON DELETE RESTRICT,
    "alias" text NOT NULL,
    CONSTRAINT aliases_name_check CHECK ((name <> ''::text)),
    CONSTRAINT aliases_alias_check CHECK ((alias <> ''::text)),
    PRIMARY KEY ("name", "domain")
);
ALTER TABLE public.aliases OWNER TO vmail;


CREATE TABLE local_domain (
    "domain" text NOT NULL PRIMARY KEY,
    CONSTRAINT local_domains_domain_check CHECK (("domain" <> ''::text))
);
ALTER TABLE public.local_domain OWNER TO vmail;


CREATE TABLE mailboxes (
    "name" text NOT NULL,
    "domain" text NOT NULL REFERENCES local_domain ON UPDATE CASCADE ON DELETE RESTRICT,
    "folder" text NOT NULL,
    CONSTRAINT mailbox_name_check CHECK ((name <> ''::text)),
    CONSTRAINT mailbox_folder_check CHECK ((folder <> ''::text)),
    PRIMARY KEY ("name", "domain")
);
ALTER TABLE public.mailboxes OWNER TO vmail;

-- --------------------------------------------------------------------------------------

DROP FUNCTION IF EXISTS is_alias_domain(TEXT);
CREATE OR REPLACE FUNCTION is_alias_domain(TEXT) RETURNS SETOF alias_domain AS $$
    SELECT "domain" FROM alias_domain WHERE "domain"=$1;
$$ LANGUAGE SQL;

DROP FUNCTION IF EXISTS is_local_domain(TEXT);
CREATE OR REPLACE FUNCTION is_local_domain(TEXT) RETURNS SETOF local_domain AS $$
    SELECT "domain" FROM local_domain WHERE "domain"=$1;
$$ LANGUAGE SQL;

DROP FUNCTION IF EXISTS get_alias(TEXT);
CREATE OR REPLACE FUNCTION get_alias(TEXT) RETURNS TABLE (alias TEXT) AS $$
    SELECT alias FROM aliases WHERE $1=name || '@' || DOMAIN;
$$ LANGUAGE SQL;

DROP FUNCTION IF EXISTS get_mailbox_folder(TEXT);
CREATE OR REPLACE FUNCTION get_mailbox_folder(TEXT) RETURNS TABLE (folder TEXT) AS $$
    SELECT folder FROM mailboxes WHERE $1=name || '@' || DOMAIN;
$$ LANGUAGE SQL;

Viewing all articles
Browse latest Browse all 10

Trending Articles