This repository has no description
13 kB
405 lines
1{
2 self,
3 config,
4 lib,
5 pkgs,
6 ...
7}:
8let
9 relayHosts = map (name: self.inputs.kartei.hosts.${name}.nets.retiolum.ip6.addr) [
10 "aergia"
11 "ignavia"
12 "coaxmetal"
13 "green"
14 "mors"
15 ];
16
17 mynetworks = map (ip: "[${ip}]") relayHosts;
18in
19{
20 imports = [
21 self.inputs.nixos-mailserver.nixosModules.default
22 ./hermes-mail-vars.nix
23 ];
24
25 mailserver = {
26 enable = true;
27 fqdn = "mail.lassul.us";
28 # neoprism.r: retiolum-internal mail (cron, root@neoprism.r from other
29 # krebs hosts) lands in lass's mailbox via catchAll.
30 domains = [
31 "lassul.us"
32 "neoprism.r"
33 ];
34
35 loginAccounts = {
36 "lass@lassul.us" = {
37 hashedPasswordFile =
38 config.clan.core.vars.generators.mailserver-lass.files."lass-mail-password-hash".path;
39 aliases = [
40 "postmaster@lassul.us"
41 "root@lassul.us"
42 "abuse@lassul.us"
43 "hostmaster@lassul.us"
44 "webmaster@lassul.us"
45 "security@lassul.us"
46 "noc@lassul.us"
47 "mailer-daemon@lassul.us"
48 "nobody@lassul.us"
49 "lassulus@lassul.us"
50 ];
51 catchAll = [
52 "lassul.us"
53 "neoprism.r"
54 ];
55 };
56 "bot@lassul.us" = {
57 hashedPasswordFile =
58 config.clan.core.vars.generators.mailserver-bot.files."bot-mail-password-hash".path;
59 };
60 # Zitadel (configs/zitadel.nix): verification codes and password resets.
61 # Zitadel 2.71 needs a password for an SMTP provider.
62 "id@lassul.us" = {
63 hashedPasswordFile = config.clan.core.vars.generators.mailserver-id.files."password-hash".path;
64 sendOnly = true;
65 };
66 # Hermes agent on coaxmetal; shared password var, see hermes-mail-vars.nix.
67 "hermes@lassul.us" = {
68 hashedPasswordFile = config.clan.core.vars.generators.hermes-mail.files."password-hash".path;
69 };
70 # Fetched by paperless (configs/services/paperless) via IMAP.
71 "scan@lassul.us" = {
72 hashedPasswordFile = config.clan.core.vars.generators.mailserver-scan.files."password-hash".path;
73 };
74 };
75
76 # TLS via existing ACME cert
77 x509.useACMEHost = "mail.lassul.us";
78
79 # DKIM — auto-managed by module
80 dkimSigning = true;
81 dkimSelector = "mail";
82
83 # ManageSieve: lets mail clients manage sieve rules dynamically (port 4190)
84 enableManageSieve = true;
85
86 # Full-text search for IMAP clients
87 fullTextSearch = {
88 enable = true;
89 memoryLimit = 500;
90 };
91
92 # 5: Sieve scripts now live in each user's Dovecot home (~/sieve) instead of
93 # the old global /var/sieve. Before deploying this bump, run the migration
94 # against /var/sieve on neoprism (see migrations/nixos-mailserver-migration-05.py
95 # in the nixos-mailserver input, or the migration docs).
96 stateVersion = 5;
97
98 # virtual.All mailbox needs more memory to sync all folders
99 imapMemoryLimit = 1024;
100 };
101
102 # nixos-mailserver mkForces virtualMail without homeMode; match its priority
103 users.users.virtualMail = lib.mkOverride 50 { homeMode = "2770"; };
104
105 # POSIX ACL default ensures new files inherit lass access (needed for muchsync hardlinks)
106 systemd.services.dovecot.serviceConfig.ExecStartPost = [
107 "+${pkgs.acl}/bin/setfacl -R -m u:lass:rwX /var/vmail/lassul.us/lass/mail"
108 "+${pkgs.acl}/bin/setfacl -R -d -m u:lass:rwX /var/vmail/lassul.us/lass/mail"
109 ];
110
111 # Password generation for mail accounts
112 clan.core.vars.generators.mailserver-lass = {
113 files."lass-mail-password" = { };
114 files."lass-mail-password-hash" = { };
115 runtimeInputs = with pkgs; [
116 coreutils
117 mkpasswd
118 pwgen
119 ];
120 script = ''
121 pwgen -s 32 1 > "$out/lass-mail-password"
122 mkpasswd -sm bcrypt < "$out/lass-mail-password" > "$out/lass-mail-password-hash"
123 '';
124 };
125
126 clan.core.vars.generators.mailserver-bot = {
127 files."bot-mail-password" = { };
128 files."bot-mail-password-hash" = { };
129 files."mail.env" = { };
130 runtimeInputs = with pkgs; [
131 coreutils
132 mkpasswd
133 pwgen
134 ];
135 script = ''
136 pwgen -s 32 1 > "$out/bot-mail-password"
137 mkpasswd -sm bcrypt < "$out/bot-mail-password" > "$out/bot-mail-password-hash"
138 cat > "$out/mail.env" << EOF
139 IMAP_USER=bot@lassul.us
140 IMAP_PASSWORD=$(cat "$out/bot-mail-password")
141 IMAP_HOST=localhost
142 EOF
143 '';
144 };
145
146 # the plaintext is read by zitadel-reconcile (root)
147 clan.core.vars.generators.mailserver-id = {
148 files."password" = { };
149 files."password-hash" = { };
150 runtimeInputs = with pkgs; [
151 coreutils
152 mkpasswd
153 pwgen
154 ];
155 script = ''
156 pwgen -s 32 1 | tr -d '\n' > "$out/password"
157 mkpasswd -sm bcrypt < "$out/password" > "$out/password-hash"
158 '';
159 };
160
161 # the plaintext goes into paperless' mail account (root-only on neoprism)
162 clan.core.vars.generators.mailserver-scan = {
163 files."password" = { };
164 files."password-hash" = { };
165 runtimeInputs = with pkgs; [
166 coreutils
167 mkpasswd
168 pwgen
169 ];
170 script = ''
171 pwgen -s 32 1 | tr -d '\n' > "$out/password"
172 mkpasswd -sm bcrypt < "$out/password" > "$out/password-hash"
173 '';
174 };
175
176 # Retiolum relay: allow krebs hosts to relay through postfix
177 services.postfix.settings.main.mynetworks = lib.mkForce (
178 [
179 "127.0.0.0/8"
180 "[::1]/128"
181 ]
182 ++ mynetworks
183 );
184
185 # ACME cert — reuse existing setup
186 security.acme.certs."mail.lassul.us" = {
187 group = "lasscert";
188 webroot = "/var/lib/acme/acme-challenge";
189 };
190 users.groups.lasscert.members = [
191 "dovecot2"
192 "postfix"
193 "nginx"
194 ];
195
196 # Firewall: SMTP(25), submission(587), submissions(465), IMAPS(993), ManageSieve(4190)
197 networking.firewall.allowedTCPPorts = [
198 25
199 587
200 465
201 993
202 4190
203 ];
204
205 # muchsync: lass user needs access to vmail for notmuch/muchsync
206 users.groups.virtualMail.members = [ "lass" ];
207 systemd.tmpfiles.rules = [
208 "L+ /home/lass/Maildir - - - - /var/vmail/lassul.us/lass/mail"
209 "z /var/vmail/lassul.us 2770 virtualMail virtualMail -"
210 "z /var/vmail/lassul.us/lass 2770 virtualMail virtualMail -"
211 "d /var/vmail/lassul.us/lass/mail 2770 virtualMail virtualMail -"
212 ];
213
214 # Thunderbird autoconfig
215 services.nginx.virtualHosts."autoconfig.lassul.us" = {
216 forceSSL = true;
217 enableACME = true;
218 locations."= /mail/config-v1.1.xml".extraConfig = ''
219 default_type application/xml;
220 return 200 '<?xml version="1.0" encoding="UTF-8"?>
221 <clientConfig version="1.1">
222 <emailProvider id="lassul.us">
223 <domain>lassul.us</domain>
224 <displayName>lassul.us Mail</displayName>
225 <displayShortName>lassul.us</displayShortName>
226 <incomingServer type="imap">
227 <hostname>mail.lassul.us</hostname>
228 <port>993</port>
229 <socketType>SSL</socketType>
230 <authentication>password-cleartext</authentication>
231 <username>%EMAILADDRESS%</username>
232 </incomingServer>
233 <outgoingServer type="smtp">
234 <hostname>mail.lassul.us</hostname>
235 <port>465</port>
236 <socketType>SSL</socketType>
237 <authentication>password-cleartext</authentication>
238 <username>%EMAILADDRESS%</username>
239 </outgoingServer>
240 </emailProvider>
241 </clientConfig>';
242 '';
243 };
244
245 # Provision virtual mailbox config to a writable directory (nix store is read-only,
246 # and the ACL plugin needs to write dovecot-acl-list next to the virtual config)
247 systemd.tmpfiles.settings."dovecot-virtual" = {
248 "/var/vmail/virtual-config".d = {
249 user = "virtualMail";
250 group = "virtualMail";
251 mode = "0750";
252 };
253 "/var/vmail/virtual-config/Unread".d = {
254 user = "virtualMail";
255 group = "virtualMail";
256 mode = "0750";
257 };
258 "/var/vmail/virtual-config/Unread/dovecot-virtual".f = {
259 user = "virtualMail";
260 group = "virtualMail";
261 mode = "0640";
262 argument = "*\n-notmuch\n-fts-flatcurve\n unseen\n";
263 };
264 "/var/vmail/virtual-config/All".d = {
265 user = "virtualMail";
266 group = "virtualMail";
267 mode = "0750";
268 };
269 "/var/vmail/virtual-config/All/dovecot-virtual".f = {
270 user = "virtualMail";
271 group = "virtualMail";
272 mode = "0640";
273 argument = "*\n-notmuch\n-fts-flatcurve\n all\n";
274 };
275 };
276
277 # Dovecot virtual mailbox + ACL-restricted shared namespace for bot
278 services.dovecot2.settings = {
279 mail_plugins = {
280 virtual = true;
281 acl = true;
282 };
283
284 # ACL plugin (dovecot 2.4): `acl_driver` is top-level; per-identifier
285 # default rights live in `acl <identifier> { acl_rights = ...; }` named
286 # filters where <identifier> is owner/anyone/authenticated/user=.../etc.
287 # `acl_shared_dict` was removed; the bot accesses shared mail via
288 # explicit subscriptions.
289 acl_driver = "vfile";
290 "acl owner" = {
291 acl_rights = "lrwstipekxa";
292 };
293
294 # In dovecot 2.4 the per-namespace `location = "driver:path:opts"` was
295 # replaced by separate mail_driver / mail_path / mail_index_path settings.
296 "namespace shared" = {
297 type = "shared";
298 separator = ".";
299 prefix = "shared.%{user | username}.";
300 mail_driver = "maildir";
301 mail_path = "/var/vmail/lassul.us/%{user | username}/mail";
302 subscriptions = false;
303 list = "children";
304 };
305
306 "namespace virtual" = {
307 prefix = "virtual.";
308 separator = ".";
309 mail_driver = "virtual";
310 mail_path = "/var/vmail/virtual-config";
311 mail_index_path = "/var/vmail/virtual-indexes/%{user}";
312 # virtual storage doesn't support maildir++ layout, override the global
313 mailbox_list_layout = "fs";
314 subscriptions = false;
315 "mailbox Unread".auto = "subscribe";
316 "mailbox All".auto = "subscribe";
317 };
318 };
319
320 # Write dovecot-acl files to grant bot read-only + flag access to lass's mail
321 systemd.services.dovecot-acl-sync = {
322 description = "Sync dovecot ACL files for bot shared access";
323 after = [ "dovecot.service" ];
324 serviceConfig = {
325 Type = "oneshot";
326 User = "virtualMail";
327 Group = "virtualMail";
328 };
329 # l=lookup, r=read, w=write-flags, s=write-seen (no insert/delete/expunge)
330 script = ''
331 acl_line="user=bot@lassul.us lrws"
332 # Only write ACL in mailbox root dirs, skip cur/new/tmp/notmuch subdirs
333 find /var/vmail/lassul.us/lass/mail -type d \
334 -not -name cur -not -name new -not -name tmp \
335 -not -path '*/.notmuch/*' -not -name .notmuch \
336 -not -path '*/fts-flatcurve/*' -not -name fts-flatcurve \
337 | while IFS= read -r dir; do
338 acl_file="$dir/dovecot-acl"
339 if [ ! -f "$acl_file" ] || [ "$(cat "$acl_file")" != "$acl_line" ]; then
340 echo "$acl_line" > "$acl_file"
341 fi
342 done
343 '';
344 };
345 systemd.timers.dovecot-acl-sync = {
346 wantedBy = [ "timers.target" ];
347 timerConfig = {
348 OnBootSec = "1min";
349 OnUnitActiveSec = "1h";
350 };
351 };
352
353 # notmuch + muchsync + msmtp for CLI mail access
354 environment.systemPackages = [
355 self.packages.${pkgs.system}.notmuch
356 pkgs.muchsync
357 pkgs.msmtp
358 ];
359
360 # After muchsync writes tags to notmuch DB, the Maildir filenames don't get updated
361 # (muchsync bypasses notmuch's flag sync). This watches the xapian DB for changes and
362 # renames files to add/remove the S (Seen) flag based on the notmuch unread tag.
363 systemd.paths.notmuch-flag-sync = {
364 wantedBy = [ "multi-user.target" ];
365 pathConfig.PathChanged = "/var/vmail/lassul.us/lass/mail/.notmuch/xapian";
366 };
367 systemd.services.notmuch-flag-sync = {
368 description = "Sync notmuch tags to Maildir flags after muchsync";
369 serviceConfig = {
370 Type = "oneshot";
371 User = "lass";
372 Group = "virtualMail";
373 ExecStart = pkgs.writeShellScript "notmuch-flag-sync" ''
374 export NOTMUCH_CONFIG="${
375 self.packages.${pkgs.system}.notmuch.passthru.configuration.configFile.path
376 }"
377 # First, pick up any IMAP flag changes into notmuch
378 ${pkgs.notmuch}/bin/notmuch new --quiet 2>/dev/null || true
379 # Add S flag to files notmuch considers read but that lack it
380 ${pkgs.notmuch}/bin/notmuch search --output=files 'NOT tag:unread' \
381 | ${pkgs.gnugrep}/bin/grep '/cur/' \
382 | while IFS= read -r f; do
383 [ -f "$f" ] || continue
384 flags="''${f##*:2,}"
385 case "$flags" in *S*) continue ;; esac
386 base="''${f%:2,*}"
387 mv "$f" "''${base}:2,''${flags}S"
388 done
389 # Remove S flag from files notmuch considers unread but that have it
390 ${pkgs.notmuch}/bin/notmuch search --output=files 'tag:unread' \
391 | ${pkgs.gnugrep}/bin/grep '/cur/' \
392 | while IFS= read -r f; do
393 [ -f "$f" ] || continue
394 flags="''${f##*:2,}"
395 case "$flags" in *S*) ;; *) continue ;; esac
396 base="''${f%:2,*}"
397 newflags="''${flags//S/}"
398 mv "$f" "''${base}:2,''${newflags}"
399 done
400 # Update notmuch DB with the renames
401 ${pkgs.notmuch}/bin/notmuch new --quiet 2>/dev/null || true
402 '';
403 };
404 };
405}