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: 'user_permitted' => PRIVILEGE_SITE | PRIVILEGE_USER,
101: 'get_mail_ip' => PRIVILEGE_SITE | PRIVILEGE_USER,
102: 'user_mailboxes' => PRIVILEGE_SITE | PRIVILEGE_USER,
103: 'convert_mailbox' => PRIVILEGE_SITE | PRIVILEGE_USER,
104: 'roll_srs' => PRIVILEGE_ADMIN,
105: '*' => PRIVILEGE_SITE,
106: 'get_provider' => PRIVILEGE_ALL,
107: 'configured' => PRIVILEGE_ALL,
108: 'providers' => PRIVILEGE_ADMIN,
109: 'merge_ssl' => PRIVILEGE_ADMIN,
110: ];
111:
112:
113: public function _proxy(): \Module_Skeleton
114: {
115: $provider = $this->get_provider();
116:
117: if ($provider === \Opcenter\Service\Contracts\DefaultNullable::NULLABLE_MARKER) {
118: // BUG. An account's provider is substituted with the provider default at creation
119: // Check for an in-place upgrade. If plan wasn't substituted because no prior def exists
120: // the marker is used.
121: $provider = \Opcenter\Mail::default();
122: }
123:
124: if ($provider === 'builtin') {
125: return $this;
126: }
127:
128: return Provider::get('mail', $provider, $this->getAuthContext());
129: }
130:
131: /**
132: * Get DNS provider
133: *
134: * @return string
135: */
136: public function get_provider(): string
137: {
138: $provider = $this->getServiceValue('mail', 'provider', \Opcenter\Mail::default());
139:
140: if ($provider === \Opcenter\Service\Contracts\DefaultNullable::NULLABLE_MARKER) {
141: // BUG. An account's provider is substituted with the provider default at creation
142: // Check for an in-place upgrade. If plan wasn't substituted because no prior def exists
143: // the marker is used.
144: $provider = \Opcenter\Mail::default();
145: }
146:
147: if ($this->permission_level & PRIVILEGE_SITE|PRIVILEGE_USER) {
148: if (self::class !== static::class && !$this->enabled()) {
149: // block _proxy() load, which calls this helper in early init
150: return 'null';
151: }
152: return $provider;
153: }
154:
155: return \Opcenter\Mail::default();
156: }
157:
158: /**
159: * Mail configured for account
160: *
161: * @return bool
162: */
163: public function configured(): bool
164: {
165: return $this->get_provider() === 'builtin';
166: }
167:
168: /**
169: * Get known mail providers
170: *
171: * @return array
172: */
173: public function providers(): array
174: {
175: return \Opcenter\Mail::providers();
176: }
177:
178: public function list_aliases()
179: {
180: return $this->list_mailboxes('forward');
181: }
182:
183: /**
184: * List all mailboxes deliverable to user
185: *
186: * @param string|null $username
187: */
188: public function user_mailboxes(string $username = null)
189: {
190: if ($username && ($this->permission_level & PRIVILEGE_USER)) {
191: return error('%(param)s disallowed as user', ['$username']);
192: }
193: $username = $username ?? $this->username;
194: if (!$this->user_exists($username)) {
195: return error('user %s does not exist', $username);
196: }
197:
198: if (!$uid = $this->user_get_uid_from_username($username)) {
199: return false;
200: }
201: // @TODO recursively solve alias destinations
202: $q = 'SELECT
203: CONCAT("user", \'@\', e1."domain") AS email
204: FROM email_lookup e1
205: JOIN domain_lookup USING (domain)
206: WHERE
207: domain_lookup.site_id = ' . $this->site_id . ' AND
208: uid = ' . $uid . ' AND type = \'' . self::MAILBOX_USER . '\'';
209:
210: $addresses = [];
211:
212: $pgdb = \PostgreSQL::initialize();
213: $pgdb->query($q);
214: while (null !== ($row = $pgdb->fetch_object())) {
215: $addresses[] = $row->email;
216: }
217: return $addresses;
218: }
219:
220: /**
221: * Retrieve mailbox delivery maps from system
222: *
223: * @param $filter string optional filter, possible values: forward, local, special, single, enabled, disabled, destination
224: * @param $address string supplementary argument to 'single', restrict address to %expr%. Mandatory for destination filter type
225: * @param $domain string optionally restrict to all addresses matching domain
226: *
227: * @return array
228: *
229: */
230: public function list_mailboxes($filter = null, $address = null, $domain = null)
231: {
232: $filter_clause = '1=1';
233:
234:
235: if ($filter == 'forward') {
236: $filter = self::MAILBOX_FORWARD;
237: } else if ($filter == 'local') {
238: $filter = self::MAILBOX_USER;
239: } else if ($filter == 'special') {
240: $filter = self::MAILBOX_SPECIAL;
241: } else if ($filter == 'disabled') {
242: $filter = self::MAILBOX_DISABLED;
243: } else if ($filter == 'enabled') {
244: $filter = self::MAILBOX_ENABLED;
245: }
246:
247: if ($filter && !in_array($filter, array(
248: self::MAILBOX_FORWARD,
249: self::MAILBOX_USER,
250: self::MAILBOX_SPECIAL,
251: self::MAILBOX_DISABLED,
252: self::MAILBOX_ENABLED,
253: self::MAILBOX_SINGLE,
254: self::MAILBOX_DESTINATION
255: ))
256: ) {
257: return error("invalid filter specification `%s'", $filter);
258: }
259:
260: if ($filter == self::MAILBOX_FORWARD) {
261: $filter_clause = 'type = \'' . self::MAILBOX_FORWARD . '\'';
262: } else if ($filter == self::MAILBOX_USER) {
263: $filter_clause = 'type = \'' . self::MAILBOX_USER . '\'';
264: } else if ($filter == self::MAILBOX_SPECIAL) {
265:
266: } else if ($filter == self::MAILBOX_SINGLE) {
267: $filter_clause = 'email_lookup."user" ' . (false !== strpos($address,
268: '%') ? 'LIKE' : '=') . ' \'' . pg_escape_string($address) . '\'';
269: } else if ($filter == self::MAILBOX_ENABLED) {
270: $filter_clause = 'enabled = 1::bit';
271: } else if ($filter == self::MAILBOX_DISABLED) {
272: $filter_clause = 'enabled = 0::bit';
273: } else if ($filter == self::MAILBOX_DESTINATION) {
274: $filter_clause = 'COALESCE(uids."user",alias_destination) = ' . pg_escape_literal($address);
275: }
276:
277: if (null !== $address && $filter !== self::MAILBOX_DESTINATION) {
278: // @TODO nasty
279: $filter_clause .= ' AND email_lookup.user = \'' . pg_escape_string(strtolower($address)) . '\'';
280: }
281: if ($domain) {
282: $filter_clause .= ' AND email_lookup.domain = \'' . pg_escape_string(strtolower($domain)) . '\'';
283: }
284: $mailboxes = array();
285: $query = '
286: SELECT
287: email_lookup."user",
288: email_lookup.domain as domain,
289: type,
290: enabled,
291: fs_destination AS target,
292: uid,
293: COALESCE(uids."user",alias_destination) as destination
294: FROM
295: email_lookup
296: JOIN
297: domain_lookup
298: ON
299: (email_lookup.domain = domain_lookup.domain)
300: LEFT JOIN
301: uids
302: USING(uid)
303: WHERE
304: (domain_lookup.site_id = ' . $this->site_id . ') AND ' . $filter_clause . ' ORDER BY "user", domain;';
305: $pgdb = \PostgreSQL::initialize();
306: $pgdb->query($query);
307: while (null !== ($row = $pgdb->fetch_object())) {
308: $mailboxes[] = array(
309: 'user' => trim($row->user),
310: 'domain' => trim($row->domain),
311: 'type' => $row->type,
312: 'enabled' => (int)$row->enabled,
313: 'mailbox' => $row->destination,
314: 'uid' => (int)$row->uid,
315: 'custom' => ($filter === 'local' ? $row->target : null),
316: 'destination' => $row->destination
317: );
318: }
319:
320: return $mailboxes;
321: }
322:
323: public function enable_address($account, $domain = null)
324: {
325: $where = 'AND email_lookup.domain = domain_lookup.domain AND domain_lookup.site_id = ' . $this->site_id;
326: if ($domain) {
327: $where .= ' AND domain_lookup.domain = \'' . pg_escape_string($domain) . '\'';
328: }
329: $pgdb = \PostgreSQL::initialize();
330: $pgdb->query('UPDATE email_lookup SET enabled = 1::bit FROM domain_lookup WHERE "user" = \'' . pg_escape_string($account) . '\' ' . $where . ';');
331:
332: return $pgdb->affected_rows() > 0;
333: }
334:
335: /**
336: * @deprecated @link modify_mailbox
337: */
338: public function rename_mailbox($olduser, $olddomain, $newuser, $newdomain, $newmailbox, $newtype = null)
339: {
340: return $this->modify_mailbox($olduser, $olddomain, $newuser, $newdomain, $newmailbox, $newtype);
341: }
342:
343: /**
344: * Rename a mailbox
345: *
346: * IMPORTANT: a mailbox may not be remapped into a catchall here
347: *
348: * @param string $olduser
349: * @param string $olddomain
350: * @param string $newuser
351: * @param string $newdomain
352: * @param string $newdestination username or integer
353: * @param string|null $newtype
354: * @return bool
355: */
356: public function modify_mailbox(
357: string $olduser,
358: string $olddomain,
359: string $newuser = '',
360: string $newdomain = '',
361: string $newdestination = '',
362: string $newtype = null
363: ): bool {
364: $args = array(
365: 'olduser',
366: 'olddomain',
367: 'newuser',
368: 'newdomain',
369: 'newtype'
370: );
371:
372: foreach ($args as $var) {
373: ${$var} = strtolower((string)${$var});
374: }
375: if (!$newuser && !$newdomain) {
376: $newuser = $olduser;
377: $newdomain = $olddomain;
378: }
379: if ($olduser === 'majordomo' && $this->majordomo_enabled() && $this->majordomo_list_mailing_lists()) {
380: return error('cannot remove majordomo email address while mailing lists exist');
381: }
382:
383: if ($olduser && !$this->address_exists($olduser, $olddomain)) {
384: return error("Address `%s@%s' does not exist", $olduser, $olddomain);
385: }
386:
387: if ($newuser && !preg_match(Regex::EMAIL, "{$newuser}@{$newdomain}")) {
388: return error("Invalid email `%s'", "{$newuser}@{$newdomain}");
389: }
390:
391: if (($olduser . '@' . $olddomain != $newuser . '@' . $newdomain) && $this->address_exists($newuser,
392: $newdomain)
393: ) {
394: return error("Email address %s@%s already exists. Can't rename!",
395: $newuser, $newdomain);
396: }
397:
398: if (!$this->transport_exists($olddomain)) {
399: return error("Mail domain `%s' not bound to account", $olddomain);
400: }
401:
402: if (!$this->transport_exists($newdomain)) {
403: return error("Mail domain `%s' not bound to account", $newdomain);
404: }
405:
406: if (!$newtype) {
407: $newtype = $this->mailbox_type($olduser, $olddomain);
408: }
409:
410: if ($newtype === self::MAILBOX_FORWARD && ($conflicts = $this->checkForwarding($newdestination))) {
411: return error('Remote forwarding is disabled. Following addresses would violate forwarding policy: %s',
412: implode(',', $conflicts)
413: );
414: }
415:
416: $pgdb = \PostgreSQL::initialize();
417: if ($newtype === self::MAILBOX_USER) {
418: if (!ctype_digit($newdestination)) {
419: $newdestination = $this->user_get_uid_from_username($newdestination);
420: }
421: if (0 !== ($uid = (int)$newdestination)) {
422: $local_user = $this->user_get_username_from_uid($uid);
423: $newdestination = self::MAILDIR_HOME;
424:
425: if (!$local_user) {
426: return error("Invalid mailbox destination, invalid uid `%d'", $uid);
427: }
428: } else if ($newdestination) {
429: if (preg_match('!^/home/([^/]+)/' . self::MAILDIR_HOME . '([/.]*)$!', $newdestination,
430: $match)) {
431: $local_user = $match[1];
432: $newdestination = ltrim(str_replace(array('/', '..'), '.', $match[2]), '.');
433: } else {
434: $local_user = $newdestination;
435: $newdestination = null;
436: }
437: } else {
438: // user rename
439: $local_user = $newuser;
440: }
441: $local_user = strtolower($local_user);
442: $users = $this->user_get_users();
443: if (!isset($users[$local_user])) {
444: return error("User account `%s' does not exist", $local_user);
445: }
446:
447: $uid = (int)$users[$local_user]['uid'];
448: if ($newdestination == '' || $newdestination === self::MAILDIR_HOME) {
449: $newdestination = null;
450: } else {
451: $this->query('email_create_maildir_backend', $local_user, $newdestination);
452: }
453: $pgdb->query("UPDATE email_lookup SET \"user\" = '" . $newuser . "', domain = '" . $newdomain . "', " .
454: 'fs_destination = ' . (($newdestination != null) ? "'" . pg_escape_string(rtrim($newdestination,
455: ' /') . '/') . "'" : 'NULL') . ', ' .
456: 'alias_destination = NULL, uid = ' . $uid . ", type = '" . self::MAILBOX_USER . "' WHERE \"user\" = '" . pg_escape_string($olduser) . "' " .
457: "AND domain = '" . pg_escape_string($olddomain) . "';");
458: } else {
459: if (!$newuser) {
460: return error('cannot forward catch-alls to external e-mail accounts');
461: }
462: $newdestination = preg_replace('/\s+/m', ',', trim($newdestination, ' ,'));
463: if (!$newdestination) {
464: return error('no forwarding destination set for `%s@%s`', $newuser, $newdomain);
465: }
466: $pgdb->query("UPDATE email_lookup SET \"user\" = '" . pg_escape_string($newuser) . "', domain = '" . pg_escape_string($newdomain) . "', " .
467: "alias_destination = '" . pg_escape_string($newdestination) . "', uid = NULL, type = '" .
468: self::MAILBOX_FORWARD . "', fs_destination = NULL WHERE \"user\" = '" .
469: pg_escape_string($olduser) . "' AND domain = '" . pg_escape_string($olddomain) . "';");
470:
471: }
472: $rows = $pgdb->affected_rows();
473: $this->_shutdown_save_mailboxes();
474:
475: return $rows > 0;
476: }
477:
478: /**
479: * Validate an input type if forwarded
480: *
481: * @param $destination
482: * @return null|array
483: */
484: protected function checkForwarding($destination): ?array {
485: /**
486: * Presently a loophole exists that would allow a domain to be attached to an account for mail,
487: * forwarding aliases created, then that domain detached. Denying such a task piles admin
488: * duties upon account holder to detach all aliases from an email account.
489: */
490: if ($this->getServiceValue('mail', 'extfwd', !MAIL_DISABLED_FORWARDING)) {
491: return null;
492: }
493:
494: if (!is_array($destination)) {
495: $destination = preg_split('/\s*,+\s*/', $destination);
496: }
497:
498: $bad = [];
499: $whitelisted = [];
500: foreach ($destination as $chk) {
501: if (false === ($pos = strpos($chk, '@'))) {
502: continue;
503: }
504: $domain = substr($chk, ++$pos);
505: if (!isset($whitelisted[$domain])) {
506: $whitelisted[$domain] = $this->transport_exists($domain);
507: }
508:
509: if (!$whitelisted[$domain]) {
510: $bad[] = $chk;
511: }
512: }
513:
514: return $bad;
515: }
516:
517: public function address_exists($user, $domain)
518: {
519: $user = strtolower($user);
520: $domain = strtolower($domain);
521: if ($user && !preg_match(Regex::EMAIL, "{$user}@{$domain}")) {
522: return false;
523: }
524: $pgdb = \PostgreSQL::initialize();
525: $pgdb->query('SELECT 1 FROM email_lookup JOIN domain_lookup ON (site_id = ' . $this->site_id . ') ' .
526: "WHERE \"user\" = '" . pg_escape_string($user) . "' AND email_lookup.domain = '" . pg_escape_string($domain) . "'");
527:
528: return $pgdb->num_rows() > 0;
529: }
530:
531: /**
532: * Get mailbox type
533: *
534: * @param $user
535: * @param $domain
536: * @return bool|null|string
537: * @throws PostgreSQLError
538: */
539: public function mailbox_type($user, $domain)
540: {
541: $user = strtolower($user);
542: $domain = strtolower($domain);
543: if (!preg_match(Regex::EMAIL, $user . '@' . $domain)) {
544: return error('invalid address `' . $user . '@' . $domain . "'");
545: }
546: $pgdb = \PostgreSQL::initialize();
547: $pgdb->query("SELECT type FROM email_lookup WHERE \"user\" = '" . $user . "' AND domain = '" . $domain . "'");
548:
549: if ($pgdb->num_rows() < 1) {
550: return null;
551: }
552:
553: return $pgdb->fetch_object()->type;
554: }
555:
556: private function _shutdown_save_mailboxes()
557: {
558: if (!IS_ISAPI) {
559: $this->save_mailboxes();
560: }
561: static $called;
562: if (isset($called)) {
563: return;
564: }
565: $called = 1;
566:
567: return register_shutdown_function(array($this, 'save_mailboxes'));
568: }
569:
570: /**
571: * Save all mailboxes to a serialized file
572: *
573: * @see restore_mailboxes()
574: *
575: * @return boolean
576: */
577: public function save_mailboxes()
578: {
579: if (!IS_CLI) {
580: if (!\apnscpSession::init()->exists($this->session_id)) {
581: // check session is active. Unit tests can trigger this.
582: return true;
583: }
584: return $this->query('email_save_mailboxes');
585: }
586:
587: if (static::class !== self::class) {
588: return true;
589: }
590:
591: $path = $this->domain_info_path();
592: if (!is_dir($path)) {
593: // site deleted, ignore save
594: return true;
595: }
596: $path .= '/' . self::MAILBOX_SAVE_DEFAULT;
597: $email = $this->dump_mailboxes();
598:
599: return (bool)file_put_contents($path, serialize($email), LOCK_EX);
600: }
601:
602: /**
603: * List all mailboxes for backup/restore purposes
604: *
605: * @return array
606: * @throws PostgreSQLError
607: */
608: public function dump_mailboxes(): array {
609: $q = 'SELECT * FROM email_lookup WHERE domain IN
610: (select domain FROM domain_lookup WHERE site_id = ' . $this->site_id . ')';
611: $db = \PostgreSQL::pdo();
612: $email = array();
613: $rs = $db->query($q);
614: while ($row = $rs->fetch(PDO::FETCH_ASSOC)) {
615: $email[] = array_map(fn($i) => is_string($i) ? trim($i) : $i, $row);
616: }
617: return $email;
618: }
619: /**
620: * Remove an e-mail alias
621: *
622: * @param string $user
623: * @param string $domain
624: */
625: public function remove_alias($user, $domain)
626: {
627: return $this->delete_mailbox($user, $domain, self::MAILBOX_FORWARD);
628: }
629:
630: public function delete_mailbox($user, $domain, $type = '')
631: {
632: $type = strtolower($type);
633: if ($type == 'l' || $type == self::MAILBOX_USER) {
634: $type = self::MAILBOX_USER;
635: } else if ($type == 'f' || $type == self::MAILBOX_FORWARD) {
636: $type = self::MAILBOX_FORWARD;
637: } else if ($type != '') {
638: return error("unknown address type `%s'", $type);
639: }
640: /**
641: * otherwise we can clog up an mqueue pretty fast
642: */
643: if ($user === 'majordomo' && $this->majordomo_enabled() && $this->majordomo_list_mailing_lists()) {
644: return error('cannot remove majordomo email address while mailing lists exist');
645: }
646:
647: $clause = '';
648: if ($type) {
649: $clause = "AND type = '$type' ";
650: }
651: $pgdb = \PostgreSQL::initialize();
652: $pgdb->query('DELETE FROM
653: email_lookup
654: WHERE
655: "user" = \'' . pg_escape_string($user) . "'
656: AND
657: domain = '" . pg_escape_string($domain) . "'
658: $clause
659: AND '" . pg_escape_string($domain) . "' IN
660: (SELECT domain from domain_lookup WHERE site_id = " . $this->site_id . ');');
661: $rows = $pgdb->affected_rows();
662: $this->_shutdown_save_mailboxes();
663:
664: return $rows > 0;
665: }
666:
667: public function get_mailbox($user, $domain)
668: {
669: $address = $this->list_mailboxes(self::MAILBOX_SINGLE, $user, $domain);
670:
671: return $address ? array_pop($address) : array();
672: }
673:
674: public function remove_maildir($mailbox)
675: {
676: // assume remove_maildir() is only called by the owner
677: if (!IS_CLI && posix_getuid()) {
678: return $this->query('email_remove_maildir', $mailbox);
679: }
680: $mailbox = trim($mailbox);
681: if ($mailbox[0] != '.') {
682: $mailbox = '.' . $mailbox;
683: }
684: if (!preg_match(Regex::EMAIL_MAILDIR_FOLDER, $mailbox)) {
685: return error("invalid maildir folder name `%s'", $mailbox);
686: }
687: $home = $this->user_get_user_home();
688: $path = join(DIRECTORY_SEPARATOR, array($home, self::MAILDIR_HOME, $mailbox));
689: if (!$this->file_delete($path, true)) {
690: return error("failed to remove maildir `%s'", $mailbox);
691: }
692:
693: $subscriptions = join(DIRECTORY_SEPARATOR,
694: array(
695: $this->domain_fs_path(),
696: $home,
697: self::MAILDIR_HOME,
698: 'subscriptions'
699: )
700: );
701: $sname = trim($mailbox, '.');
702: if (!file_exists($subscriptions)) {
703: $contents = array();
704: } else {
705: $contents = file($subscriptions, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
706: }
707: if (false === ($key = array_search($sname, $contents))) {
708: return true;
709: }
710: unset($contents[$key]);
711: file_put_contents($subscriptions, join("\n", $contents) . "\n");
712:
713: return Filesystem::chogp($subscriptions, $this->user_id, $this->group_id, 0600);
714:
715: }
716:
717: /**
718: * Restore a saved copy of mailboxes
719: *
720: * @return boolean
721: */
722: public function restore_mailboxes(string $file = self::MAILBOX_SAVE_DEFAULT): bool
723: {
724: if (!IS_CLI && posix_getuid()) {
725: return $this->query('email_restore_mailboxes', $file);
726: }
727: if (!preg_match('/^[\w_-]+$/', $file)) {
728: return error("invalid mailbox backup `%s'", $file);
729: }
730: $file = $this->domain_info_path() . '/' . $file;
731: if (!file_exists($file)) {
732: return error("mailbox backup `%s' not found", basename($file));
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 && !$this->getServiceValue('mail', 'catchallfwd', 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_permitted($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: public function user_enabled($user = null, $svc = null)
2002: {
2003: deprecated_func('Use user_permitted');
2004: return $this->user_permitted($user, $svc);
2005: }
2006: /**
2007: * Mail service is enabled for user
2008: *
2009: * @return bool
2010: */
2011: public function user_permitted($user = null, $svc = null)
2012: {
2013: if (!$user || ($this->permission_level & PRIVILEGE_USER)) {
2014: $user = $this->username;
2015: }
2016: if ($svc && $svc != 'imap' && $svc != 'smtp' && $svc != 'smtp_relay' && $svc !== 'pop3') {
2017: return error("unknown service `%s'", $svc);
2018: }
2019: if (!$this->enabled($svc)) {
2020: return false;
2021: }
2022: $enabled = 1;
2023: if (!$svc) {
2024: $enabled = (new Util_Pam($this->getAuthContext()))->check($user, 'imap');
2025: $svc = 'smtp_relay';
2026: } else if ($svc == 'smtp') {
2027: $svc = 'smtp_relay';
2028: }
2029:
2030: return $enabled && (new Util_Pam($this->getAuthContext()))->check($user, $svc);
2031: }
2032:
2033: /**
2034: * Verify service is enabled
2035: *
2036: * @param null|string $which
2037: * @return bool
2038: */
2039: public function enabled(string $which = null): bool
2040: {
2041: // @TODO rename sendmail to smtp service
2042: if (platform_is('7.5')) {
2043: $which = $which === 'smtp_relay' ? 'smtp' : $which;
2044: } else {
2045: $which = $which === 'smtp' ? 'smtp_relay' : $which;
2046: }
2047: if ($which && $which !== 'smtp' && $which !== 'smtp_relay' && $which !== 'imap' && $which !== 'pop3') {
2048: return error("unknown service `%s'", $which);
2049: }
2050: if ($which) {
2051: $which = platform_is('7.5') ? 'mail' : 'sendmail';
2052:
2053: return (bool)$this->getServiceValue($which, 'enabled');
2054: }
2055:
2056: return $this->enabled('smtp') && $this->enabled('imap');
2057: }
2058:
2059: /**
2060: * Merge issued certificates into haproxy's SNI
2061: *
2062: * @param string|array|null one or more sites to cherry-pick SSL from
2063: * @return bool
2064: */
2065: public function merge_ssl($site = null) {
2066: if (!IS_CLI) {
2067: return $this->query('email_merge_ssl', $site);
2068: }
2069:
2070: if (!MAIL_PROXY) {
2071: return warn('No mail proxy installed');
2072: }
2073: if (!$site) {
2074: $site = Enumerate::sites();
2075: }
2076: try {
2077: $sites = array_map(static function ($s) {
2078: if (!($site = Auth::get_site_id_from_anything($s))) {
2079: throw new \Exception("Unknown site `{$s}'");
2080: }
2081: return 'site' . $site;
2082: }, (array)$site);
2083: } catch (\Exception $e) {
2084: return error($e->getMessage());
2085: }
2086:
2087: $status = true;
2088: foreach ($sites as $site) {
2089: $context = Auth::context(null, $site);
2090: $afi = apnscpFunctionInterceptor::factory($context);
2091: if (!$afi->ssl_key_exists()) {
2092: continue;
2093: }
2094: if (!($ssl = $afi->ssl_get_certificates())) {
2095: continue;
2096: }
2097: $fst = $context->domain_fs_path();
2098: if ( !($pem = Ssl::unify($ssl[0], $fst)) ) {
2099: $status &= error('Failed to unify SSL data into pem: %s', $site);
2100: continue;
2101: }
2102: $pemfile = static::SSL_PROXY_DIR . "/{$site}.pem";
2103: if (!file_put_contents($pemfile, $pem)) {
2104: file_exists($pemfile) && unlink($pemfile);
2105: $status &= error("Failed to populate SSL for `%s'", $site);
2106: }
2107: }
2108: call_user_func([\Opcenter\Mail::serviceClass(MAIL_PROXY), 'reload']);
2109:
2110: return $status;
2111:
2112: }
2113:
2114: /**
2115: * Update forwarded e-mail dependencies on user change
2116: *
2117: * @param $user
2118: * @param $usernew
2119: * @return int number mailboxes changed, -1 if update fails
2120: */
2121: private function _update_email_aliases($user, $usernew)
2122: {
2123: $prepfunc = static function ($domain) use ($user) {
2124: return '\b' . preg_quote($user, '/') . '@(' . preg_quote($domain, '/') . ')\b';
2125: };
2126:
2127: $regexcb = static function ($matches) use ($usernew) {
2128: return $usernew . '@' . $matches[1];
2129: };
2130:
2131: $domains = $this->list_virtual_transports();
2132: $regex = '/' . join('|', array_map($prepfunc, $domains)) . '/S';
2133:
2134: $forwards = $this->list_mailboxes(self::MAILBOX_FORWARD);
2135: $changed = 0;
2136: foreach ($forwards as $forward) {
2137: $cnt = 0;
2138: $new = preg_replace_callback($regex, $regexcb, $forward['destination'], -1, $cnt);
2139: if ($cnt < 1) {
2140: continue;
2141: }
2142: if ($this->modify_mailbox(
2143: $forward['user'],
2144: $forward['domain'],
2145: $forward['user'],
2146: $forward['domain'],
2147: $new,
2148: $forward['type']
2149: )
2150: ) {
2151: if ($changed > -1) {
2152: $changed++;
2153: }
2154: } else {
2155: warn('failed to adjust mailbox `%s@%s`', $forward['user'], $forward['domain']);
2156: $changed = -1;
2157: }
2158:
2159: }
2160:
2161: return $changed;
2162: }
2163:
2164: public function list_virtual_transports()
2165: {
2166: $virtual = array();
2167: $res = \PostgreSQL::initialize()->query('SELECT domain FROM domain_lookup WHERE site_id = ' . $this->site_id);
2168: while (null !== ($row = $res->fetch_object())) {
2169: $virtual[] = trim($row->domain);
2170: }
2171:
2172: return $virtual;
2173: }
2174:
2175: private function _addMTA($ip)
2176: {
2177: $hosts = file(Dns_Module::HOSTS_FILE, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
2178: $regex = Regex::compile(
2179: Regex::EMAIL_MTA_IP_RECORD,
2180: array(
2181: 'ip' => preg_quote($ip, '/')
2182: )
2183: );
2184: foreach ($hosts as $host) {
2185: if (preg_match($regex, $host)) {
2186: return -1;
2187: }
2188: }
2189:
2190: $hosts[] = $ip . ' internal-multihome';
2191: $hosts[] = '';
2192:
2193: return file_put_contents(Dns_Module::HOSTS_FILE, join(PHP_EOL, $hosts), LOCK_EX) !== false;
2194: }
2195:
2196: public function _delete_user(string $user)
2197: {
2198: foreach ($this->list_mailboxes(self::MAILBOX_DESTINATION, $user) as $mailbox) {
2199: $this->delete_mailbox($mailbox['user'], $mailbox['domain']);
2200: }
2201: }
2202:
2203: public function permit_user($user, $svc = null)
2204: {
2205: if ($svc && $svc != 'smtp' && $svc != 'imap' && $svc != 'smtp_relay' && $svc !== 'pop3') {
2206: return error('service ' . $svc . ' is unknown (imap, smtp, pop3)');
2207: }
2208:
2209: if ($this->auth_is_demo()) {
2210: return error('Email disabled for demo account');
2211: }
2212:
2213: if ($svc === 'smtp_relay') {
2214: $svc = 'smtp';
2215: }
2216:
2217: $pam = new Util_Pam($this->getAuthContext());
2218: if ($svc) {
2219: return $pam->add($user, $svc);
2220: }
2221:
2222: $ret = true;
2223: foreach (['imap', 'smtp', 'pop3'] as $svc) {
2224: $ret &= $pam->check($user, $svc) || $pam->add($user, $svc);
2225: }
2226:
2227: return (bool)$ret;
2228: }
2229:
2230: public function deny_user($user, $svc = null)
2231: {
2232: if ($svc && $svc != 'smtp' && $svc != 'imap' && $svc != 'smtp_relay' && $svc !== 'pop3') {
2233: return error('service ' . $svc . ' not in list');
2234: }
2235: $pam = new Util_Pam($this->getAuthContext());
2236: if (!$svc) {
2237: $pam->remove($user, 'smtp');
2238: $svc = 'imap';
2239: } else if ($svc == 'smtp') {
2240: $svc = 'smtp_relay';
2241: }
2242: // v7.5 doesn't differentiate between IMAP/POP3 yet
2243: if ($svc === 'imap' && platform_is('7.5')) {
2244: $pam->remove($user, 'pop3');
2245: } else if ($svc === 'pop3' && platform_is('7.5')) {
2246: $pam->remove($user, 'imap');
2247: }
2248:
2249: return $pam->remove($user, $svc);
2250: }
2251:
2252: /**
2253: * Generate new SRS secret
2254: *
2255: * @return bool
2256: */
2257: public function roll_srs(): bool
2258: {
2259: if (!IS_CLI) {
2260: return $this->query("email_roll_srs");
2261: }
2262:
2263: if (!Postsrsd::exists()) {
2264: return error("Service %(name)s does not exist", ['name' => 'postsrsd']);
2265: }
2266:
2267: if (!Postsrsd::running()) {
2268: return error("Service %(name)s is inactive", ['name' => 'postsrsd']);
2269: }
2270:
2271: return Postsrsd::roll();
2272: }
2273:
2274: /**
2275: * Convert mailbox using Dovecot
2276: *
2277: * @param string $src source mail location
2278: * @param string $dest target Maildir path
2279: * @param array $args doveadm-sync flags
2280: * @return bool
2281: */
2282: public function convert_mailbox(string $src, string $dest = '~/' . Storage::MAILDIR_HOME, array $args = []): bool
2283: {
2284: if (!IS_CLI) {
2285: return $this->query('email_convert_mailbox', $src, $dest, $args);
2286: }
2287:
2288: $defaultArgs = [
2289: 'to' => Storage::FORMAT,
2290: 'from' => 'mdbox',
2291: 'single' => false,
2292: 'oneway' => false,
2293: 'reverse' => false,
2294: 'full' => false,
2295: 'purge' => false,
2296: 'debug' => is_debug(),
2297: 'begin' => null,
2298: 'end' => null
2299: ];
2300:
2301: $args += $defaultArgs;
2302: if (!Dovecot::exists()) {
2303: return error("Mail disabled on host");
2304: }
2305:
2306: if (!($stat = $this->file_stat($src))) {
2307: return error("Path `%s' does not exist", $src);
2308: }
2309:
2310: if (!$stat['can_write']) {
2311: return error("Path is not writeable");
2312: }
2313:
2314: if (!($stat = $this->file_stat(dirname($dest)))) {
2315: return error("Destination parent does not exist");
2316: }
2317:
2318: if (!$stat['can_write']) {
2319: return error("Destination is not writeable");
2320: }
2321:
2322: if (null !== $args['begin'] && (!is_int($args['begin']) || $args['begin'] < 0 || $args['begin'] > ($args['end'] ?? PHP_INT_MAX))) {
2323: return error("Invalid begin timestamp");
2324: }
2325:
2326: if (null !== $args['end'] && (!is_int($args['end']) || $args['end'] < 0 || $args['end'] < $args['begin'])) {
2327: return error("Invalid end timestamp");
2328: }
2329:
2330: $types = ['mbox', 'maildir', 'mdbox', 'sdbox'];
2331: if (!in_array($args['to'], $types, true)) {
2332: return error("Unknown target format `%s'", $args['to']);
2333: }
2334:
2335: if (!in_array($args['from'], $types, true)) {
2336: return error("Unknown source format `%s'", $args['from']);
2337: }
2338:
2339: $cmd = '/usr/bin/doveadm %(debug)s -o mail_location=%(out)s:%(dest)s sync ' .
2340: ($args['begin'] ? '-t ' . $args['begin'] : '') . ' ' .
2341: ($args['end'] ? '-e ' . $args['end'] : '') . ' %(reverse)s %(oneway)s %(full)s %(purge)s -u %(user)s@%(domain)s %(in)s:%(path)s';
2342: $ret = \Util_Process_Safe::exec($cmd, [
2343: 'uid' => $stat['uid'],
2344: 'user' => $stat['owner'],
2345: 'domain' => $this->domain,
2346: 'in' => $args['from'],
2347: 'out' => $args['to'],
2348: 'path' => $src,
2349: 'dest' => $dest,
2350: 'reverse' => !empty($args['reverse']) ? '-R' : null,
2351: 'oneway' => !empty($args['oneway']) ? '-1' : null,
2352: 'full' => !empty($args['full']) ? '-f' : null,
2353: 'debug' => !empty($args['debug']) ? '-D' : null,
2354: 'purge' => !empty($args['purge']) ? '-P' : null
2355: ], [0,2]);
2356:
2357: if (!$ret['success']) {
2358: return error(coalesce($ret['stderr'], $ret['stdout']));
2359: }
2360:
2361: return $ret['return'] === 0 || warn("Conversion partially succeeded: %s",
2362: coalesce($ret['stderr'], $ret['stdout']));
2363: }
2364:
2365: public function _verify_conf(ConfigurationContext $ctx): bool
2366: {
2367: return true;
2368: }
2369:
2370: public function _cron(Cronus $c)
2371: {
2372: $c->schedule(MAIL_SRS_AUTOROLL ?: null, 'email.roll-srs-key', $this->roll_srs(...));
2373: }
2374:
2375: public function _housekeeping()
2376: {
2377: $dummyfile = webapp_path('webmail/dummyset.php');
2378: $dest = '/var/www/html/dummyset.php';
2379: if (!file_exists($dest) || fileinode($dummyfile) !== fileinode($dest)) {
2380: file_exists($dest) && unlink($dest);
2381: $apnscpHome = realpath(INCLUDE_PATH);
2382: if (!Filesystem\Mount::sameMount('/var/www/html', $apnscpHome)) {
2383: warn("/var and %s are on different mount points - copying dummyset", $apnscpHome);
2384: copy($dummyfile, $dest);
2385: } else {
2386: link($dummyfile, $dest);
2387: }
2388: }
2389: return true;
2390: }
2391:
2392: protected function buildWarningTemplates(): void
2393: {
2394: $path = '/usr/libexec/dovecot/quota-warning.sh';
2395:
2396: $template = new \Opcenter\Provisioning\ConfigurationWriter('mail.quota-warning-command', null);
2397: if (!$template->shouldRefresh($path)) {
2398: return;
2399: }
2400:
2401: $template->write($path) && Filesystem::chogp($path, 0, 0, 0755);
2402: }
2403: }