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 Opcenter\Filesystem;
16: use Opcenter\Mail\Services\Majordomo;
17: use Opcenter\Mail\Services\Postfix;
18:
19: /**
20: * Majordomo mailing list functions
21: *
22: * @package core
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: * {{{ void __construct(void)
35: *
36: * @ignore
37: */
38: public function __construct()
39: {
40: parent::__construct();
41: if (!($this->permission_level & PRIVILEGE_SITE) || !$this->enabled()) {
42: // permission level is null in backend enumeration
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: * Service enabled
68: *
69: * @return bool
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: // ensure list ends in newline
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: /** check that we have the very basic majordomo mapping */
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: // alias not found, add it
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: // delete in case it was replicated by an alias addition
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: // let this run independently
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: // @TODO move to templates/
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: // add aliases
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: // FIXME: we lose the 0 otherwise, which is significant
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: /* should check .config/.info/.intro/.auto */
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: // that was the last mailing list
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: // currently handled by create_mailing_list
483:
484: /**
485: * @return bool At least one mailing list exists
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: // TODO: Implement _create() method.
512: }
513:
514: public function _edit()
515: {
516: // TODO: Implement _edit() method.
517: }
518:
519: public function _create_user(string $user)
520: {
521: // TODO: Implement _create_user() method.
522: }
523:
524: public function _delete_user(string $user)
525: {
526: // TODO: Implement _delete_user() method.
527: }
528:
529: public function _edit_user(string $userold, string $usernew, array $oldpwd)
530: {
531: // TODO: Implement _edit_user() method.
532: }
533:
534:
535: }