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