1<?php
2# $Id$
3
4/**
5 * Handlers User level alias actions - e.g. add alias, get aliases, update etc.
6 */
7class AliasHandler extends PFAHandler
8{
9    protected $db_table = 'alias';
10    protected $id_field = 'address';
11    protected $domain_field = 'domain';
12    protected $searchfields = array('address', 'goto');
13
14    /**
15     *
16     * @public
17     */
18    public $return = null;
19
20    protected function initStruct()
21    {
22        # hide 'goto_mailbox' if $this->new
23        # (for existing aliases, init() hides it for non-mailbox aliases)
24        $mbgoto = 1 - $this->new;
25
26        $this->struct = array(
27            # field name                allow       display in...   type    $PALANG label                     $PALANG description                 default / ...
28            #                           editing?    form    list
29            'status'           => pacol(0,          0,      0,      'html', ''                              , ''                                , '', array(),
30                array('not_in_db' => 1)  ),
31            'address'          => pacol($this->new, 1,      1,      'mail', 'alias'                         , 'pCreate_alias_catchall_text'     ),
32            'localpart'        => pacol($this->new, 0,      0,      'text', 'alias'                         , 'pCreate_alias_catchall_text'     , '',
33                /*options*/ array(),
34                /*not_in_db*/ 1                         ),
35            'domain'           => pacol($this->new, 0,      1,      'enum', ''                              , ''                                , '',
36                /*options*/ $this->allowed_domains      ),
37            'goto'             => pacol(1,          1,      1,      'txtl', 'to'                            , 'pEdit_alias_help'                , array() ),
38            'is_mailbox'       => pacol(0,          0,      1,      'int', ''                             , ''                                , 0 ,
39                # technically 'is_mailbox' is bool, but the automatic bool conversion breaks the query. Flagging it as int avoids this problem.
40                # Maybe having a vbool type (without the automatic conversion) would be cleaner - we'll see if we need it.
41                /*options*/ array(),
42                /*not_in_db*/ 0,
43                /*dont_write_to_db*/ 1,
44                /*select*/ 'coalesce(__is_mailbox,0) as is_mailbox' ),
45                /*extrafrom set via set_is_mailbox_extrafrom() */
46            '__mailbox_username' => pacol( 0,       0,      1,      'vtxt', ''                              , ''                                , 0),  # filled via is_mailbox
47            'goto_mailbox'     => pacol($mbgoto,    $mbgoto,$mbgoto,'bool', 'pEdit_alias_forward_and_store' , ''                                , 0,
48                /*options*/ array(),
49                /*not_in_db*/ 1                         ), # read_from_db_postprocess() sets the value
50            'on_vacation'      => pacol(1,          0,      1,      'bool', 'pUsersMenu_vacation'           , ''                                , 0 ,
51                /*options*/ array(),
52                /*not_in_db*/ 1                         ), # read_from_db_postprocess() sets the value - TODO: read active flag from vacation table instead?
53            'created'          => pacol(0,          0,      0,      'ts',   'created'                       , ''                                ),
54            'modified'         => pacol(0,          0,      1,      'ts',   'last_modified'                 , ''                                ),
55            'active'           => pacol(1,          1,      1,      'bool', 'active'                        , ''                                , 1     ),
56            '_can_edit'        => pacol(0,          0,      1,      'vnum', ''                              , ''                                , 0 , array(),
57                array('select' => '1 as _can_edit')  ),
58            '_can_delete'      => pacol(0,          0,      1,      'vnum', ''                              , ''                                , 0 , array(),
59                array('select' => '1 as _can_delete')  ), # read_from_db_postprocess() updates the value
60                # aliases listed in $CONF[default_aliases] are read-only for domain admins if $CONF[special_alias_control] is NO.
61        );
62
63        $this->set_is_mailbox_extrafrom();
64    }
65
66    /*
67     * set $this->struct['is_mailbox']['extrafrom'] based on the search conditions.
68     * If a listing for a specific domain is requested, optimize the subquery to only return mailboxes from that domain.
69     * This doesn't change the result of the main query, but improves the performance a lot on setups with lots of mailboxes.
70     * When using this function to optimize the is_mailbox extrafrom, don't forget to reset it to the default value
71     * (all domains for this admin) afterwards.
72     */
73    private function set_is_mailbox_extrafrom($condition=array(), $searchmode=array())
74    {
75        $extrafrom = 'LEFT JOIN ( ' .
76            ' SELECT 1 as __is_mailbox, username as __mailbox_username ' .
77            ' FROM ' . table_by_key('mailbox') .
78            ' WHERE username IS NOT NULL ';
79
80        if (isset($condition['domain']) && !isset($searchmode['domain']) && in_array($condition['domain'], $this->allowed_domains)) {
81            # listing for a specific domain, so restrict subquery to that domain
82            $extrafrom .= ' AND ' . db_in_clause($this->domain_field, array($condition['domain']));
83        } else {
84            # restrict subquery to all domains accessible to this admin
85            $extrafrom .= ' AND ' . db_in_clause($this->domain_field, $this->allowed_domains);
86        }
87
88        $extrafrom .= ' ) AS __mailbox ON __mailbox_username = address';
89
90        $this->struct['is_mailbox']['extrafrom'] = $extrafrom;
91    }
92
93
94    protected function initMsg()
95    {
96        $this->msg['error_already_exists'] = 'email_address_already_exists';
97        $this->msg['error_does_not_exist'] = 'alias_does_not_exist';
98        $this->msg['confirm_delete'] = 'confirm_delete_alias';
99        $this->msg['list_header'] = 'pOverview_alias_title';
100
101        if ($this->new) {
102            $this->msg['logname'] = 'create_alias';
103            $this->msg['store_error'] = 'pCreate_alias_result_error';
104            $this->msg['successmessage'] = 'pCreate_alias_result_success';
105        } else {
106            $this->msg['logname'] = 'edit_alias';
107            $this->msg['store_error'] = 'pEdit_alias_result_error';
108            $this->msg['successmessage'] = 'alias_updated';
109        }
110    }
111
112
113    public function webformConfig()
114    {
115        if ($this->new) { # the webform will display a localpart field + domain dropdown on $new
116            $this->struct['address']['display_in_form'] = 0;
117            $this->struct['localpart']['display_in_form'] = 1;
118            $this->struct['domain']['display_in_form'] = 1;
119        }
120
121        if (Config::bool('show_status')) {
122            $this->struct['status']['display_in_list'] = 1;
123            $this->struct['status']['label'] = ' ';
124        }
125
126        return array(
127            # $PALANG labels
128            'formtitle_create'  => 'pMain_create_alias',
129            'formtitle_edit'    => 'pEdit_alias_welcome',
130            'create_button'     => 'add_alias',
131
132            # various settings
133            'required_role' => 'admin',
134            'listview'      => 'list-virtual.php',
135            'early_init'    => 0,
136            'prefill'       => array('domain'),
137        );
138    }
139
140    /**
141     * AliasHandler needs some special handling in init() and therefore overloads the function.
142     * It also calls parent::init()
143     */
144    public function init(string $id) : bool
145    {
146        $bits = explode('@', $id);
147        if (sizeof($bits) == 2) {
148            $local_part = $bits[0];
149            $domain = $bits[1];
150            if ($local_part == '*') { # catchall - postfix expects '@domain', not '*@domain'
151                $id = '@' . $domain;
152            }
153        }
154
155        $retval = parent::init($id);
156
157        if (!$retval) {
158            return false;
159        } # parent::init() failed, no need to continue
160
161        # hide 'goto_mailbox' for non-mailbox aliases
162        # parent::init called view() before, so we can rely on having $this->result filled
163        # (only validate_new_id() is called from parent::init and could in theory change $this->result)
164        if ($this->new || $this->result['is_mailbox'] == 0) {
165            $this->struct['goto_mailbox']['editable']        = 0;
166            $this->struct['goto_mailbox']['display_in_form'] = 0;
167            $this->struct['goto_mailbox']['display_in_list'] = 0;
168        }
169
170        if (!$this->new && $this->result['is_mailbox'] && $this->admin_username != ''&& !authentication_has_role('global-admin')) {
171            # domain admins are not allowed to change mailbox alias $CONF['alias_control_admin'] = NO
172            # TODO: apply the same restriction to superadmins?
173            if (!Config::bool('alias_control_admin')) {
174                # TODO: make translateable
175                $this->errormsg[] = "Domain administrators do not have the ability to edit user's aliases (check config.inc.php - alias_control_admin)";
176                return false;
177            }
178        }
179
180        return $retval;
181    }
182
183    protected function domain_from_id()
184    {
185        list(/*NULL*/, $domain) = explode('@', $this->id);
186        return $domain;
187    }
188
189    protected function validate_new_id()
190    {
191        if ($this->id == '') {
192            $this->errormsg[$this->id_field] = Config::lang('pCreate_alias_address_text_error1');
193            return false;
194        }
195
196        list($local_part, $domain) = explode('@', $this->id);
197
198        if (!$this->create_allowed($domain)) {
199            $this->errormsg[$this->id_field] = Config::lang('pCreate_alias_address_text_error3');
200            return false;
201        }
202
203        # TODO: already checked in set() - does it make sense to check it here also? Only advantage: it's an early check
204        #        if (!in_array($domain, $this->allowed_domains)) {
205        #            $this->errormsg[] = Config::lang('pCreate_alias_address_text_error1');
206        #            return false;
207        #        }
208
209        if ($local_part == '') { # catchall
210            $valid = true;
211        } else {
212            $email_check = check_email($this->id);
213            if ($email_check == '') {
214                $valid = true;
215            } else {
216                $this->errormsg[$this->id_field] = $email_check;
217                $valid = false;
218            }
219        }
220
221        return $valid;
222    }
223
224    /**
225     * check number of existing aliases for this domain - is one more allowed?
226     */
227    private function create_allowed($domain)
228    {
229        if ($this->called_by == 'MailboxHandler') {
230            return true;
231        } # always allow creating an alias for a mailbox
232
233        $limit = get_domain_properties($domain);
234
235        if ($limit['aliases'] == 0) {
236            return true;
237        } # unlimited
238        if ($limit['aliases'] < 0) {
239            return false;
240        } # disabled
241        if ($limit['alias_count'] >= $limit['aliases']) {
242            return false;
243        }
244        return true;
245    }
246
247
248    /**
249     * merge localpart and domain to address
250     * called by edit.php (if id_field is editable and hidden in editform) _before_ ->init
251     */
252    public function mergeId($values)
253    {
254        if ($this->struct['localpart']['display_in_form'] == 1 && $this->struct['domain']['display_in_form']) { # webform mode - combine to 'address' field
255            if (empty($values['localpart']) || empty($values['domain'])) { # localpart or domain not set
256                return "";
257            }
258            if ($values['localpart'] == '*') {
259                $values['localpart'] = '';
260            } # catchall
261            return $values['localpart'] . '@' . $values['domain'];
262        } else {
263            return $values[$this->id_field];
264        }
265    }
266
267    protected function setmore(array $values)
268    {
269        if ($this->new) {
270            if ($this->struct['address']['display_in_form'] == 1) { # default mode - split off 'domain' field from 'address' # TODO: do this unconditional?
271                list(/*NULL*/, $domain) = explode('@', $values['address']);
272                $this->values['domain'] = $domain;
273            }
274        }
275
276        if (! $this->new) { # edit mode - preserve vacation and mailbox alias if they were included before
277            $old_ah = new AliasHandler();
278
279            if (!$old_ah->init($this->id)) {
280                $this->errormsg[] = $old_ah->errormsg[0];
281            } elseif (!$old_ah->view()) {
282                $this->errormsg[] = $old_ah->errormsg[0];
283            } else {
284                $oldvalues = $old_ah->result();
285
286                if (!isset($values['goto'])) { # no new value given?
287                    $values['goto'] = $oldvalues['goto'];
288                }
289
290                if (!isset($values['on_vacation'])) { # no new value given?
291                    $values['on_vacation'] = $oldvalues['on_vacation'];
292                }
293
294                if ($values['on_vacation']) {
295                    $values['goto'][] = $this->getVacationAlias();
296                }
297
298                if ($oldvalues['is_mailbox']) { # alias belongs to a mailbox - add/keep mailbox to/in goto
299                    if (!isset($values['goto_mailbox'])) { # no new value given?
300                        $values['goto_mailbox'] = $oldvalues['goto_mailbox'];
301                    }
302                    if ($values['goto_mailbox']) {
303                        $values['goto'][] = $this->id;
304
305                        # if the alias points to the mailbox, don't display the "empty goto" error message
306                        if (isset($this->errormsg['goto']) && $this->errormsg['goto'] == Config::lang('pEdit_alias_goto_text_error1')) {
307                            unset($this->errormsg['goto']);
308                        }
309                    }
310                }
311            }
312        }
313
314        $this->values['goto'] = join(',', $values['goto']);
315    }
316
317    protected function postSave() : bool
318    {
319        # TODO: if alias belongs to a mailbox, update mailbox active status
320        return true;
321    }
322
323    protected function read_from_db_postprocess($db_result)
324    {
325        foreach ($db_result as $key => $value) {
326            # split comma-separated 'goto' into an array
327            $goto = $db_result[$key]['goto'] ?? null;
328            if (is_string($goto)) {
329                $db_result[$key]['goto'] = explode(',', $goto);
330            }
331
332            # Vacation enabled?
333            list($db_result[$key]['on_vacation'], $db_result[$key]['goto']) = remove_from_array($db_result[$key]['goto'], $this->getVacationAlias());
334
335            # if it is a mailbox, does the alias point to the mailbox?
336            if ($db_result[$key]['is_mailbox']) {
337                # this intentionally does not match mailbox targets with recipient delimiter.
338                # if it would, we would have to make goto_mailbox a text instead of a bool (which would annoy 99% of the users)
339                list($db_result[$key]['goto_mailbox'], $db_result[$key]['goto']) = remove_from_array($db_result[$key]['goto'], $key);
340            } else { # not a mailbox
341                $db_result[$key]['goto_mailbox'] = 0;
342            }
343
344            # editing a default alias (postmaster@ etc.) is only allowed if special_alias_control is allowed or if the user is a superadmin
345            $tmp = preg_split('/\@/', $db_result[$key]['address']);
346            if (!$this->is_superadmin && !Config::bool('special_alias_control') && array_key_exists($tmp[0], Config::read_array('default_aliases'))) {
347                $db_result[$key]['_can_edit'] = 0;
348                $db_result[$key]['_can_delete'] = 0;
349            }
350
351            if ($this->struct['status']['display_in_list'] && Config::bool('show_status')) {
352                $db_result[$key]['status'] = gen_show_status($db_result[$key]['address']);
353            }
354        }
355
356        return $db_result;
357    }
358
359    private function condition_ignore_mailboxes($condition, $searchmode)
360    {
361        # only list aliases that do not belong to mailboxes
362        if (is_array($condition)) {
363            $condition['__mailbox_username'] = 1;
364            $searchmode['__mailbox_username'] = 'NULL';
365        } else {
366            if ($condition != '') {
367                $condition = " ( $condition ) AND ";
368            }
369            $condition = " $condition __mailbox_username IS NULL ";
370        }
371        return array($condition, $searchmode);
372    }
373
374    public function getList($condition, $searchmode = array(), $limit=-1, $offset=-1) : bool
375    {
376        list($condition, $searchmode) = $this->condition_ignore_mailboxes($condition, $searchmode);
377        $this->set_is_mailbox_extrafrom($condition, $searchmode);
378        $result = parent::getList($condition, $searchmode, $limit, $offset);
379        $this->set_is_mailbox_extrafrom(); # reset to default
380        return $result;
381    }
382
383    public function getPagebrowser($condition, $searchmode = array())
384    {
385        list($condition, $searchmode) = $this->condition_ignore_mailboxes($condition, $searchmode);
386        $this->set_is_mailbox_extrafrom($condition, $searchmode);
387        $result = parent::getPagebrowser($condition, $searchmode);
388        $this->set_is_mailbox_extrafrom(); # reset to default
389        return $result;
390    }
391
392
393
394    protected function _validate_goto($field, $val)
395    {
396        if (count($val) == 0) {
397            # empty is ok for mailboxes - this is checked in setmore() which can clear the error message
398            $this->errormsg[$field] = Config::lang('pEdit_alias_goto_text_error1');
399            return false;
400        }
401
402        $errors = array();
403
404        foreach ($val as $singlegoto) {
405            if (substr($this->id, 0, 1) == '@' && substr($singlegoto, 0, 1) == '@') { # domain-wide forward - check only the domain part
406                # only allowed if $this->id is a catchall
407                # Note: alias domains are better, but we should keep this way supported for backward compatibility
408                #       and because alias domains can't forward to external domains
409                list(/*NULL*/, $domain) = explode('@', $singlegoto);
410                $domain_check = check_domain($domain);
411                if ($domain_check != '') {
412                    $errors[] = "$singlegoto: $domain_check";
413                }
414            } else {
415                $email_check = check_email($singlegoto);
416                // preg_match -> allows for redirect to a local system account.
417                if ($email_check != '' && !preg_match('/^[a-z0-9]+$/', $singlegoto)) {
418                    $errors[] = "$singlegoto: $email_check";
419                }
420            }
421            if ($this->called_by != "MailboxHandler" && $this->id == $singlegoto) {
422                // The MailboxHandler needs to create an alias that points to itself (for the mailbox)
423                // Otherwise, disallow such aliases as they cause severe trouble in the mail system
424                $errors[] = "$singlegoto: " . Config::Lang('alias_points_to_itself');
425            }
426        }
427
428        if (count($errors)) {
429            $this->errormsg[$field] = join("   ", $errors); # TODO: find a way to display multiple error messages per field
430            return false;
431        } else {
432            return true;
433        }
434    }
435
436    /**
437     * on $this->new, set localpart based on address
438     */
439    protected function _missing_localpart($field)
440    {
441        if (isset($this->RAWvalues['address'])) {
442            $parts = explode('@', $this->RAWvalues['address']);
443            if (count($parts) == 2) {
444                $this->RAWvalues['localpart'] = $parts[0];
445            }
446        }
447    }
448
449    /**
450     * on $this->new, set domain based on address
451     */
452    protected function _missing_domain($field)
453    {
454        if (isset($this->RAWvalues['address'])) {
455            $parts = explode('@', $this->RAWvalues['address']);
456            if (count($parts) == 2) {
457                $this->RAWvalues['domain'] = $parts[1];
458            }
459        }
460    }
461
462
463    /**
464    * Returns the vacation alias for this user.
465    * i.e. if this user's username was roger@example.com, and the autoreply domain was set to
466    * autoreply.fish.net in config.inc.php we'd return roger#example.com@autoreply.fish.net
467    *
468    * @return string an email alias.
469    */
470    protected function getVacationAlias()
471    {
472        $vacation_goto = str_replace('@', '#', $this->id);
473        return $vacation_goto . '@' . Config::read_string('vacation_domain');
474    }
475
476    /**
477     *  @return boolean
478     */
479    public function delete()
480    {
481        if (! $this->view()) {
482            $this->errormsg[] = Config::Lang('alias_does_not_exist');
483            return false;
484        }
485
486        if ($this->result['is_mailbox']) {
487            $this->errormsg[] = Config::Lang('mailbox_alias_cant_be_deleted');
488            return false;
489        }
490
491        if (!$this->can_delete) {
492            $this->errormsg[] = Config::Lang_f('protected_alias_cant_be_deleted', $this->id);
493            return false;
494        }
495
496        db_delete('alias', 'address', $this->id);
497
498        list(/*NULL*/, $domain) = explode('@', $this->id);
499        db_log($domain, 'delete_alias', $this->id);
500        $this->infomsg[] = Config::Lang_f('pDelete_delete_success', $this->id);
501        return true;
502    }
503}
504
505/* vim: set expandtab softtabstop=4 tabstop=4 shiftwidth=4: */
506