Self-host your own digital island
1# nixos-mailserver: a simple mail server
2# Copyright (C) 2016-2018 Robin Raymond
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>
16
17{ config, pkgs, lib, ... }:
18
19with (import ./common.nix { inherit config pkgs lib; });
20
21let
22 inherit (lib.strings) concatStringsSep;
23 cfg = config.mailserver;
24
25 # Merge several lookup tables. A lookup table is a attribute set where
26 # - the key is an address (user@example.com) or a domain (@example.com)
27 # - the value is a list of addresses
28 mergeLookupTables = tables: lib.zipAttrsWith (n: v: lib.flatten v) tables;
29
30 # valiases_postfix :: Map String [String]
31 valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
32 (name: value:
33 let to = name;
34 in map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name))
35 cfg.loginAccounts));
36
37 # catchAllPostfix :: Map String [String]
38 catchAllPostfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
39 (name: value:
40 let to = name;
41 in map (from: {"@${from}" = to;}) value.catchAll)
42 cfg.loginAccounts));
43
44 # all_valiases_postfix :: Map String [String]
45 all_valiases_postfix = mergeLookupTables [valiases_postfix extra_valiases_postfix];
46
47 # attrsToLookupTable :: Map String (Either String [ String ]) -> Map String [String]
48 attrsToLookupTable = aliases: let
49 lookupTables = lib.mapAttrsToList (from: to: {"${from}" = to;}) aliases;
50 in mergeLookupTables lookupTables;
51
52 # extra_valiases_postfix :: Map String [String]
53 extra_valiases_postfix = attrsToLookupTable cfg.extraVirtualAliases;
54
55 # forwards :: Map String [String]
56 forwards = attrsToLookupTable cfg.forwards;
57
58 # lookupTableToString :: Map String [String] -> String
59 lookupTableToString = attrs: let
60 valueToString = value: lib.concatStringsSep ", " value;
61 in lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs);
62
63 # valiases_file :: Path
64 valiases_file = let
65 content = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix]);
66 in builtins.toFile "valias" content;
67
68 # denied_recipients_postfix :: [ String ]
69 denied_recipients_postfix = (map
70 (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}")
71 (lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts)));
72 denied_recipients_file = builtins.toFile "denied_recipients" (lib.concatStringsSep "\n" denied_recipients_postfix);
73
74 reject_senders_postfix = (map
75 (sender:
76 "${sender} REJECT")
77 (cfg.rejectSender));
78 reject_senders_file = builtins.toFile "reject_senders" (lib.concatStringsSep "\n" (reject_senders_postfix)) ;
79
80 reject_recipients_postfix = (map
81 (recipient:
82 "${recipient} REJECT")
83 (cfg.rejectRecipients));
84 # rejectRecipients :: [ Path ]
85 reject_recipients_file = builtins.toFile "reject_recipients" (lib.concatStringsSep "\n" (reject_recipients_postfix)) ;
86
87 # vhosts_file :: Path
88 vhosts_file = builtins.toFile "vhosts" (concatStringsSep "\n" cfg.domains);
89
90 # vaccounts_file :: Path
91 # see
92 # https://blog.grimneko.de/2011/12/24/a-bunch-of-tips-for-improving-your-postfix-setup/
93 # for details on how this file looks. By using the same file as valiases,
94 # every alias is owned (uniquely) by its user.
95 # The user's own address is already in all_valiases_postfix.
96 vaccounts_file = builtins.toFile "vaccounts" (lookupTableToString all_valiases_postfix);
97
98 submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" (''
99 # Removes sensitive headers from mails handed in via the submission port.
100 # See https://thomas-leister.de/mailserver-debian-stretch/
101 # Uses "pcre" style regex.
102
103 /^Received:/ IGNORE
104 /^X-Originating-IP:/ IGNORE
105 /^X-Mailer:/ IGNORE
106 /^User-Agent:/ IGNORE
107 /^X-Enigmail:/ IGNORE
108 '' + lib.optionalString cfg.rewriteMessageId ''
109
110 # Replaces the user submitted hostname with the server's FQDN to hide the
111 # user's host or network.
112
113 /^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}>
114 '');
115
116 inetSocket = addr: port: "inet:[${toString port}@${addr}]";
117 unixSocket = sock: "unix:${sock}";
118
119 smtpdMilters =
120 (lib.optional cfg.dkimSigning "unix:/run/opendkim/opendkim.sock")
121 ++ [ "unix:/run/rspamd/rspamd-milter.sock" ];
122
123 policyd-spf = pkgs.writeText "policyd-spf.conf" cfg.policydSPFExtraConfig;
124
125 mappedFile = name: "hash:/var/lib/postfix/conf/${name}";
126
127 submissionOptions =
128 {
129 smtpd_tls_security_level = "encrypt";
130 smtpd_sasl_auth_enable = "yes";
131 smtpd_sasl_type = "dovecot";
132 smtpd_sasl_path = "/run/dovecot2/auth";
133 smtpd_sasl_security_options = "noanonymous";
134 smtpd_sasl_local_domain = "$myhostname";
135 smtpd_client_restrictions = "permit_sasl_authenticated,reject";
136 smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts";
137 smtpd_sender_restrictions = "reject_sender_login_mismatch";
138 smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject";
139 cleanup_service_name = "submission-header-cleanup";
140 };
141in
142{
143 config = with cfg; lib.mkIf enable {
144
145 services.postfix = {
146 enable = true;
147 hostname = "${sendingFqdn}";
148 networksStyle = "host";
149 mapFiles."valias" = valiases_file;
150 mapFiles."vaccounts" = vaccounts_file;
151 mapFiles."denied_recipients" = denied_recipients_file;
152 mapFiles."reject_senders" = reject_senders_file;
153 mapFiles."reject_recipients" = reject_recipients_file;
154 sslCert = certificatePath;
155 sslKey = keyPath;
156 enableSubmission = cfg.enableSubmission;
157 enableSubmissions = cfg.enableSubmissionSsl;
158 virtual = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix forwards]);
159
160 config = {
161 # Extra Config
162 mydestination = "";
163 recipient_delimiter = cfg.recipientDelimiter;
164 smtpd_banner = "${fqdn} ESMTP NO UCE";
165 disable_vrfy_command = true;
166 message_size_limit = toString cfg.messageSizeLimit;
167
168 # virtual mail system
169 virtual_uid_maps = "static:5000";
170 virtual_gid_maps = "static:5000";
171 virtual_mailbox_base = mailDirectory;
172 virtual_mailbox_domains = vhosts_file;
173 virtual_mailbox_maps = mappedFile "valias";
174 virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp";
175 # Avoid leakage of X-Original-To, X-Delivered-To headers between recipients
176 lmtp_destination_recipient_limit = "1";
177
178 # sasl with dovecot
179 smtpd_sasl_type = "dovecot";
180 smtpd_sasl_path = "/run/dovecot2/auth";
181 smtpd_sasl_auth_enable = true;
182 smtpd_relay_restrictions = [
183 "permit_mynetworks" "permit_sasl_authenticated" "reject_unauth_destination"
184 ];
185
186 policy-spf_time_limit = "3600s";
187
188 # reject selected senders
189 smtpd_sender_restrictions = [
190 "check_sender_access ${mappedFile "reject_senders"}"
191 ];
192
193 # quota and spf checking
194 smtpd_recipient_restrictions = [
195 "check_recipient_access ${mappedFile "denied_recipients"}"
196 "check_recipient_access ${mappedFile "reject_recipients"}"
197 "check_policy_service inet:localhost:12340"
198 "check_policy_service unix:private/policy-spf"
199 ];
200
201 # TLS settings, inspired by https://github.com/jeaye/nix-files
202 # Submission by mail clients is handled in submissionOptions
203 smtpd_tls_security_level = "may";
204
205 # strong might suffice and is computationally less expensive
206 smtpd_tls_eecdh_grade = "ultra";
207
208 # Disable obselete protocols
209 smtpd_tls_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3";
210 smtp_tls_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3";
211 smtpd_tls_mandatory_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3";
212 smtp_tls_mandatory_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3";
213
214 smtp_tls_ciphers = "high";
215 smtpd_tls_ciphers = "high";
216 smtp_tls_mandatory_ciphers = "high";
217 smtpd_tls_mandatory_ciphers = "high";
218
219 # Disable deprecated ciphers
220 smtpd_tls_mandatory_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
221 smtpd_tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
222 smtp_tls_mandatory_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
223 smtp_tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
224
225 tls_preempt_cipherlist = true;
226
227 # Allowing AUTH on a non encrypted connection poses a security risk
228 smtpd_tls_auth_only = true;
229 # Log only a summary message on TLS handshake completion
230 smtpd_tls_loglevel = "1";
231
232 # Configure a non blocking source of randomness
233 tls_random_source = "dev:/dev/urandom";
234
235 smtpd_milters = smtpdMilters;
236 non_smtpd_milters = lib.mkIf cfg.dkimSigning ["unix:/run/opendkim/opendkim.sock"];
237 milter_protocol = "6";
238 milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer}";
239
240 };
241
242 submissionOptions = submissionOptions;
243 submissionsOptions = submissionOptions;
244
245 masterConfig = {
246 "lmtp" = {
247 # Add headers when delivering, see http://www.postfix.org/smtp.8.html
248 # D => Delivered-To, O => X-Original-To, R => Return-Path
249 args = [ "flags=O" ];
250 };
251 "policy-spf" = {
252 type = "unix";
253 privileged = true;
254 chroot = false;
255 command = "spawn";
256 args = [ "user=nobody" "argv=${pkgs.pypolicyd-spf}/bin/policyd-spf" "${policyd-spf}"];
257 };
258 "submission-header-cleanup" = {
259 type = "unix";
260 private = false;
261 chroot = false;
262 maxproc = 0;
263 command = "cleanup";
264 args = ["-o" "header_checks=pcre:${submissionHeaderCleanupRules}"];
265 };
266 };
267 };
268 };
269}