.
**
** 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_escape($val);
break;
case 'Subject':
$this->subject .= $value; # Escape on sending
break;
case 'Cc':
foreach ((array)$value as $val)
$this->cc[] = $this->addrlist_escape($val);
break;
case 'Bcc':
foreach ((array)$value as $val)
$this->bcc[] = $this->addrlist_escape($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_escape($value) : $this->header_escape($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
* @return True if mail was accepted for delivery
*/
function send($p = array())
{
$to = join(",", $this->to);
$headers = array();
if (count($this->cc) > 0)
$headers[] = "Cc: " . join(",", $this->cc);
if (count($this->bcc) > 0)
$headers[] = "Bcc: " . join(",", $this->bcc);
for ($i = 0; $i < count($this->header_names); $i++)
$headers[] = $this->header_names[$i] . ': ' . $this->header_values[$i];
/* 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_escape($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_escape($this->subject), $text, join("\n", $headers), $this->flags)) === false)
it::error(array('title' => "failed sending mail to $to subject $this->subject", 'body' => D($text, $headers, $this->flags)));
return $result;
}
else
return ED($to, $this->header_escape($this->subject), $text, $headers, $this->flags);
}
/**
* 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_escape($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_escape($string)
{
# Exclude e-mail addresses from being encoded as
# e.g. GMail or Exchange have problems with that
foreach (str_split($string) as $char)
{
if ($char == '"')
$quoted = !$quoted;
if ($char == ',' && !$quoted)
$n++;
else
$mailboxes[$n] .= $char;
}
foreach ((array)$mailboxes as $mailbox)
{
if (preg_match('/^(.*)(\s+?<[^>]+@[^>]+>\s*)$/', $mailbox, $matches))
$result[] = $this->header_escape(trim($matches[1])) . $matches[2];
else
$result[] = trim($mailbox);
}
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.
*/
function fullname_escape($string)
{
return '"' . preg_replace('/["\x00-\x1f]/', '', $string) . '"';
}
/**
* 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;
}
/**
* 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;
/* Check if username starts with www. or not well-formed => reject */
if (!preg_match('/^www\./', $email) && preg_match('/^[a-z0-9&_+.-]+@([a-z0-9.-]+\.[a-z]+)$/i', $email, $regs))
{
$domain = $regs[1];
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;
}
}
?>