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