1: | <?php |
2: | declare(strict_types=1); |
3: | |
4: | |
5: | |
6: | |
7: | |
8: | |
9: | |
10: | |
11: | |
12: | |
13: | |
14: | |
15: | use Opcenter\Filesystem; |
16: | use Opcenter\Mail\Services\Majordomo; |
17: | use Opcenter\Mail\Services\Postfix; |
18: | |
19: | |
20: | |
21: | |
22: | |
23: | |
24: | class Majordomo_Module extends Module_Skeleton implements \Module\Skeleton\Contracts\Hookable |
25: | { |
26: | const DEPENDENCY_MAP = [ |
27: | 'mail' |
28: | ]; |
29: | const MAJORDOMO_SETUID = 'nobody'; |
30: | private array $majordomo_skeleton; |
31: | private string $majordomo_preamble; |
32: | |
33: | |
34: | |
35: | |
36: | |
37: | |
38: | public function __construct() |
39: | { |
40: | parent::__construct(); |
41: | if (!($this->permission_level & PRIVILEGE_SITE) || !$this->enabled()) { |
42: | |
43: | $this->exportedFunctions = [ |
44: | '*' => PRIVILEGE_NONE, |
45: | 'enabled' => PRIVILEGE_SITE |
46: | ]; |
47: | return; |
48: | } |
49: | |
50: | $this->exportedFunctions = array( |
51: | '*' => PRIVILEGE_SITE, |
52: | 'list_mailing_lists_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
53: | 'create_mailing_list_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
54: | 'delete_mailing_list_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
55: | 'get_mailing_list_users_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
56: | 'get_domain_from_list_name_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC |
57: | ); |
58: | include_once(INCLUDE_PATH . '/lib/configuration_driver.php'); |
59: | include(INCLUDE_PATH . '/lib/modules/majordomo/config_skeleton.php'); |
60: | |
61: | |
62: | $this->majordomo_skeleton = $__majordomo_skeleton; |
63: | $this->majordomo_preamble = $__majordomo_preamble; |
64: | } |
65: | |
66: | |
67: | |
68: | |
69: | |
70: | |
71: | public function enabled(): bool { |
72: | if (!platform_is('7.5')) { |
73: | return true; |
74: | } |
75: | return $this->email_get_provider() === 'builtin' && |
76: | $this->getConfig('mlist', 'enabled') && |
77: | $this->getConfig('mlist', 'provider', 'majordomo') === 'majordomo'; |
78: | } |
79: | |
80: | public function get_mailing_list_users($list) |
81: | { |
82: | if (!IS_CLI) { |
83: | return $this->query('majordomo_get_mailing_list_users', $list); |
84: | } |
85: | |
86: | if (!preg_match(Regex::MAILING_LIST_NAME, $list)) { |
87: | return error('Invalid list ' . $list); |
88: | } |
89: | |
90: | if (!file_exists($this->domain_fs_path() . Majordomo::MAILING_LIST_HOME . '/lists/' . $list)) { |
91: | return error('Invalid list name ' . $list); |
92: | } |
93: | |
94: | return file_get_contents($this->domain_fs_path() . Majordomo::MAILING_LIST_HOME . '/lists/' . $list); |
95: | } |
96: | |
97: | public function create_mailing_list($list, $password, $email = null, $domain = null) |
98: | { |
99: | $max = $this->getServiceValue('mlist', 'max'); |
100: | if ($max !== null) { |
101: | $count = \count($this->list_mailing_lists()); |
102: | if ($count >= $max) { |
103: | return error("Mailing list limit `%d' reached", $count); |
104: | } |
105: | } |
106: | $list = strtolower(trim($list)); |
107: | |
108: | if (!$domain) { |
109: | $domain = $this->getServiceValue('siteinfo', 'domain'); |
110: | } |
111: | if (!$email) { |
112: | $email = trim($this->getServiceValue('siteinfo', 'email')); |
113: | } |
114: | |
115: | $email = strtolower($email); |
116: | |
117: | if (!preg_match(Regex::MAILING_LIST_NAME, $list)) { |
118: | return error('Invalid list name'); |
119: | } |
120: | if ($this->mailing_list_exists($list)) { |
121: | return error('Mailing list already exists'); |
122: | } |
123: | if (!$password) { |
124: | return error('Invalid argument: missing password'); |
125: | } |
126: | if (!in_array($domain, $this->email_list_virtual_transports())) { |
127: | return error('Domain not configured to handle mail'); |
128: | } |
129: | if (!preg_match(Regex::EMAIL, $email)) { |
130: | return error('Invalid owner e-mail address'); |
131: | } |
132: | |
133: | $status = $this->query('majordomo_create_mailing_list_backend', |
134: | $list, |
135: | $password, |
136: | $email, |
137: | $domain |
138: | ); |
139: | |
140: | if ($status instanceof Exception) { |
141: | return $status; |
142: | } |
143: | |
144: | $this->email_add_alias($list, $domain, $list . '+' . $domain); |
145: | $this->email_add_alias($list . '-approval', $domain, $email); |
146: | $this->email_add_alias($list . '-owner', $domain, $email); |
147: | $this->email_add_alias('owner-' . $list, $domain, $list . '-owner'); |
148: | $this->email_add_alias($list . '-request', $domain, $list . '-request+' . $domain); |
149: | |
150: | if (!$this->email_address_exists('majordomo-owner', $domain)) { |
151: | $this->email_add_alias('majordomo-owner', $domain, $email); |
152: | } |
153: | if (!$this->email_address_exists('majordomo', $domain)) { |
154: | $this->email_add_alias('majordomo', $domain, 'majordomo+' . $domain); |
155: | } |
156: | |
157: | return true; |
158: | } |
159: | |
160: | public function set_mailing_list_users($list, $members) |
161: | { |
162: | if (!IS_CLI) { |
163: | if (!preg_match(Regex::MAILING_LIST_NAME, $list)) { |
164: | return error("`%s': invalid list name", $list); |
165: | } |
166: | |
167: | if (is_array($members)) { |
168: | $members = join("\n", $members); |
169: | } |
170: | |
171: | return $this->query('majordomo_set_mailing_list_users', $list, $members); |
172: | } |
173: | |
174: | if (!file_exists($this->domain_fs_path() . Majordomo::MAILING_LIST_HOME . '/lists/' . $list)) { |
175: | return error("list `%s' does not exist", $list); |
176: | } |
177: | |
178: | |
179: | $members = trim($members) . "\n"; |
180: | $listHome = Majordomo::bindTo($this->domain_fs_path())->getListHome(); |
181: | file_put_contents("{$listHome}/lists/{$list}", $members); |
182: | return Filesystem::chogp("{$listHome}/lists/{$list}", self::MAJORDOMO_SETUID, $this->group_id, 0644); |
183: | } |
184: | |
185: | public function create_mailing_list_backend($list, $password, $email, $domain) |
186: | { |
187: | $prefix = $this->domain_fs_path(); |
188: | |
189: | if (file_exists($prefix . Majordomo::MAILING_LIST_HOME . '/lists/' . $list)) { |
190: | return error("list `%s' already exists", $list); |
191: | } |
192: | |
193: | |
194: | $ret = \Util_Process_Safe::exec('/usr/sbin/postalias -q majordomo+%s %s', $domain, Postfix::getAliasesPath(), [0, 1]); |
195: | |
196: | if ($ret['return'] === 1) { |
197: | |
198: | $aliasPath = Postfix::getAliasesPath(); |
199: | file_put_contents( |
200: | $aliasPath, |
201: | trim(file_get_contents($aliasPath)) . "\n" . |
202: | 'majordomo+' . $domain . ': "| env HOME=/usr/lib/majordomo MAJORDOMO_CF=' . |
203: | $prefix . '/etc/majordomo-' . $domain . '.cf /usr/lib/majordomo/majordomo"' |
204: | ); |
205: | |
206: | $this->email_remove_alias('majordomo', $domain); |
207: | $this->email_add_alias('majordomo', $domain, 'majordomo+' . $domain); |
208: | $proc = new Util_Account_Editor($this->getAuthContext()->getAccount(), $this->getAuthContext()); |
209: | |
210: | if (version_compare(platform_version(), '7.5', '>=')) { |
211: | $svc = 'mlist'; |
212: | } else { |
213: | $svc = 'majordomo'; |
214: | } |
215: | $proc->setConfig($svc, 'enabled', 1); |
216: | $proc->edit(); |
217: | } |
218: | if (!file_exists($prefix . '/etc/majordomo.cf')) { |
219: | |
220: | (new \Opcenter\Provisioning\ConfigurationWriter('majordomo.majordomo-cf', \Opcenter\SiteConfiguration::shallow($this->getAuthContext())))-> |
221: | write($prefix . '/etc/majordomo.cf'); |
222: | Filesystem::chogp($prefix . '/etc/majordomo.cf', 0, 0); |
223: | } |
224: | file_put_contents($prefix . '/etc/majordomo-' . $domain . '.cf', |
225: | preg_replace('/^\s*\$whereami.+$/m', '$whereami = "' . $domain . '";', |
226: | file_get_contents($prefix . '/etc/majordomo.cf'))); |
227: | Filesystem::chogp($prefix . "/etc/majordomo-{$domain}.cf", 0, 0); |
228: | if (!file_exists($listHome = Majordomo::bindTo($prefix)->getListHome())) { |
229: | Filesystem::mkdir($listHome, self::MAJORDOMO_SETUID, $this->group_id); |
230: | \Util_Process_Safe::exec('setfacl -d -m user:%d:7 %s', $this->user_id, $listHome); |
231: | } |
232: | |
233: | foreach (array('archives', 'digest', 'lists', 'OLDLOGS', 'tmp') as $dir) { |
234: | if (!file_exists("{$listHome}/{$dir}")) { |
235: | mkdir("{$listHome}/{$dir}"); |
236: | } |
237: | Filesystem::chogp("{$listHome}/{$dir}", self::MAJORDOMO_SETUID, $this->group_id, 02771) && |
238: | \Util_Process_Safe::exec('setfacl -m user:postfix:7 -d -m user:%s:7 %s/%s', self::MAJORDOMO_SETUID, $listHome, $dir); |
239: | } |
240: | $aliasPath = Postfix::getAliasesPath(); |
241: | file_put_contents( |
242: | $aliasPath, |
243: | trim(file_get_contents($aliasPath)) . "\n" . |
244: | $list . '+' . $domain . ': "| env HOME=/usr/lib/majordomo /usr/lib/majordomo/wrapper resend -C ' . $prefix . '/etc/majordomo-' . $domain . '.cf -l ' . $list . ' -h ' . $domain . ' ' . $list . '-outgoing+' . $domain . '"' . "\n" . |
245: | $list . '-outgoing+' . $domain . ': :include:' . $listHome . '/lists/' . $list . "\n" . |
246: | $list . '-request+' . $domain . ': "| env HOME=/usr/lib/majordomo MAJORDOMO_CF=' . $prefix . '/etc/majordomo-' . $domain . '.cf /usr/lib/majordomo/request-answer ' . $list . ' -h ' . $domain . '"' . "\n" |
247: | ); |
248: | |
249: | |
250: | Util_Process::exec('/usr/sbin/postalias -w %s', $aliasPath); |
251: | foreach (array($list, $list . '.config', $list . '.intro', $list . '.info') as $file) { |
252: | Filesystem::touch("{$listHome}/lists/{$file}", self::MAJORDOMO_SETUID, $this->group_id); |
253: | } |
254: | file_put_contents("{$listHome}/lists/{$list}", $email . "\n"); |
255: | file_put_contents("{$listHome}/lists/{$list}.config", |
256: | $this->change_configuration_options(array( |
257: | 'admin_passwd' => $password, |
258: | 'resend_host' => $domain, |
259: | 'resend_dmarc' => true, |
260: | 'restrict_post' => $list, |
261: | 'reply_to' => '$SENDER', |
262: | 'sender' => 'owner-' . $list |
263: | ))); |
264: | |
265: | chmod($listHome, 0755); |
266: | chmod("{$listHome}/lists", 02751); |
267: | chmod("{$listHome}/lists/{$list}", 0644); |
268: | Util_Process_Safe::exec('setfacl -d -m user:%s:7 -m user:postfix:7 %s/*', |
269: | self::MAJORDOMO_SETUID, |
270: | $listHome |
271: | ); |
272: | Util_Process_Safe::exec('setfacl -m user:%d:7 %s/lists/%s*', |
273: | $this->user_id, |
274: | $listHome, |
275: | $list |
276: | ); |
277: | Util_Process_Safe::exec('setfacl -R -m user:%s:7 -m user:postfix:7 %s', |
278: | self::MAJORDOMO_SETUID, |
279: | $listHome |
280: | ); |
281: | |
282: | return true; |
283: | } |
284: | |
285: | public function change_configuration_options(array $options) |
286: | { |
287: | $configuration = $this->majordomo_skeleton; |
288: | foreach ($options as $option => $value) { |
289: | if (!isset($configuration[$option])) { |
290: | continue; |
291: | } |
292: | if ($configuration[$option]['type'] == enum) { |
293: | $configuration[$option]['value'] = in_array($value, $configuration[$option]['values']) ? |
294: | $value : |
295: | (isset($configuration[$option]['default']) ? $configuration[$option]['default'] : ''); |
296: | } else { |
297: | $configuration[$option]['value'] = $value; |
298: | } |
299: | } |
300: | |
301: | return $this->generate_configuration($configuration); |
302: | } |
303: | |
304: | public function generate_configuration(array $config) |
305: | { |
306: | $configuration = $this->majordomo_preamble; |
307: | foreach ($config as $opt_name => $opt_params) { |
308: | |
309: | $configuration .= wordwrap('# ' . $opt_params['help'], 72, "\n# ") . "\n" . $opt_name; |
310: | if ($opt_params['type'] == text) { |
311: | $configuration .= " << END \n" . (isset($opt_params['value']) ? $opt_params['value'] : (isset($opt_params['default']) ? $opt_params['default'] : '')) . "\n" . 'END'; |
312: | } else { |
313: | $configuration .= ' = '; |
314: | if ($opt_params['type'] == bool) { |
315: | $configuration .= (isset($opt_params['value']) ? ($opt_params['value'] ? 'yes' : 'no') : ((isset($opt_params['default']) && $opt_params['default']) ? 'yes' : 'no')); |
316: | } else { |
317: | $configuration .= (isset($opt_params['value']) ? $opt_params['value'] : (isset($opt_params['default']) ? $opt_params['default'] : '')); |
318: | } |
319: | } |
320: | $configuration .= "\n\n"; |
321: | |
322: | } |
323: | |
324: | return $configuration; |
325: | |
326: | } |
327: | |
328: | public function load_configuration_options($list) |
329: | { |
330: | return $this->_parse_configuration($this->file_get_file_contents(Majordomo::MAILING_LIST_HOME . '/lists/' . $list . '.config')); |
331: | } |
332: | |
333: | private function _parse_configuration($text) |
334: | { |
335: | if (!preg_match_all(Regex::MAJORDOMO_CONFIG_ENTRY, $text, $matches, PREG_SET_ORDER)) { |
336: | return false; |
337: | } |
338: | $base = $this->majordomo_skeleton; |
339: | foreach ($matches as $match => $value) { |
340: | if (isset($base[$value[1]])) { |
341: | $base[$value[1]]['value'] = trim(($base[$value[1]]['type'] == text) ? str_replace('END', '', |
342: | $value[2]) : $value[2]); |
343: | } |
344: | } |
345: | |
346: | return array_merge($base, array_intersect_key($base, $base)); |
347: | } |
348: | |
349: | public function save_configuration_options($list, $data) |
350: | { |
351: | |
352: | return $this->file_put_file_contents(Majordomo::MAILING_LIST_HOME . "/lists/{$list}.config", $data, true) && |
353: | $this->file_chmod(Majordomo::MAILING_LIST_HOME . "/lists/{$list}.config", 644); |
354: | } |
355: | |
356: | public function _delete() |
357: | { |
358: | if (!$this->enabled()) { |
359: | return true; |
360: | } |
361: | foreach ($this->list_mailing_lists() as $list) { |
362: | $this->delete_mailing_list($list); |
363: | } |
364: | } |
365: | |
366: | public function list_mailing_lists() |
367: | { |
368: | if (!IS_CLI) { |
369: | return $this->query('majordomo_list_mailing_lists'); |
370: | } |
371: | |
372: | $entries = array(); |
373: | $listHome = Majordomo::bindTo($this->domain_fs_path())->getListHome(); |
374: | if (!file_exists("{$listHome}/lists")) { |
375: | return $entries; |
376: | } |
377: | $dh = dir("{$listHome}/lists"); |
378: | while (false !== ($entry = $dh->read())) { |
379: | |
380: | if (false !== strpos($entry, '.')) { |
381: | continue; |
382: | } |
383: | $entries[] = $entry; |
384: | } |
385: | $dh->close(); |
386: | |
387: | return $entries; |
388: | } |
389: | |
390: | public function delete_mailing_list($list) |
391: | { |
392: | if (!IS_CLI) { |
393: | return $this->query('majordomo_delete_mailing_list', $list); |
394: | } |
395: | |
396: | $list = trim(strtolower($list)); |
397: | if (!preg_match(Regex::MAILING_LIST_NAME, $list)) { |
398: | return error('Invalid list name'); |
399: | } else { |
400: | if (!$this->mailing_list_exists($list)) { |
401: | return error("mailing list `%s' does not exist", $list); |
402: | } |
403: | } |
404: | |
405: | $domain = $this->get_domain_from_list_name($list); |
406: | $listHome = Majordomo::bindTo($this->domain_fs_path())->getListHome() . '/lists'; |
407: | foreach (array($list, $list . '.config', $list . '.intro', $list . '.info') as $file) { |
408: | $path = "{$listHome}/{$file}"; |
409: | if (file_exists($path)) { |
410: | unlink($path); |
411: | } |
412: | } |
413: | |
414: | |
415: | $moreLists = $this->mailing_lists_exist(); |
416: | |
417: | if (!$moreLists) { |
418: | $this->email_remove_alias('majordomo', $domain); |
419: | } |
420: | $lines = []; |
421: | foreach (explode("\n", file_get_contents(Postfix::getAliasesPath())) as $line) { |
422: | if (preg_match('!' . $list . '(?:-outgoing|-request)?\+' . $domain . ':!', $line)) { |
423: | continue; |
424: | } else { |
425: | if (!$moreLists && preg_match('!majordomo\+' . $domain . ':!', $line)) { |
426: | continue; |
427: | } |
428: | } |
429: | $lines[] = $line; |
430: | } |
431: | |
432: | $this->email_remove_alias($list, $domain); |
433: | $this->email_remove_alias($list . '-approval', $domain); |
434: | $this->email_remove_alias($list . '-owner', $domain); |
435: | $this->email_remove_alias('owner-' . $list, $domain); |
436: | $this->email_remove_alias($list . '-request', $domain); |
437: | |
438: | |
439: | file_put_contents(Postfix::getAliasesPath(), join("\n", $lines)); |
440: | Util_Process::exec('postalias -r %s', Postfix::getAliasesPath()); |
441: | |
442: | return true; |
443: | |
444: | } |
445: | |
446: | public function mailing_list_exists($list) |
447: | { |
448: | if (!IS_CLI) { |
449: | return $this->query('majordomo_mailing_list_exists', $list); |
450: | } |
451: | |
452: | return file_exists(Majordomo::bindTo($this->domain_fs_path())->getListHome() . "/lists/{$list}.config"); |
453: | } |
454: | |
455: | public function get_domain_from_list_name($list) |
456: | { |
457: | if (!IS_CLI) { |
458: | return $this->query('majordomo_get_domain_from_list_name', $list); |
459: | } |
460: | |
461: | if (!preg_match(Regex::MAILING_LIST_NAME, $list)) { |
462: | return error('Invalid list ' . $list); |
463: | } |
464: | |
465: | $listHome = Majordomo::bindTo($this->domain_fs_path())->getListHome(); |
466: | if (!file_exists("{$listHome}/lists/{$list}")) { |
467: | return error($list . ' does not exist'); |
468: | } |
469: | |
470: | $file = "{$listHome}/lists/{$list}.config"; |
471: | if (!file_exists($file)) { |
472: | return null; |
473: | } else if (preg_match('/^\s*resend_host\s*=[ \t]*(\S+)/m', file_get_contents($file), $domain)) { |
474: | $domain = $domain[1]; |
475: | } else { |
476: | $domain = $this->getServiceValue('siteinfo', 'domain'); |
477: | } |
478: | |
479: | return $domain; |
480: | } |
481: | |
482: | |
483: | |
484: | |
485: | |
486: | |
487: | public function mailing_lists_exist() |
488: | { |
489: | if (!IS_CLI) { |
490: | return $this->query('majordomo_mailing_lists_exist'); |
491: | } |
492: | |
493: | $listHome = Majordomo::bindTo($this->domain_fs_path())->getListHome(); |
494: | if (!file_exists("{$listHome}/lists")) { |
495: | return false; |
496: | } |
497: | |
498: | $glob = glob("{$listHome}/lists"); |
499: | |
500: | return sizeof($glob) > 1; |
501: | |
502: | } |
503: | |
504: | public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool |
505: | { |
506: | return true; |
507: | } |
508: | |
509: | public function _create() |
510: | { |
511: | |
512: | } |
513: | |
514: | public function _edit() |
515: | { |
516: | |
517: | } |
518: | |
519: | public function _create_user(string $user) |
520: | { |
521: | |
522: | } |
523: | |
524: | public function _delete_user(string $user) |
525: | { |
526: | |
527: | } |
528: | |
529: | public function _edit_user(string $userold, string $usernew, array $oldpwd) |
530: | { |
531: | |
532: | } |
533: | |
534: | |
535: | } |