1: <?php
2: declare(strict_types=1);
3: /**
4: * +------------------------------------------------------------+
5: * | apnscp |
6: * +------------------------------------------------------------+
7: * | Copyright (c) Apis Networks |
8: * +------------------------------------------------------------+
9: * | Licensed under Artistic License 2.0 |
10: * +------------------------------------------------------------+
11: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
12: * +------------------------------------------------------------+
13: */
14:
15: use Module\Provider;
16: use Module\Skeleton\Contracts\Hookable;
17: use Module\Skeleton\Contracts\Proxied;
18: use Module\Skeleton\Contracts\Reactive;
19: use Module\Skeleton\Contracts\Tasking;
20: use Opcenter\Account\Enumerate;
21: use Opcenter\Crypto\Ssl;
22: use Opcenter\Database\PostgreSQL\EscapePolyfill\{function pg_escape_literal, function pg_escape_string};
23: use Opcenter\Dns\Record;
24: use Opcenter\Filesystem;
25: use Opcenter\Mail\Services\Dovecot;
26: use Opcenter\Mail\Services\Haproxy;
27: use Opcenter\Mail\Services\Postfix;
28: use Opcenter\Mail\Services\Postsrsd;
29: use Opcenter\Mail\Services\Rspamd\Dkim;
30: use Opcenter\Mail\Services\Webmail;
31: use Opcenter\Mail\Storage;
32: use Opcenter\Mail\Vacation;
33: use Opcenter\Service\ConfigurationContext;
34:
35: /**
36: * E-mail functions (aliases, virtual mailboxes)
37: *
38: * @package core
39: */
40: class Email_Module extends Module_Skeleton implements Hookable, Proxied, Reactive, Tasking
41: {
42: const DEPENDENCY_MAP = [
43: 'siteinfo',
44: 'ipinfo',
45: 'ipinfo6',
46: 'users',
47: 'aliases',
48: 'dns'
49: ];
50: const MAILDIR_HOME = Storage::MAILDIR_HOME;
51: const MAILBOX_SPECIAL = 's';
52: const MAILBOX_FORWARD = 'a';
53: const MAILBOX_USER = 'v';
54: const MAILBOX_DISABLED = 'd';
55: const MAILBOX_ENABLED = 'e';
56: const MAILBOX_SINGLE = '1';
57: const MAILBOX_DESTINATION = 'destination';
58:
59: const MAILBOX_SAVE_DEFAULT = 'email_addr';
60:
61: const VACATION_PREFKEY = 'mail.vacapref';
62: // webmail installations
63: const POSTFIX_CMD = '/usr/sbin/postfix';
64: const SSL_PROXY_DIR = '/etc/haproxy/ssl.d';
65: private $_webmail = array(
66: 'sqmail' => array(
67: 'subdomain' => 'mail',
68: 'path' => '/var/www/html/mail'
69: ),
70: 'horde' => array(
71: 'subdomain' => 'horde',
72: 'path' => '/var/www/html/horde'
73: ),
74: 'roundcube' => array(
75: 'subdomain' => 'roundcube',
76: 'path' => '/var/www/html/roundcube'
77: )
78: );
79:
80: protected $exportedFunctions = [
81: 'address_exists' => PRIVILEGE_SITE | PRIVILEGE_USER,
82: 'create_maildir_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC,
83: 'get_spool_size_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC,
84: /** Vacation methods */
85: 'add_vacation' => PRIVILEGE_SITE | PRIVILEGE_USER,
86: 'add_vacation_backend' => PRIVILEGE_SITE | PRIVILEGE_USER,
87: 'set_vacation' => PRIVILEGE_SITE | PRIVILEGE_USER,
88: 'set_vacation_options' => PRIVILEGE_SITE | PRIVILEGE_USER,
89: 'get_vacation_options' => PRIVILEGE_SITE | PRIVILEGE_USER,
90: 'vacation_exists' => PRIVILEGE_SITE | PRIVILEGE_USER,
91: 'enable_vacation' => PRIVILEGE_SITE | PRIVILEGE_USER,
92: 'remove_vacation' => PRIVILEGE_SITE | PRIVILEGE_USER,
93: 'get_vacation_message' => PRIVILEGE_SITE | PRIVILEGE_USER,
94: 'change_vacation_message' => PRIVILEGE_SITE | PRIVILEGE_USER,
95: 'get_webmail_location' => PRIVILEGE_SITE | PRIVILEGE_USER,
96: 'webmail_apps' => PRIVILEGE_SITE | PRIVILEGE_USER,
97: 'create_maildir' => PRIVILEGE_SITE | PRIVILEGE_USER,
98: 'remove_maildir' => PRIVILEGE_SITE | PRIVILEGE_USER,
99: 'user_enabled' => PRIVILEGE_SITE | PRIVILEGE_USER,
100: 'get_mail_ip' => PRIVILEGE_SITE | PRIVILEGE_USER,
101: 'user_mailboxes' => PRIVILEGE_SITE | PRIVILEGE_USER,
102: 'convert_mailbox' => PRIVILEGE_SITE | PRIVILEGE_USER,
103: 'roll_srs' => PRIVILEGE_ADMIN,
104: '*' => PRIVILEGE_SITE,
105: 'get_provider' => PRIVILEGE_ALL,
106: 'configured' => PRIVILEGE_ALL,
107: 'providers' => PRIVILEGE_ADMIN,
108: 'merge_ssl' => PRIVILEGE_ADMIN,
109: ];
110:
111:
112: public function _proxy(): \Module_Skeleton
113: {
114: $provider = $this->get_provider();
115:
116: if ($provider === \Opcenter\Service\Contracts\DefaultNullable::NULLABLE_MARKER) {
117: // BUG. An account's provider is substituted with the provider default at creation
118: // Check for an in-place upgrade. If plan wasn't substituted because no prior def exists
119: // the marker is used.
120: $provider = \Opcenter\Mail::default();
121: }
122:
123: if ($provider === 'builtin') {
124: return $this;
125: }
126:
127: return Provider::get('mail', $provider, $this->getAuthContext());
128: }
129:
130: /**
131: * Get DNS provider
132: *
133: * @return string
134: */
135: public function get_provider(): string
136: {
137: $provider = $this->getServiceValue('mail', 'provider', \Opcenter\Mail::default());
138:
139: if ($provider === \Opcenter\Service\Contracts\DefaultNullable::NULLABLE_MARKER) {
140: // BUG. An account's provider is substituted with the provider default at creation
141: // Check for an in-place upgrade. If plan wasn't substituted because no prior def exists
142: // the marker is used.
143: $provider = \Opcenter\Mail::default();
144: }
145:
146: if ($this->permission_level & PRIVILEGE_SITE|PRIVILEGE_USER) {
147: if (self::class !== static::class && !$this->enabled()) {
148: // block _proxy() load, which calls this helper in early init
149: return 'null';
150: }
151: return $provider;
152: }
153:
154: return \Opcenter\Mail::default();
155: }
156:
157: /**
158: * Mail configured for account
159: *
160: * @return bool
161: */
162: public function configured(): bool
163: {
164: return $this->get_provider() === 'builtin';
165: }
166:
167: /**
168: * Get known mail providers
169: *
170: * @return array
171: */
172: public function providers(): array
173: {
174: return \Opcenter\Mail::providers();
175: }
176:
177: public function list_aliases()
178: {
179: return $this->list_mailboxes('forward');
180: }
181:
182: /**
183: * List all mailboxes deliverable to user
184: *
185: * @param string|null $username
186: */
187: public function user_mailboxes(string $username = null)
188: {
189: if ($username && ($this->permission_level & PRIVILEGE_USER)) {
190: return error('%(param)s disallowed as user', ['$username']);
191: }
192: $username = $username ?? $this->username;
193: if (!$this->user_exists($username)) {
194: return error('user %s does not exist', $username);
195: }
196:
197: if (!$uid = $this->user_get_uid_from_username($username)) {
198: return false;
199: }
200: // @TODO recursively solve alias destinations
201: $q = 'SELECT
202: CONCAT("user", \'@\', e1."domain") AS email
203: FROM email_lookup e1
204: JOIN domain_lookup USING (domain)
205: WHERE
206: domain_lookup.site_id = ' . $this->site_id . ' AND
207: uid = ' . $uid . ' AND type = \'' . self::MAILBOX_USER . '\'';
208:
209: $addresses = [];
210:
211: $pgdb = \PostgreSQL::initialize();
212: $pgdb->query($q);
213: while (null !== ($row = $pgdb->fetch_object())) {
214: $addresses[] = $row->email;
215: }
216: return $addresses;
217: }
218:
219: /**
220: * Retrieve mailbox delivery maps from system
221: *
222: * @param $filter string optional filter, possible values: forward, local, special, single, enabled, disabled, destination
223: * @param $address string supplementary argument to 'single', restrict address to %expr%. Mandatory for destination filter type
224: * @param $domain string optionally restrict to all addresses matching domain
225: *
226: * @return array
227: *
228: */
229: public function list_mailboxes($filter = null, $address = null, $domain = null)
230: {
231: $filter_clause = '1=1';
232:
233:
234: if ($filter == 'forward') {
235: $filter = self::MAILBOX_FORWARD;
236: } else if ($filter == 'local') {
237: $filter = self::MAILBOX_USER;
238: } else if ($filter == 'special') {
239: $filter = self::MAILBOX_SPECIAL;
240: } else if ($filter == 'disabled') {
241: $filter = self::MAILBOX_DISABLED;
242: } else if ($filter == 'enabled') {
243: $filter = self::MAILBOX_ENABLED;
244: }
245:
246: if ($filter && !in_array($filter, array(
247: self::MAILBOX_FORWARD,
248: self::MAILBOX_USER,
249: self::MAILBOX_SPECIAL,
250: self::MAILBOX_DISABLED,
251: self::MAILBOX_ENABLED,
252: self::MAILBOX_SINGLE,
253: self::MAILBOX_DESTINATION
254: ))
255: ) {
256: return error("invalid filter specification `%s'", $filter);
257: }
258:
259: if ($filter == self::MAILBOX_FORWARD) {
260: $filter_clause = 'type = \'' . self::MAILBOX_FORWARD . '\'';
261: } else if ($filter == self::MAILBOX_USER) {
262: $filter_clause = 'type = \'' . self::MAILBOX_USER . '\'';
263: } else if ($filter == self::MAILBOX_SPECIAL) {
264:
265: } else if ($filter == self::MAILBOX_SINGLE) {
266: $filter_clause = 'email_lookup."user" ' . (false !== strpos($address,
267: '%') ? 'LIKE' : '=') . ' \'' . pg_escape_string($address) . '\'';
268: } else if ($filter == self::MAILBOX_ENABLED) {
269: $filter_clause = 'enabled = 1::bit';
270: } else if ($filter == self::MAILBOX_DISABLED) {
271: $filter_clause = 'enabled = 0::bit';
272: } else if ($filter == self::MAILBOX_DESTINATION) {
273: $filter_clause = 'COALESCE(uids."user",alias_destination) = ' . pg_escape_literal($address);
274: }
275:
276: if (null !== $address && $filter !== self::MAILBOX_DESTINATION) {
277: // @TODO nasty
278: $filter_clause .= ' AND email_lookup.user = \'' . pg_escape_string(strtolower($address)) . '\'';
279: }
280: if ($domain) {
281: $filter_clause .= ' AND email_lookup.domain = \'' . pg_escape_string(strtolower($domain)) . '\'';
282: }
283: $mailboxes = array();
284: $query = '
285: SELECT
286: email_lookup."user",
287: email_lookup.domain as domain,
288: type,
289: enabled,
290: fs_destination AS target,
291: uid,
292: COALESCE(uids."user",alias_destination) as destination
293: FROM
294: email_lookup
295: JOIN
296: domain_lookup
297: ON
298: (email_lookup.domain = domain_lookup.domain)
299: LEFT JOIN
300: uids
301: USING(uid)
302: WHERE
303: (domain_lookup.site_id = ' . $this->site_id . ') AND ' . $filter_clause . ' ORDER BY "user", domain;';
304: $pgdb = \PostgreSQL::initialize();
305: $pgdb->query($query);
306: while (null !== ($row = $pgdb->fetch_object())) {
307: $mailboxes[] = array(
308: 'user' => trim($row->user),
309: 'domain' => trim($row->domain),
310: 'type' => $row->type,
311: 'enabled' => (int)$row->enabled,
312: 'mailbox' => $row->destination,
313: 'uid' => (int)$row->uid,
314: 'custom' => ($filter === 'local' ? $row->target : null),
315: 'destination' => $row->destination
316: );
317: }
318:
319: return $mailboxes;
320: }
321:
322: public function enable_address($account, $domain = null)
323: {
324: $where = 'AND email_lookup.domain = domain_lookup.domain AND domain_lookup.site_id = ' . $this->site_id;
325: if ($domain) {
326: $where .= 'AND domain_lookup.domain = \'' . pg_escape_string($domain) . '\'';
327: }
328: $pgdb = \PostgreSQL::initialize();
329: $pgdb->query('UPDATE email_lookup SET enabled = 1::bit FROM domain_lookup WHERE "user" = \'' . pg_escape_string($account) . '\' ' . $where . ';');
330:
331: return $pgdb->affected_rows() > 0;
332: }
333:
334: /**
335: * @deprecated @link modify_mailbox
336: */
337: public function rename_mailbox($olduser, $olddomain, $newuser, $newdomain, $newmailbox, $newtype = null)
338: {
339: return $this->modify_mailbox($olduser, $olddomain, $newuser, $newdomain, $newmailbox, $newtype);
340: }
341:
342: /**
343: * Rename a mailbox
344: *
345: * IMPORTANT: a mailbox may not be remapped into a catchall here
346: *
347: * @param string $olduser
348: * @param string $olddomain
349: * @param string $newuser
350: * @param string $newdomain
351: * @param string $newdestination username or integer
352: * @param string|null $newtype
353: * @return bool
354: */
355: public function modify_mailbox(
356: string $olduser,
357: string $olddomain,
358: string $newuser = '',
359: string $newdomain = '',
360: string $newdestination = '',
361: string $newtype = null
362: ): bool {
363: $args = array(
364: 'olduser',
365: 'olddomain',
366: 'newuser',
367: 'newdomain',
368: 'newtype'
369: );
370:
371: foreach ($args as $var) {
372: ${$var} = strtolower((string)${$var});
373: }
374: if (!$newuser && !$newdomain) {
375: $newuser = $olduser;
376: $newdomain = $olddomain;
377: }
378: if ($olduser === 'majordomo' && $this->majordomo_enabled() && $this->majordomo_list_mailing_lists()) {
379: return error('cannot remove majordomo email address while mailing lists exist');
380: }
381:
382: if ($olduser && !$this->address_exists($olduser, $olddomain)) {
383: return error("Address `%s@%s' does not exist", $olduser, $olddomain);
384: }
385:
386: if ($newuser && !preg_match(Regex::EMAIL, "${newuser}@${newdomain}")) {
387: return error("Invalid email `%s'", "${newuser}@${newdomain}");
388: }
389:
390: if (($olduser . '@' . $olddomain != $newuser . '@' . $newdomain) && $this->address_exists($newuser,
391: $newdomain)
392: ) {
393: return error("Email address %s@%s already exists. Can't rename!",
394: $newuser, $newdomain);
395: }
396:
397: if (!$this->transport_exists($olddomain)) {
398: return error("Mail domain `%s' not bound to account", $olddomain);
399: }
400:
401: if (!$this->transport_exists($newdomain)) {
402: return error("Mail domain `%s' not bound to account", $newdomain);
403: }
404:
405: if (!$newtype) {
406: $newtype = $this->mailbox_type($olduser, $olddomain);
407: }
408:
409: if ($newtype === self::MAILBOX_FORWARD && ($conflicts = $this->checkForwarding($newdestination))) {
410: return error('Remote forwarding is disabled. Following addresses would violate forwarding policy: %s',
411: implode(',', $conflicts)
412: );
413: }
414:
415: $pgdb = \PostgreSQL::initialize();
416: if ($newtype === self::MAILBOX_USER) {
417: if (!ctype_digit($newdestination)) {
418: $newdestination = $this->user_get_uid_from_username($newdestination);
419: }
420: if (0 !== ($uid = (int)$newdestination)) {
421: $local_user = $this->user_get_username_from_uid($uid);
422: $newdestination = self::MAILDIR_HOME;
423:
424: if (!$local_user) {
425: return error("Invalid mailbox destination, invalid uid `%d'", $uid);
426: }
427: } else if ($newdestination) {
428: if (preg_match('!^/home/([^/]+)/' . self::MAILDIR_HOME . '([/.]*)$!', $newdestination,
429: $match)) {
430: $local_user = $match[1];
431: $newdestination = ltrim(str_replace(array('/', '..'), '.', $match[2]), '.');
432: } else {
433: $local_user = $newdestination;
434: $newdestination = null;
435: }
436: } else {
437: // user rename
438: $local_user = $newuser;
439: }
440: $local_user = strtolower($local_user);
441: $users = $this->user_get_users();
442: if (!isset($users[$local_user])) {
443: return error("User account `%s' does not exist", $local_user);
444: }
445:
446: $uid = (int)$users[$local_user]['uid'];
447: if ($newdestination == '' || $newdestination === self::MAILDIR_HOME) {
448: $newdestination = null;
449: } else {
450: $this->query('email_create_maildir_backend', $local_user, $newdestination);
451: }
452: $pgdb->query("UPDATE email_lookup SET \"user\" = '" . $newuser . "', domain = '" . $newdomain . "', " .
453: 'fs_destination = ' . (($newdestination != null) ? "'" . pg_escape_string(rtrim($newdestination,
454: ' /') . '/') . "'" : 'NULL') . ', ' .
455: 'alias_destination = NULL, uid = ' . $uid . ", type = '" . self::MAILBOX_USER . "' WHERE \"user\" = '" . pg_escape_string($olduser) . "' " .
456: "AND domain = '" . pg_escape_string($olddomain) . "';");
457: } else {
458: if (!$newuser) {
459: return error('cannot forward catch-alls to external e-mail accounts');
460: }
461: $newdestination = preg_replace('/\s+/m', ',', trim($newdestination, ' ,'));
462: if (!$newdestination) {
463: return error('no forwarding destination set for `%s@%s`', $newuser, $newdomain);
464: }
465: $pgdb->query("UPDATE email_lookup SET \"user\" = '" . pg_escape_string($newuser) . "', domain = '" . pg_escape_string($newdomain) . "', " .
466: "alias_destination = '" . pg_escape_string($newdestination) . "', uid = NULL, type = '" .
467: self::MAILBOX_FORWARD . "', fs_destination = NULL WHERE \"user\" = '" .
468: pg_escape_string($olduser) . "' AND domain = '" . pg_escape_string($olddomain) . "';");
469:
470: }
471: $rows = $pgdb->affected_rows();
472: $this->_shutdown_save_mailboxes();
473:
474: return $rows > 0;
475: }
476:
477: /**
478: * Validate an input type if forwarded
479: *
480: * @param $destination
481: * @return null|array
482: */
483: protected function checkForwarding($destination): ?array {
484: /**
485: * Presently a loophole exists that would allow a domain to be attached to an account for mail,
486: * forwarding aliases created, then that domain detached. Denying such a task piles admin
487: * duties upon account holder to detach all aliases from an email account.
488: */
489: if (!MAIL_DISABLED_FORWARDING) {
490: return null;
491: }
492:
493: if (!is_array($destination)) {
494: $destination = preg_split('/\s*,+\s*/', $destination);
495: }
496:
497: $bad = [];
498: $whitelisted = [];
499: foreach ($destination as $chk) {
500: if (false === ($pos = strpos($chk, '@'))) {
501: continue;
502: }
503: $domain = substr($chk, ++$pos);
504: if (!isset($whitelisted[$domain])) {
505: $whitelisted[$domain] = $this->transport_exists($domain);
506: }
507:
508: if (!$whitelisted[$domain]) {
509: $bad[] = $chk;
510: }
511: }
512:
513: return $bad;
514: }
515:
516: public function address_exists($user, $domain)
517: {
518: $user = strtolower($user);
519: $domain = strtolower($domain);
520: if ($user && !preg_match(Regex::EMAIL, "${user}@${domain}")) {
521: return false;
522: }
523: $pgdb = \PostgreSQL::initialize();
524: $pgdb->query('SELECT 1 FROM email_lookup JOIN domain_lookup ON (site_id = ' . $this->site_id . ') ' .
525: "WHERE \"user\" = '" . pg_escape_string($user) . "' AND email_lookup.domain = '" . pg_escape_string($domain) . "'");
526:
527: return $pgdb->num_rows() > 0;
528: }
529:
530: /**
531: * Get mailbox type
532: *
533: * @param $user
534: * @param $domain
535: * @return bool|null|string
536: * @throws PostgreSQLError
537: */
538: public function mailbox_type($user, $domain)
539: {
540: $user = strtolower($user);
541: $domain = strtolower($domain);
542: if (!preg_match(Regex::EMAIL, $user . '@' . $domain)) {
543: return error('invalid address `' . $user . '@' . $domain . "'");
544: }
545: $pgdb = \PostgreSQL::initialize();
546: $pgdb->query("SELECT type FROM email_lookup WHERE \"user\" = '" . $user . "' AND domain = '" . $domain . "'");
547:
548: if ($pgdb->num_rows() < 1) {
549: return null;
550: }
551:
552: return $pgdb->fetch_object()->type;
553: }
554:
555: private function _shutdown_save_mailboxes()
556: {
557: if (!IS_ISAPI) {
558: $this->save_mailboxes();
559: }
560: static $called;
561: if (isset($called)) {
562: return;
563: }
564: $called = 1;
565:
566: return register_shutdown_function(array($this, 'save_mailboxes'));
567: }
568:
569: /**
570: * Save all mailboxes to a serialized file
571: *
572: * @see restore_mailboxes()
573: *
574: * @return boolean
575: */
576: public function save_mailboxes()
577: {
578: if (!IS_CLI) {
579: if (!\apnscpSession::init()->exists($this->session_id)) {
580: // check session is active. Unit tests can trigger this.
581: return true;
582: }
583: return $this->query('email_save_mailboxes');
584: }
585:
586: if (static::class !== self::class) {
587: return true;
588: }
589:
590: $path = $this->domain_info_path();
591: if (!is_dir($path)) {
592: // site deleted, ignore save
593: return true;
594: }
595: $path .= '/' . self::MAILBOX_SAVE_DEFAULT;
596: $email = $this->dump_mailboxes();
597:
598: return (bool)file_put_contents($path, serialize($email), LOCK_EX);
599: }
600:
601: /**
602: * List all mailboxes for backup/restore purposes
603: *
604: * @return array
605: * @throws PostgreSQLError
606: */
607: public function dump_mailboxes(): array {
608: $q = 'SELECT * FROM email_lookup WHERE domain IN
609: (select domain FROM domain_lookup WHERE site_id = ' . $this->site_id . ')';
610: $db = \PostgreSQL::pdo();
611: $email = array();
612: $rs = $db->query($q);
613: while ($row = $rs->fetch(PDO::FETCH_ASSOC)) {
614: $email[] = array_map(fn($i) => is_string($i) ? trim($i) : $i, $row);
615: }
616: return $email;
617: }
618: /**
619: * Remove an e-mail alias
620: *
621: * @param string $user
622: * @param string $domain
623: */
624: public function remove_alias($user, $domain)
625: {
626: return $this->delete_mailbox($user, $domain, self::MAILBOX_FORWARD);
627: }
628:
629: public function delete_mailbox($user, $domain, $type = '')
630: {
631: $type = strtolower($type);
632: if ($type == 'l' || $type == self::MAILBOX_USER) {
633: $type = self::MAILBOX_USER;
634: } else if ($type == 'f' || $type == self::MAILBOX_FORWARD) {
635: $type = self::MAILBOX_FORWARD;
636: } else if ($type != '') {
637: return error("unknown address type `%s'", $type);
638: }
639: /**
640: * otherwise we can clog up an mqueue pretty fast
641: */
642: if ($user === 'majordomo' && $this->majordomo_enabled() && $this->majordomo_list_mailing_lists()) {
643: return error('cannot remove majordomo email address while mailing lists exist');
644: }
645:
646: $clause = '';
647: if ($type) {
648: $clause = "AND type = '$type' ";
649: }
650: $pgdb = \PostgreSQL::initialize();
651: $pgdb->query('DELETE FROM
652: email_lookup
653: WHERE
654: "user" = \'' . pg_escape_string($user) . "'
655: AND
656: domain = '" . pg_escape_string($domain) . "'
657: $clause
658: AND '" . pg_escape_string($domain) . "' IN
659: (SELECT domain from domain_lookup WHERE site_id = " . $this->site_id . ');');
660: $rows = $pgdb->affected_rows();
661: $this->_shutdown_save_mailboxes();
662:
663: return $rows > 0;
664: }
665:
666: public function get_mailbox($user, $domain)
667: {
668: $address = $this->list_mailboxes(self::MAILBOX_SINGLE, $user, $domain);
669:
670: return $address ? array_pop($address) : array();
671: }
672:
673: public function remove_maildir($mailbox)
674: {
675: // assume remove_maildir() is only called by the owner
676: if (!IS_CLI && posix_getuid()) {
677: return $this->query('email_remove_maildir', $mailbox);
678: }
679: $mailbox = trim($mailbox);
680: if ($mailbox[0] != '.') {
681: $mailbox = '.' . $mailbox;
682: }
683: if (!preg_match(Regex::EMAIL_MAILDIR_FOLDER, $mailbox)) {
684: return error("invalid maildir folder name `%s'", $mailbox);
685: }
686: $home = $this->user_get_user_home();
687: $path = join(DIRECTORY_SEPARATOR, array($home, self::MAILDIR_HOME, $mailbox));
688: if (!$this->file_delete($path, true)) {
689: return error("failed to remove maildir `%s'", $mailbox);
690: }
691:
692: $subscriptions = join(DIRECTORY_SEPARATOR,
693: array(
694: $this->domain_fs_path(),
695: $home,
696: self::MAILDIR_HOME,
697: 'subscriptions'
698: )
699: );
700: $sname = trim($mailbox, '.');
701: if (!file_exists($subscriptions)) {
702: $contents = array();
703: } else {
704: $contents = file($subscriptions, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
705: }
706: if (false === ($key = array_search($sname, $contents))) {
707: return true;
708: }
709: unset($contents[$key]);
710: file_put_contents($subscriptions, join("\n", $contents) . "\n");
711:
712: return Filesystem::chogp($subscriptions, $this->user_id, $this->group_id, 0600);
713:
714: }
715:
716: /**
717: * Restore a saved copy of mailboxes
718: *
719: * @return boolean
720: */
721: public function restore_mailboxes(string $file = self::MAILBOX_SAVE_DEFAULT): bool
722: {
723: if (!IS_CLI && posix_getuid()) {
724: return $this->query('email_restore_mailboxes', $file);
725: }
726: if (!preg_match('/^[\w_-]+$/', $file)) {
727: return error("invalid mailbox backup `%s'", $file);
728: }
729: $file = $this->domain_info_path() . '/' . $file;
730: if (!file_exists($file)) {
731: warn("mailbox backup `%s' not found", basename($file));
732: return -1;
733: } else if (is_link($file)) {
734: return error("restoration file `%s' must be regular file", $this->file_unmake_path($file));
735: }
736: $recs = \Util_PHP::unserialize(file_get_contents($file));
737: $escapef = static function ($rec) {
738: return '"' . $rec . '"';
739: };
740: $domainHash = [];
741: $escapev = static function ($rec) {
742: // empty values inserted as NULL
743: if ($rec === '') {
744: return 'NULL';
745: }
746:
747: if (ctype_digit($rec)) {
748: if ($rec == 0 || $rec == 1) {
749: /*
750: * assume this is the "enabled" column,
751: * which mandates a bit
752: */
753: $rec .= '::bit';
754: }
755:
756: return $rec;
757: }
758:
759: return "'" . pg_escape_string($rec) . "'";
760: };
761: $db = \PostgreSQL::initialize()->getHandler();
762: foreach ($recs as $r) {
763: $hostname = $r['domain'];
764: if (!isset($domainHash[$hostname])) {
765: if ($this->transport_exists($hostname)) {
766: $domainHash[$hostname] = true;
767: } else {
768: // try first to automatically add
769: [$spltsb, $spltd] = array_values($this->web_split_host($r['domain']));
770: if ( !($domainHash[$hostname] = $this->add_virtual_transport($spltd, $spltsb)) ) {
771: warn("Failed to add mail transport `%s'", $hostname);
772: }
773: }
774: }
775: if (!$domainHash[$hostname]) {
776: warn("Host `%s' not attached as mail transport - skipping `%s@%s'", $hostname, $r['user'], $r['domain']);
777: continue;
778: }
779: $fields = array_map($escapef, array_keys($r));
780: $values = array_map($escapev, array_values($r));
781: $q = 'INSERT INTO email_lookup (' . implode(',', $fields) .
782: ') VALUES(' . implode(',', $values) . ')';
783: pg_send_query($db, $q);
784: while (pg_connection_busy($db)) {
785: usleep(50);
786: }
787: $res = pg_get_result($db);
788:
789: if ($err = pg_result_error($res)) {
790: $errid = (int)pg_result_error_field($res, PGSQL_DIAG_SQLSTATE);
791: /**
792: * 23505 (unique_violation) Query violates unique key
793: *
794: * @link http://www.postgresql.org/docs/8.2/static/errcodes-appendix.html
795: */
796: if ($errid === 23505) {
797: warn("skipped duplicate entry `%s@%s'",
798: $r['user'], $r['domain']);
799: } else if ($errid === 23514) {
800: warn("skipped entry `%s@%s': domain for `%s' not " .
801: 'assigned to handle mail', $r['user'],
802: $r['domain'], $r['domain']);
803: } else {
804: error("skipped `%s@%s': unknown query error",
805: $r['user'], $r['domain']);
806: }
807: }
808: }
809:
810: return true;
811: }
812:
813: public function remove_mailbox($user, $domain)
814: {
815: return $this->delete_mailbox($user, $domain);
816: }
817:
818: /**
819: * Domain is designated to receive e-mail on hosting server
820: *
821: * @param string $domain
822: * @return bool
823: */
824: public function transport_exists($domain)
825: {
826: $db = \PostgreSQL::initialize();
827: $q = $db->query("SELECT site_id FROM domain_lookup WHERE domain = '" . pg_escape_string($domain) . "'");
828:
829: return $q->num_rows() > 0 && $q->fetch_object()->site_id == $this->site_id;
830: }
831:
832: /***
833: * int get_spool_size (string)
834: *
835: * @privilege PRIVILEGE_SITE
836: * @return int size of the spool in bytes
837: * @param $username username of the spool; note well this differs from
838: * {@link File_Module::report_quota} in that the username is used instead of the uid.
839: * This may change in the future. There is a limitation in this process
840: * in that it solely scans the main spool file for a user and excludes
841: * all other inboxes created from IMAP applications (such as SquirrelMail).
842: */
843: public function get_spool_size($username)
844: {
845:
846: if (!array_key_exists($username, $this->user_get_users())) {
847: return error("Invalid user `%s'", $username);
848: }
849:
850: return $this->query(
851: 'email_get_spool_size_backend',
852: $this->domain_fs_path() . '/home/' . $username . '/' . self::MAILDIR_HOME
853: );
854:
855: }
856:
857: /**
858: * Get mail folder size
859: *
860: * @param string $path
861: * @return bool|int
862: */
863: public function get_spool_size_backend($path)
864: {
865: if (!file_exists($path)) {
866: return 0;
867: }
868: $proc = Util_Process_Safe::exec('du -s %s', $path);
869: if (!$proc['success']) {
870: return false;
871: }
872:
873: return intval($proc['output']) * 1024;
874: }
875:
876: /**
877: * Get vacation options
878: *
879: * @return array
880: */
881: public function get_vacation_options(): array
882: {
883: $prefs = array_get(\Preferences::factory($this->getAuthContext()), self::VACATION_PREFKEY, []);
884: $mb = Vacation::get($this->getAuthContext());
885: $defaults = $mb->getDefaults();
886:
887: return array_merge($defaults, array_intersect_key($prefs, $defaults));
888: }
889:
890: public function get_vacation_message($user = null)
891: {
892: if (!IS_CLI) {
893: return $this->query('email_get_vacation_message', $user);
894: }
895: if (null !== $user && !($this->permission_level & PRIVILEGE_SITE)) {
896: return error('unprivileged user may not setup vacation responder for other users');
897: }
898:
899: if (null === $user) {
900: $user = $this->username;
901: } else if (!$this->user_exists($user)) {
902: return error("unknown user `%s'", $user);
903: }
904: $svc = Vacation::getActiveService();
905: $class = 'Vacation\\Providers\\' . $svc . '\\Options\\Message';
906: $fqns = Vacation::appendNamespace($class);
907:
908: return (new $fqns)->getFromUser($user);
909: }
910:
911: /**
912: * Wrapper to set_vacation
913: *
914: * @deprecated
915: * @param $response
916: * @param null $user
917: * @param array|null $flags
918: * @return bool|mixed|void
919: */
920: public function add_vacation($response, $user = null, array $flags = null)
921: {
922: deprecated_func('use enable_vacation()');
923:
924: return $this->enable_vacation($response, $user, $flags);
925: }
926:
927: /**
928: * Enable vacation auto-responder
929: *
930: * @param null|string $user
931: * @param array|null $flags optional flags
932: * @return bool|mixed|void
933: */
934: public function enable_vacation($user = null, array $flags = null)
935: {
936: if (!IS_CLI) {
937: return $this->query('email_enable_vacation', $user, $flags);
938: }
939: if (null !== $user && !($this->permission_level & PRIVILEGE_SITE)) {
940: return error('Non-privileged user may not setup vacation responder for other users');
941: }
942:
943: if (null === $user) {
944: $ctx = $this->getAuthContext();
945: } else if (!$this->user_exists($user)) {
946: return error("user `%s' does not exist", $user);
947: } else if ($user && $flags) {
948: return error('changing flags of secondary users not implemented');
949: } else {
950: $ctx = Auth::context($user, $this->site);
951: }
952:
953: $driver = Vacation::get($ctx);
954: $afi = \apnscpFunctionInterceptor::factory($ctx);
955: if ($flags) {
956: $afi->email_set_vacation_options($flags);
957: }
958:
959: return $driver->enable();
960: }
961:
962: /**
963: * Set vacation options
964: *
965: * @param array $options
966: * @return bool
967: */
968: public function set_vacation_options(array $options): bool
969: {
970: $driver = Vacation::get($this->getAuthContext());
971: foreach ($driver->getDefaults() as $k => $v) {
972: if (isset($options[$k]) && !$driver->setOption($k, $options[$k])) {
973: unset($options[$k]);
974: }
975: }
976: $pref = \Preferences::factory($this->getAuthContext());
977: $pref->unlock(apnscpFunctionInterceptor::factory($this->getAuthContext()));
978: array_set($pref, self::VACATION_PREFKEY, $options);
979:
980: return true;
981: }
982:
983: public function vacation_exists($user = null)
984: {
985: if (null !== $user && (($this->permission_level & PRIVILEGE_SITE) !== PRIVILEGE_SITE)) {
986: return error('Unable to check vacation for non-admin account');
987: }
988:
989: if (null === $user) {
990: $ctx = $this->getAuthContext();
991: } else {
992: if (!$this->user_exists($user)) {
993: return false;
994: }
995: $ctx = Auth::context($user, $this->site);
996: }
997:
998: if (!$this->user_exists($ctx->username)) {
999: return error("Invalid user `%s'", $ctx->username);
1000: }
1001:
1002: return Vacation::get($ctx)->enabled();
1003: }
1004:
1005: /**
1006: * Change existing vacation message
1007: *
1008: * @param string $response
1009: * @param string|null $user
1010: * @param array|null $flags
1011: * @return bool
1012: */
1013: public function change_vacation_message($response, $user = null, array $flags = [])
1014: {
1015: deprecated_func('use set_vacation');
1016:
1017: return $this->enable_vacation($response, $user, $flags);
1018: }
1019:
1020: /**
1021: * Disable vacation status
1022: *
1023: * @param string|null user
1024: * @return bool
1025: */
1026: public function remove_vacation(string $user = null)
1027: {
1028: if (!IS_CLI) {
1029: return $this->query('email_remove_vacation', $user);
1030: }
1031:
1032: if ($user && ($this->permission_level & PRIVILEGE_SITE) !== PRIVILEGE_SITE) {
1033: return error('Unable to check vacation for non-admin account');
1034: }
1035:
1036: if ($user && !$this->user_exists($user)) {
1037: return error($user . ': invalid user');
1038: }
1039:
1040: $ctx = !$user ? $this->getAuthContext() : \Auth::context($user, $this->site);
1041:
1042: return Vacation::get($ctx)->disable();
1043: }
1044:
1045: /**
1046: * Clone inboxes from domain
1047: *
1048: * Wrapper to emulate dns:import-from-domain similarity
1049: *
1050: * @param string $domain domain to import into
1051: * @param string $src domain to derive mailboxes from
1052: * @return bool
1053: * @throws PostgreSQLError
1054: */
1055: public function import_from_domain(string $domain, string $src): bool
1056: {
1057: return $this->clone_domain_mailboxes($src, $domain);
1058: }
1059:
1060: /**
1061: * Clone inboxes from domain
1062: *
1063: * @param $source
1064: * @param $destination
1065: * @return bool
1066: * @throws PostgreSQLError
1067: */
1068: public function clone_domain_mailboxes($source, $destination)
1069: {
1070: if ($source === $destination) {
1071: return error('cannot clone, source and destination same');
1072: }
1073: $this->remove_virtual_transport($destination);
1074: if (!$this->add_virtual_transport($destination)) {
1075: return false;
1076: }
1077:
1078: foreach ($this->list_mailboxes(null, null, $source) as $mailbox) {
1079: if ($mailbox['type'] == self::MAILBOX_USER) {
1080: if (preg_match('!^/home/([^/]+)/' . self::MAILDIR_HOME . '/?(.*)$!', $mailbox['destination'],
1081: $mailbox_dest)) {
1082: $username = $mailbox_dest[1];
1083: $subfolder = $mailbox_dest[2];
1084: } else {
1085: $subfolder = '';
1086: $username = $mailbox['destination'];
1087: }
1088:
1089: $this->add_mailbox($mailbox['user'],
1090: $destination,
1091: $this->user_get_uid_from_username($username),
1092: $subfolder);
1093: } else if ($mailbox['type'] == self::MAILBOX_FORWARD) {
1094: $this->add_alias($mailbox['user'],
1095: $destination,
1096: str_replace($source,
1097: $destination,
1098: $mailbox['destination']));
1099: }
1100:
1101: if (!$mailbox['enabled']) {
1102: $this->disable_address($mailbox['user'], $mailbox['domain']);
1103: }
1104:
1105: }
1106:
1107: return true;
1108: }
1109:
1110: /**
1111: * Deauthorize server from handling mail for domain
1112: *
1113: * @param string $domain domain name to deauthorize
1114: * @param bool $keepdns purge DNS MX settings, null auto-detect to purge
1115: * @return bool|int
1116: */
1117: public function remove_virtual_transport($domain, $keepdns = null)
1118: {
1119: $pgdb = \PostgreSQL::initialize();
1120: $q = $pgdb->query("SELECT site_id FROM domain_lookup WHERE domain = '" . pg_escape_string($domain) . "'");
1121: if ($q->num_rows() < 1) {
1122: return false;
1123: }
1124:
1125: $site_id = $pgdb->fetch_object()->site_id;
1126:
1127: if ($site_id && $site_id != $this->site_id) {
1128: return error('Table entry ' . $domain . ' owned by another site (' . $site_id . ')');
1129: } else if ($pgdb->num_rows() < 1) {
1130: return error('Domain ' . $domain . ' not found in table');
1131: }
1132: if ($this->majordomo_enabled()) {
1133: foreach ($this->majordomo_list_mailing_lists() as $list) {
1134: $tmp = $this->majordomo_get_domain_from_list_name($list);
1135: if ($tmp == $domain) {
1136: warn("Mailing list `%s' sends from `%s'. Delete via Mail > Mailing Lists", $list, $domain);
1137: }
1138: }
1139: }
1140: $pgdb->query("DELETE FROM domain_lookup WHERE domain = '" . pg_escape_string($domain) . "' AND site_id = " . (int)$this->site_id . ';');
1141: $ok = $pgdb->affected_rows() > 0;
1142:
1143: if (!$this->dns_configured()) {
1144: return warn("DNS is not configured for `%s' - unable to remove MX records automatically", $domain);
1145: }
1146:
1147: if (!$this->dns_zone_exists($domain)) {
1148: // zone removed
1149: return true;
1150: }
1151:
1152: if ($keepdns) {
1153: return $ok;
1154: }
1155:
1156: $split = $this->web_split_host($domain);
1157: $mailrecords = $this->provisioning_records($split['domain'], $split['subdomain']);
1158: if (null === $keepdns) {
1159: // do an intelligent lookup to see if MX is default
1160: $hostname = ltrim($split['subdomain'] . '.' . $split['domain'], '.');
1161: $rec = $this->dns_get_records($split['subdomain'], 'MX', $split['domain']);
1162: // record exists, confirm MX value
1163: if (!is_array($rec)) {
1164: warn("error retrieving mx records for `%s'", $hostname);
1165: Error_Reporter::report("unable to remove record for `%s'", $hostname);
1166: return $ok;
1167: }
1168: if (!count($rec)) {
1169: // MX record exists remotely but not on the server
1170: info("no MX records found for hostname `%s'", $hostname);
1171: return $ok;
1172: }
1173:
1174: // check last record
1175: $rec = array_pop($rec);
1176:
1177: // determine if MX record is unchanged from stock records
1178: // @XXX this algorithm is terrible
1179: $match = new Record($hostname, [
1180: 'name' => $rec['subdomain'],
1181: 'rr' => 'MX',
1182: 'parameter' => $rec['parameter'],
1183: ]);
1184: $match2 = $match;
1185:
1186: foreach ($mailrecords as $r) {
1187: if (!$r->is($match)) {
1188: continue;
1189: }
1190: [$priority, $target] = preg_split('/\s+/', $match['parameter'], 2, PREG_SPLIT_NO_EMPTY);
1191: foreach ($mailrecords as $r2) {
1192: if ($r2->matches('hostname', $target)) {
1193: $match2 = $r2;
1194: break;
1195: }
1196: }
1197:
1198: break;
1199: }
1200:
1201: $keepdns = $match2 === $match;
1202:
1203: if ($keepdns) {
1204: warn("MX record for `%s' points to third-party server and thus will not be removed from local DNS",
1205: $domain);
1206: return -1;
1207: }
1208: }
1209:
1210: foreach ($mailrecords as $r) {
1211: if ($this->dns_record_exists($r->getZone(), $r['name'], $r['rr'], $r['parameter'])) {
1212: if (!$this->dns_remove_record($r->getZone(), $r['name'], $r['rr'], $r['parameter'])) {
1213: warn(
1214: 'Failed to remove record %s.%s (%s) => %s',
1215: $r['name'],
1216: $r->getZone(),
1217: $r['rr'],
1218: $r['parameter']
1219: );
1220: }
1221: }
1222: }
1223:
1224: return $ok;
1225: }
1226:
1227: /**
1228: * Get DNS records
1229: *
1230: * @deprecated use provisioning_records()
1231: *
1232: * @param string $domain
1233: * @param string $subdomain
1234: * @return array
1235: */
1236: public function get_records(string $domain, string $subdomain = ''): array
1237: {
1238: deprecated_func('use provisioning_records()');
1239: return $this->provisioning_records($domain, $subdomain);
1240: }
1241:
1242: /**
1243: * Get DNS records
1244: *
1245: * @param string $domain
1246: * @param string $subdomain
1247: * @return Record[]
1248: */
1249: public function provisioning_records(string $domain, string $subdomain = ''): array
1250: {
1251: if (!IS_CLI) {
1252: return $this->query('email_provisioning_records', $domain, $subdomain);
1253: }
1254: if (!$this->enabled() || ($this->getServiceValue('mail', 'provider') === 'builtin' &&
1255: !$this->transport_exists(ltrim("$subdomain.$domain", '.'))))
1256: {
1257: // dns calls email_provisioning_records, email depends upon dns - without
1258: // a callback there's no way to know for sure if a domain is authoritative
1259: // for mail; transport_exists() doesn't work at this stage
1260: return [];
1261: }
1262:
1263: $ttl = $this->dns_get_default('ttl');
1264: $myips = $this->get_mail_ip();
1265: $template = BladeLite::factory('templates/dns')->render('email', [
1266: 'svc' => \Opcenter\SiteConfiguration::shallow($this->getAuthContext()),
1267: 'ttl' => $ttl,
1268: 'zone' => $domain,
1269: 'subdomain' => $subdomain,
1270: 'hostname' => ltrim(implode('.', [$subdomain, $domain]), '.'),
1271: 'ips' => (array)$myips,
1272: 'dkim' => Dkim::instantiateContexted($this->getAuthContext())
1273: ]);
1274:
1275: $regex = Regex::compile(Regex::DNS_AXFR_REC_DOMAIN, [
1276: 'rr' => implode('|', $this->dns_permitted_records() + [99999 => 'SOA']),
1277: 'domain' => $domain
1278: ]);
1279: if (!preg_match_all($regex, $template, $matches, PREG_SET_ORDER)) {
1280: debug('No provisioning records discovered from template');
1281: return [];
1282: }
1283: $records = [];
1284: foreach ($matches as $record) {
1285: $records[] = new Record($domain, [
1286: 'ttl' => $record['ttl'],
1287: 'parameter' => $record['parameter'],
1288: 'rr' => $record['rr'],
1289: 'name' => rtrim($record['subdomain'], '.')
1290: ]);
1291: }
1292: return $records;
1293: }
1294:
1295: /**
1296: * Add transport to handle mail
1297: * @param string $domain primary domain
1298: * @param string $subdomain optional subdomain
1299: * @return bool|void
1300: * @throws PostgreSQLError
1301: */
1302: public function add_virtual_transport($domain, $subdomain = '')
1303: {
1304: $aliases = $this->aliases_list_aliases();
1305: if (($domain !== $this->domain) && !in_array($domain, $aliases, true)) {
1306: return error("domain `%s' not owned by site", $domain);
1307: }
1308: $transport = ($subdomain ? $subdomain . '.' : '') . $domain;
1309: $pgdb = \PostgreSQL::initialize();
1310: $rs = $pgdb->query("SELECT site_id FROM domain_lookup WHERE domain = '" . pg_escape_string($transport) . "'");
1311: $nr = $pgdb->num_rows();
1312:
1313: if ($nr > 0) {
1314: $site = (int)$rs->fetch_object()->site_id;
1315: if ($site !== $this->site_id) {
1316: return error("table entry `%(transport)s' owned by another site (%(id)d)",
1317: ['transport' => $transport, 'id' => $site]);
1318: }
1319:
1320: return true;
1321: }
1322: $pgdb->query("INSERT INTO domain_lookup (domain, site_id) VALUES('" . pg_escape_string($transport) . "', " . (int)$this->site_id . ');');
1323: if ($pgdb->affected_rows() < 1) {
1324: return error("failed to add e-mail transport `%s'", $transport);
1325: }
1326:
1327: if (!$this->dns_domain_uses_nameservers($domain)) {
1328: $nsrecs = join(', ', $this->dns_get_hosting_nameservers($domain));
1329: warn('Domain %(domain)s uses third-party nameservers to provide DNS. Continuing to make ' .
1330: 'local MX records on local nameservers. Email configuration in Mail > Manage Mailboxes ' .
1331: 'will not be reflected until nameservers are changed to %(nsrecs)s',
1332: ['domain' => $domain, 'nsrecs' => $nsrecs]
1333: );
1334: }
1335:
1336: /**
1337: * forcefully set the record just in case, if external DNS is used or
1338: * if MX destination record is already present, elicit a warning
1339: */
1340: if (!$this->dns_configured() || !$this->dns_zone_exists($domain)) {
1341: return warn("DNS is not configured for `%s' - unable to provision DNS automatically", $domain);
1342: }
1343:
1344: $hostname = ltrim($subdomain . '.' . $domain, '.');
1345: $uuidRecord = $this->dns_get_records($this->dns_uuid_name(), 'TXT', $hostname);
1346: if (($chk = array_get($uuidRecord, '0.parameter')) && $chk && $chk !== $this->dns_uuid()) {
1347: return warn("UUID %(check)s does not match expected %(expected)s. Not provisioning DNS records for %(hostname)s", [
1348: 'check' => $chk,
1349: 'expected' => $this->dns_uuid(),
1350: 'hostname' => $hostname
1351: ]);
1352: }
1353: $mailrecords = $this->provisioning_records($domain, $subdomain);
1354: $srvrec = $this->dns_get_records($subdomain, 'MX', $domain);
1355:
1356: if ($srvrec) {
1357: // MX exists, examine for completeness
1358: $srvrec = array_pop($srvrec);
1359:
1360: $match = new Record($domain, [
1361: 'name' => $srvrec['subdomain'],
1362: 'rr' => 'MX',
1363: 'parameter' => $srvrec['parameter'],
1364: ]);
1365: $match2 = $match;
1366:
1367: foreach ($mailrecords as $r) {
1368: if (!$r->is($match)) {
1369: continue;
1370: }
1371:
1372: [$priority, $target] = preg_split('/\s+/', $match['parameter'], 2, PREG_SPLIT_NO_EMPTY);
1373: foreach ($mailrecords as $r2) {
1374: if ($r2->matches('hostname', $target)) {
1375: $match2 = $r2;
1376: break;
1377: }
1378: }
1379:
1380: break;
1381: }
1382: // wasn't updated
1383: $hasCustomRecords = $match2 === $match;
1384:
1385: if ($hasCustomRecords) {
1386: $hostname = trim(implode('.', [$match['name'], $match['zone']]), '.');
1387: return warn('MX record for %s points to %s, not overwriting! Email will not ' .
1388: 'route properly until MX records are reset via Toolbox in DNS Manager.',
1389: $hostname,
1390: $srvrec['parameter']
1391: );
1392: }
1393: // make sure records are present
1394: }
1395:
1396: foreach ($mailrecords as $r) {
1397: if (($r['rr'] === 'A' || $r['rr'] === 'AAAA') && $this->dns_record_exists($r->getZone(), $r['name'], 'CNAME')) {
1398: info('Record %(subdomain)s%(domain)s already exists as CNAME - not adding %(rr)s',
1399: [
1400: 'rr' => $r['rr'],
1401: 'subdomain' => ltrim($subdomain . '.', '.'),
1402: 'domain' => $r->getZone()
1403: ]
1404: );
1405: continue;
1406: }
1407: if (!$this->dns_record_exists($r->getZone(), $r['name'], $r['rr'], $r['parameter'])) {
1408: $this->dns_add_record($r->getZone(), $r['name'], $r['rr'], $r['parameter']);
1409: }
1410: }
1411:
1412: return true;
1413: }
1414:
1415: /**
1416: * Add mailbox for account
1417: *
1418: * @param $user
1419: * @param $domain
1420: * @param $uid
1421: * @param string $mailbox
1422: * @return bool|void
1423: * @throws PostgreSQLError
1424: */
1425: public function add_mailbox($user, $domain, $uid, $mailbox = '')
1426: {
1427: $user = strtolower(trim($user));
1428: $domain = strtolower(trim($domain));
1429: if ($this->address_exists($user, $domain)) {
1430: if (!$user) {
1431: return error("catch-all for $domain already exists");
1432: }
1433:
1434: return error('%s@%s: address exists', $user, $domain);
1435: }
1436:
1437: if ($user && !preg_match(Regex::EMAIL, "${user}@${domain}")) {
1438: return error("Invalid email `%s'", "${user}@${domain}");
1439: }
1440:
1441: if (!$this->transport_exists($domain)) {
1442: return error("Mail transport `%s' not bound to account", $domain);
1443: }
1444: $mailbox = ltrim(str_replace(array('/', '..'), '.', $mailbox), '.');
1445: $uid = (int)$uid;
1446: $pgdb = \PostgreSQL::initialize();
1447: if ($mailbox) {
1448: $pgdb->query('SELECT "user" as name FROM uids WHERE uid = ' . $uid . ' AND site_id = ' . $this->site_id);
1449: $luser = $pgdb->fetch_object();
1450: if (!$luser) {
1451: return error("lookup failed for `%s' with uid `%s'", $user, $uid);
1452: }
1453: $luser = trim($luser->name);
1454: $this->query('email_create_maildir_backend', $luser, $mailbox);
1455: $mailbox = pg_escape_string($mailbox);
1456: }
1457:
1458: $pgdb->query("INSERT INTO email_lookup (\"user\", domain, uid, type, enabled, fs_destination)
1459: VALUES ('" . pg_escape_string($user) . "',
1460: '" . pg_escape_string($domain) . "',
1461: " . intval($uid) . ",
1462: '" . self::MAILBOX_USER . "',
1463: 1::bit,
1464: " . ($mailbox ? "'" . $mailbox . "'" : 'NULL') . ');');
1465: $rows = $pgdb->affected_rows();
1466:
1467: $this->_shutdown_save_mailboxes();
1468:
1469: return $rows > 0 ?: error('Failed to create mailbox: %s', $pgdb->error);
1470:
1471: }
1472:
1473: public function add_alias($user, $domain, $destination)
1474: {
1475: $user = strtolower($user);
1476: $domain = strtolower($domain);
1477: if ($this->address_exists($user, $domain)) {
1478: return error('%s@%s: address exists', $user, $domain);
1479: }
1480:
1481: if ($conflicts = $this->checkForwarding($destination)) {
1482: return error('Remote forwarding is disabled. Following addresses would violate forwarding policy: %s',
1483: implode(',', $conflicts)
1484: );
1485: }
1486:
1487: if (!$this->transport_exists($domain)) {
1488: return error("Mail transport `%s' not bound to account", $domain);
1489: }
1490: $user = trim($user);
1491: if (!$user && !MAIL_FORWARDED_CATCHALL) {
1492: return error('catch-all may not be forwarded');
1493: }
1494: $destination = preg_replace('/\s+|,+/', ',', trim($destination, ' ,'));
1495: if (!$destination) {
1496: return error('no destination specified');
1497: }
1498: $pgdb = \PostgreSQL::initialize();
1499: $pgdb->query('INSERT INTO email_lookup ' .
1500: '("user", domain, alias_destination, type, enabled) ' .
1501: "VALUES('" . pg_escape_string($user) . "', '" . pg_escape_string($domain) . "', '" .
1502: trim(pg_escape_string($destination), ',') . "', '" . self::MAILBOX_FORWARD . "', 1::bit);");
1503: $rows = $pgdb->affected_rows();
1504: $this->_shutdown_save_mailboxes();
1505:
1506: return $rows > 0;
1507: }
1508:
1509: public function disable_address($account, $domain = null)
1510: {
1511: $where = 'AND email_lookup.domain = domain_lookup.domain AND domain_lookup.site_id = ' . $this->site_id;
1512: if ($domain) {
1513: $where .= 'AND domain_lookup.domain = \'' . pg_escape_string($domain) . '\'';
1514: }
1515: $pgdb = \PostgreSQL::initialize();
1516: $pgdb->query('UPDATE email_lookup SET enabled = 0::bit FROM domain_lookup WHERE "user" = \'' . pg_escape_string($account) . '\' ' . $where . ';');
1517:
1518: return $pgdb->affected_rows() > 0;
1519: }
1520:
1521: /**
1522: * Get mail server IPs
1523: *
1524: * @return array
1525: */
1526: public function get_mail_ip(): array
1527: {
1528: $ips = [];
1529: if ($tmp = $this->dns_get_public_ip()) {
1530: $ips = (array)$tmp;
1531: }
1532: if ($tmp = $this->dns_get_public_ip6()) {
1533: $ips = array_merge($ips, (array)$tmp);
1534: }
1535:
1536: return $ips;
1537: }
1538:
1539: /**
1540: * Bind subdomain for webmail access
1541: *
1542: * @param string $app
1543: * @param string|null $subdomain null to delete
1544: * @return bool
1545: * @throws RedisException
1546: */
1547: public function set_webmail_location(string $app, ?string $subdomain): bool
1548: {
1549: if (!IS_CLI) {
1550: return $this->query('email_set_webmail_location', $app, $subdomain);
1551: }
1552:
1553: $webmailInstance = Webmail::instantiateContexted($this->getAuthContext());
1554: if (!$webmailInstance->exists($app)) {
1555: return error("unknown webmail app `%s'", $app);
1556: }
1557:
1558: $dnsRecords = $this->email_provisioning_records($this->email_list_virtual_transports()[0] ?? $this->domain);
1559: $locations = $this->webmail_apps();
1560: $oldsubdomain = $locations[$app];
1561: // when renaming a mail subdomain, preserve DNS record if A/AAAA is MX target
1562: $keepDns = (bool)array_first($dnsRecords, function (Record $record) use ($oldsubdomain) {
1563: if (!($record['zone'] === $this->domain && $record['name'] === '' && $record['rr'] === 'MX')) {
1564: return false;
1565: }
1566:
1567: return !strcasecmp(rtrim($record->getMeta('data'), '.'), "$oldsubdomain." . $record['zone']);
1568: });
1569:
1570: defer($_, function () {
1571: $cache = Cache_Account::spawn($this->getAuthContext());
1572: $cache->del(Webmail::CACHE_KEY);
1573: });
1574:
1575: if (null === $subdomain) {
1576: if ($webmailInstance->assigned($app)) {
1577: $this->web_remove_subdomain($oldsubdomain, $keepDns);
1578: }
1579: return $webmailInstance->forget($app);
1580: }
1581:
1582: $subdomain = strtolower($subdomain);
1583: if ($oldsubdomain === $subdomain) {
1584: return true;
1585: }
1586: if (!preg_match(Regex::SUBDOMAIN, $subdomain)) {
1587: return error("invalid subdomain `%s'", $subdomain);
1588: }
1589:
1590: if ($this->web_subdomain_exists($subdomain)) {
1591: return error("subdomain `%s' already exists - cannot overwrite", $subdomain);
1592: }
1593:
1594: // system-default webmail locations won't appear in subdomain_exists() query
1595: if ($this->web_subdomain_exists($oldsubdomain) && !$this->web_remove_subdomain($oldsubdomain, $keepDns)) {
1596: warn("cannot remove old webmail location `%s'", $oldsubdomain);
1597: }
1598: if (!$webmailInstance->set($app, $subdomain)) {
1599: return false;
1600: }
1601: $fspath = $webmailInstance->getPathFromApp($app);
1602: if (!$this->web_add_subdomain($subdomain, $fspath)) {
1603: return error("Failed to map webmail `%(name)s' to `%(path)s'", [
1604: 'name' => $app,
1605: 'path' => $fspath
1606: ]);
1607: }
1608:
1609: if ($this->letsencrypt_exists()) {
1610: $this->letsencrypt_append($subdomain . '.' . $this->domain);
1611: }
1612:
1613: return info("webmail location changed from `%(old)s' to `%(new)s'", [
1614: 'old' => $oldsubdomain . '.' . $this->domain,
1615: 'new' => $subdomain . '.' . $this->domain
1616: ]);
1617: }
1618:
1619: public function webmail_apps()
1620: {
1621: if (!IS_CLI) {
1622: $cache = Cache_Account::spawn($this->getAuthContext());
1623: if (false !== ($webmail = $cache->get(Webmail::CACHE_KEY))) {
1624: return $webmail;
1625: }
1626: $apps = $this->query('email_webmail_apps');
1627: $cache->set(Webmail::CACHE_KEY, $apps);
1628:
1629: return $apps;
1630: }
1631: return Webmail::instantiateContexted($this->getAuthContext())->getAll();
1632: }
1633:
1634: public function get_webmail_location($app)
1635: {
1636: $cache = Cache_Account::spawn($this->getAuthContext());
1637: if (false !== ($webmail = $cache->get(Webmail::CACHE_KEY))) {
1638: return $webmail[$app];
1639: }
1640: $webmail = $this->query('email_webmail_apps');
1641: if (!isset($webmail[$app])) {
1642: return error("unknown webmail app `%s'", $app);
1643: }
1644:
1645: return $webmail[$app];
1646: }
1647:
1648: public function _create()
1649: {
1650: // populate spam folders
1651: $conf = $this->getAuthContext()->getAccount()->cur;
1652: $user = $conf['siteinfo']['admin_user'];
1653: // stupid thor...
1654: $svcs = array('smtp_relay', 'imap', 'pop3');
1655: $pam = new Util_Pam($this->getAuthContext());
1656: foreach ($svcs as $svc) {
1657: if ($this->auth_is_demo() && $pam->check($user, $svc)) {
1658: $pam->remove($user, $svc);
1659: }
1660: }
1661: if (platform_is('7.5', '<')) {
1662: return true;
1663: }
1664: if (!$this->_create_user($user)) {
1665: return false;
1666: }
1667: if (!$this->transport_exists($this->domain)) {
1668: $this->add_virtual_transport($this->domain);
1669: }
1670: $this->add_mailbox('postmaster', $this->domain, $this->user_id);
1671: $this->add_mailbox($this->username, $this->domain, $this->user_id);
1672: }
1673:
1674: public function _create_user(string $user)
1675: {
1676: // flush Dovecot auth cache to acknowledge pwdb changes
1677: $this->_reload('adduser');
1678: if (!$pwd = $this->user_getpwnam($user)) {
1679: return false;
1680: }
1681:
1682: if (!$pwd['home']) {
1683: return false;
1684: }
1685:
1686: // use imap as a marker for email creation
1687: $svc = 'imap';
1688:
1689: $path = $this->domain_fs_path() . DIRECTORY_SEPARATOR . $pwd['home'] .
1690: DIRECTORY_SEPARATOR . self::MAILDIR_HOME;
1691: if (!is_dir($path)) {
1692: Opcenter\Filesystem::mkdir($path, $pwd['uid'], $this->group_id, 0700, false);
1693: Storage::bindTo($this->domain_fs_path())->createMaildir($this->file_unmake_path($path),
1694: $pwd['uid'], $pwd['gid']);
1695: file_put_contents($path . '/subscriptions', 'INBOX', FILE_APPEND);
1696: }
1697:
1698: foreach (Storage::BASE_FOLDERS as $folder) {
1699: $dir = $path . DIRECTORY_SEPARATOR . ".${folder}";
1700: if (!is_dir($dir)) {
1701: $this->create_maildir_backend($user, $folder);
1702: }
1703:
1704: }
1705:
1706: return true;
1707: }
1708:
1709: public function _reload(string $why = '', array $args = [])
1710: {
1711:
1712: if ($why === Ssl_Module::USER_RHOOK || $why === Ssl_Module::SYS_RHOOK) {
1713: if (Haproxy::exists()) {
1714: // ignore reloads triggered by admin
1715: if ($this->site) {
1716: $this->merge_ssl($this->site);
1717: }
1718: Haproxy::restart(HTTPD_RELOAD_DELAY);
1719: }
1720: // update ssl certs
1721: if (Dovecot::exists()) {
1722: Dovecot::restart(HTTPD_RELOAD_DELAY);
1723: }
1724: Postfix::restart(HTTPD_RELOAD_DELAY);
1725: return true;
1726: }
1727:
1728: if ($why === 'adduser') {
1729: // just flush auth cache
1730: if (!Dovecot::exists()) {
1731: return warn(
1732: "Dovecot appears to not be installed. Mail provider other than 'null' selected. Switch " .
1733: "provider module to `null' from `%s' to avoid unexpected side-effects.", $this->get_provider()
1734: );
1735: }
1736: return Dovecot::flushAuth();
1737: }
1738:
1739: return true;
1740: }
1741:
1742: /**
1743: * Create Maildir backend
1744: *
1745: * @param $user
1746: * @param $mailbox
1747: * @return bool|void
1748: */
1749: public function create_maildir_backend($user, $mailbox)
1750: {
1751: $mailbox = '.' . ltrim($mailbox, '.');
1752: if (!preg_match(Regex::EMAIL_MAILDIR_FOLDER, $mailbox)) {
1753: return error("invalid maildir folder name `%s'", $mailbox);
1754: }
1755:
1756: $pwd = $this->user_getpwnam($user);
1757: if (!$pwd) {
1758: return error("failed to create Maildir storage, user `%s' does not exist", $user);
1759: }
1760:
1761: $path = $pwd['home'] . DIRECTORY_SEPARATOR .
1762: static::MAILDIR_HOME . DIRECTORY_SEPARATOR . Storage::mailbox2Maildir($mailbox);
1763: $chkvpath = dirname($path);
1764: $chkrpath = $this->domain_fs_path($chkvpath);
1765: if (!is_dir($chkrpath)) {
1766: return error("mail home `%s' does not exist", $chkvpath);
1767: }
1768:
1769: return Storage::bindTo($this->domain_fs_path())->createMaildir($path, $pwd['uid'],
1770: $pwd['gid']);
1771: }
1772:
1773: public function create_maildir($mailbox)
1774: {
1775: if (!IS_CLI) {
1776: return $this->query('email_create_maildir', $mailbox);
1777: }
1778:
1779: return $this->create_maildir_backend($this->username, $mailbox);
1780: }
1781:
1782: public function _delete()
1783: {
1784: // remove HAproxy is present
1785: $pemfile = static::SSL_PROXY_DIR . '/' . $this->site . '.pem';
1786: if (file_exists($pemfile)) {
1787: unlink($pemfile);
1788: }
1789: $conf = $this->getAuthContext()->getAccount()->cur;
1790: $ips = $conf['ipinfo']['ipaddrs'] + append_config((array)$conf['ipinfo6']['ipaddrs']);
1791: if (!$ips) {
1792: return true;
1793: }
1794: foreach ($ips as $ip) {
1795: $this->_removeMTA($ip);
1796: }
1797: $this->_removeIMAP($this->site);
1798:
1799:
1800: }
1801:
1802: private function _removeMTA($ip)
1803: {
1804: $hosts = file(Dns_Module::HOSTS_FILE, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
1805: $regex = Regex::compile(
1806: Regex::EMAIL_MTA_IP_RECORD,
1807: array(
1808: 'ip' => preg_quote($ip, '/')
1809: )
1810: );
1811:
1812: $new = array();
1813: $found = false;
1814: foreach ($hosts as $host) {
1815: if (preg_match($regex, $host)) {
1816: $found = true;
1817: continue;
1818: }
1819: $new[] = $host;
1820: }
1821: $new[] = '';
1822: if (!$found) {
1823: return -1;
1824: }
1825:
1826: /**
1827: * it's here for future consideration, but likely unnecessary
1828: * $proc = new Util_Process_Schedule("1 minute");
1829: * $proc->run(self::POSTFIX_CMD . ' reload');
1830: */
1831: return file_put_contents(Dns_Module::HOSTS_FILE, join(PHP_EOL, $new), LOCK_EX) !== false;
1832: }
1833:
1834: /**
1835: * Remove site configuration
1836: *
1837: * @param $site
1838: */
1839: private function _removeIMAP($site)
1840: {
1841: $path = self::SSL_PROXY_DIR . '/' . $site;
1842: $extensions = array('conf', 'crt', 'key', 'pem');
1843: foreach ($extensions as $ext) {
1844: $file = $path . '.' . $ext;
1845: if (file_exists($file)) {
1846: unlink($file);
1847: }
1848: }
1849: }
1850:
1851: public function _edit()
1852: {
1853: $conf_new = $this->getAuthContext()->getAccount()->new;
1854: $conf_old = $this->getAuthContext()->getAccount()->old;
1855: $user = array(
1856: 'old' => $conf_old['siteinfo']['admin_user'],
1857: 'new' => $conf_new['siteinfo']['admin_user']
1858: );
1859:
1860: if ($conf_old['mail']['provider'] === 'builtin' && $conf_new['mail']['provider'] !== 'builtin') {
1861: $oldInstance = $this;
1862: $module = Email_Module::instantiateContexted($this->getAuthContext());
1863: $this->getApnscpFunctionInterceptor()->swap('email', $module);
1864: $this->save_mailboxes();
1865: foreach ($module->list_virtual_transports() as $transport) {
1866: $waserr = \Error_Reporter::is_error();
1867: $module->remove_virtual_transport($transport);
1868: if (!$waserr && \Error_Reporter::is_error()) {
1869: \Error_Reporter::downgrade(\Error_Reporter::E_WARNING);
1870: }
1871: }
1872: $this->getApnscpFunctionInterceptor()->swap('email', $oldInstance);
1873:
1874: } else if ($conf_new['mail']['provider'] === 'builtin' && $conf_old['mail']['provider'] !== 'builtin') {
1875: if (file_exists($this->domain_info_path(self::MAILBOX_SAVE_DEFAULT))) {
1876: $this->restore_mailboxes();
1877: } else {
1878: warn("No mail transports enabled. These must be enabled under Mail > Mail Routing");
1879: }
1880: }
1881:
1882: /**
1883: * update alias mapping, mailbox mappings update on the backend
1884: *
1885: * @TODO phase out legacy backend
1886: */
1887: if ($user['old'] !== $user['new']) {
1888: // @XXX bug: _edit is called after EVD completes
1889: // old pwd is lost, but send anyway to placate _edit_user
1890: $this->_edit_user(
1891: $user['old'],
1892: $user['new'],
1893: $this->user_getpwnam($user['new'])
1894: );
1895: }
1896:
1897: // Aliases can be edited in 2 manners: Nexus/EditDomain or aliases:remove-domain
1898: // It's impractical to move this logic into ServiceValidators\Aliases\Aliases as it couples validation
1899: // with an unrelated service class. Handle email detachment here so that surrogates/provider modules
1900: // may override behavior
1901: $aliases = [
1902: 'old' => array_merge($conf_old['aliases']['aliases'], (array)$conf_old['siteinfo']['domain']),
1903: 'new' => array_merge($conf_new['aliases']['aliases'], (array)$conf_new['siteinfo']['domain'])
1904: ];
1905: $toremove = array_diff($aliases['old'], $aliases['new']);
1906:
1907: foreach ($toremove as $domain) {
1908: if ($this->transport_exists($domain)) {
1909: $this->remove_virtual_transport($domain);
1910: }
1911: }
1912:
1913: /**
1914: * Update private smtp routing + whitelabel dovecot config
1915: */
1916: $ipcur = $conf_old['ipinfo'];
1917: $ipnew = $conf_new['ipinfo'];
1918:
1919: if ($ipnew === $ipcur) {
1920: return true;
1921: }
1922: // ip either added or removed
1923: if (!$ipcur['namebased'] && $ipnew['namebased']) {
1924: foreach ($ipcur['ipaddrs'] as $ip) {
1925: $this->_removeMTA($ip);
1926: $this->_removeIMAP($this->site);
1927: }
1928: } else if ($ipcur['namebased'] && !$ipnew['namebased']) {
1929: foreach ($ipnew['ipaddrs'] as $ip) {
1930: $this->_addMTA($ip);
1931: }
1932: } else if ($ipcur['ipaddrs'] != $ipnew['ipaddrs']) {
1933: $remove = array_diff($ipcur['ipaddrs'], $ipnew['ipaddrs']);
1934: $add = array_diff($ipnew['ipaddrs'], $ipcur['ipaddrs']);
1935: foreach ($remove as $ip) {
1936: $this->_removeMTA($ip);
1937: }
1938: foreach ($add as $ip) {
1939: $this->_addMTA($ip);
1940: }
1941: // @TODO update Dovecot config
1942: }
1943: return true;
1944: }
1945:
1946: public function _edit_user(string $userold, string $usernew, array $oldpwd)
1947: {
1948: // Dovecot is a finnicky bastard
1949: $this->_reload('adduser');
1950: if ($userold === $usernew) {
1951: return;
1952: }
1953: // edit_user hooks enumerated after user changed
1954: $uid = $this->user_get_uid_from_username($usernew);
1955: if (!$uid) {
1956: return error("cannot determine uid from user `%s' in mailbox translation", $userold);
1957: }
1958: $pam = new Util_Pam($this->getAuthContext());
1959: mute_warn();
1960: foreach ($this->_pam_services() as $svc) {
1961: if ($this->user_enabled($userold, $svc)) {
1962: $pam->remove($userold, $svc);
1963: // edit_user hook renames user then calls
1964: $pam->add($usernew, $svc);
1965: }
1966: }
1967: unmute_warn();
1968:
1969: // make 2 sweeps:
1970: // sweep 1: update mailboxes that refer to the uid
1971: // sweep 2: update aliases that forward to the user
1972: // aliases that deliver locally
1973: $mailboxes = $this->list_mailboxes('local', $userold);
1974: foreach ($mailboxes as $mailbox) {
1975: $target = '';
1976: if ($mailbox['type'] === self::MAILBOX_USER) {
1977: $target = '/home/' . $mailbox['mailbox'] . '/' .
1978: self::MAILDIR_HOME . '/' . $mailbox['custom'];
1979: } else if ($mailbox['mailbox'] !== self::MAILDIR_HOME) {
1980: $target = $mailbox['mailbox'];
1981: }
1982: $this->modify_mailbox($mailbox['user'],
1983: $mailbox['domain'],
1984: $usernew,
1985: $mailbox['domain'],
1986: $target,
1987: $mailbox['type']
1988: );
1989: }
1990: // sweep 2
1991: $this->_update_email_aliases($userold, $usernew);
1992:
1993: return true;
1994: }
1995:
1996: private function _pam_services()
1997: {
1998: return ['smtp', 'imap', 'pop3'];
1999: }
2000:
2001: /**
2002: * Mail service is enabled for user
2003: *
2004: * @param null $user
2005: * @param null $svc
2006: * @return bool|void
2007: */
2008: public function user_enabled($user = null, $svc = null)
2009: {
2010: if (!$user || ($this->permission_level & PRIVILEGE_USER)) {
2011: $user = $this->username;
2012: }
2013: if ($svc && $svc != 'imap' && $svc != 'smtp' && $svc != 'smtp_relay' && $svc !== 'pop3') {
2014: return error("unknown service `%s'", $svc);
2015: }
2016: if (!$this->enabled($svc)) {
2017: return false;
2018: }
2019: $enabled = 1;
2020: if (!$svc) {
2021: $enabled = (new Util_Pam($this->getAuthContext()))->check($user, 'imap');
2022: $svc = 'smtp_relay';
2023: } else if ($svc == 'smtp') {
2024: $svc = 'smtp_relay';
2025: }
2026:
2027: return $enabled && (new Util_Pam($this->getAuthContext()))->check($user, $svc);
2028: }
2029:
2030: /**
2031: * Verify service is enabled
2032: *
2033: * @param null|string $which
2034: * @return bool
2035: */
2036: public function enabled(string $which = null): bool
2037: {
2038: // @TODO rename sendmail to smtp service
2039: if (platform_is('7.5')) {
2040: $which = $which === 'smtp_relay' ? 'smtp' : $which;
2041: } else {
2042: $which = $which === 'smtp' ? 'smtp_relay' : $which;
2043: }
2044: if ($which && $which !== 'smtp' && $which !== 'smtp_relay' && $which !== 'imap' && $which !== 'pop3') {
2045: return error("unknown service `%s'", $which);
2046: }
2047: if ($which) {
2048: $which = platform_is('7.5') ? 'mail' : 'sendmail';
2049:
2050: return (bool)$this->getServiceValue($which, 'enabled');
2051: }
2052:
2053: return $this->enabled('smtp') && $this->enabled('imap');
2054: }
2055:
2056: /**
2057: * Merge issued certificates into haproxy's SNI
2058: *
2059: * @param string|array|null one or more sites to cherry-pick SSL from
2060: * @return bool
2061: */
2062: public function merge_ssl($site = null) {
2063: if (!IS_CLI) {
2064: return $this->query('email_merge_ssl', $site);
2065: }
2066:
2067: if (!MAIL_PROXY) {
2068: return warn('No mail proxy installed');
2069: }
2070: if (!$site) {
2071: $site = Enumerate::sites();
2072: }
2073: try {
2074: $sites = array_map(static function ($s) {
2075: if (!($site = Auth::get_site_id_from_anything($s))) {
2076: throw new \Exception("Unknown site `${s}'");
2077: }
2078: return 'site' . $site;
2079: }, (array)$site);
2080: } catch (\Exception $e) {
2081: return error($e->getMessage());
2082: }
2083:
2084: $status = true;
2085: foreach ($sites as $site) {
2086: $context = Auth::context(null, $site);
2087: $afi = apnscpFunctionInterceptor::factory($context);
2088: if (!$afi->ssl_key_exists()) {
2089: continue;
2090: }
2091: if (!($ssl = $afi->ssl_get_certificates())) {
2092: continue;
2093: }
2094: $fst = $context->domain_fs_path();
2095: if ( !($pem = Ssl::unify($ssl[0], $fst)) ) {
2096: $status &= error('Failed to unify SSL data into pem: %s', $site);
2097: continue;
2098: }
2099: $pemfile = static::SSL_PROXY_DIR . "/${site}.pem";
2100: if (!file_put_contents($pemfile, $pem)) {
2101: file_exists($pemfile) && unlink($pemfile);
2102: $status &= error("Failed to populate SSL for `%s'", $site);
2103: }
2104: }
2105: call_user_func([\Opcenter\Mail::serviceClass(MAIL_PROXY), 'reload']);
2106:
2107: return $status;
2108:
2109: }
2110:
2111: /**
2112: * Update forwarded e-mail dependencies on user change
2113: *
2114: * @param $user
2115: * @param $usernew
2116: * @return int number mailboxes changed, -1 if update fails
2117: */
2118: private function _update_email_aliases($user, $usernew)
2119: {
2120: $prepfunc = static function ($domain) use ($user) {
2121: return '\b' . preg_quote($user, '/') . '@(' . preg_quote($domain, '/') . ')\b';
2122: };
2123:
2124: $regexcb = static function ($matches) use ($usernew) {
2125: return $usernew . '@' . $matches[1];
2126: };
2127:
2128: $domains = $this->list_virtual_transports();
2129: $regex = '/' . join('|', array_map($prepfunc, $domains)) . '/S';
2130:
2131: $forwards = $this->list_mailboxes(self::MAILBOX_FORWARD);
2132: $changed = 0;
2133: foreach ($forwards as $forward) {
2134: $cnt = 0;
2135: $new = preg_replace_callback($regex, $regexcb, $forward['destination'], -1, $cnt);
2136: if ($cnt < 1) {
2137: continue;
2138: }
2139: if ($this->modify_mailbox(
2140: $forward['user'],
2141: $forward['domain'],
2142: $forward['user'],
2143: $forward['domain'],
2144: $new,
2145: $forward['type']
2146: )
2147: ) {
2148: if ($changed > -1) {
2149: $changed++;
2150: }
2151: } else {
2152: warn('failed to adjust mailbox `%s@%s`', $forward['user'], $forward['domain']);
2153: $changed = -1;
2154: }
2155:
2156: }
2157:
2158: return $changed;
2159: }
2160:
2161: public function list_virtual_transports()
2162: {
2163: $virtual = array();
2164: $res = \PostgreSQL::initialize()->query('SELECT domain FROM domain_lookup WHERE site_id = ' . $this->site_id);
2165: while (null !== ($row = $res->fetch_object())) {
2166: $virtual[] = trim($row->domain);
2167: }
2168:
2169: return $virtual;
2170: }
2171:
2172: private function _addMTA($ip)
2173: {
2174: $hosts = file(Dns_Module::HOSTS_FILE, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
2175: $regex = Regex::compile(
2176: Regex::EMAIL_MTA_IP_RECORD,
2177: array(
2178: 'ip' => preg_quote($ip, '/')
2179: )
2180: );
2181: foreach ($hosts as $host) {
2182: if (preg_match($regex, $host)) {
2183: return -1;
2184: }
2185: }
2186:
2187: $hosts[] = $ip . ' internal-multihome';
2188: $hosts[] = '';
2189:
2190: return file_put_contents(Dns_Module::HOSTS_FILE, join(PHP_EOL, $hosts), LOCK_EX) !== false;
2191: }
2192:
2193: public function _delete_user(string $user)
2194: {
2195: foreach ($this->list_mailboxes(self::MAILBOX_DESTINATION, $user) as $mailbox) {
2196: $this->delete_mailbox($mailbox['user'], $mailbox['domain']);
2197: }
2198: }
2199:
2200: public function permit_user($user, $svc = null)
2201: {
2202: if ($svc && $svc != 'smtp' && $svc != 'imap' && $svc != 'smtp_relay' && $svc !== 'pop3') {
2203: return error('service ' . $svc . ' is unknown (imap, smtp, pop3)');
2204: }
2205:
2206: if ($this->auth_is_demo()) {
2207: return error('Email disabled for demo account');
2208: }
2209:
2210: $pam = new Util_Pam($this->getAuthContext());
2211: if (!$svc) {
2212: $pam->add($user, 'imap');
2213: $svc = 'smtp_relay';
2214: } else if ($svc == 'smtp') {
2215: $svc = 'smtp_relay';
2216: } else if (platform_is('7.5')) {
2217: //
2218: $mirror = $svc === 'imap' ? 'pop3' : 'imap';
2219: $pam->add($user, $mirror);
2220: }
2221:
2222: return $pam->add($user, $svc);
2223: }
2224:
2225: public function deny_user($user, $svc = null)
2226: {
2227: if ($svc && $svc != 'smtp' && $svc != 'imap' && $svc != 'smtp_relay' && $svc !== 'pop3') {
2228: return error('service ' . $svc . ' not in list');
2229: }
2230: $pam = new Util_Pam($this->getAuthContext());
2231: if (!$svc) {
2232: $pam->remove($user, 'smtp');
2233: $svc = 'imap';
2234: } else if ($svc == 'smtp') {
2235: $svc = 'smtp_relay';
2236: }
2237: // v7.5 doesn't differentiate between IMAP/POP3 yet
2238: if ($svc === 'imap' && platform_is('7.5')) {
2239: $pam->remove($user, 'pop3');
2240: } else if ($svc === 'pop3' && platform_is('7.5')) {
2241: $pam->remove($user, 'imap');
2242: }
2243:
2244: return $pam->remove($user, $svc);
2245: }
2246:
2247: /**
2248: * Generate new SRS secret
2249: *
2250: * @return bool
2251: */
2252: public function roll_srs(): bool
2253: {
2254: if (!Postsrsd::exists()) {
2255: return error("Service %(name)s does not exist", ['name' => 'postsrsd']);
2256: }
2257:
2258: if (!Postsrsd::running()) {
2259: return error("Service %(name)s is inactive", ['name' => 'postsrsd']);
2260: }
2261:
2262: return Postsrsd::roll();
2263: }
2264:
2265: /**
2266: * Convert mailbox using Dovecot
2267: *
2268: * @param string $src source mail location
2269: * @param string $dest target Maildir path
2270: * @param array $args doveadm-sync flags
2271: * @return bool
2272: */
2273: public function convert_mailbox(string $src, string $dest = '~/' . Storage::MAILDIR_HOME, array $args = []): bool
2274: {
2275: if (!IS_CLI) {
2276: return $this->query('email_convert_mailbox', $src, $dest, $args);
2277: }
2278:
2279: $defaultArgs = [
2280: 'to' => Storage::FORMAT,
2281: 'from' => 'mdbox',
2282: 'single' => false,
2283: 'oneway' => false,
2284: 'reverse' => false,
2285: 'full' => false,
2286: 'purge' => false,
2287: 'debug' => is_debug(),
2288: 'begin' => null,
2289: 'end' => null
2290: ];
2291:
2292: $args += $defaultArgs;
2293: if (!Dovecot::exists()) {
2294: return error("Mail disabled on host");
2295: }
2296:
2297: if (!($stat = $this->file_stat($src))) {
2298: return error("Path `%s' does not exist", $src);
2299: }
2300:
2301: if (!$stat['can_write']) {
2302: return error("Path is not writeable");
2303: }
2304:
2305: if (!($stat = $this->file_stat(dirname($dest)))) {
2306: return error("Destination parent does not exist");
2307: }
2308:
2309: if (!$stat['can_write']) {
2310: return error("Destination is not writeable");
2311: }
2312:
2313: if (null !== $args['begin'] && (!is_int($args['begin']) || $args['begin'] < 0 || $args['begin'] > ($args['end'] ?? PHP_INT_MAX))) {
2314: return error("Invalid begin timestamp");
2315: }
2316:
2317: if (null !== $args['end'] && (!is_int($args['end']) || $args['end'] < 0 || $args['end'] < $args['begin'])) {
2318: return error("Invalid end timestamp");
2319: }
2320:
2321: $types = ['mbox', 'maildir', 'mdbox', 'sdbox'];
2322: if (!in_array($args['to'], $types, true)) {
2323: return error("Unknown target format `%s'", $args['to']);
2324: }
2325:
2326: if (!in_array($args['from'], $types, true)) {
2327: return error("Unknown source format `%s'", $args['from']);
2328: }
2329:
2330: $cmd = '/usr/bin/doveadm %(debug)s -o mail_location=%(out)s:%(dest)s sync ' .
2331: ($args['begin'] ? '-t ' . $args['begin'] : '') . ' ' .
2332: ($args['end'] ? '-e ' . $args['end'] : '') . ' %(reverse)s %(oneway)s %(full)s %(purge)s -u %(user)s@%(domain)s %(in)s:%(path)s';
2333: $ret = \Util_Process_Safe::exec($cmd, [
2334: 'uid' => $stat['uid'],
2335: 'user' => $stat['owner'],
2336: 'domain' => $this->domain,
2337: 'in' => $args['from'],
2338: 'out' => $args['to'],
2339: 'path' => $src,
2340: 'dest' => $dest,
2341: 'reverse' => !empty($args['reverse']) ? '-R' : null,
2342: 'oneway' => !empty($args['oneway']) ? '-1' : null,
2343: 'full' => !empty($args['full']) ? '-f' : null,
2344: 'debug' => !empty($args['debug']) ? '-D' : null,
2345: 'purge' => !empty($args['purge']) ? '-P' : null
2346: ], [0,2]);
2347:
2348: if (!$ret['success']) {
2349: return error(coalesce($ret['stderr'], $ret['stdout']));
2350: }
2351:
2352: return $ret['return'] === 0 || warn("Conversion partially succeeded: %s",
2353: coalesce($ret['stderr'], $ret['stdout']));
2354: }
2355:
2356: public function _verify_conf(ConfigurationContext $ctx): bool
2357: {
2358: return true;
2359: }
2360:
2361: public function _cron(Cronus $c)
2362: {
2363: $c->schedule(MAIL_SRS_AUTOROLL ?: null, 'email.roll-srs-key', $this->roll_srs(...));
2364: }
2365:
2366: public function _housekeeping()
2367: {
2368: $dummyfile = webapp_path('webmail/dummyset.php');
2369: $dest = '/var/www/html/dummyset.php';
2370: if (!file_exists($dest) || fileinode($dummyfile) !== fileinode($dest)) {
2371: file_exists($dest) && unlink($dest);
2372: $apnscpHome = realpath(INCLUDE_PATH);
2373: if (!Filesystem\Mount::sameMount('/var/www/html', $apnscpHome)) {
2374: warn("/var and %s are on different mount points - copying dummyset", $apnscpHome);
2375: copy($dummyfile, $dest);
2376: } else {
2377: link($dummyfile, $dest);
2378: }
2379: }
2380: return true;
2381: }
2382:
2383: protected function buildWarningTemplates(): void
2384: {
2385: $path = '/usr/libexec/dovecot/quota-warning.sh';
2386:
2387: $template = new \Opcenter\Provisioning\ConfigurationWriter('mail.quota-warning-command', null);
2388: if (!$template->shouldRefresh($path)) {
2389: return;
2390: }
2391:
2392: $template->write($path) && Filesystem::chogp($path, 0, 0, 0755);
2393: }
2394: }