2024-11-27 14:26:48 +01:00
{
config ,
lib ,
pkgs ,
. . .
} :
let
priv_domain = config . secrets . secrets . global . domains . mail_private ;
domain = config . secrets . secrets . global . domains . mail_public ;
mailDomains = [
priv_domain
domain
] ;
mailBackupDir = " / v a r / c a c h e / m a i l - b a c k u p " ;
dataDir = " / v a r / l i b / s t a l w a r t - m a i l " ;
in
{
age . secrets . resticpasswd = {
generator . script = " a l n u m " ;
} ;
age . secrets . stalwartHetznerSshKey = {
generator . script = " s s h - e d 2 5 5 1 9 " ;
} ;
services . restic . backups = {
main = {
user = " r o o t " ;
timerConfig = {
OnCalendar = " 0 6 : 0 0 " ;
Persistent = true ;
RandomizedDelaySec = " 3 h " ;
} ;
initialize = true ;
passwordFile = config . age . secrets . resticpasswd . path ;
hetznerStorageBox = {
enable = true ;
inherit ( config . secrets . secrets . global . hetzner ) mainUser ;
inherit ( config . secrets . secrets . global . hetzner . users . stalwart-mail ) subUid path ;
sshAgeSecret = " s t a l w a r t H e t z n e r S s h K e y " ;
} ;
paths = [
mailBackupDir
] ;
#pruneOpts = [
# "--keep-daily 10"
# "--keep-weekly 7"
# "--keep-monthly 12"
# "--keep-yearly 75"
#];
} ;
} ;
systemd . services . backup-mail = {
description = " M a i l b a c k u p " ;
environment = {
STALWART_DATA = dataDir ;
IDMAIL_DATA = config . services . idmail . dataDir ;
BACKUP_DIR = mailBackupDir ;
} ;
serviceConfig = {
SyslogIdentifier = " b a c k u p - m a i l " ;
Type = " o n e s h o t " ;
User = " s t a l w a r t - m a i l " ;
Group = " s t a l w a r t - m a i l " ;
ExecStart = lib . getExe (
pkgs . writeShellApplication {
name = " b a c k u p - m a i l " ;
runtimeInputs = [ pkgs . sqlite ] ;
text = ''
sqlite3 " $ S T A L W A R T _ D A T A / d a t a b a s e . s q l i t e 3 " " . b a c k u p ' $ B A C K U P _ D I R / d a t a b a s e . s q l i t e 3 ' "
sqlite3 " $ I D M A I L _ D A T A / d a t a b a s e . s q l i t e 3 " " . b a c k u p ' $ B A C K U P _ D I R / i d m a i l . d b ' "
cp - r " $ S T A L W A R T _ D A T A / d k i m " " $ B A C K U P _ D I R / "
'' ;
}
) ;
ReadWritePaths = [
dataDir
config . services . idmail . dataDir
mailBackupDir
] ;
Restart = " n o " ;
} ;
requiredBy = [ " r e s t i c - b a c k u p s - m a i n . s e r v i c e " ] ;
before = [ " r e s t i c - b a c k u p s - m a i n . s e r v i c e " ] ;
} ;
age . secrets . stalwart-admin-pw = {
generator . script = " a l n u m " ;
mode = " 0 0 0 " ;
intermediary = true ;
} ;
age . secrets . stalwart-admin-hash = {
generator . dependencies = [ config . age . secrets . stalwart-admin-pw ] ;
generator . script = " a r g o n 2 i d " ;
mode = " 4 4 0 " ;
group = " s t a l w a r t - m a i l " ;
} ;
users . groups . acme . members = [ " s t a l w a r t - m a i l " ] ;
networking . firewall . allowedTCPPorts = [
25 # smtp
465 # submission tls
# 587 # submission starttls
993 # imap tls
# 143 # imap starttls
4190 # manage sieve
] ;
environment . persistence . " / p e r s i s t " . directories = [
{
directory = dataDir ;
user = " s t a l w a r t - m a i l " ;
group = " s t a l w a r t - m a i l " ;
mode = " 0 7 0 0 " ;
}
] ;
# Needed so we don't run out of tmpfs space for large backups.
# Technically this could be cleared each boot but whatever.
environment . persistence . " / s t a t e " . directories = [
{
directory = mailBackupDir ;
user = " s t a l w a r t - m a i l " ;
group = " s t a l w a r t - m a i l " ;
mode = " 0 7 0 0 " ;
}
] ;
services . nginx = {
2024-11-29 21:20:08 +01:00
enable = true ;
recommendedSetup = true ;
2024-11-27 14:26:48 +01:00
upstreams . stalwart = {
servers . " 1 2 7 . 0 . 0 . 1 : 8 0 8 0 " = { } ;
extraConfig = ''
zone stalwart 6 4 k ;
keepalive 2 ;
'' ;
} ;
virtualHosts =
{
$ { domain } = {
forceSSL = true ;
2024-11-29 21:20:08 +01:00
useACMEHost = domain ;
2024-11-27 14:26:48 +01:00
extraConfig = ''
client_max_body_size 5 1 2 M ;
'' ;
locations . " / " = {
proxyPass = " h t t p : / / s t a l w a r t " ;
proxyWebsockets = true ;
} ;
} ;
}
// lib . genAttrs
[
" a u t o c o n f i g . ${ domain } "
" a u t o d i s c o v e r . ${ domain } "
" m t a - s t s . ${ domain } "
]
( _ : {
forceSSL = true ;
2024-11-29 21:20:08 +01:00
useACMEHost = domain ;
2024-11-27 14:26:48 +01:00
locations . " / " . proxyPass = " h t t p : / / s t a l w a r t " ;
} ) ;
} ;
systemd . services . stalwart-mail =
let
cfg = config . services . stalwart-mail ;
configFormat = pkgs . formats . toml { } ;
configFile = configFormat . generate " s t a l w a r t - m a i l . t o m l " cfg . settings ;
in
{
preStart = lib . mkAfter (
''
cat $ { configFile } > /run/stalwart-mail/config.toml
cat $ { config . age . secrets . stalwart-admin-hash . path } \
| tr - d ' \ n' > /run/stalwart-mail/admin-hash
mkdir - p /var/lib/stalwart-mail/dkim
''
# Generate DKIM keys if necessary
+ lib . concatLines (
lib . forEach mailDomains ( domain : ''
if [ [ ! - e /var/lib/stalwart-mail/dkim/rsa- $ { domain } . key ] ] ; then
echo " G e n e r a t i n g D K I M k e y f o r ${ domain } ( r s a ) "
$ { lib . getExe pkgs . openssl } genrsa - traditional - out /var/lib/stalwart-mail/dkim/rsa- $ { domain } . key 2048
fi
if [ [ ! - e /var/lib/stalwart-mail/dkim/ed25519- $ { domain } . key ] ] ; then
echo " G e n e r a t i n g D K I M k e y f o r ${ domain } ( e d 2 5 5 1 9 ) "
$ { lib . getExe pkgs . openssl } genpkey - algorithm ed25519 - out /var/lib/stalwart-mail/dkim/ed25519- $ { domain } . key
fi
'' )
)
) ;
serviceConfig = {
RuntimeDirectory = " s t a l w a r t - m a i l " ;
ReadWritePaths = [ config . services . idmail . dataDir ] ;
ExecStart = lib . mkForce [
" "
" ${ cfg . package } / b i n / s t a l w a r t - m a i l - - c o n f i g = / r u n / s t a l w a r t - m a i l / c o n f i g . t o m l "
] ;
RestartSec = " 6 0 " ; # Retry every minute
} ;
} ;
services . stalwart-mail = {
enable = true ;
settings =
let
case = field : check : value : data : {
" i f " = field ;
$ { check } = value ;
" t h e n " = data ;
} ;
ifthen = field : data : {
" i f " = field ;
" t h e n " = data ;
} ;
otherwise = value : { " e l s e " = value ; } ;
is-smtp = case " l i s t e n e r " " e q " " s m t p " ;
is-authenticated = data : {
" i f " = " ! i s _ e m p t y ( a u t h e n t i c a t e d _ a s ) " ;
" t h e n " = data ;
} ;
in
lib . mkForce {
config . local-keys = [
" s t o r e . * "
" d i r e c t o r y . * "
" t r a c e r . * "
" s e r v e r . * "
" ! s e r v e r . b l o c k e d - i p . * "
" ! s e r v e r . a l l o w e d - i p . * "
" a u t h e n t i c a t i o n . f a l l b a c k - a d m i n . * "
" c l u s t e r . n o d e - i d "
" s t o r a g e . d a t a "
" s t o r a g e . b l o b "
" s t o r a g e . l o o k u p "
" s t o r a g e . f t s "
" s t o r a g e . d i r e c t o r y "
" l o o k u p . d e f a u l t . h o s t n a m e "
" c e r t i f i c a t e . * "
" a u t h . d k i m . * "
" s i g n a t u r e . * "
] ;
authentication . fallback-admin = {
user = " a d m i n " ;
secret = " % { f i l e : / r u n / s t a l w a r t - m a i l / a d m i n - h a s h } % " ;
} ;
tracer . stdout = {
# Do not use the built-in journal tracer, as it shows much less auxiliary
# information for the same loglevel
type = " s t d o u t " ;
level = " i n f o " ;
ansi = false ; # no colour markers to journald
enable = true ;
} ;
store . db = {
type = " s q l i t e " ;
path = " ${ dataDir } / d a t a b a s e . s q l i t e 3 " ;
} ;
store . idmail = {
type = " s q l i t e " ;
path = " ${ config . services . idmail . dataDir } / i d m a i l . d b " ;
query =
let
# Remove comments from SQL and make it single-line
toSingleLineSql =
sql :
lib . concatStringsSep " " (
lib . forEach ( lib . flatten ( lib . split " \n " sql ) ) (
line : lib . optionalString ( builtins . match " ^ [ [ : s p a c e : ] ] * - - . * " line == null ) line
)
) ;
in
{
# "SELECT name, type, secret, description, quota FROM accounts WHERE name = ?1 AND active = true";
name = toSingleLineSql ''
SELECT
m . address AS name ,
' individual' AS type ,
m . password_hash AS secret ,
m . address AS description ,
0 AS quota
FROM mailboxes AS m
JOIN domains AS d ON m . domain = d . domain
JOIN users AS u ON m . owner = u . username
WHERE m . address = ? 1
AND m . active = true
AND d . active = true
AND u . active = true
'' ;
# "SELECT member_of FROM group_members WHERE name = ?1";
members = " " ;
# "SELECT name FROM emails WHERE address = ?1";
recipients = toSingleLineSql ''
- - It is important that we return only one value here , but in theory three UNIONed
- - queries are guaranteed to be distinct . This is because a mailbox address
- - and alias address can never be the same , their cross-table uniqueness is guaranteed on insert .
- - The catch-all union can also only return something if @ domain . tld is given as a parameter ,
- - which is invalid for aliases and mailboxes .
- -
- - Nonetheless , it may be beneficial to allow an alias to override an existing mailbox ,
- - so we can have send-only mailboxes which have their incoming mail redirected somewhere else .
- - Therefore , we make sure to order the query by ( aliases -> mailboxes -> catch all ) and only return the
- - highest priority one .
SELECT name FROM (
- - Select the target of a matching alias ( if any )
- - but make sure that all related parts are active .
SELECT a . target AS name , 1 AS rowOrder
FROM aliases AS a
JOIN domains AS d ON a . domain = d . domain
JOIN (
- - To check whether the owner is active we need to make a subquery
- - because the owner could be a user or mailbox
SELECT username
FROM users
WHERE active = true
UNION
SELECT m . address AS username
FROM mailboxes AS m
JOIN users AS u ON m . owner = u . username
WHERE m . active = true
AND u . active = true
) AS u ON a . owner = u . username
WHERE a . address = ? 1
AND a . active = true
AND d . active = true
- - Select the primary mailbox address if it matches and
- - all related parts are active .
UNION
SELECT m . address AS name , 2 AS rowOrder
FROM mailboxes AS m
JOIN domains AS d ON m . domain = d . domain
JOIN users AS u ON m . owner = u . username
WHERE m . address = ? 1
AND m . active = true
AND d . active = true
AND u . active = true
- - Finally , select any catch_all address that would catch this .
- - Again make sure everything is active .
UNION
SELECT d . catch_all AS name , 3 AS rowOrder
FROM domains AS d
JOIN mailboxes AS m ON d . catch_all = m . address
JOIN users AS u ON m . owner = u . username
WHERE ? 1 = ( ' @ ' || d . domain )
AND d . active = true
AND m . active = true
AND u . active = true
ORDER BY rowOrder , name ASC
LIMIT 1
)
'' ;
# "SELECT address FROM emails WHERE name = ?1 AND type != 'list' ORDER BY type DESC, address ASC";
emails = toSingleLineSql ''
- - Return first the primary address , then any aliases .
SELECT address FROM (
- - Select primary address , if active
SELECT m . address AS address , 1 AS rowOrder
FROM mailboxes AS m
JOIN domains AS d ON m . domain = d . domain
JOIN users AS u ON m . owner = u . username
WHERE m . address = ? 1
AND m . active = true
AND d . active = true
AND u . active = true
- - Select any active aliases
UNION
SELECT a . address AS address , 2 AS rowOrder
FROM aliases AS a
JOIN domains AS d ON a . domain = d . domain
JOIN (
- - To check whether the owner is active we need to make a subquery
- - because the owner could be a user or mailbox
SELECT username
FROM users
WHERE active = true
UNION
SELECT m . address AS username
FROM mailboxes AS m
JOIN users AS u ON m . owner = u . username
WHERE m . active = true
AND u . active = true
) AS u ON a . owner = u . username
WHERE a . target = ? 1
AND a . active = true
AND d . active = true
- - Select the catch-all marker , if we are the target .
UNION
- - Order 2 is correct , it counts as an alias
SELECT ( ' @ ' || d . domain ) AS address , 2 AS rowOrder
FROM domains AS d
JOIN mailboxes AS m ON d . catch_all = m . address
JOIN users AS u ON m . owner = u . username
WHERE d . catch_all = ? 1
AND d . active = true
AND m . active = true
AND u . active = true
ORDER BY rowOrder , address ASC
)
'' ;
# "SELECT address FROM emails WHERE address LIKE '%' || ?1 || '%' AND type = 'primary' ORDER BY address LIMIT 5";
verify = toSingleLineSql ''
SELECT m . address AS address
FROM mailboxes AS m
JOIN domains AS d ON m . domain = d . domain
JOIN users AS u ON m . owner = u . username
WHERE m . address LIKE ' % ' || ? 1 || ' % '
AND m . active = true
AND d . active = true
AND u . active = true
UNION
SELECT a . address AS address
FROM aliases AS a
JOIN domains AS d ON a . domain = d . domain
JOIN (
- - To check whether the owner is active we need to make a subquery
- - because the owner could be a user or mailbox
SELECT username
FROM users
WHERE active = true
UNION
SELECT m . address AS username
FROM mailboxes AS m
JOIN users AS u ON m . owner = u . username
WHERE m . active = true
AND u . active = true
) AS u ON a . owner = u . username
WHERE a . address LIKE ' % ' || ? 1 || ' % '
AND a . active = true
AND d . active = true
ORDER BY address
LIMIT 5
'' ;
# "SELECT p.address FROM emails AS p JOIN emails AS l ON p.name = l.name WHERE p.type = 'primary' AND l.address = ?1 AND l.type = 'list' ORDER BY p.address LIMIT 50";
# XXX: We don't actually expand, but return the same address if it exists since we don't support mailing lists
expand = toSingleLineSql ''
SELECT m . address AS address
FROM mailboxes AS m
JOIN domains AS d ON m . domain = d . domain
JOIN users AS u ON m . owner = u . username
WHERE m . address = ? 1
AND m . active = true
AND d . active = true
AND u . active = true
UNION
SELECT a . address AS address
FROM aliases AS a
JOIN domains AS d ON a . domain = d . domain
JOIN (
- - To check whether the owner is active we need to make a subquery
- - because the owner could be a user or mailbox
SELECT username
FROM users
WHERE active = true
UNION
SELECT m . address AS username
FROM mailboxes AS m
JOIN users AS u ON m . owner = u . username
WHERE m . active = true
AND u . active = true
) AS u ON a . owner = u . username
WHERE a . address = ? 1
AND a . active = true
AND d . active = true
ORDER BY address
LIMIT 50
'' ;
# "SELECT 1 FROM emails WHERE address LIKE '%@' || ?1 LIMIT 1";
domains = toSingleLineSql ''
SELECT domain
FROM domains
WHERE domain = ? 1
'' ;
} ;
} ;
storage = {
data = " d b " ;
fts = " d b " ;
lookup = " d b " ;
blob = " d b " ;
directory = " i d m a i l " ;
} ;
directory . idmail = {
type = " s q l " ;
store = " i d m a i l " ;
columns = {
name = " n a m e " ;
description = " d e s c r i p t i o n " ;
secret = " s e c r e t " ;
email = " e m a i l " ;
#quota = "quota";
class = " t y p e " ;
} ;
} ;
resolver = {
type = " s y s t e m " ;
public-suffix = [
" f i l e : / / ${ pkgs . publicsuffix-list } / s h a r e / p u b l i c s u f f i x / p u b l i c _ s u f f i x _ l i s t . d a t "
] ;
} ;
config . resource . spam-filter = " f i l e : / / ${ config . services . stalwart-mail . package } / e t c / s t a l w a r t / s p a m f i l t e r . t o m l " ;
config . resource . webadmin = " f i l e : / / ${ config . services . stalwart-mail . package . webadmin } / w e b a d m i n . z i p " ;
webadmin . path = " / v a r / c a c h e / s t a l w a r t - m a i l " ;
certificate . default = {
cert = " % { f i l e : ${ config . security . acme . certs . ${ domain } . directory } / f u l l c h a i n . p e m } % " ;
private-key = " % { f i l e : ${ config . security . acme . certs . ${ domain } . directory } / k e y . p e m } % " ;
default = true ;
} ;
lookup . default . hostname = domain ;
server = {
tls = {
certificate = " d e f a u l t " ;
ignore-client-order = true ;
} ;
socket = {
nodelay = true ;
reuse-addr = true ;
} ;
listener = {
smtp = {
protocol = " s m t p " ;
bind = " [ : : ] : 2 5 " ;
} ;
submissions = {
protocol = " s m t p " ;
bind = " [ : : ] : 4 6 5 " ;
tls . implicit = true ;
} ;
imaps = {
protocol = " i m a p " ;
bind = " [ : : ] : 9 9 3 " ;
tls . implicit = true ;
} ;
http = {
# jmap, web interface
protocol = " h t t p " ;
bind = " [ : : ] : 8 0 8 0 " ;
url = " h t t p s : / / ${ domain } " ;
use-x-forwarded = true ;
} ;
sieve = {
protocol = " m a n a g e s i e v e " ;
bind = " [ : : ] : 4 1 9 0 " ;
tls . implicit = true ;
} ;
} ;
} ;
imap = {
request . max-size = 52428800 ;
auth = {
max-failures = 3 ;
allow-plain-text = false ;
} ;
timeout = {
authentication = " 3 0 m " ;
anonymous = " 1 m " ;
idle = " 3 0 m " ;
} ;
rate-limit = {
requests = " 2 0 0 0 0 / 1 m " ;
concurrent = 32 ;
} ;
} ;
auth . dkim . sign = [
( ifthen " i s _ l o c a l _ d o m a i n ( ' * ' , s e n d e r _ d o m a i n ) " " [ ' r s a - ' + s e n d e r _ d o m a i n , ' e d 2 5 5 1 9 - ' + s e n d e r _ d o m a i n ] " )
( otherwise false )
] ;
signature = lib . mergeAttrsList (
lib . forEach mailDomains ( domain : {
" e d 2 5 5 1 9 - ${ domain } " = {
private-key = " % { f i l e : / v a r / l i b / s t a l w a r t - m a i l / d k i m / e d 2 5 5 1 9 - ${ domain } . k e y } % " ;
inherit domain ;
selector = " e d _ d e f a u l t " ;
headers = [
" F r o m "
" T o "
" D a t e "
" S u b j e c t "
" M e s s a g e - I D "
] ;
algorithm = " e d 2 5 5 1 9 - s h a 2 5 6 " ;
canonicalization = " r e l a x e d / r e l a x e d " ;
set-body-length = false ;
report = true ;
} ;
" r s a - ${ domain } " = {
private-key = " % { f i l e : / v a r / l i b / s t a l w a r t - m a i l / d k i m / r s a - ${ domain } . k e y } % " ;
inherit domain ;
selector = " r s a _ d e f a u l t " ;
headers = [
" F r o m "
" T o "
" D a t e "
" S u b j e c t "
" M e s s a g e - I D "
] ;
algorithm = " r s a - s h a 2 5 6 " ;
canonicalization = " r e l a x e d / r e l a x e d " ;
set-body-length = false ;
report = true ;
} ;
} )
) ;
session . extensions = {
pipelining = true ;
chunking = true ;
requiretls = true ;
no-soliciting = " " ;
dsn = false ;
expn = [
( is-authenticated true )
( otherwise false )
] ;
vrfy = [
( is-authenticated true )
( otherwise false )
] ;
future-release = [
( is-authenticated " 3 0 d " )
( otherwise false )
] ;
deliver-by = [
( is-authenticated " 3 6 5 d " )
( otherwise false )
] ;
mt-priority = [
( is-authenticated " m i x e r " )
( otherwise false )
] ;
} ;
session . ehlo = {
require = true ;
reject-non-fqdn = [
( is-smtp true )
( otherwise false )
] ;
} ;
session . rcpt = {
catch-all = true ;
relay = [
( is-authenticated true )
( otherwise false )
] ;
max-recipients = 25 ;
} ;
} ;
} ;
}