FRED™  3.0
FRED™: Framework for Rapid and Easy Development
Mail.php
Go to the documentation of this file.
1 <?php
2 
3 namespace Rsi\Fred;
4 
5 class Mail extends Component{
6 
7  public $defaultFrom = null;
8  public $restrict = []; //!< Regular expressions that the e-mail address has to match (at least one; empty = allow all).
9  public $mailers = []; //!< Domain specific mailer/transport (key = domain name; value = array with mailer or host, port,
10  // username, and password for SMTP - all optional).
11  public $subjectId = '*Subject'; //!< Get subject from translation component (when body is array of tags); asterisk is
12  // replaced with the subject (code).
13  public $bodyId = '*Message'; //!< Get message body from translation component (when body is array of tags); asterisk is
14  // replaced with the subject (code).
15  public $markup = ['b' => '*','i' => '/','u' => '_'];
16  public $dataPrefix = 'data-'; //!< Prefix for data tags (empty = do not allow).
17  public $imageExtMask = '(gif|jpe?g|png|svg)'; //!< Allowed file extensions for (local) embedable images.
18  public $attachmentExtMask = '(docx?|pdf|txt|xlsx?)'; //!< Allowed file extensions for (local) attachments.
19  public $forward = []; //!< Forwarding rules (array of records with regex for from, to, cc, bcc, subject, and/or body; if all
20  // match the mail is forwarded to the null key).
21 
22  protected $_mailer = null;
23  protected $_logger = null;
24  protected $_transport = null;
25  protected $_imap = null;
26  protected $_queue = null;
27 
28  protected function init(){
29  parent::init();
30  $this->component('log')->debug('Initializing Swift Mailer version ' . \Swift::VERSION,__FILE__,__LINE__); //initialize Swift Mailer autoloader
31  }
32  /**
33  * Filter a list of recipients.
34  * @param array $recipients A list of recipients.
35  * @return array Filtered list of recipients (only those matched by the restriction set, or all if no restrictions).
36  */
37  public function filterRecipients($recipients){
38  if(!$this->restrict) return $recipients;
39  $allowed = [];
40  foreach(\Rsi\Record::explode($recipients) as $key => $value){
41  $email = is_numeric($key) ? $value : $key;
42  foreach($this->restrict as $filter) if(preg_match(substr($filter,0,1) == '/' ? $filter : '/' . preg_quote($filter,'/') . '/',$email)){
43  $allowed[$key] = $value;
44  break;
45  }
46  }
47  return $allowed;
48  }
49  /**
50  * Add a sent message to the log.
51  * @param \\Swift_Message $message
52  * @param int $result Result from the send function.
53  * @param array $failures Addresses that failed.
54  * @param array $context Extra context for the log message.
55  */
56  protected function log($message,$result,$failures = null,$context = null){
57  $context = array_merge($context ?: [],compact('result','failures'));
58  foreach(['from','to','cc','bcc','subject','body'] as $key) if($value = call_user_func([$message,'get' . ucfirst($key)])) $context[$key] = $value;
59  $this->component('log')->debug('Sending mail "' . $message->getSubject() .'"',__FILE__,__LINE__,$context);
60  }
61  /**
62  * Check if a filename is valid (external or in document root).
63  * @param string $filename
64  * @param string $ext Allowed extension (local file only).
65  * @return bool True when valid.
66  */
67  protected function validFilename($filename,$ext){
68  return
69  preg_match('/^https?:\\/\\//i',$filename) ||
70  (preg_match('/^[\\w\\-\\/]+\\.' . $ext . '$/i',$filename) && is_file($filename = \Rsi\Http::docRoot() . DIRECTORY_SEPARATOR . $filename));
71  }
72  /**
73  * Find all tags with a certain data tag.
74  * @param string $body HTML message body.
75  * @param string $data Data tag to look for (without prefix).
76  * @param string $tag HTML tag.
77  * @return array Array with matches (full tags).
78  */
79  protected function dataTags($body,$data,$tag = null){
80  return $this->dataPrefix && preg_match_all("/<$tag(?=\\s)[^>]*\\s{$this->dataPrefix}$data(?=[=\\s>]).*?>/is",$body,$matches) ? $matches[0] : [];
81  }
82  /**
83  * Remove all tags from the message body.
84  * Link addresses are placed between parenthesis after the original anchor text.
85  * @param string $body Message in HTML format.
86  * @return string Message in plain text.
87  */
88  protected function textBody($body){
89  $body = preg_replace('/<style>.*?<\\/style>/s','',$body);
90  foreach($this->markup as $tag => $char) $body = preg_replace("/(<$tag.*?>|<\\/$tag>)/",$char,$body);
91  if(preg_match_all('/<a.*?href\s*=\s*([^\s>]+).*?>(.*?)<\\/a>/',$body,$matches,PREG_SET_ORDER)) foreach($matches as list($full,$link,$descr)){
92  $link = \Rsi\Str::stripQuotes($link);
93  $body = str_replace($full,$descr . ($link == $descr ? '' : " ($link)"),$body);
94  }
95  return strip_tags($body);
96  }
97  /**
98  * Process HTML body.
99  * @param string $body Message in HTML format.
100  * @param \\Swift_Message $message Swift Mailer message object.
101  * @return string Message in HTML format.
102  */
103  protected function htmlBody($body,$message){
104  $log = $this->component('log');
105  //inline style
106  $style = null;
107  if(preg_match_all('/<link rel=[\'"]stylesheet[\'"] href=[\'"]\\/?([^\'"]+)[\'"].*?>/i',$body,$matches,PREG_SET_ORDER)) foreach($matches as list($full,$filename)){
108  if($this->validFilename($filename,'css')) $style .= file_get_contents($filename);
109  else $full = basename($filename) . ' not found!';
110  $body = str_replace($full,'',$body);
111  }
112  if(preg_match_all('/<style>(.*?)<\\/style>/is',$body,$matches,PREG_SET_ORDER)) foreach($matches as list($full,$inline)){
113  $style .= $inline;
114  $body = str_replace($full,'',$body);
115  }
116  if($style) $body = (new \TijsVerkoyen\CssToInlineStyles\CssToInlineStyles($body,$style))->convert();
117  //embed images (optional)
118  $cids = [];
119  foreach($this->dataTags($body,'embed','img') as $tag) if($this->validFilename($filename = \Rsi\Record::iget(\Rsi\Str::attributes($tag),'src'),$this->imageExtMask)) try{
120  if(!array_key_exists($filename,$cids)){
121  $cids[$filename] = $cid = $message->embed(\Swift_Image::fromPath($filename));
122  $log->debug("Embedded image '$filename' as '$cid'",__FILE__,__LINE__);
123  }
124  $body = str_replace($tag,str_replace($filename,$cids[$filename],$tag),$body);
125  }
126  catch(\Exception $e){
127  $log->info("Could not inline image '$tag': " . $e->getMessage(),$e->getFile(),$e->getLine(),$e->getTrace());
128  }
129  //add attachments (optional)
130  foreach($this->dataTags($body,$data = 'attach','a') as $tag) if($this->validFilename($filename = \Rsi\Record::iget($attributes = \Rsi\Str::attributes($tag),'href'),$this->attachmentExtMask)) try{
131  $attachment = \Swift_Attachment::fromPath($filename);
132  if($download = \Rsi\Record::iget($attributes,'download')) $attachment->setFilename($download);
133  $message->attach($attachment);
134  $log->debug("Attached file '$filename'",__FILE__,__LINE__);
135  if(!is_bool($replace = \Rsi\Record::iget($attributes,$this->dataPrefix . $data))) $body = preg_replace('/' . preg_quote($tag,'/') . '.*?<\\/a>/is',$replace,$body);
136  }
137  catch(\Exception $e){
138  $log->info("Could not attach file '$tag': " . $e->getMessage(),$e->getFile(),$e->getLine(),$e->getTrace());
139  }
140  return $body;
141  }
142  /**
143  * Create a new Swift Mailer message object.
144  * @param mixed $from Sender address (defaultFrom or ini sendmail_from when empty).
145  * @param mixed $to Recipient(s).
146  * @param string $subject Subject line, or translation ID when the body is an array.
147  * @param string|array $body Message body, or translation tags.
148  * @param bool $html True when the message body is in HTML format.
149  * @return \\Swift_Message
150  */
151  public function message($from = null,$to = null,$subject = null,$body = null,$html = false){
152  if(is_array($tags = $body)){
153  $trans = $this->component('trans');
154  $body = $trans->id(str_replace('*',$id = $subject,$this->bodyId),$tags);
155  $subject = $trans->id(str_replace('*',$id,$this->subjectId),$tags);
156  }
157  $message = new \Swift_Message();
158  $message->setFrom($from ?: ($this->defaultFrom ?: ini_get('sendmail_from')));
159  $message->setTo($to);
160  if($subject) $message->setSubject($subject);
161  if($body){
162  if($html) $message->setBody($this->htmlBody($body,$message),'text/html')->addPart($this->textBody($body),'text/plain');
163  else $message->setBody($body);
164  }
165  return $message;
166  }
167  /**
168  * Domain specific mailer.
169  * @param string $host Host name of the sender.
170  * @return \\Swift_MailTransport Specific mailer, or default mailer.
171  */
172  public function mailer($host = null){
173  $mailer = $this->mailer; //init optional logger
174  if(array_key_exists($host,$this->mailers)){
175  if(!array_key_exists('mailer',$config = $this->mailers[$host])){
176  $this->mailers[$host]['mailer'] = new \Swift_Mailer(new \Swift_SmtpTransport($config['host'] ?? $host,$config['port'] ?? 25));
177  if($this->_logger) $this->mailers[$host]['mailer']->registerPlugin(new \Swift_Plugins_LoggerPlugin($this->_logger));
178  }
179  $mailer = $this->mailers[$host]['mailer'];
180  }
181  return $mailer;
182  }
183  /**
184  * Send a message.
185  * If the message is not an object, it is created from all the parameters.
186  * @see message()
187  * @param \\Swift_Message $message
188  * @return int Number of addresses that succeeded.
189  */
190  public function send($message){
191  if(!($message instanceof \Swift_Message)) $message = call_user_func_array([$this,'message'],func_get_args());
192  foreach(['To','Cc','Bcc'] as $key) call_user_func([$message,'set' . $key],$this->filterRecipients(call_user_func([$message,'get' . $key])));
193  $result = $failures = false;
194  try{
195  $result = $this->mailer(($from = imap_rfc822_parse_adrlist(\Rsi\Record::key($message->getFrom()),null)) ? $from[0]->host : null)->send($message,$failures);
196  $this->log($message,$result,$failures);
197  $message->setCc(null);
198  $message->setBcc(null);
199  if($result) foreach($this->forward as $forward){
200  foreach($forward as $key => $mask) if($key && !preg_match($mask,implode('#',(array)call_user_func([$message,'get' . ucfirst($key)])))) continue 2;
201  $message->setTo($forward[null]);
202  $this->mailer->send($message);
203  }
204  }
205  catch(\Exception $e){
206  $this->log($message,null,null,$this->_logger ? ['logger' => $this->_logger->dump()] : null);
207  throw $e;
208  }
209  return $result;
210  }
211  /**
212  * Add a message to the queue (with possible delay).
213  * Note: requires the calling of spool() on a regular interval.
214  * @param \\Swift_Message $message
215  * @param int $delay Delay in seconds.
216  * @param string $id ID for the message (if two messages have the same ID, the previous one is overwritten; empty = random).
217  * @return bool ID when added to the queue successfully, false on failure.
218  */
219  public function queue($message,$delay = 0,$id = null){
220  $result = false;
221  if($this->queue->path){
222  if(!$id) while(file_exists($this->queue->path . ($id = \Rsi\Str::random(32,'+')) . $this->queue->ext));
223  if(
224  \Rsi\File::serialize($temp = ($filename = $this->queue->path . $id . $this->queue->ext) . $this->queue->tempExt,$message) &&
225  touch($temp,time() + $delay)
226  ) $result = rename($temp,$filename);
227  else $this->component('log')->error("Could not queue message '$id': " . $message->getSubject());
228  }
229  return $result ? $id : false;
230  }
231  /**
232  * Delete a message from the queue.
233  * @param string $id Message ID.
234  * @return bool True if the message was deleted.
235  */
236  public function delete($id){
237  return \Rsi\File::unlink($this->queue->path . $id . $this->queue->ext);
238  }
239  /**
240  * Spool the message queue.
241  * @param int $time Maximum execution time (seconds; empty = unlimited).
242  * @return int Number of sent messages.
243  */
244  public function spool($time = null){
245  $log = $this->component('log');
246  if($time) $time += time();
247  $count = 0;
248  foreach((new \FilesystemIterator($this->queue->path)) as $filename => $file) try{
249  if(\Rsi\Str::endsWith($filename,$this->queue->ext . $this->queue->tempExt)){
250  if($file->getMTime() < time() - $this->queue->timeout)
251  $log->warning('Purged temp message',['result' => \Rsi\File::unlink($file->getPathname())]);;
252  }
253  elseif(\Rsi\Str::endsWith($filename,$this->queue->ext . $this->queue->busyExt)){
254  if($file->getMTime() < time() - $this->queue->timeout)
255  $log->warning('Reset busy message',['result' => rename($filename,substr($file->getPathname(),0,-strlen($this->queue->busyExt)))]);
256  }
257  elseif(
258  \Rsi\Str::endsWith($filename,$this->queue->ext) &&
259  ($file->getMTime() <= time()) &&
260  class_exists('Swift_Message') &&
261  rename($filename,$busy = $filename . $this->queue->busyExt) &&
262  $this->send(\Rsi\File::unserialize($busy)) &&
263  unlink($busy)
264  ) $count++;
265  if($time && ($time < time())) break;
266  }
267  catch(\Exception $e){
268  $log->error($e);
269  }
270  return $count;
271  }
272  /**
273  * Open an IMAP mailbox.
274  * @param string $name Mailbox to connect to (empty = default).
275  * @return \\Rsi\\Imap\\Mailbox
276  */
277  public function box($name = null){
278  return $this->imap->mailbox($name);
279  }
280 
281  protected function getImap(){
282  if(!$this->_imap){
283  $imap = new \Rsi\Wrapper\Record($this->config('imap'));
284  $this->_imap = new \Rsi\Imap($imap->host,$imap->username,$imap->password,$imap->options,$imap->port);
285  }
286  return $this->_imap;
287  }
288 
289  protected function getMailer(){
290  if(!$this->_mailer){
291  $this->_mailer = new \Swift_Mailer($this->transport);
292  if($this->_fred->debug) $this->_mailer->registerPlugin(new \Swift_Plugins_LoggerPlugin(
293  $this->_logger = new \Swift_Plugins_Loggers_ArrayLogger()
294  ));
295  }
296  return $this->_mailer;
297  }
298 
299  protected function getTransport(){
300  if(!$this->_transport){
301  if($smtp = $this->config('smtp')){
302  if($host = $smtp['host'] ?? null) $port = $smtp['port'] ?? 25;
303  else{
304  $host = ini_get('SMTP');
305  $port = ini_get('smtp_port');
306  }
307  $this->_transport = new \Swift_SmtpTransport($host,$port);
308  if($username = $smtp['username'] ?? null) $this->_transport->setUsername($username);
309  if($password = $smtp['password'] ?? null) $this->_transport->setPassword($password);
310  }
311  elseif($sendmail = $this->config('sendmail')) $this->_transport = new \Swift_SendmailTransport($sendmail);
312  else $this->_transport = new \Swift_MailTransport();
313  }
314  return $this->_transport;
315  }
316 
317  protected function getQueue(){
318  if(!$this->_queue) $this->_queue = new \Rsi\Wrapper\Record($this->config('queue') + [
319  'path' => null,
320  'ext' => '.message',
321  'tempExt' => '.temp',
322  'busyExt' => '.busy',
323  'timeout' => 60
324  ]);
325  return $this->_queue;
326  }
327 
328  public function __invoke($from = null,$to = null,$subject = null,$body = null,$html = false){
329  return $this->send($from,$to,$subject,$body,$html);
330  }
331 
332 }
log($message, $result, $failures=null, $context=null)
Add a sent message to the log.
Definition: Mail.php:56
__invoke($from=null, $to=null, $subject=null, $body=null, $html=false)
Definition: Mail.php:328
$restrict
Regular expressions that the e-mail address has to match (at least one; empty = allow all)...
Definition: Mail.php:8
queue($message, $delay=0, $id=null)
Add a message to the queue (with possible delay).
Definition: Mail.php:219
$attachmentExtMask
Allowed file extensions for (local) attachments.
Definition: Mail.php:18
$bodyId
Get message body from translation component (when body is array of tags); asterisk is...
Definition: Mail.php:13
$dataPrefix
Prefix for data tags (empty = do not allow).
Definition: Mail.php:16
config($key, $default=null)
Retrieve a config value.
Definition: Component.php:53
spool($time=null)
Spool the message queue.
Definition: Mail.php:244
message($from=null, $to=null, $subject=null, $body=null, $html=false)
Create a new Swift Mailer message object.
Definition: Mail.php:151
$subjectId
Get subject from translation component (when body is array of tags); asterisk is. ...
Definition: Mail.php:11
$imageExtMask
Allowed file extensions for (local) embedable images.
Definition: Mail.php:17
$defaultFrom
Definition: Mail.php:7
getTransport()
Definition: Mail.php:299
$forward
Forwarding rules (array of records with regex for from, to, cc, bcc, subject, and/or body; if all...
Definition: Mail.php:19
dataTags($body, $data, $tag=null)
Find all tags with a certain data tag.
Definition: Mail.php:79
htmlBody($body, $message)
Process HTML body.
Definition: Mail.php:103
Basic component class.
Definition: Component.php:8
$mailers
Domain specific mailer/transport (key = domain name; value = array with mailer or host...
Definition: Mail.php:9
textBody($body)
Remove all tags from the message body.
Definition: Mail.php:88
mailer($host=null)
Domain specific mailer.
Definition: Mail.php:172
send($message)
Send a message.
Definition: Mail.php:190
validFilename($filename, $ext)
Check if a filename is valid (external or in document root).
Definition: Mail.php:67
filterRecipients($recipients)
Filter a list of recipients.
Definition: Mail.php:37
component($name)
Get a component (local or default).
Definition: Component.php:80
box($name=null)
Open an IMAP mailbox.
Definition: Mail.php:277