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