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