• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

postfixadmin / postfixadmin / 21353098705

26 Jan 2026 09:44AM UTC coverage: 17.071% (-0.01%) from 17.084%
21353098705

push

github

web-flow
Merge pull request #974 from csware/pf3.3-alias-id-check

Check for address validity before checking domain existence (backport 3.3)

6 of 7 new or added lines in 1 file covered. (85.71%)

1 existing line in 1 file now uncovered.

831 of 4868 relevant lines covered (17.07%)

1.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

71.3
/model/AliasHandler.php
1
<?php
2

3
# $Id$
4

5
/**
6
 * Handlers User level alias actions - e.g. add alias, get aliases, update etc.
7
 */
8
class AliasHandler extends PFAHandler {
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
        # hide 'goto_mailbox' if $this->new
22
        # (for existing aliases, init() hides it for non-mailbox aliases)
23
        $mbgoto = 1 - $this->new;
14✔
24

25
        $this->struct = array(
14✔
26
            # field name                allow       display in...   type    $PALANG label                     $PALANG description                 default / ...
27
            #                           editing?    form    list
28
            'status'           => pacol(0,          0,      0,      'html', ''                              , ''                                , '', array(),
14✔
29
                array('not_in_db' => 1)  ),
14✔
30
            'address'          => pacol($this->new, 1,      1,      'mail', 'alias'                         , 'pCreate_alias_catchall_text'     ),
14✔
31
            'localpart'        => pacol($this->new, 0,      0,      'text', 'alias'                         , 'pCreate_alias_catchall_text'     , '',
14✔
32
                /*options*/ array(),
14✔
33
                /*not_in_db*/ 1                         ),
14✔
34
            'domain'           => pacol($this->new, 0,      1,      'enum', ''                              , ''                                , '',
14✔
35
                /*options*/ $this->allowed_domains      ),
14✔
36
            'goto'             => pacol(1,          1,      1,      'txtl', 'to'                            , 'pEdit_alias_help'                , array() ),
14✔
37
            'is_mailbox'       => pacol(0,          0,      1,      'int', ''                             , ''                                , 0 ,
14✔
38
                # technically 'is_mailbox' is bool, but the automatic bool conversion breaks the query. Flagging it as int avoids this problem.
39
                # Maybe having a vbool type (without the automatic conversion) would be cleaner - we'll see if we need it.
40
                /*options*/ array(),
14✔
41
                /*not_in_db*/ 0,
14✔
42
                /*dont_write_to_db*/ 1,
14✔
43
                /*select*/ 'coalesce(__is_mailbox,0) as is_mailbox' ),
14✔
44
                /*extrafrom set via set_is_mailbox_extrafrom() */
45
            '__mailbox_username' => pacol( 0,       0,      1,      'vtxt', ''                              , ''                                , 0),  # filled via is_mailbox
14✔
46
            'goto_mailbox'     => pacol($mbgoto,    $mbgoto,$mbgoto,'bool', 'pEdit_alias_forward_and_store' , ''                                , 0,
14✔
47
                /*options*/ array(),
14✔
48
                /*not_in_db*/ 1                         ), # read_from_db_postprocess() sets the value
14✔
49
            'on_vacation'      => pacol(1,          0,      1,      'bool', 'pUsersMenu_vacation'           , ''                                , 0 ,
14✔
50
                /*options*/ array(),
14✔
51
                /*not_in_db*/ 1                         ), # read_from_db_postprocess() sets the value - TODO: read active flag from vacation table instead?
14✔
52
            'created'          => pacol(0,          0,      0,      'ts',   'created'                       , ''                                ),
14✔
53
            'modified'         => pacol(0,          0,      1,      'ts',   'last_modified'                 , ''                                ),
14✔
54
            'active'           => pacol(1,          1,      1,      'bool', 'active'                        , ''                                , 1     ),
14✔
55
            '_can_edit'        => pacol(0,          0,      1,      'vnum', ''                              , ''                                , 0 , array(),
14✔
56
                array('select' => '1 as _can_edit')  ),
14✔
57
            '_can_delete'      => pacol(0,          0,      1,      'vnum', ''                              , ''                                , 0 , array(),
14✔
58
                array('select' => '1 as _can_delete')  ), # read_from_db_postprocess() updates the value
14✔
59
                # aliases listed in $CONF[default_aliases] are read-only for domain admins if $CONF[special_alias_control] is NO.
60
        );
61

62
        $this->set_is_mailbox_extrafrom();
14✔
63
    }
14✔
64

65
    /*
66
     * set $this->struct['is_mailbox']['extrafrom'] based on the search conditions.
67
     * If a listing for a specific domain is requested, optimize the subquery to only return mailboxes from that domain.
68
     * This doesn't change the result of the main query, but improves the performance a lot on setups with lots of mailboxes.
69
     * When using this function to optimize the is_mailbox extrafrom, don't forget to reset it to the default value
70
     * (all domains for this admin) afterwards.
71
     */
72
    private function set_is_mailbox_extrafrom($condition=array(), $searchmode=array()) {
73
        $extrafrom = 'LEFT JOIN ( ' .
74
            ' SELECT 1 as __is_mailbox, username as __mailbox_username ' .
75
            ' FROM ' . table_by_key('mailbox') .
14✔
76
            ' WHERE username IS NOT NULL ';
14✔
77

78
        if (isset($condition['domain']) && !isset($searchmode['domain']) && in_array($condition['domain'], $this->allowed_domains)) {
14✔
79
            # listing for a specific domain, so restrict subquery to that domain
80
            $extrafrom .= ' AND ' . db_in_clause($this->domain_field, array($condition['domain']));
×
81
        } else {
82
            # restrict subquery to all domains accessible to this admin
83
            $extrafrom .= ' AND ' . db_in_clause($this->domain_field, $this->allowed_domains);
14✔
84
        }
85

86
        $extrafrom .= ' ) AS __mailbox ON __mailbox_username = address';
14✔
87

88
        $this->struct['is_mailbox']['extrafrom'] = $extrafrom;
14✔
89
    }
14✔
90

91

92
    protected function initMsg() {
93
        $this->msg['error_already_exists'] = 'email_address_already_exists';
14✔
94
        $this->msg['error_does_not_exist'] = 'alias_does_not_exist';
14✔
95
        $this->msg['confirm_delete'] = 'confirm_delete_alias';
14✔
96
        $this->msg['list_header'] = 'pOverview_alias_title';
14✔
97

98
        if ($this->new) {
14✔
99
            $this->msg['logname'] = 'create_alias';
12✔
100
            $this->msg['store_error'] = 'pCreate_alias_result_error';
12✔
101
            $this->msg['successmessage'] = 'pCreate_alias_result_success';
12✔
102
        } else {
103
            $this->msg['logname'] = 'edit_alias';
6✔
104
            $this->msg['store_error'] = 'pEdit_alias_result_error';
6✔
105
            $this->msg['successmessage'] = 'alias_updated';
6✔
106
        }
107
    }
14✔
108

109

110
    public function webformConfig() {
111
        if ($this->new) { # the webform will display a localpart field + domain dropdown on $new
×
112
            $this->struct['address']['display_in_form'] = 0;
×
113
            $this->struct['localpart']['display_in_form'] = 1;
×
114
            $this->struct['domain']['display_in_form'] = 1;
×
115
        }
116

117
        if (Config::bool('show_status')) {
×
118
            $this->struct['status']['display_in_list'] = 1;
×
119
            $this->struct['status']['label'] = ' ';
×
120
        }
121

122
        return array(
123
            # $PALANG labels
124
            'formtitle_create'  => 'pMain_create_alias',
×
125
            'formtitle_edit'    => 'pEdit_alias_welcome',
126
            'create_button'     => 'add_alias',
127

128
            # various settings
129
            'required_role' => 'admin',
130
            'listview'      => 'list-virtual.php',
131
            'early_init'    => 0,
132
            'prefill'       => array('domain'),
133
        );
134
    }
135

136
    /**
137
     * AliasHandler needs some special handling in init() and therefore overloads the function.
138
     * It also calls parent::init()
139
     */
140
    public function init(string $id): bool {
141
        $bits = explode('@', $id);
12✔
142
        if (sizeof($bits) == 2) {
12✔
143
            $local_part = $bits[0];
12✔
144
            $domain = $bits[1];
12✔
145
            if ($local_part == '*') { # catchall - postfix expects '@domain', not '*@domain'
12✔
146
                $id = '@' . $domain;
×
147
            }
148
        }
149

150
        $retval = parent::init($id);
12✔
151

152
        if (!$retval) {
10✔
153
            return false;
2✔
154
        } # parent::init() failed, no need to continue
155

156
        # hide 'goto_mailbox' for non-mailbox aliases
157
        # parent::init called view() before, so we can rely on having $this->result filled
158
        # (only validate_new_id() is called from parent::init and could in theory change $this->result)
159
        if ($this->new || $this->result['is_mailbox'] == 0) {
10✔
160
            $this->struct['goto_mailbox']['editable']        = 0;
10✔
161
            $this->struct['goto_mailbox']['display_in_form'] = 0;
10✔
162
            $this->struct['goto_mailbox']['display_in_list'] = 0;
10✔
163
        }
164

165
        if (!$this->new && $this->result['is_mailbox'] && $this->admin_username != ''&& !authentication_has_role('global-admin')) {
10✔
166
            # domain admins are not allowed to change mailbox alias $CONF['alias_control_admin'] = NO
167
            # TODO: apply the same restriction to superadmins?
168
            if (!Config::bool('alias_control_admin')) {
×
169
                # TODO: make translateable
170
                $this->errormsg[] = "Domain administrators do not have the ability to edit user's aliases (check config.inc.php - alias_control_admin)";
×
171
                return false;
×
172
            }
173
        }
174

175
        return $retval;
10✔
176
    }
177

178
    protected function domain_from_id() {
179
        list(/*NULL*/, $domain) = explode('@', $this->id);
10✔
180
        return $domain;
10✔
181
    }
182

183
    protected function validate_new_id() {
184
        if ($this->id == '') {
12✔
185
            $this->errormsg[$this->id_field] = Config::lang('pCreate_alias_address_text_error1');
×
186
            return false;
×
187
        }
188

189
        list($local_part, $domain) = explode('@', $this->id);
12✔
190

191
        # TODO: already checked in set() - does it make sense to check it here also? Only advantage: it's an early check
192
        #        if (!in_array($domain, $this->allowed_domains)) {
193
        #            $this->errormsg[] = Config::lang('pCreate_alias_address_text_error1');
194
        #            return false;
195
        #        }
196

197
        if ($local_part != '') { # catchall
12✔
198
            $email_check = check_email($this->id);
12✔
199
            if ($email_check != '') {
12✔
UNCOV
200
                $this->errormsg[$this->id_field] = $email_check;
×
NEW
201
                return false;
×
202
            }
203
        }
204

205
        if (!$this->create_allowed($domain)) {
12✔
206
            $this->errormsg[$this->id_field] = Config::lang('pCreate_alias_address_text_error3');
2✔
207
            return false;
2✔
208
        }
209

210
        return true;
10✔
211
    }
212

213
    /**
214
     * check number of existing aliases for this domain - is one more allowed?
215
     */
216
    private function create_allowed($domain) {
217
        if ($this->called_by == 'MailboxHandler') {
12✔
218
            return true;
2✔
219
        } # always allow creating an alias for a mailbox
220

221
        $limit = get_domain_properties($domain);
10✔
222

223
        if ($limit['aliases'] == 0) {
8✔
224
            return true;
×
225
        } # unlimited
226
        if ($limit['aliases'] < 0) {
8✔
227
            return false;
×
228
        } # disabled
229
        if ($limit['alias_count'] >= $limit['aliases']) {
8✔
230
            return false;
2✔
231
        }
232
        return true;
8✔
233
    }
234

235

236
    /**
237
     * merge localpart and domain to address
238
     * called by edit.php (if id_field is editable and hidden in editform) _before_ ->init
239
     */
240
    public function mergeId($values) {
241
        if ($this->struct['localpart']['display_in_form'] == 1 && $this->struct['domain']['display_in_form']) { # webform mode - combine to 'address' field
×
242
            if (empty($values['localpart']) || empty($values['domain'])) { # localpart or domain not set
×
243
                return "";
×
244
            }
245
            if ($values['localpart'] == '*') {
×
246
                $values['localpart'] = '';
×
247
            } # catchall
248
            return $values['localpart'] . '@' . $values['domain'];
×
249
        } else {
250
            return $values[$this->id_field];
×
251
        }
252
    }
253

254
    protected function setmore(array $values) {
255
        if ($this->new) {
10✔
256
            if ($this->struct['address']['display_in_form'] == 1) { # default mode - split off 'domain' field from 'address' # TODO: do this unconditional?
10✔
257
                list(/*NULL*/, $domain) = explode('@', $values['address']);
10✔
258
                $this->values['domain'] = $domain;
10✔
259
            }
260
        }
261

262
        if (! $this->new) { # edit mode - preserve vacation and mailbox alias if they were included before
10✔
263
            $old_ah = new AliasHandler();
2✔
264

265
            if (!$old_ah->init($this->id)) {
2✔
266
                $this->errormsg[] = $old_ah->errormsg[0];
×
267
            } elseif (!$old_ah->view()) {
2✔
268
                $this->errormsg[] = $old_ah->errormsg[0];
×
269
            } else {
270
                $oldvalues = $old_ah->result();
2✔
271

272
                if (!isset($values['goto'])) { # no new value given?
2✔
273
                    $values['goto'] = $oldvalues['goto'];
2✔
274
                }
275

276
                if (!isset($values['on_vacation'])) { # no new value given?
2✔
277
                    $values['on_vacation'] = $oldvalues['on_vacation'];
2✔
278
                }
279

280
                if ($values['on_vacation']) {
2✔
281
                    $values['goto'][] = $this->getVacationAlias();
×
282
                }
283

284
                if ($oldvalues['is_mailbox']) { # alias belongs to a mailbox - add/keep mailbox to/in goto
2✔
285
                    if (!isset($values['goto_mailbox'])) { # no new value given?
2✔
286
                        $values['goto_mailbox'] = $oldvalues['goto_mailbox'];
2✔
287
                    }
288
                    if ($values['goto_mailbox']) {
2✔
289
                        $values['goto'][] = $this->id;
2✔
290

291
                        # if the alias points to the mailbox, don't display the "empty goto" error message
292
                        if (isset($this->errormsg['goto']) && $this->errormsg['goto'] == Config::lang('pEdit_alias_goto_text_error1')) {
2✔
293
                            unset($this->errormsg['goto']);
×
294
                        }
295
                    }
296
                }
297
            }
298
        }
299

300
        $this->values['goto'] = join(',', $values['goto']);
10✔
301
    }
10✔
302

303
    protected function postSave(): bool {
304
        # TODO: if alias belongs to a mailbox, update mailbox active status
305
        return true;
8✔
306
    }
307

308
    protected function read_from_db_postprocess($db_result) {
309
        foreach ($db_result as $key => $value) {
14✔
310
            # split comma-separated 'goto' into an array
311
            $goto = $db_result[$key]['goto'] ?? null;
10✔
312
            if (is_string($goto)) {
10✔
313
                $db_result[$key]['goto'] = explode(',', $goto);
10✔
314
            }
315

316
            # Vacation enabled?
317
            list($db_result[$key]['on_vacation'], $db_result[$key]['goto']) = remove_from_array($db_result[$key]['goto'], $this->getVacationAlias());
10✔
318

319
            # if it is a mailbox, does the alias point to the mailbox?
320
            if ($db_result[$key]['is_mailbox']) {
10✔
321
                # this intentionally does not match mailbox targets with recipient delimiter.
322
                # if it would, we would have to make goto_mailbox a text instead of a bool (which would annoy 99% of the users)
323
                list($db_result[$key]['goto_mailbox'], $db_result[$key]['goto']) = remove_from_array($db_result[$key]['goto'], $key);
2✔
324
            } else { # not a mailbox
325
                $db_result[$key]['goto_mailbox'] = 0;
8✔
326
            }
327

328
            # editing a default alias (postmaster@ etc.) is only allowed if special_alias_control is allowed or if the user is a superadmin
329
            $tmp = preg_split('/\@/', $db_result[$key]['address']);
10✔
330
            if (!$this->is_superadmin && !Config::bool('special_alias_control') && array_key_exists($tmp[0], Config::read_array('default_aliases'))) {
10✔
331
                $db_result[$key]['_can_edit'] = 0;
×
332
                $db_result[$key]['_can_delete'] = 0;
×
333
            }
334

335
            if ($this->struct['status']['display_in_list'] && Config::bool('show_status')) {
10✔
336
                $db_result[$key]['status'] = gen_show_status($db_result[$key]['address']);
×
337
            }
338
        }
339

340
        return $db_result;
14✔
341
    }
342

343
    private function condition_ignore_mailboxes($condition, $searchmode) {
344
        # only list aliases that do not belong to mailboxes
345
        if (is_array($condition)) {
10✔
346
            $condition['__mailbox_username'] = 1;
×
347
            $searchmode['__mailbox_username'] = 'NULL';
×
348
        } else {
349
            if ($condition != '') {
10✔
350
                $condition = " ( $condition ) AND ";
×
351
            }
352
            $condition = " $condition __mailbox_username IS NULL ";
10✔
353
        }
354
        return array($condition, $searchmode);
10✔
355
    }
356

357
    public function getList($condition, $searchmode = array(), $limit=-1, $offset=-1): bool {
358
        list($condition, $searchmode) = $this->condition_ignore_mailboxes($condition, $searchmode);
10✔
359
        $this->set_is_mailbox_extrafrom($condition, $searchmode);
10✔
360
        $result = parent::getList($condition, $searchmode, $limit, $offset);
10✔
361
        $this->set_is_mailbox_extrafrom(); # reset to default
10✔
362
        return $result;
10✔
363
    }
364

365
    public function getPagebrowser($condition, $searchmode = array()) {
366
        list($condition, $searchmode) = $this->condition_ignore_mailboxes($condition, $searchmode);
×
367
        $this->set_is_mailbox_extrafrom($condition, $searchmode);
×
368
        $result = parent::getPagebrowser($condition, $searchmode);
×
369
        $this->set_is_mailbox_extrafrom(); # reset to default
×
370
        return $result;
×
371
    }
372

373

374

375
    protected function _validate_goto($field, $val) {
376
        if (count($val) == 0) {
10✔
377
            # empty is ok for mailboxes - this is checked in setmore() which can clear the error message
378
            $this->errormsg[$field] = Config::lang('pEdit_alias_goto_text_error1');
×
379
            return false;
×
380
        }
381

382
        $errors = array();
10✔
383

384
        foreach ($val as $singlegoto) {
10✔
385
            if (substr($this->id, 0, 1) == '@' && substr($singlegoto, 0, 1) == '@') { # domain-wide forward - check only the domain part
10✔
386
                # only allowed if $this->id is a catchall
387
                # Note: alias domains are better, but we should keep this way supported for backward compatibility
388
                #       and because alias domains can't forward to external domains
389
                list(/*NULL*/, $domain) = explode('@', $singlegoto);
×
390
                $domain_check = check_domain($domain);
×
391
                if ($domain_check != '') {
×
392
                    $errors[] = "$singlegoto: $domain_check";
×
393
                }
394
            } else {
395
                $email_check = check_email($singlegoto);
10✔
396
                // preg_match -> allows for redirect to a local system account.
397
                if ($email_check != '' && !preg_match('/^[a-z0-9]+$/', $singlegoto)) {
10✔
398
                    $errors[] = "$singlegoto: $email_check";
×
399
                }
400
            }
401
            if ($this->called_by != "MailboxHandler" && $this->id == $singlegoto) {
10✔
402
                // The MailboxHandler needs to create an alias that points to itself (for the mailbox)
403
                // Otherwise, disallow such aliases as they cause severe trouble in the mail system
404
                $errors[] = "$singlegoto: " . Config::Lang('alias_points_to_itself');
2✔
405
            }
406
        }
407

408
        if (count($errors)) {
10✔
409
            $this->errormsg[$field] = join("   ", $errors); # TODO: find a way to display multiple error messages per field
2✔
410
            return false;
2✔
411
        } else {
412
            return true;
8✔
413
        }
414
    }
415

416
    /**
417
     * on $this->new, set localpart based on address
418
     */
419
    protected function _missing_localpart($field) {
420
        if (isset($this->RAWvalues['address'])) {
2✔
421
            $parts = explode('@', $this->RAWvalues['address']);
2✔
422
            if (count($parts) == 2) {
2✔
423
                $this->RAWvalues['localpart'] = $parts[0];
2✔
424
            }
425
        }
426
    }
2✔
427

428
    /**
429
     * on $this->new, set domain based on address
430
     */
431
    protected function _missing_domain($field) {
432
        if (isset($this->RAWvalues['address'])) {
2✔
433
            $parts = explode('@', $this->RAWvalues['address']);
2✔
434
            if (count($parts) == 2) {
2✔
435
                $this->RAWvalues['domain'] = $parts[1];
2✔
436
            }
437
        }
438
    }
2✔
439

440

441
    /**
442
    * Returns the vacation alias for this user.
443
    * i.e. if this user's username was roger@example.com, and the autoreply domain was set to
444
    * autoreply.fish.net in config.inc.php we'd return roger#example.com@autoreply.fish.net
445
    *
446
    * @return string an email alias.
447
    */
448
    protected function getVacationAlias() {
449
        if ($this->id !== null) {
10✔
450
            $vacation_goto = str_replace('@', '#', $this->id);
10✔
451
            return $vacation_goto . '@' . Config::read_string('vacation_domain');
10✔
452
        }
453
        return "unknown@" . Config::read_string('vacation_domain');
2✔
454
    }
455

456
    /**
457
     *  @return boolean
458
     */
459
    public function delete() {
460
        if (! $this->view()) {
×
461
            $this->errormsg[] = Config::Lang('alias_does_not_exist');
×
462
            return false;
×
463
        }
464

465
        if ($this->result['is_mailbox']) {
×
466
            $this->errormsg[] = Config::Lang('mailbox_alias_cant_be_deleted');
×
467
            return false;
×
468
        }
469

470
        if (!$this->can_delete) {
×
471
            $this->errormsg[] = Config::Lang_f('protected_alias_cant_be_deleted', $this->id);
×
472
            return false;
×
473
        }
474

475
        db_delete('alias', 'address', $this->id);
×
476

477
        list(/*NULL*/, $domain) = explode('@', $this->id);
×
478
        db_log($domain, 'delete_alias', $this->id);
×
479
        $this->infomsg[] = Config::Lang_f('pDelete_delete_success', $this->id);
×
480
        return true;
×
481
    }
482
}
483

484
/* vim: set expandtab softtabstop=4 tabstop=4 shiftwidth=4: */
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc