. ** ** mail.class - Create a mail object, add header and content and send it */ define('IT_MAIL_PLAIN', 0); define('IT_MAIL_HTML', 1); /* Return codes of it_mail::check_email() */ define('IT_MAIL_CHECKEMAIL_INVALID', 0); define('IT_MAIL_CHECKEMAIL_MAILBOXFULL', 5); define('IT_MAIL_CHECKEMAIL_OK', 10); /** * Construct an email message with headers, body (plaintext and/or HTML) and * send it. Also provides utility function to check email address validity.
* On devel/twin machines, will output mail instead of sending it.
* Example:
* * if (!it_mail::check_email($to))
*     die("Invalid email address '$to'\n");
*
* $mail = new it_mail(array('To' => $to, 'From' => 'itools@gna.ch', 'Subject' => 'Example'));
* $mail->add_body('Plain text message body');
* $mail->add_body('HTML message with <a href="http://gna.ch/">link</a>', IT_MAIL_HTML);
* $mail->send();
*
*/ class it_mail { var $header_names = array(); var $header_values = array(); var $body = array(); var $attachments = array(); var $to = array(); var $subject = ""; var $cc = array(); var $bcc = array(); var $flags = ""; var $charset; /** * Construct a new email message. Headers and body can be added later. * Note: Headers To, Cc, Bcc can be arrays * @param $headers Array of headers for this email, e.g. From, To and Subject */ function __construct($headers = array()) { $this->charset = ini_get('default_charset'); foreach ((array)$headers as $header => $value) $this->add_header($header, $value); } /** * Add header line to the this email message. Can be called repeatedly. * Note: Headers To, Cc, Bcc can be arrays * @param $header Header to be added, e.g. Cc, Bcc or X-My-Header * @param $value Value of header, e.g. itools@gna.ch for Bcc header */ function add_header($header, $value) { switch ($header) { case 'To': foreach ((array)$value as $val) $this->to[] = $this->addrlist_encode($val); break; case 'Subject': $this->subject .= $value; # Escape on sending break; case 'Cc': foreach ((array)$value as $val) $this->cc[] = $this->addrlist_encode($val); break; case 'Bcc': foreach ((array)$value as $val) $this->bcc[] = $this->addrlist_encode($val); break; case 'charset': $this->charset = $value; break; case 'Errors-To': $this->flags = "-f " . escapeshellarg(preg_replace('/.*<([^>]+)>.*/', '$1', $value)); /* FALLTHROUGH */ default: $this->header_names[] = $header; $this->header_values[] = $header == 'From' || $header =='Reply-To' ? $this->addrlist_encode($value) : $this->header_encode($value); break; } } /** * Add body part to this email message. Can be called repeatedly. * @param $text Text to be added to email message * @param $type Type of text, one of IT_MAIL_PLAIN (default) or IT_MAIL_HTML */ function add_body($text, $type = IT_MAIL_PLAIN) { switch ($type) { case IT_MAIL_PLAIN: case IT_MAIL_HTML: $this->body[$type] .= $text; break; default: it::fatal("it_mail::add_body invalid type $type"); break; } } /** * Add attachment to this email message. Can be called repeatedly. * @param $data Data to be attached * @param $p['dispositon'] optional Content-Disposition value: "inline" or "attachment" * @param $p['mimetype'] optional MIME-Type of attached file * @param $p['name'] optional name of attached file * @return 'cid:'.Content-ID of this attachment */ function add_attachment($data, $p = array(), $legacy_name = null) { if (!is_array($p)) # legacy mode: two scalars mime type / name $p = array('mimetype' => $p) + ($legacy_name ? array('name' => $legacy_name) : array()); $this->attachments[] = $p + array( 'cid' => $cid = bin2hex(random_bytes(16)), 'data' => $data, 'disposition' => "inline", 'mimetype' => "application/octet-stream", 'name' => "Attachment" . (count($this->attachments) + 1), ); return "cid:$cid"; } /** * Add file attachment to this email message. Can be called repeatedly. * @param $filename File to be attached * @param $p['dispositon'] optional Content-Disposition value: "inline" or "attachment" * @param $p['mimetype'] MIME-Type of attached file * @param $p['name'] Name of attached file as stored in mail */ function add_file($filename, $p = array(), $legacy_name = null) { if (!is_array($p)) # legacy mode: two scalars mime type / name $p = array('mimetype' => $p) + ($legacy_name ? array('name' => $legacy_name) : array()); return $this->add_attachment(it::file_get_contents($filename), $p + array('name' => basename($filename))); } /** * Send this email message * @param $p['forcemail'] Send mail even if on twin or devel machine * @param $p['it_error'] Error handling params for bad email addresses * @return True if mail was accepted for delivery */ function send($p = array()) { $to = implode(",", $this->to); $headers = array(); if (count($this->cc) > 0) $headers[] = "Cc: " . implode(",", $this->cc); if (count($this->bcc) > 0) $headers[] = "Bcc: " . implode(",", $this->bcc); for ($i = 0; $i < count($this->header_names); $i++) $headers[] = $this->header_names[$i] . ': ' . $this->header_values[$i]; foreach (array_merge((array)$this->to, (array)$this->cc, (array)$this->bcc) as $addr) if (($error = self::address_error($addr))) it::error((array)$p['it_error'] + ['title' => "address error '$error'", 'body' => $this]); /* Automatically add doctype if none given */ if ($this->body[IT_MAIL_HTML] && !preg_match('/^body[IT_MAIL_HTML])) $this->body[IT_MAIL_HTML] = '' . "\n" . $this->body[IT_MAIL_HTML]; $headers[] = "MIME-Version: 1.0"; if ($this->attachments) { /* Attachments need multipart MIME mail */ $boundary1 = bin2hex(random_bytes(16)); $mixedtype = "Content-Type: multipart/mixed; boundary=\"$boundary1\""; $headers[] = $mixedtype; $text .= "This is a multi-part message in MIME format."; $text .= "\n--$boundary1\n"; } /* Headers for plain and HTML content */ $plaintype = "Content-Type: text/plain; charset=".$this->charset."\nContent-Transfer-Encoding: 8bit"; $htmltype = "Content-Type: text/html; charset=".$this->charset."\nContent-Transfer-Encoding: 8bit"; if ($this->body[IT_MAIL_PLAIN]) { if ($this->body[IT_MAIL_HTML]) { $boundary2 = bin2hex(random_bytes(16)); $alternativetype = "Content-Type: multipart/alternative; boundary=\"$boundary2\""; /* Plain and HTML */ if ($this->attachments) $text .= "$alternativetype\n\n"; else $headers[] = $alternativetype; $text .= "--$boundary2\n$plaintype\n\n"; $text .= $this->body[IT_MAIL_PLAIN]; $text .= "\n--$boundary2\n$htmltype\n\n"; $text .= $this->body[IT_MAIL_HTML]; $text .= "\n--$boundary2--\n"; } else { /* Just plain */ if ($this->attachments) $text .= "$plaintype\n\n"; else $headers[] = $plaintype; $text .= $this->body[IT_MAIL_PLAIN]; } } else if ($this->body[IT_MAIL_HTML]) { /* Just HTML */ if ($this->attachments) { if (strstr($this->body[IT_MAIL_HTML], "cid:")) { $boundary3 = bin2hex(random_bytes(16)); $text .= "Content-Type: multipart/related; boundary=\"$boundary3\"\n\n--$boundary3\n"; } $text .= "$htmltype\n\n"; } else $headers[] = $htmltype; $text .= $this->body[IT_MAIL_HTML]; } if ($this->attachments) { $text .= "\n"; $boundary = $boundary3 ? $boundary3 : $boundary1; foreach ($this->attachments as $attachment) { $name = $this->header_encode($attachment['name']); $text .= "\n--$boundary\nContent-Type: {$attachment['mimetype']}; name=\"$name\"\nContent-Transfer-Encoding: base64\nContent-ID: <{$attachment['cid']}>\nContent-Disposition: {$attachment['disposition']}; filename=\"$name\"\n\n"; $text .= chunk_split(base64_encode($attachment['data'])); } if ($boundary3) $text .= "\n--$boundary3--\n\n"; $text .= "--$boundary1--\n"; } if (it::is_live() || EDC('forcemail') || $p['forcemail']) { if (($result = mail($to, $this->header_encode($this->subject), $text, implode("\n", $headers), $this->flags)) === false) it::error(['title' => "failed sending mail to $to subject $this->subject", 'body' => ['text' => $text, 'headers' => $headers, 'flags' => $this->flags]]); return $result; } else return ED($to, $this->header_encode($this->subject), $text, $headers, $this->flags); } /** * INTERNAL: Split address lists in headers like From, To, Cc, Bcc into * name/email pairs * @param $string String containing address list * @return Generator returning name/email tuples */ static function addrlist_split($string) { $quoted = false; foreach (str_split($string) as $char) { if ($char == '"') $quoted = !$quoted; if ($char == ',' && !$quoted) $n++; else $mailboxes[$n] .= $char; } foreach ((array)$mailboxes as $mailbox) { $mode = 'name'; $name = $email = ''; $quoted = false; foreach (str_split($mailbox) as $char) { if ($char == '"') $quoted = !$quoted; switch ($mode) { case 'name': if ($char == '<' && !$quoted) $mode = 'email'; else $name .= $char; break; case 'email': if ($char == '>' && !$quoted) { $mode = 'finish'; break 2; } else $email .= $char; break; } } if ($mode == 'email') { $name = $name . '<' . $email; $email = ''; $mode = 'name'; } if ($mode == 'name' && !$email) { $email = it::replace(['^"(.*)"$' => '$1'], trim($name)); $name = ''; } yield [$name, trim($email)]; } } /** * INTERNAL: Escape header line with ?=??Q if necessary, e.g. in Subject line * @param $string String to be escaped * @return String escape suitable for sending in header line */ function header_encode($string) { return preg_match('/[\x00-\x1f\x7f-\xff]/', $string) ? ltrim( ($encoded = @iconv_mime_encode('', $string, array('scheme' => 'Q', 'input-charset' => $this->charset, 'output-charset' => $this->charset))) !== false ? $encoded : (($encoded = @iconv_mime_encode('', $string, array('scheme' => 'B', 'input-charset' => $this->charset, 'output-charset' => $this->charset))) !== false ? $encoded : iconv_mime_encode('', $string, array('scheme', 'B', 'input-charset' => 'ISO-8859-1', 'output-charset' => $this->charset))), ' :' ) : $string; } /** * INTERNAL: Escape address lists in headers like From, To, Cc, Bcc * @param $string String containing address list * @return String suitable for sending in address list headers */ function addrlist_encode($string) { # Exclude e-mail addresses from being encoded as # e.g. GMail or Exchange have problems with that foreach (self::addrlist_split($string) as list($name, $email)) { $email = self::email_encode($email); if ($name && $email) $result[] = $this->header_encode(trim($name)) . " <$email>"; else if (!$name && $email) $result[] = $email; } return implode(', ', $result); } /** * Make string safe to be used in "$fullname <$email>": Remove illegal * characters and enclose in double quotes. * @param $string Fulle name to be escaped * @return String to be safely used in "$fullname <$email>" for To: etc. */ static function fullname_encode($string) { return '"' . preg_replace('/["\x00-\x1f]/', '', $string) . '"'; } /** * INTERNAL: Split email-address into local part and doomain * @return Array with to elements: local part and domain */ static function email_split($email) { $mode = 'local'; $local = $domain = ''; $quoted = false; foreach (str_split($email) as $char) { if ($char == '"') $quoted = !$quoted; switch ($mode) { case 'local': if ($char == '@' && !$quoted) $mode = 'domain'; else $local .= $char; break; case 'domain': $domain .= $char; } } return [$local, $domain]; } /** * INTERNAL: Convert domain part of email address into ascii idn form * @return String with converted email address */ static function email_encode($email) { list($local, $domain) = self::email_split($email); return $local . ($domain ? '@' . idn_to_ascii($domain) : ''); } /** * INTERNAL: Send SMTP command and return true if result is 2XX * @param $fp Connection to SMTP server opened using fsockopen * @param $command Command to send, e.g. HELO gna.ch * @param $anwer String containing full answer from SMTP server * @param timeoutok Whether a timeout is considered ok * @param $failcode Lowest SMTP result code which is considered a failure (default 300) * @return True if SMTP result code is lower than $failcode * */ static function send_smtp_cmd($fp, $cmd, &$answer, $timeoutok = false, $failcode = 300) { $result = false; $answer = ''; if (!$cmd || (!feof($fp) && fwrite($fp, "$cmd\r\n"))) { while (!feof($fp) && preg_match('/^(...)(.?)(.*)[\r\n]*$/', fgets($fp, 1024), $regs)) { $answer .= $regs[0]; $resultcode = intval($regs[1]); if ($regs[2] != '-') /* Multi line response? */ { if (($resultcode >= 100) && ($resultcode < $failcode)) $result = true; break; } } } /* A timeout can be considered ok for SLOW mail servers */ if ($answer == '') $result = $timeoutok; EDC('mailcheck', time(), $cmd, $result, $answer); return $result; } /* Return errors found with email address, null otherwise */ static function address_error($addresslist) { foreach (self::addrlist_split($addresslist) as list($dummy, $email)) { $email = self::email_encode($email); if (filter_var($email, FILTER_VALIDATE_EMAIL, FILTER_FLAG_EMAIL_UNICODE) === false && !it::match('^\s*[a-z][-a-z0-9]*\s*$', $email)) return "invalid format on $email"; } } /** * Check if given email address is valid (syntax, MX record). If you set * checkmailbox to true it also connects to MX and asks if mailbox is valid. * Note: Connecting to the MX can be slow. * @param $email email to be check * @param $checkmailbox True if you want to connect to MX and check account * @return One of IT_MAIL_CHECKEMAIL_INVALID (0), IT_MAIL_CHECKEMAIL_MAILBOXFULL * or IT_MAIL_CHECKEMAIL_OK (both != 0). */ static function check_email($email, $checkmailbox = false) { $result = IT_MAIL_CHECKEMAIL_INVALID; $email = self::email_encode($email); list($dummy, $domain) = self::email_split($email); /* Check if username starts with www. or not well-formed => reject */ if (!preg_match('/^www\./', $email) && filter_var($email, FILTER_VALIDATE_EMAIL) !== false && $domain) { if (!getmxrr($domain, $mxhosts, $weights)) { /* If we find no MX we check if domain exists */ if (gethostbynamel($domain)) { $mxhosts = array($domain); $weights = array(1); } } if (array_filter($mxhosts)) /* We need at least one valid MX */ { if ($checkmailbox) { $mx = array(); for ($i = 0; $i < count($mxhosts); $i++) $mx[$mxhosts[$i]] = $weights[$i]; asort($mx, SORT_NUMERIC); $port = getservbyname('smtp', 'tcp'); $from = "itools@" . (($domain = it::match('[^.]*\.(.+)', getenv('ULTRAHOSTNAME') ? getenv('ULTRAHOSTNAME') : it::exec('hostname -f 2>/dev/null || hostname 2>/dev/null'))) ? $domain : "gna.ch"); /* Determine domain and email for test, skip if called as it_mail::check_email() */ if (isset($this) && is_object($this)) { for ($i = 0; $i < count($this->header_names); $i++) { if ($this->header_names[$i] == 'From') { $from = $this->header_values[$i]; break; } } } if (preg_match('/@([a-z0-9.-]+\.[a-z]+)$/i', $from, $regs)) $fromdomain = $regs[1]; else $fromdomain = 'gna.ch'; $finished = false; $connected = 0; if (function_exists('stream_set_timeout')) $timeout = 'stream_set_timeout'; else $timeout = 'socket_set_timeout'; foreach ($mx as $mxhost => $dummy_weight) { if ($fp = @fsockopen($mxhost, $port, $dummy_errno, $dummy_errstr, 5)) { $connected++; $timeout($fp, 45); $answer = ''; if (it_mail::send_smtp_cmd($fp, '', $answer) && it_mail::send_smtp_cmd($fp, "HELO $fromdomain", $answer) && it_mail::send_smtp_cmd($fp, "MAIL FROM: <$from>", $answer)) { $timeout($fp, 2); $timeoutok = ($domain != 'bluewin.ch'); if (it_mail::send_smtp_cmd($fp, "RCPT TO: <$email>", $answer, $timeoutok, 500)) # 450 is often used for Greylisting $result = IT_MAIL_CHECKEMAIL_OK; else if (preg_match('/quota|full|exceeded storage/i', $answer)) $result = IT_MAIL_CHECKEMAIL_MAILBOXFULL; /* Do not try other MX if we got this far */ $finished = true; } $timeout($fp, 0, 1); it_mail::send_smtp_cmd($fp, 'RSET', $answer); it_mail::send_smtp_cmd($fp, 'QUIT', $answer); fclose($fp); } if ($finished) break; } /* If we cannot connect to _any_ MX it could be because of temporary overload / virus attack */ if (!$connected) $result = IT_MAIL_CHECKEMAIL_OK; } else $result = IT_MAIL_CHECKEMAIL_OK; } } return $result; } }