Elgg  Version master
EmailService.php
Go to the documentation of this file.
1 <?php
2 
3 namespace Elgg;
4 
14 use Laminas\Mail\Message as MailMessage;
16 use Laminas\Mime\Message as MimeMessage;
20 
27 class EmailService {
28 
29  use Loggable;
30 
42  public function __construct(
43  protected Config $config,
44  protected EventsService $events,
45  protected TransportInterface $mailer,
46  protected HtmlFormatter $html_formatter,
47  protected ViewsService $views,
48  protected ImageFetcherService $image_fetcher,
49  protected CssCompiler $css_compiler
50  ) {
51  }
52 
61  public function send(Email $email): bool {
62  $email = $this->events->triggerResults('prepare', 'system:email', [], $email);
63  if (!$email instanceof Email) {
64  $msg = "'prepare','system:email' event handlers should return an instance of " . Email::class;
65  throw new RuntimeException($msg);
66  }
67 
68  $is_valid = $email->getFrom() && !empty($email->getTo());
69  if (!$this->events->triggerResults('validate', 'system:email', ['email' => $email], $is_valid)) {
70  return false;
71  }
72 
73  return $this->transport($email);
74  }
75 
84  public function transport(Email $email): bool {
85  if ($this->events->triggerResults('transport', 'system:email', ['email' => $email], false)) {
86  return true;
87  }
88 
89  // create the e-mail message
90  $message = new MailMessage();
91  $message->setEncoding('UTF-8');
92  $message->setSender($email->getFrom());
93  $message->addFrom($email->getFrom());
94  $message->addTo($email->getTo());
95  $message->addCc($email->getCc());
96  $message->addBcc($email->getBcc());
97 
98  // set headers
99  $headers = [
100  'MIME-Version' => '1.0',
101  'Content-Transfer-Encoding' => '8bit',
102  ];
103  $headers = array_merge($headers, $email->getHeaders());
104 
105  foreach ($headers as $name => $value) {
106  // See #11018
107  // Create a headerline as a concatenated string "name: value"
108  // This is done to force correct class detection for each header type,
109  // which influences the output of the header in the message
110  $message->getHeaders()->addHeaderLine("{$name}: {$value}");
111  }
112 
113  // add the body to the message
114  try {
115  $message = $this->setMessageBody($message, $email);
116  } catch (InvalidArgumentException $e) {
117  $this->getLogger()->error($e->getMessage());
118 
119  return false;
120  }
121 
122  $message->setSubject($this->prepareSubject($email->getSubject()));
123 
124  // allow others to modify the $message content
125  // eg. add html body, add attachments
126  $message = $this->events->triggerResults('zend:message', 'system:email', ['email' => $email], $message);
127 
128  // fix content type header
129  // @see https://github.com/Elgg/Elgg/issues/12555
130  $ct = $message->getHeaders()->get('Content-Type');
131  if ($ct instanceof ContentType) {
132  $ct->addParameter('format', 'flowed');
133  }
134 
135  try {
136  $this->mailer->send($message);
137  } catch (RuntimeException $e) {
138  $this->getLogger()->error($e->getMessage());
139 
140  return false;
141  }
142 
143  return true;
144  }
145 
153  protected function prepareSubject(string $subject): string {
154  $subject = elgg_strip_tags($subject);
155  $subject = html_entity_decode($subject, ENT_QUOTES, 'UTF-8');
156  // Sanitise subject by stripping line endings
157  $subject = preg_replace("/(\r\n|\r|\n)/", ' ', $subject);
158  return trim($subject);
159  }
160 
169  protected function setMessageBody(MailMessage $message, Email $email): MailMessage {
170  // create body
171  $multipart = new MimeMessage();
172  $raw_body = $email->getBody();
173  $message_content_type = '';
174 
175  // add plain text part
176  $plain_text_part = new PlainTextPart($raw_body);
177  $multipart->addPart($plain_text_part);
178 
179  $make_html = (bool) elgg_get_config('email_html_part');
180 
181  if ($make_html) {
182  $multipart->addPart($this->makeHtmlPart($email));
183  $message_content_type = Mime::MULTIPART_ALTERNATIVE;
184  }
185 
186  $body = $multipart;
187 
188  // process attachments
189  $attachments = $email->getAttachments();
190  if (!empty($attachments)) {
191  if ($make_html) {
192  $multipart_content = new Part($multipart->generateMessage());
193  $multipart_content->setType(Mime::MULTIPART_ALTERNATIVE);
194  $multipart_content->setBoundary($multipart->getMime()->boundary());
195 
196  $body = new MimeMessage();
197  $body->addPart($multipart_content);
198  }
199 
200  foreach ($attachments as $attachement) {
201  $body->addPart($attachement);
202  }
203 
204  $message_content_type = Mime::MULTIPART_MIXED;
205  }
206 
207  $message->setBody($body);
208 
209  if (!empty($message_content_type)) {
210  // set correct message content type
211 
212  $headers = $message->getHeaders();
213  foreach ($headers as $header) {
214  if (!$header instanceof ContentType) {
215  continue;
216  }
217 
218  $header->setType($message_content_type);
219  $header->addParameter('boundary', $body->getMime()->boundary());
220  break;
221  }
222  }
223 
224  return $message;
225  }
226 
234  protected function makeHtmlPart(\Elgg\Email $email): Part {
235  $mail_params = $email->getParams();
236  $html_text = elgg_extract('html_message', $mail_params);
237  if ($html_text instanceof Part) {
238  return $html_text;
239  }
240 
241  if (is_string($html_text)) {
242  // html text already provided
243  if (elgg_extract('convert_css', $mail_params, true)) {
244  // still needs to be converted to inline CSS
245  $css = (string) elgg_extract('css', $mail_params);
246  $html_text = $this->html_formatter->inlineCss($html_text, $css);
247  }
248  } else {
249  $html_text = $this->makeHtmlBody([
250  'subject' => $email->getSubject(),
251  'body' => elgg_extract('html_body', $mail_params, $email->getBody()),
252  'email' => $email,
253  ]);
254  }
255 
256  // normalize urls in text
257  $html_text = $this->html_formatter->normalizeUrls($html_text);
258  if (empty($html_text)) {
259  return new HtmlPart($html_text);
260  }
261 
262  $email_html_part_images = elgg_get_config('email_html_part_images');
263  if ($email_html_part_images !== 'base64' && $email_html_part_images !== 'attach') {
264  return new HtmlPart($html_text);
265  }
266 
267  $images = $this->findImages($html_text);
268  if (empty($images)) {
269  return new HtmlPart($html_text);
270  }
271 
272  if ($email_html_part_images === 'base64') {
273  foreach ($images as $url) {
274  // remove wrapping quotes from the url
275  $image_url = substr($url, 1, -1);
276 
277  // get the image contents
278  $image = $this->image_fetcher->getImage($image_url);
279  if (empty($image)) {
280  continue;
281  }
282 
283  // build a valid uri
284  // https://en.wikipedia.org/wiki/Data_URI_scheme
285  $base64image = $image['content-type'] . ';charset=UTF-8;base64,' . base64_encode($image['data']);
286 
287  // build inline image
288  $replacement = str_replace($image_url, "data:{$base64image}", $url);
289 
290  // replace in text
291  $html_text = str_replace($url, $replacement, $html_text);
292  }
293 
294  return new HtmlPart($html_text);
295  }
296 
297  // attach images
298  $attachments = [];
299  foreach ($images as $url) {
300  // remove wrapping quotes from the url
301  $image_url = substr($url, 1, -1);
302 
303  // get the image contents
304  $image = $this->image_fetcher->getImage($image_url);
305  if (empty($image)) {
306  continue;
307  }
308 
309  // Unique ID
310  $uid = uniqid();
311 
312  $attachments[$uid] = $image;
313 
314  // replace url in the text with uid
315  $replacement = str_replace($image_url, "cid:{$uid}", $url);
316 
317  $html_text = str_replace($url, $replacement, $html_text);
318  }
319 
320  // split html body and related images
321  $message = new MimeMessage();
322  $message->addPart(new HtmlPart($html_text));
323 
324  foreach ($attachments as $uid => $image_data) {
325  $attachment = Attachment::factory([
326  'id' => $uid,
327  'content' => $image_data['data'],
328  'type' => $image_data['content-type'],
329  'filename' => $image_data['name'],
330  'encoding' => Mime::ENCODING_BASE64,
331  'disposition' => Mime::DISPOSITION_INLINE,
332  'charset' => 'UTF-8',
333  ]);
334 
335  $message->addPart($attachment);
336  }
337 
338  $part = new Part($message->generateMessage());
339  $part->setType(Mime::MULTIPART_RELATED);
340  $part->setBoundary($message->getMime()->boundary());
341 
342  return $part;
343  }
344 
352  protected function makeHtmlBody(array $options = []): string {
353  $defaults = [
354  'subject' => '',
355  'body' => '',
356  'language' => elgg_get_current_language(),
357  ];
358 
359  $options = array_merge($defaults, $options);
360 
361  $options['body'] = $this->html_formatter->formatBlock($options['body']);
362 
363  // generate HTML mail body
364  $options['body'] = $this->views->renderView('email/elements/body', $options);
365 
366  $css_compiled = $this->css_compiler->compile($this->views->renderView('email/email.css', $options));
367  $minifier = new \MatthiasMullie\Minify\CSS($css_compiled);
368  $css = $minifier->minify();
369 
370  $options['css'] = $css;
371 
372  $html = $this->views->renderView('email/elements/html', $options);
373 
374  return $this->html_formatter->inlineCss($html, $css);
375  }
376 
384  protected function findImages(string $text): array {
385  if (empty($text)) {
386  return [];
387  }
388 
389  // find all matches
390  $matches = [];
391  $pattern = '/\ssrc=([\'"]\S+[\'"])/i';
392 
393  preg_match_all($pattern, $text, $matches);
394 
395  if (empty($matches) || !isset($matches[1])) {
396  return [];
397  }
398 
399  // return all the found image urls
400  return array_unique($matches[1]);
401  }
402 }
elgg_get_current_language()
Get the current system/user language or &#39;en&#39;.
Definition: languages.php:27
getTo()
Returns recipient address.
Definition: Email.php:169
findImages(string $text)
Find img src&#39;s in text.
Exception thrown if an argument is not of the expected type.
Exception thrown if an error which can only be found on runtime occurs.
elgg_get_config(string $name, $default=null)
Get an Elgg configuration value.
$defaults
Generic entity header upload helper.
Definition: header.php:6
if(!$user||!$user->canDelete()) $name
Definition: delete.php:22
Events service.
$email
Definition: change_email.php:7
getBcc()
Returns recipient address from bcc.
Definition: Email.php:211
Html part for email.
Definition: HtmlPart.php:15
prepareSubject(string $subject)
Prepare the subject string.
makeHtmlPart(\Elgg\Email $email)
Make the html part of the e-mail message.
transport(Email $email)
Transports an email.
$value
Definition: generic.php:51
setMessageBody(MailMessage $message, Email $email)
Build the body part of the e-mail message.
$html
A wrapper to render a section of the page shell.
Definition: section.php:9
elgg_extract($key, $array, $default=null, bool $strict=true)
Checks for $array[$key] and returns its value if it exists, else returns $default.
Definition: elgglib.php:254
$config
Advanced site settings, debugging section.
Definition: debugging.php:6
if($who_can_change_language=== 'nobody') elseif($who_can_change_language=== 'admin_only'&&!elgg_is_admin_logged_in()) $options
Definition: language.php:20
trait Loggable
Enables adding a logger.
Definition: Loggable.php:14
Fetch external images server side.
send(Email $email)
Sends an email.
Email message.
Definition: Email.php:13
getCc()
Returns recipient address from cc.
Definition: Email.php:190
getSubject()
Returns the subject.
Definition: Email.php:234
Views service.
Various helper method for formatting and sanitizing output.
$image
Definition: image_block.php:26
makeHtmlBody(array $options=[])
Create the HTML content for use in a HTML email part.
$css
Definition: install.css.php:5
$body
Definition: useradd.php:55
getBody()
Returns email body.
Definition: Email.php:255
Compile CSS with CSSCrush.
Definition: CssCompiler.php:16
getLogger()
Returns logger.
Definition: Loggable.php:37
Plaintext part for email.
foreach($periods as $period) $header
Definition: cron.php:76
$attachments
Outputs attachments.
Definition: attachments.php:9
getHeaders()
Returns headers.
Definition: Email.php:310
Email service.
foreach($plugin_guids as $guid) if(empty($deactivated_plugins)) $url
Definition: deactivate.php:39
getFrom()
Returns sender address.
Definition: Email.php:148
$text
Definition: button.php:33
__construct(protected Config $config, protected EventsService $events, protected TransportInterface $mailer, protected HtmlFormatter $html_formatter, protected ViewsService $views, protected ImageFetcherService $image_fetcher, protected CssCompiler $css_compiler)
Constructor.
$subject
Definition: useradd.php:54
$views
Definition: item.php:17
elgg_strip_tags(string $string, string $allowable_tags=null)
Strip tags and offer plugins the chance.
Definition: output.php:323
getAttachments()
Get all attachments.
Definition: Email.php:348