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