Code Coverage |
||||||||||
Classes and Traits |
Functions and Methods |
Lines |
||||||||
Total | |
100.00% |
2 / 2 |
|
100.00% |
16 / 16 |
CRAP | |
100.00% |
205 / 205 |
EmailException | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |
100.00% |
2 / 2 |
__construct | |
100.00% |
1 / 1 |
1 | |
100.00% |
2 / 2 |
|||
|
100.00% |
1 / 1 |
|
100.00% |
15 / 15 |
118 | |
100.00% |
203 / 203 |
|
__construct | |
100.00% |
1 / 1 |
6 | |
100.00% |
9 / 9 |
|||
getBackEnd | |
100.00% |
1 / 1 |
17 | |
100.00% |
15 / 15 |
|||
get | |
100.00% |
1 / 1 |
1 | |
100.00% |
4 / 4 |
|||
to | |
100.00% |
1 / 1 |
1 | |
100.00% |
2 / 2 |
|||
replyTo | |
100.00% |
1 / 1 |
1 | |
100.00% |
2 / 2 |
|||
cc | |
100.00% |
1 / 1 |
1 | |
100.00% |
2 / 2 |
|||
bcc | |
100.00% |
1 / 1 |
1 | |
100.00% |
2 / 2 |
|||
subject | |
100.00% |
1 / 1 |
1 | |
100.00% |
2 / 2 |
|||
message | |
100.00% |
1 / 1 |
1 | |
100.00% |
2 / 2 |
|||
template | |
100.00% |
1 / 1 |
1 | |
100.00% |
3 / 3 |
|||
attachFile | |
100.00% |
1 / 1 |
7 | |
100.00% |
10 / 10 |
|||
attachData | |
100.00% |
1 / 1 |
5 | |
100.00% |
6 / 6 |
|||
send | |
100.00% |
1 / 1 |
62 | |
100.00% |
121 / 121 |
|||
address | |
100.00% |
1 / 1 |
7 | |
100.00% |
14 / 14 |
|||
cronMinute | |
100.00% |
1 / 1 |
6 | |
100.00% |
9 / 9 |
<?php | |
/** | |
* PHP Portal Engine v3.0.0 | |
* https://github.com/bztsrc/phppe3/. | |
* | |
* Copyright LGPL 2016 bzt | |
* | |
* This program is free software; you can redistribute it and/or modify | |
* it under the terms of the GNU Lesser General Public License as published | |
* by the Free Software Foundation, either version 3 of the License, or | |
* (at your option) any later version. | |
* | |
* This program is distributed in the hope that it will be useful, | |
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
* GNU Lesser General Public License for more details. | |
* | |
* <http://www.gnu.org/licenses/> | |
* | |
* @file vendor/phppe/Core/libs/Email.php | |
* | |
* @author bzt | |
* @date 1 Jan 2016 | |
* @brief Email message object, included in Pack | |
* This class is not as flexible as it's competition, but smart | |
* enough to work in 99% of usecases. | |
*/ | |
namespace PHPPE; | |
/** | |
* Exception class. | |
*/ | |
class EmailException extends \Exception | |
{ | |
public function __construct($message = '', $code = 0, Exception $previous = null) | |
{ | |
parent::__construct($message, $code, $previous); | |
} | |
} | |
class Email extends Extension | |
{ | |
public $name; | |
//! smtp relay parameters | |
private $via; | |
private static $host; | |
private static $port; | |
private static $user; | |
private static $pass; | |
private static $sender; | |
private static $forge; | |
//! email object properties | |
private $header; | |
private $message; | |
private $attach; | |
/** | |
* constructor. You can pass a previosly dumped object to it. | |
* | |
* @param string object data dumped by get() | |
* @param string hostname | |
*/ | |
public function __construct($msg = '') | |
{ | |
//! use Core's smtp relay configuration | |
//! we allow an array here, to specify additional fields in config.php | |
$cfg = is_array(Core::$core->mailer) ? Core::$core->mailer : @parse_url(Core::$core->mailer); | |
$this->getBackEnd($cfg); | |
//! load data from dumped object | |
if (!empty($msg)) { | |
$msg = json_decode($msg, true); | |
if (json_last_error() || !is_array($msg)) { | |
throw new EmailException(json_last_error_msg()); | |
} | |
foreach (['header', 'message', 'attach'] as $k) { | |
$this->$k = $msg[$k]; | |
} | |
} | |
} | |
private function getBackEnd($cfg) | |
{ | |
//! populate properties | |
$p = !empty($cfg['protocol']) ? $cfg['protocol'] : (!empty($cfg['scheme']) ? $cfg['scheme'] : ''); | |
if (empty($p) && is_string($cfg)) $p = $cfg; | |
if (empty($p) && !empty($cfg['path'])) $p = $cfg['path']; | |
$this->via = !empty($p) && in_array($p, [ | |
//! backends | |
'smtp', //! speak smtp directly (no dependency at all, default) | |
'mime', //! just build mime message and return it | |
'log', //! only log message but do not send it for real | |
'mail', //! use php's mail() | |
'sendmail', //! use sendmail command through pipe | |
'db', //! store message in database queue | |
'phpmailer', //! use PHPMailer class for sending | |
]) ? $p : ''; | |
self::$host = !empty($cfg['host']) ? $cfg['host'] : 'localhost'; | |
self::$port = !empty($cfg['port']) ? $cfg['port'] : 25; | |
self::$user = !empty($cfg['user']) ? $cfg['user'] : ''; | |
self::$pass = !empty($cfg['pass']) ? $cfg['pass'] : ''; | |
self::$sender = !empty($cfg['sender']) ? $cfg['sender'] : | |
'no-reply@'.(!empty($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : | |
(!empty(Core::$core->hostname) ? Core::$core->hostname : 'localhost')); | |
self::$forge = !empty($cfg['forge']) ? $cfg['forge'] : ''; | |
} | |
/** | |
* you can call this to get email in a form that can be stored in database. | |
* | |
* @return string dumped email object | |
*/ | |
public function get() | |
{ | |
return json_encode([ | |
'header' => $this->header, | |
'message' => $this->message, | |
'attach' => $this->attach, | |
]); | |
} | |
/** | |
* methods to add headers to email. | |
*/ | |
public function to($email) | |
{ | |
$this->address($email, 'To'); | |
return $this; | |
} | |
public function replyTo($email) | |
{ | |
$this->address($email, 'Reply-to'); | |
return $this; | |
} | |
public function cc($email) | |
{ | |
$this->address($email, 'Cc'); | |
return $this; | |
} | |
public function bcc($email) | |
{ | |
$this->address($email, 'Bcc'); | |
return $this; | |
} | |
public function subject($subject) | |
{ | |
$this->header['Subject'] = '=?utf-8?Q?'.quoted_printable_encode(str_replace("\r", '', str_replace("\n", ' ', $subject))).'?='; | |
return $this; | |
} | |
public function message($message) | |
{ | |
$this->message = trim(str_replace("\r", '', $message)); | |
return $this; | |
} | |
public function template($template, $args) | |
{ | |
View::assign('args', $args); | |
$this->message = trim(str_replace("\r", '', View::template($template))); | |
return $this; | |
} | |
/** | |
* methods to add attachments to email. | |
*/ | |
public function attachFile($file, $mime = '') | |
{ | |
if (!empty($file)) { | |
$fn = ""; | |
foreach ([$file, '.tmp/'.$file, 'data/'.$file, 'vendor/phppe/*/'.$file] as $v) { | |
$fn = @glob($v)[0]; | |
if(!empty($fn)) break; | |
} | |
if(!empty($fn)) | |
$this->attach[] = [ | |
'file' => basename($file), | |
'mime' => !empty($mime) && strpos($mime, '/') ? $mime : mime_content_type($fn), | |
]; | |
} | |
return $this; | |
} | |
public function attachData($data, $mime = '', $filename = '') | |
{ | |
if (!empty($data)) { | |
$this->attach[] = [ | |
'file' => !empty($filename) ? $filename : '', | |
'mime' => !empty($mime) && strpos($mime, '/') ? $mime : 'application/octet-stream', | |
'data' => $data, ]; | |
} | |
return $this; | |
} | |
/** | |
* construct mime email and send it out. | |
* | |
* @param string transport backend | |
*/ | |
public function send($via = '') | |
{ | |
//! allow temporarly override backend. Only url allowed, not array | |
if (!empty($via)) { | |
$this->getBackEnd(@parse_url($via)); | |
} | |
//! sanity checks | |
if (empty($this->via)) { | |
throw new EmailException(L('Mailer backend not configured!')); | |
} | |
if (empty($this->message)) { | |
throw new EmailException(L('Empty message!')); | |
} | |
if (empty($this->header['Subject'])) { | |
throw new EmailException(L('No subject given!')); | |
} | |
if (empty($this->header['To'])) { | |
throw new EmailException(L('No recipient given!')); | |
} | |
if (count($this->header['To']) > 64) { | |
// @codeCoverageIgnoreStart | |
throw new EmailException(L('Too many recipients!')); | |
} | |
// @codeCoverageIgnoreEnd | |
$this->address(self::$sender, 'From'); | |
$local = @explode('@', array_keys($this->header['From'])[0])[1]; | |
if (empty($local)) $local = 'localhost'; | |
$id = sha1(uniqid()).'_'.microtime(true).'@'.$local; | |
//! message type | |
$isHtml = preg_match('/<html/i', $this->message); | |
//! *** handle transport backends that does not require mime message *** | |
if ($this->via == 'db') { | |
//! mail queue in database | |
if (empty(DS::db())) { | |
throw new EmailException(L('DB queue backend without configured datasource!')); | |
} | |
return DS::exec('INSERT INTO email_queue (data,created) VALUES (?,?);', [$this->get(), Core::$core->now]) > 0 ? true : false; | |
} elseif ($this->via == 'phpmailer') { | |
//! PHP Mailer | |
if (!ClassMap::has('PHPMailer')) { | |
throw new EmailException(L('PHPMailer not installed!')); | |
} | |
// @codeCoverageIgnoreStart | |
$mail = new \PHPMailer(); | |
$mail->Subject = $this->header['Subject']; | |
$mail->SetFrom(implode(', ', $this->header['From'])); | |
if (!empty($this->header['Reply-To'])) { | |
$mail->AddReplyTo($this->header['Reply-To']); | |
} | |
foreach (['To', 'Cc', 'Bcc'] as $type) { | |
foreach ($this->header[$type] as $rcpt => $full) { | |
list($name) = explode('<', $full); | |
$mail->SetAddress(self::$forge ? self::$forge : $rcpt, trim($name)); | |
} | |
} | |
foreach ($this->attach as $attach) { | |
$mail->AddAttachment($attach['file']); | |
} | |
$mail->MsgHTML($this->message); | |
return $mail->Send(); | |
// @codeCoverageIgnoreEnd | |
} | |
//! *** build mime message *** | |
//! mime headers | |
$headers['MIME-Version'] = '1.0'; | |
$headers['Content-Class'] = 'urn:content-classes:message'; | |
$headers['Content-Type'] = 'text/plain;charset=utf-8'; | |
$headers['Content-Transfer-Encoding'] = '8bit'; | |
$headers['Sender'] = implode(', ', $this->header['From']); | |
$headers['Message-ID'] = '<'.$id.'>'; | |
$headers['Date'] = date('r', Core::$core->now); | |
$headers['X-Mailer'] = 'PHPPE '.VERSION; | |
foreach ($this->header as $k => $v) { | |
$headers[$k] = is_array($v) ? implode(', ', $v) : $v; | |
} | |
//! mime body | |
if (!$isHtml) { | |
//! plain text email | |
$message = wordwrap($this->message, 78); | |
} else { | |
$boundary = uniqid(); | |
//! html email with a plain text alternative | |
$headers['Content-Type'] = "multipart/alternative;\n boundary=\"".$boundary.'"'; | |
$message = "This is a multi-part message in MIME format.\r\n"; | |
$message .= '--'.$boundary."\n". | |
"Content-type: text/plain;charset=utf-8\n". | |
"Content-Transfer-Encoding: 8bit\n\n". | |
wordwrap( | |
preg_replace("/\<.*?\>/m", '', | |
strtr($this->message, [ | |
'</h1>' => "\n\n", | |
'</h2>' => "\n\n", | |
'</h3>' => "\n\n", | |
'</h4>' => "\n\n", | |
'</h5>' => "\n\n", | |
'</h6>' => "\n\n", | |
'</p>' => "\n\n", | |
'</td>' => "\t", | |
'</tr>' => "\n", | |
'</table>' => "\n", | |
'<br>' => "\n", | |
'<br/>' => "\n", | |
])), 78)."\r\n"; | |
//! look for images in html, if found, we have to create a multipart/related block | |
if (preg_match_all("/(http|images\/|data\/).*?\.(gif|png|jpe?g)/mis", $this->message, $m, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) { | |
$boundary2 = uniqid(); | |
$diff = 0; | |
foreach ($m as $k => $c) { | |
//if it's a absolute url, don't replace it | |
if ($c[1][0] == 'http') { | |
unset($m[$k]); | |
continue; | |
} | |
//generate a cid | |
$m[$k][3] = uniqid(); | |
//get local path for filename | |
if ($c[1][0] == 'data/' && file_exists($c[0][0])) $m[$k][4] = $c[0][0]; | |
elseif (file_exists('public/'.$c[0][0])) $m[$k][4] = 'public/'.$c[0][0]; | |
else { | |
foreach (['vendor/phppe/*/', 'vendor/*/', 'vendor/*/*/'] as $d) { | |
if ($m[$k][4] = @glob($d.$c[0][0])[0]) { | |
break; | |
} | |
} | |
} | |
//replace image url in message | |
$new = 'cid:'.$m[$k][3]; | |
$this->message = | |
substr($this->message, 0, $c[0][1] + $diff). | |
$new. | |
substr($this->message, $c[0][1] + $diff + strlen($c[0][0])); | |
$diff -= strlen($c[0][0]) - strlen($new); | |
} | |
} | |
if (!empty($m)) { | |
//! add the html part as related | |
$message .= '--'.$boundary."\n". | |
"Content-type: multipart/related;\n boundary=\"".$boundary2."\"\n\n". | |
"This is a multi-part message in MIME format.\r\n--".$boundary2."\n". | |
"Content-Type: text/html;charset=utf-8\n". | |
"Content-Transfer-Encoding: 8bit\n\n". | |
wordwrap($this->message, 78)."\r\n"; | |
foreach ($m as $c) { | |
$data = empty($c[4]) ? '' : (substr($c[4], 0, 4) == 'http' ? Core::get($c[4]) : file_get_contents($c[4])); | |
if (!$data) continue; | |
//get content | |
$message .= '--'.$boundary2."\n". | |
'Content-Type: image/'.($c[2][0] == 'jpg' ? 'jpeg' : $c[2][0])."\n". | |
"Content-Transfer-Encoding: base64\n". | |
"Content-Disposition: inline\n". | |
'Content-ID: <'.$c[3].">\n\n". | |
chunk_split(base64_encode($data), 78, "\n"); | |
} | |
$message .= '--'.$boundary2."--\n"; | |
} else { | |
$message .= '--'.$boundary."\n". | |
"Content-type: text/html;charset=utf-8\n". | |
"Content-Transfer-Encoding: 8bit\n\n". | |
wordwrap($this->message, 78)."\r\n"; | |
} | |
$message .= '--'.$boundary."--\n"; | |
} | |
if (!empty($this->attach)) { | |
$boundary = uniqid(); | |
$headers['Content-Type'] = "multipart/mixed;\n boundary=\"".$boundary.'"'; | |
$message = "This is a multi-part message in MIME format.\r\n--".$boundary."\n".$message; | |
foreach ($this->attach as $attach) { | |
$data = !empty($attach['data']) ? $attach['data'] : (substr($attach['file'], 0, 4) == 'http' ? Core::get($attach['file']) : @file_get_contents(substr($attach['file'], 0, 6) == 'images' ? @glob('vendor/phppe/*/'.$attach['file'])[0] : $attach['file'])); | |
if (!$data) continue; | |
$message .= '--'.$boundary."\n". | |
'Content-type: '.(!empty($attach['mime']) ? $attach['mime'] : 'application-octet-stream')."\n". | |
'Content-Disposition: attachment'.(!empty($attach['file']) ? ";\n filename=\"".basename($attach['file']).'"' : '')."\n". | |
"Content-Transfer-Encoding: base64\n\n". | |
chunk_split(base64_encode($data), 78, "\n"); | |
} | |
$message .= '--'.$boundary."--\n"; | |
} | |
//! flat headers | |
$header = ''; | |
//! redirect message to a specific address (for testing) | |
if (!empty(self::$forge)) { | |
// @codeCoverageIgnoreStart | |
$headers['To'] = self::$forge; | |
$headers['Cc'] = ''; | |
$headers['Bcc'] = ''; | |
// @codeCoverageIgnoreEnd | |
} | |
foreach ($headers as $k => $v) { | |
$header .= $k.': '.$v."\r\n"; | |
} | |
//! log that we are sending a mail | |
Core::log('I', 'To: '.$headers['To'].', Subject: '.$headers['Subject'].', ID: '.$id, 'email'); | |
//if email directory exists, save the full mime message as well for debug | |
@file_put_contents('data/log/email/'.$id, 'Backend: '.$this->via.' '.self::$user.':'.self::$pass.'@'.self::$host.':'.self::$port."\r\n\r\n".$header."\r\n".$message); | |
//! *** handle transport backends *** | |
switch ($this->via) { | |
//! only log and possibly save message in file, do not send for real. Nothing left to do | |
case 'log': break; | |
//! return constructed mime message | |
case 'mime': return $header."\r\n".$message; break; | |
//! use php's mail() | |
case 'mail': { | |
$to = $headers['To']; | |
$subj = $headers['Subject']; | |
unset($headers['To']); | |
unset($headers['Subject']); | |
$header = ''; | |
foreach ($headers as $k => $v) { | |
$header .= $k.': '.$v."\r\n"; | |
} | |
if (!mail($to, $subj, $message, $header)) { | |
Core::log('E', 'mail() failed, To: '.$to.', Subject: '.$subj.', ID: '.$id, 'email'); | |
return false; | |
} | |
// @codeCoverageIgnoreStart | |
} break; | |
//! sendmail through pipe | |
case 'sendmail': { | |
$f = @popen('/usr/sbin/sendmail -t -i', 'w'); | |
if ($f) { | |
fputs($f, $header."\r\n".$message); | |
pclose($f); | |
} else { | |
Core::log('E', 'mail() failed, To: '.$headers['To'].', Subject: '.$headers['Subject'].', ID: '.$id, 'email'); | |
return false; | |
} | |
} break; | |
//! this is how real programmers do it, let's speak smtp directly! | |
default: { | |
//open socket | |
$s = @fsockopen(self::$host, self::$port, $en, $es, 5); | |
$l = ''; | |
//get welcome message | |
if ($s) { | |
stream_set_timeout($s, 5); | |
$l = fgets($s, 1024); | |
} | |
if (!$s || substr($l, 0, 3) != '220') { | |
Core::log('E', 'connection error to '.self::$host.':'.self::$port.', '.trim($l), 'email'); | |
return false; | |
} | |
//we silently assume we got 8BITMIME here, it's a safe assumption as of 2016 | |
while ($l[3] == '-') { | |
$l = fgets($s, 1024); | |
} | |
//greet remote | |
fputs($s, 'EHLO '.$local."\r\n"); | |
$l = fgets($s, 1024); | |
while ($l[3] == '-') { | |
$l = fgets($s, 1024); | |
} | |
//tell who are sending | |
fputs($s, 'MAIL FROM: <'.array_keys($this->header['From'])[0].">\r\n"); | |
$l = fgets($s, 1024); | |
if (substr($l, 0, 3) != '250') { | |
PPHPE3::log('E', 'from error: '.trim($l), 'email'); | |
return false; | |
} | |
//to whom | |
$addresses = array_merge(array_keys($this->header['To']), array_keys($this->header['Cc']), array_keys($this->header['Bcc'])); | |
foreach ($addresses as $a) { | |
fputs($s, 'RCPT TO: <'.$a.">\r\n"); | |
$l = fgets($s, 1024); | |
if (substr($l, 0, 3) != '250') { | |
Core::log('E', 'recipient error: '.trim($l), 'email'); | |
} | |
} | |
//the message | |
fputs($s, "DATA\r\n"); | |
$l = fgets($s, 1024); | |
if (substr($l, 0, 3) != '250') { | |
Core::log('E', 'data error: '.trim($l), 'email'); | |
return false; | |
} | |
fputs($header."\r\n".str_replace(array("\n.\n", "\n.\r"), array("\n..\n", "\n..\r"), $message)."\r\n.\r\n"); | |
$l = fgets($s, 1024); | |
if (substr($l, 0, 3) != '250') { | |
Core::log('E', 'data send error: '.trim($l), 'email'); | |
return false; | |
} | |
//say bye | |
fputs($s, "QUIT\r\n"); | |
fclose($s); | |
// @codeCoverageIgnoreEnd | |
} | |
} | |
return true; | |
} | |
/** | |
* validate and shape an email address. | |
* | |
* @param string email address (in either "name <account@domain>", "account@domain", "name <account@[ip address]>" or "account@[ip address]" format | |
* @param string header type to add address to (To, Cc, Bcc) | |
*/ | |
private function address($email, $type = 'To') | |
{ | |
//! check if it's a valid email address | |
if (preg_match("/(.*?)?[\<]?(([^\<]+)\@((\[?)[a-zA-Z0-9\-\.\:\_]+([a-zA-Z]+|[0-9]{1,3})(\]?)))[\>]?$/", $email, $m)) { | |
//! only localhost allowed not to contain dot | |
if (strpos($m[4], '.') === false && $m[4] != 'localhost') { | |
throw new EmailException(L('invalid email address').': '.$email); | |
} | |
//! remove if it's already exists in headers to avoid duplications | |
foreach (['To', 'Cc', 'Bcc'] as $rcpt) { | |
if (!empty($this->header[$rcpt][$m[2]])) { | |
unset($this->header[$rcpt][$m[2]]); | |
} | |
} | |
//! add to headers | |
$this->header[$type][$m[2]] = '=?utf-8?Q?'. | |
quoted_printable_encode( | |
str_replace("\r", '', | |
str_replace("\n", ' ', | |
str_replace('@', ' AT ', !empty($m[1]) ? trim($m[1]) : $m[3])))). | |
'?= <'.$m[2].'>'; | |
return true; | |
} | |
throw new EmailException(L('invalid email address').': '.$email); | |
} | |
/** | |
* Cron job to read mails from queue and send them out | |
*/ | |
public function cronMinute($item) | |
{ | |
//! get real mailer backend ($core->mailer points to db queue backend) | |
// @codeCoverageIgnoreStart | |
if (empty(Core::$core->realmailer)) { | |
Core::log('C', L('Real mailer backend not configured!')); | |
} | |
// @codeCoverageIgnoreEnd | |
//! get items from database | |
$lastId = 0; | |
while ($row = DS::fetch('*', 'email_queue', 'id>?', '', 'id ASC', [$lastId])) { | |
if(empty($row->id)) | |
break; | |
$email = new self($row->data); | |
$lastId = $row->id; | |
try { | |
if (!$email->send(Core::$core->realmailer)) { | |
// @codeCoverageIgnoreStart | |
throw new \Exception('send() returned false'); | |
} | |
DS::exec('DELETE FROM email_queue WHERE id=?;', [$row->id]); | |
} catch (\Exception $e) { | |
Core::log('E', sprintf(L('Unable to send #%s from queue'),$row->id).': '.$e->getMessage()); | |
} | |
// @codeCoverageIgnoreEnd | |
sleep(1); | |
} | |
} | |
} |