Wow, so much to learn about in order to get things working. This is not a step-by-step guide, more some notes to help you get through the critical parts of the documentation. You really do need to get a good understanding of this stuff.
Updated configuration:
- MailJet is now completely out of the picture. They’re not interested in providing the service I wanted, and it turns out I don’t need them.
- Postfix server in cloud (mx.bangdash.space) is my mail receiver that should always be available, and relays all mail for my domains to my home IP.
- Internal bangdash.space server now runs Postfix+Dovecot+LDAP+OpenDKIM
- Postfix handles the SMTP receiving from mx.bangdash.space and sending email to all other domains according to DNS records.
- Dovecot implements Local Mail Transport Protocol (LMTP) to accept mail for local addresses from Postfix. All my domains are defined in Postfix as virtual domains, with rules set up to route all mail into my personal mailbox.
- As a side benefit, I’m now actually receiving email reports from daily scheduled tasks on my server reporting issues.
- LDAP handles user authentication and mail aliases
- My outgoing email address is not the same as my internal userid, making bruteforce login attempts harder.
- DNS entries for authenticated mail have been created for all domains, SPF and DKIM
- OpenDKIM is signing outgoing emails using public/private key cryptography with the public key in DNS records. This means sites that insist on valid DKIM signing will accept our email.
MailJet
So why have I dropped Mailjet? Well, let’s look at why I thought I needed them. Advice on various web sites was saying that most home IP addresses are likely to be on email blocklists, making it really difficult to rehabilitate. That turns out not to be the case so much here in Australia. We’ve got a lot more IP addresses per person than most developed countries. Even without going to IPv6, I have a dedicated IP address that goes to my Internet-facing router, not using “carrier-grade” Network Address Translation at all. So blocklists turn out to not be an issue as long as I keep our email system secure from spammers. More on that below.
Postfix server in cloud
This is a fairly simple configuration. TLS is configured with a LetsEncrypt certificate. LetsEncrypt’s certbot is scheduled to run twice a month, using a script that enables a firewall rule for port 80, updates any certs, and then disables the firewall rule. The ssh port on mx.bangdash.space is only open for traffic coming from my IP address, and access is only accepted using an ssl cert. No username/password login is possible. Relayed email is sent on to my home server, using a specified port in my home router that is only open for traffic from the cloud server.
A few postfix settings that cut out a lot of spam sources:
/etc/postfix.main.cf (partial)
## Security Settings
smtpd_helo_required = yes
smtpd_helo_restrictions = reject_non_fqdn_helo_hostname,
reject_invalid_helo_hostname,
reject_unknown_helo_hostname, permit
smtpd_relay_restrictions = reject_unauth_destination
smtpd_recipient_restrictions = reject_unknown_client_hostname,
reject_unknown_sender_domain, reject_unknown_recipient_domain,
reject_unauth_pipelining, reject_unauth_destination
A lot of spam servers don’t bother with the HELO / EHLO starting announcements, so that, in itself, cuts out a fair chunk. Spammers are in a hurry. We then further insist that EHLO statements use fully qualified domain names, and that the name given matches the source’s IP address in DNS.
Any attempt to send to a domain other than the relay_domains I have configured is rejected, as are any emails where the From: address domain is not in DNS
/etc/postfix.main.cf (partial)
## Really reject when the rejecting is on
unknown_address_reject_code = 550
unknown_hostname_reject_code = 550
unknown_client_reject_code = 550
If we’re rejecting mail, we just reject it permanently, rather than holding onto it for a retry.
/etc/postfix.main.cf (partial)
mydestination =
local_recipient_maps =
local_transport = error:local mail delivery is disabled
No local delivery desired. Any attempt to send email to @localhost addresses will fail.
/etc/postfix/master.cf (partial)
bounce unix - - y - 0 discard
trace unix - - y - 0 discard
Since I’m using wildcard routing of my domains, we shouldn’t get any bounces that aren’t attempts to send to an invalid address, and I see no need to help other people sort out their email issues using my servers.
/etc/postfix/main.cf (partial)
relay_recipient_maps = hash:/etc/postfix/relay_recipients
transport_maps = hash:/etc/postfix/transport
/etc/postfix/relay_recipients
@bangdash.space x
@otherdomain.tld x
/etc/postfix/transport
bangdash.space smtp:[bangdash.space]:######
otherdomain.tld smtp:[bangdash.space]:######
(the “#####” there is the port number I have opened up in my firewall for traffic only from the cloud instance)
This is the critical part to get the relays happening to my internal mail server.
“relay_recipient maps” just turns on relaying to given email addresses. I’m allowing all addresses in my domains to be relayed at this stage.
“transport_maps” tells postfix how to deliver to the specific domains.
Importantly, we’re only relaying mail for our own domains, so we’re not an open relay.
Internal Postfix + Dovecot
As I mentioned earlier, I ended up completely blowing away my Postfix + Dovecot configuration and rebuilding from scratch. Actually, include LDAP in that collection of rebuilding. I was having trouble getting everything synced up, and decided to clean everything out and start from scratch. I still had all my LDIF files with the required config, and I had a better idea what I was doing, so it all went a lot quicker.
Dovecot has been set up to talk to LDAP, using this chain of included conf files.
/etc/dovecot/conf.d/10-auth.conf
##
## Authentication processes
##
auth_username_format = %Ln
auth_username_chars = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@
auth_mechanisms = plain login
!include auth-system.conf.ext
!include auth-ldap.conf.ext
/etc/dovecot/conf.d/auth-ldap.conf.ext
# Authentication for LDAP users. Included from 10-auth.conf.
passdb {
driver = ldap
args = /etc/dovecot/dovecot-ldap.conf.ext
}
userdb {
driver = ldap
args = /etc/dovecot/dovecot-ldap.conf.ext
}
/etc/dovecot/dovecot-ldap.conf.ext
hosts = bangdash.space
dn = cn=dovecot,dc=bangdash,dc=space
dnpass = REPLACEME
tls = yes
tls_require_cert = never
base = ou=People,dc=bangdash,dc=space
scope = subtree
default_pass_scheme = MD5
auth_bind = yes
auth_bind_userdn = cn=%u,ou=People,dc=bangdash,dc=space
ldap_version = 3
user_attrs = homeDirectory=home,uidNumber=uid,gidNumber=gid
user_filter = (&(objectClass=posixAccount)(uid=%n))
pass_attrs = uid=user,userPassword=password
pass_filter = (&(objectClass=posixAccount)(uid=%n))
Postfix hooks into Dovecot in order to authenticate users for sending email.
\etc\postfix\main.cf (partial)
smtpd_sasl_auth_enable = yes
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/dovecot-auth
smtpd_sasl_authenticated_header = yes
smtpd_sasl_security_options = noanonymous
smtpd_sasl_local_domain = $myhostname
broken_sasl_auth_clients = yes
Postfix also consults LDAP directly for both address verification and resolution of email aliases, so Dovecot only has the final real email address to deliver:
/etc/postfix/main.cf (partial)
smtpd_sender_login_maps = ldap:/etc/postfix/ldap_senders.cf
virtual_alias_maps = ldap:/etc/postfix/ldap_virtual_users.cf
virtual_alias_domains = $virtual_alias_maps
virtual_mailbox_domains = hash:/etc/postfix/virtual-mailbox-domains
virtual_mailbox_maps = $virtual_alias_maps
/etc/postfix/ldap_senders.cf
server_host = ldap://localhost
search_base = ou=People,dc=bangdash,dc=space
bind = yes
bind_dn = cn=mail,dc=bangdash,dc=space
bind_pw = REPLACEME
version = 3
query_filter = (&(objectclass=postfixUser)(|(mailacceptinggeneralid=%s)(mailacceptinggeneralid=%d)))
result_attribute = maildrop
domain = bangdash.space
/etc/postfix/virtual-mailbox-domains
bangdash.space OK
other.domain OK
/etc/postfix/ldap_virtual_users.cf
server_host = ldap://localhost
search_base = ou=People,dc=bangdash,dc=space
bind = yes
bind_dn = cn=mail,dc=bangdash,dc=space
bind_pw = REPLACEME
version = 3
query_filter = (&(objectclass=postfixUser)(|(mailacceptinggeneralid=%s)(mailacceptinggeneralid=%d)))
result_attribute = maildrop
The virtual-mailbox-domains file is telling Postfix which domains it is the final destination for, while ldap_virtual_users finds user matches for mail addresses, using the full email address and just the domain part to find wildcard addresses. To get this to work, I needed to add the Postfix schema to my LDAP and then configure mail aliases in my User accounts.
Postfix also relies on Dovecot for the final distribution of mail into user mail files, thanks to configuring Dovecot as an LMTP agent. That took almost no changes to Dovecot’s /etc/dovecot/conf.d/15-lda.conf file, and these additions to Postfix’s config:
/etc/postfix/main.cf (partial)
virtual_transport = lmtp:unix:private/dovecot-lmtp
mailbox_command = /usr/lib/dovecot/deliver -c /etc/dovecot/dovecot.conf -m "${EXTENSION}"
## This setting will generate an error if you restart Postfix before
## adding the appropriate service definition in master.cf, so make
## sure to get that taken care of!
dovecot_destination_recipient_limit = 1
/etc/postfix/master.cf (partial)
## Dovecot LDA
dovecot unix - n n - - pipe
flags=DRhu user=dovecot:dovecot argv=/usr/lib/dovecot/deliver
-f ${sender} -d ${user}
Using the Mailstack package gave me a huge head start in getting Postfix using Dovecot as an authentication provider, though I ended up changing almost all the details in order to get LDAP working, and finally uninstalled the metapackage in order to simplify my Ansible scripts. I didn’t want the MySQL part of the install anyway, and now I just have lists of “postfix_packages” and “dovecote_packages” for the Ansible playbook to ensure are installed.
Sending email to the world
After I had incoming email being delivered to my mailbox, I then had to get outgoing mail configured. Simply replying to the test email from gmail turned out to work straight away, once I sorted out the SASL authentication.
Now, I thought at this point, that I had configured relaying through MailJet. But when I checked my MailJet account page, it showed no mail having been sent, and also still said my email sending was disabled, even a couple of days after I’d changed my email address to my domain. It was about then that my suspicion grew that I might be ok with not using a relay at all. It turned out I hadn’t re-added the Mailjet relay config after blowing everything away and starting again. But it seemed the outgoing mail was working fine without it!
Here’s the parts of the Postfix config that cover outgoing mail:
/etc/postfix/main.cf
append_dot_mydomain = no
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_use_tls = yes
canonical_maps = hash:/etc/postfix/canonical
smtp_tls_note_starttls_offer = yes
smtp_tls_security_level = may
The canonical_maps hashmap is used to remap mail sent by server processes like Apache or NGINX into a real email address, and other than that, there really wasn’t anything I needed to change, it seemed. Well, almost.
When I tried sending test emails to Ann’s Uni account and my work account that weren’t replies, they did not arrive.
Ah. Yes, that would be mail servers these days insisting on some authentication.
The first step for fixing that is SPF Authentication. It’s a really simple system where you put a record in your DNS that tells the world where to expect emails from that domain to come from. So, one DNS entry required:
bangdash.space. 3599 IN TXT "v=spf1 ip4:220.233.90.45 ?all"
Pretty simple, it’s a SPF version 1 record, saying all mail comes from that ip4 address, and that simple addition was enough to get our mail accepted by most places. There may be some sites that ask for a little more, so I added a couple more packages, opendkim and opendkim-tools, and configure postfix to use it thus:
/etc/postfix/main.cf (partial)
milter_default_action = accept
milter_protocol = 6
smtpd_milters = inet:localhost:99999
non_smtpd_milters = $smtpd_milters
The OpenDKIM config itself is pretty simple. It needs to be set up to listen on the same port that Postfix is sending to:
/etc/opendkim.conf (partial):
SOCKET=inet:99999@localhost
(And ensure that /defaults/opendkim
doesn’t override it)
Other than that, a certificate needs to be created, but there’s no trust networks required for this, so using the the opendkim-tools package will generate the private key for opendkim and a txt file with the necessary DNS entry:
opendkim-genkey -t -s mail -d ubuntu.ro
The text file is a bit confusing, as it looks like this:
mail._domainkey IN TXT ( "v=DKIM1; h=sha256; k=rsa; "
"p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4wHiNZUm85LAK3161GrFNaAvrVdeHrmQEOmua2JUa6qtaPzgBWc9T1mYSboVt7VhImA9HemlWDgxz4U4Xul9B7PdN8JMLxK5R70oXrR5BLy4Dph5XAz5mBHMzBxcreIDBLF2D0yQeVcKYyK8fwiXbt3yJRl5gGELMMoAdhx3tQv91g4+HY2kPDL9BSntOMblLnvVPUD8e9+n6W"
"RRE1/xPIRxNRe2NfOWqpJ3nLtuSw5Mo3a6N/N8/S30ModOnglTfGvPb6QqNgIlvEORdeoe0NGaTHyxjAO1D4dCBBIgiTKuk0NvH65CwFYvLzzJxuwI53tkgaJeknrISXilGBnd+QIDAQAB" ) ; ----- DKIM key mail for bangdash.space
and if you aren’t hosting your own DNS, chances are you will need to combine all the bits in double quotes into a single string, removing the quotes and all white-space, and add that as a single TXT DNS entry.
DomainKeys Identified Mail (DKIM) is a way to ensure that a specific piece of mail definitely came from the owner of the DNS entries. It uses public/private key crypto to include a signature of the email headers and content that can be verified using the public key stored in another TXT DNS entry. It’s a way to re-enable one of the “simple” parts of the Simple Mail Transport Protocol, simple mail forwarding. The idea is that if a mail server is not available, an alternate delivery location can be specified to hold the mail until the final destination is available once more. SPF breaks that process, since the later forwarding has the mail coming from a different address than the SPF one. DKIM allows a mail to be authenticated once it has left the original source domain.
Anyone setting a mail system that has an unreliable connection should not be configuring SPF without allowing a fallback to DKIM authentication. Obviously, most sites are going to ignore DKIM if the SPF test passes. Why add the crypto overhead if you’re already happy that the mail is legit?
You may note that I have no tests for SPF or DKIM on the incoming email to the Internal server. That’s because the only incoming SMTP requests it will accept are either from the local network, which require LDAP authentication, or connections on a port that is only passed to the server if it comes from my cloud instance, and is addressed to local domains.
I may add SPF/DKIM tests to the cloud instance if I start to see spam coming in.
Putting it all in Ansible
Speaking of my Ansible scripts, after initially attempting to use Ansible’s lineinfile module to tweak specific settings, I have finally abandoned that attempt due to the extensive documentation in Dovecot and Postfix config files. Getting a regexp to match the commented out actual config line instead of an “e.g.” copy somewhere else in the file was a non-trivial exercise, and I decided that the risk of unwanted config items wrecking things was too high, so I have copied all the config files to the Ansible config and now push out a complete config, including files I haven’t really changed.
Getting a single Ansible Playbook to configure the full SMTP stack meant I needed to add details for mx.bangdash.space into my Ansible inventory, and that I also needed to write the Ansible script in such a way that it only tries to install and manage the required packages on each server, and doesn’t throw errors due to failed service module steps or missing config variables.
playbook_smtp.yml:
---
- name: Configure SMTP mail stack
hosts: internal,cloud
become: true
pre_tasks:
- import_tasks: tasks_smtp_pre.yml
tasks:
- import_tasks: tasks_postfix_config.yml
when: postfix_packages is defined
- import_tasks: tasks_opendkim_config.yml
when: dkim_packages is defined
- import_tasks: tasks_dovecot_config.yml
when: dovecot_packages is defined
post_tasks:
- import_tasks: tasks_smtp_post.yml
~~~
Another Ansible specific step is putting all password entries that need to be in config files into an Ansible Vault and replacing placeholder text via the replace module.
- name: Inject password
replace:
regexp: "REPLACEME"
replace: "{{ mail_ldap_password }}"
path: "{{ item.file_dest }}"
loop: "{{ postfix_config_files }}"
With the time I had available on my days off, this has taken me nearly a month, but I certainly know a heck of a lot more about email systems than I used to!