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