1{ lib, config, pkgs, ... }:
2
3with lib;
4
5let
6 cfg = config.services.roundcube;
7 fpm = config.services.phpfpm.pools.roundcube;
8 localDB = cfg.database.host == "localhost";
9 user = cfg.database.username;
10 phpWithPspell = pkgs.php83.withExtensions ({ enabled, all }: [ all.pspell ] ++ enabled);
11in
12{
13 options.services.roundcube = {
14 enable = mkOption {
15 type = types.bool;
16 default = false;
17 description = ''
18 Whether to enable roundcube.
19
20 Also enables nginx virtual host management.
21 Further nginx configuration can be done by adapting `services.nginx.virtualHosts.<name>`.
22 See [](#opt-services.nginx.virtualHosts) for further information.
23 '';
24 };
25
26 hostName = mkOption {
27 type = types.str;
28 example = "webmail.example.com";
29 description = "Hostname to use for the nginx vhost";
30 };
31
32 package = mkPackageOption pkgs "roundcube" {
33 example = "roundcube.withPlugins (plugins: [ plugins.persistent_login ])";
34 };
35
36 database = {
37 username = mkOption {
38 type = types.str;
39 default = "roundcube";
40 description = ''
41 Username for the postgresql connection.
42 If `database.host` is set to `localhost`, a unix user and group of the same name will be created as well.
43 '';
44 };
45 host = mkOption {
46 type = types.str;
47 default = "localhost";
48 description = ''
49 Host of the postgresql server. If this is not set to
50 `localhost`, you have to create the
51 postgresql user and database yourself, with appropriate
52 permissions.
53 '';
54 };
55 password = mkOption {
56 type = types.str;
57 description = "Password for the postgresql connection. Do not use: the password will be stored world readable in the store; use `passwordFile` instead.";
58 default = "";
59 };
60 passwordFile = mkOption {
61 type = types.str;
62 description = ''
63 Password file for the postgresql connection.
64 Must be formatted according to PostgreSQL .pgpass standard (see https://www.postgresql.org/docs/current/libpq-pgpass.html)
65 but only one line, no comments and readable by user `nginx`.
66 Ignored if `database.host` is set to `localhost`, as peer authentication will be used.
67 '';
68 };
69 dbname = mkOption {
70 type = types.str;
71 default = "roundcube";
72 description = "Name of the postgresql database";
73 };
74 };
75
76 plugins = mkOption {
77 type = types.listOf types.str;
78 default = [];
79 description = ''
80 List of roundcube plugins to enable. Currently, only those directly shipped with Roundcube are supported.
81 '';
82 };
83
84 dicts = mkOption {
85 type = types.listOf types.package;
86 default = [];
87 example = literalExpression "with pkgs.aspellDicts; [ en fr de ]";
88 description = ''
89 List of aspell dictionaries for spell checking. If empty, spell checking is disabled.
90 '';
91 };
92
93 maxAttachmentSize = mkOption {
94 type = types.int;
95 default = 18;
96 description = ''
97 The maximum attachment size in MB.
98
99 Note: Since roundcube only uses 70% of max upload values configured in php
100 30% is added automatically to [](#opt-services.roundcube.maxAttachmentSize).
101 '';
102 apply = configuredMaxAttachmentSize: "${toString (configuredMaxAttachmentSize * 1.3)}M";
103 };
104
105 configureNginx = lib.mkOption {
106 type = lib.types.bool;
107 default = true;
108 description = "Configure nginx as a reverse proxy for roundcube.";
109 };
110
111 extraConfig = mkOption {
112 type = types.lines;
113 default = "";
114 description = "Extra configuration for roundcube webmail instance";
115 };
116 };
117
118 config = mkIf cfg.enable {
119 # backward compatibility: if password is set but not passwordFile, make one.
120 services.roundcube.database.passwordFile = mkIf (!localDB && cfg.database.password != "") (mkDefault ("${pkgs.writeText "roundcube-password" cfg.database.password}"));
121 warnings = lib.optional (!localDB && cfg.database.password != "") "services.roundcube.database.password is deprecated and insecure; use services.roundcube.database.passwordFile instead";
122
123 environment.etc."roundcube/config.inc.php".text = ''
124 <?php
125
126 ${lib.optionalString (!localDB) ''
127 $password = file('${cfg.database.passwordFile}')[0];
128 $password = preg_split('~\\\\.(*SKIP)(*FAIL)|\:~s', $password);
129 $password = rtrim(end($password));
130 $password = str_replace("\\:", ":", $password);
131 $password = str_replace("\\\\", "\\", $password);
132 ''}
133
134 $config = array();
135 $config['db_dsnw'] = 'pgsql://${cfg.database.username}${lib.optionalString (!localDB) ":' . $password . '"}@${if localDB then "unix(/run/postgresql)" else cfg.database.host}/${cfg.database.dbname}';
136 $config['log_driver'] = 'syslog';
137 $config['max_message_size'] = '${cfg.maxAttachmentSize}';
138 $config['plugins'] = [${concatMapStringsSep "," (p: "'${p}'") cfg.plugins}];
139 $config['des_key'] = file_get_contents('/var/lib/roundcube/des_key');
140 $config['mime_types'] = '${pkgs.nginx}/conf/mime.types';
141 # Roundcube uses PHP-FPM which has `PrivateTmp = true;`
142 $config['temp_dir'] = '/tmp';
143 $config['enable_spellcheck'] = ${if cfg.dicts == [] then "false" else "true"};
144 # by default, spellchecking uses a third-party cloud services
145 $config['spellcheck_engine'] = 'pspell';
146 $config['spellcheck_languages'] = array(${lib.concatMapStringsSep ", " (dict: let p = builtins.parseDrvName dict.shortName; in "'${p.name}' => '${dict.fullName}'") cfg.dicts});
147
148 ${cfg.extraConfig}
149 '';
150
151 services.nginx = lib.mkIf cfg.configureNginx {
152 enable = true;
153 virtualHosts = {
154 ${cfg.hostName} = {
155 forceSSL = mkDefault true;
156 enableACME = mkDefault true;
157 root = cfg.package;
158 locations."/" = {
159 index = "index.php";
160 priority = 1100;
161 extraConfig = ''
162 add_header Cache-Control 'public, max-age=604800, must-revalidate';
163 '';
164 };
165 locations."~ ^/(SQL|bin|config|logs|temp|vendor)/" = {
166 priority = 3110;
167 extraConfig = ''
168 return 404;
169 '';
170 };
171 locations."~ ^/(CHANGELOG.md|INSTALL|LICENSE|README.md|SECURITY.md|UPGRADING|composer.json|composer.lock)" = {
172 priority = 3120;
173 extraConfig = ''
174 return 404;
175 '';
176 };
177 locations."~* \\.php(/|$)" = {
178 priority = 3130;
179 extraConfig = ''
180 fastcgi_pass unix:${fpm.socket};
181 fastcgi_param PATH_INFO $fastcgi_path_info;
182 fastcgi_split_path_info ^(.+\.php)(/.+)$;
183 include ${config.services.nginx.package}/conf/fastcgi.conf;
184 '';
185 };
186 };
187 };
188 };
189
190 assertions = [
191 {
192 assertion = localDB -> cfg.database.username == cfg.database.dbname;
193 message = ''
194 When setting up a DB and its owner user, the owner and the DB name must be
195 equal!
196 '';
197 }
198 ];
199
200 services.postgresql = mkIf localDB {
201 enable = true;
202 ensureDatabases = [ cfg.database.dbname ];
203 ensureUsers = [ {
204 name = cfg.database.username;
205 ensureDBOwnership = true;
206 } ];
207 };
208
209 users.users.${user} = mkIf localDB {
210 group = user;
211 isSystemUser = true;
212 createHome = false;
213 };
214 users.groups.${user} = mkIf localDB {};
215
216 services.phpfpm.pools.roundcube = {
217 user = if localDB then user else "nginx";
218 phpOptions = ''
219 error_log = 'stderr'
220 log_errors = on
221 post_max_size = ${cfg.maxAttachmentSize}
222 upload_max_filesize = ${cfg.maxAttachmentSize}
223 '';
224 settings = mapAttrs (name: mkDefault) {
225 "listen.owner" = "nginx";
226 "listen.group" = "nginx";
227 "listen.mode" = "0660";
228 "pm" = "dynamic";
229 "pm.max_children" = 75;
230 "pm.start_servers" = 2;
231 "pm.min_spare_servers" = 1;
232 "pm.max_spare_servers" = 20;
233 "pm.max_requests" = 500;
234 "catch_workers_output" = true;
235 };
236 phpPackage = phpWithPspell;
237 phpEnv.ASPELL_CONF = "dict-dir ${pkgs.aspellWithDicts (_: cfg.dicts)}/lib/aspell";
238 };
239 systemd.services.phpfpm-roundcube.after = [ "roundcube-setup.service" ];
240
241 # Restart on config changes.
242 systemd.services.phpfpm-roundcube.restartTriggers = [
243 config.environment.etc."roundcube/config.inc.php".source
244 ];
245
246 systemd.services.roundcube-setup = mkMerge [
247 (mkIf (cfg.database.host == "localhost") {
248 requires = [ "postgresql.service" ];
249 after = [ "postgresql.service" ];
250 })
251 {
252 wants = [ "network-online.target" ];
253 after = [ "network-online.target" ];
254 wantedBy = [ "multi-user.target" ];
255
256 path = [ config.services.postgresql.package ];
257 script = let
258 psql = "${lib.optionalString (!localDB) "PGPASSFILE=${cfg.database.passwordFile}"} psql ${lib.optionalString (!localDB) "-h ${cfg.database.host} -U ${cfg.database.username} "} ${cfg.database.dbname}";
259 in
260 ''
261 version="$(${psql} -t <<< "select value from system where name = 'roundcube-version';" || true)"
262 if ! (grep -E '[a-zA-Z0-9]' <<< "$version"); then
263 ${psql} -f ${cfg.package}/SQL/postgres.initial.sql
264 fi
265
266 if [ ! -f /var/lib/roundcube/des_key ]; then
267 base64 /dev/urandom | head -c 24 > /var/lib/roundcube/des_key;
268 # we need to log out everyone in case change the des_key
269 # from the default when upgrading from nixos 19.09
270 ${psql} <<< 'TRUNCATE TABLE session;'
271 fi
272
273 ${phpWithPspell}/bin/php ${cfg.package}/bin/update.sh
274 '';
275 serviceConfig = {
276 Type = "oneshot";
277 StateDirectory = "roundcube";
278 User = if localDB then user else "nginx";
279 # so that the des_key is not world readable
280 StateDirectoryMode = "0700";
281 };
282 }
283 ];
284 };
285}