1<?php
2// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project
3//
4// All Rights Reserved. See copyright.txt for details and a complete list of authors.
5// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
6// $Id$
7
8/**
9 * set some default params (mainly utf8 as tiki is utf8) + use the mailCharset pref from a user
10 */
11
12class TikiMail
13{
14	/**
15	 * @var \Zend\Mail\Message
16	 */
17	private $mail;
18	private $charset;
19	public $errors;
20
21	/**
22	 * @param string|null $user	to username
23	 * @param string|null $from	from email
24	 * @param string|null $fromName	from Name
25	 */
26	function __construct($user = null, $from = null, $fromName = null)
27	{
28		global $user_preferences, $prefs;
29
30		require_once __DIR__ . '/../mail/maillib.php';
31
32		$tikilib = TikiLib::lib('tiki');
33		$userlib = TikiLib::lib('user');
34
35		$to = '';
36		$this->errors = [];
37		if (! empty($user)) {
38			if ($userlib->user_exists($user)) {
39				$to = $userlib->get_user_email($user);
40				$tikilib->get_user_preferences($user, ['mailCharset']);
41				$this->charset = $user_preferences[$user]['mailCharset'];
42			} else {
43				$str = tra('Mail to: User not found');
44				trigger_error($str);
45				$this->errors = [$str];
46				return;
47			}
48		}
49
50		if (! empty($from)) {
51			$this->mail = tiki_get_basic_mail();
52			try {
53				$this->mail->setFrom($from, $fromName);
54				$this->mail->setSender($from);
55			} catch (Exception $e) {
56				// was already set, then do nothing
57			}
58		} else {
59			$this->mail = tiki_get_admin_mail($fromName);
60		}
61		if (! empty($to)) {
62			$this->mail->addTo($to);
63		}
64
65		if (empty($this->charset)) {
66			$this->charset = $prefs['users_prefs_mailCharset'];
67		}
68	}
69
70	function setUser($user)
71	{
72	}
73
74	function setFrom($email, $name = null)
75	{
76		if (! $name) {
77			$name = null;	// zend now requires "Name must be a string" (or null, not false)
78		}
79		$this->mail->setFrom($email, $name);
80	}
81
82	function setReplyTo($email, $name = null)
83	{
84		if (! $name) {
85			$name = null;	// zend now requires "Name must be a string" (or null, not false)
86		}
87		$this->mail->setReplyTo($email, $name);
88	}
89
90	function setSubject($subject)
91	{
92		$this->mail->setSubject($subject);
93	}
94
95	function setHtml($html, $text = null, $images_dir = null)
96	{
97		global $prefs;
98		if ($prefs['mail_apply_css'] != 'n') {
99			$html = $this->applyStyle($html);
100		}
101
102		$body = $this->mail->getBody();
103		if (! ($body instanceof \Zend\Mime\Message) && ! empty($body)) {
104			$this->convertBodyToMime($body);
105			$body = $this->mail->getBody();
106		}
107
108		if (! $body instanceof Zend\Mime\Message) {
109			$body = new Zend\Mime\Message();
110		}
111
112		$partHtml = false;
113		$partText = false;
114
115		$parts = [];
116		foreach ($body->getParts() as $part) {
117			/* @var $part Zend\Mime\Part */
118			if ($part->getType() == Zend\Mime\Mime::TYPE_HTML) {
119				$partHtml = $part;
120				$part->setContent($html);
121				if ($this->charset) {
122					$part->setCharset($this->charset);
123				}
124			} elseif ($part->getType() == Zend\Mime\Mime::TYPE_TEXT) {
125				$partText = $part;
126				if ($text) {
127					$part->setContent($text);
128					if ($this->charset) {
129						$part->setCharset($this->charset);
130					}
131				}
132			} else {
133				$parts[] = $part;
134			}
135		}
136
137		if (! $partText && $text) {
138			$partText = new Zend\Mime\Part($text);
139			$partText->setType(Zend\Mime\Mime::TYPE_TEXT);
140			if ($this->charset) {
141				$partText->setCharset($this->charset);
142			}
143		}
144		if ($partText) {
145			$parts[] = $partText;
146		}
147
148		if (! $partHtml) {
149			$partHtml = new Zend\Mime\Part($html);
150			$partHtml->setType(Zend\Mime\Mime::TYPE_HTML);
151			if ($this->charset) {
152				$partHtml->setCharset($this->charset);
153			}
154		}
155		$parts[] = $partHtml;
156
157		$body->setParts($parts);
158		$this->mail->setBody($body);
159		// use multipart/alternative for mail clients to display html and fall back to plain text parts
160		if ($text) {
161			$this->mail->getHeaders()->get('content-type')->setType('multipart/alternative');
162		}
163	}
164
165	function setText($text = '')
166	{
167		$body = $this->mail->getBody();
168		if ($body instanceof \Zend\Mime\Message) {
169			$parts = $body->getParts();
170			$textPartFound = false;
171			foreach ($parts as $part) {
172				/* @var $part Zend\Mime\Part */
173				if ($part->getType() == Zend\Mime\Mime::TYPE_TEXT) {
174					$part->setContent($text);
175					if ($this->charset) {
176						$part->setCharset($this->charset);
177					}
178					$textPartFound = true;
179					break;
180				}
181			}
182			if (! $textPartFound) {
183				$part = new Zend\Mime\Part($text);
184				$part->setType(Zend\Mime\Mime::TYPE_TEXT);
185				if ($this->charset) {
186					$part->setCharset($this->charset);
187				}
188				$parts[] = $part;
189			}
190			$body->setParts($parts);
191		} else {
192			$this->mail->setBody($text);
193			if ($this->charset) {
194				$headers = $this->mail->getHeaders();
195				$headers->removeHeader($headers->get('Content-type'));
196				$headers->addHeaderLine(
197					'Content-type: text/plain; charset=' . $this->charset
198				);
199			}
200		}
201	}
202
203	function setCc($address)
204	{
205		foreach ((array) $address as $cc) {
206			$this->mail->addCc($cc);
207		}
208	}
209
210	function setBcc($address)
211	{
212		foreach ((array) $address as $bcc) {
213			$this->mail->addBcc($bcc);
214		}
215	}
216
217	function setHeader($name, $value)
218	{
219		$headers = $this->mail->getHeaders();
220		switch ($name) {
221			case 'Message-Id':
222				$headers->addHeader(Zend\Mail\Header\MessageId::fromString('Message-ID: ' . trim($value)));
223				break;
224			case 'In-Reply-To':
225				$headers->addHeader(Zend\Mail\Header\InReplyTo::fromString('In-Reply-To: ' . trim($value)));
226				break;
227			case 'References':
228				$headers->addHeader(Zend\Mail\Header\References::fromString('References: ' . trim($value)));
229				break;
230			default:
231				$this->mail->getHeaders()->addHeaderLine($name, $value);
232				break;
233		}
234	}
235
236	function addPart($content, $type) {
237		$body = $this->mail->getBody();
238		if (! ($body instanceof \Zend\Mime\Message)) {
239			$this->convertBodyToMime($body);
240			$body = $this->mail->getBody();
241		}
242		$part = new Zend\Mime\Part($content);
243		$part->setType($type);
244		$part->setCharset($this->charset);
245		$body->addPart($part);
246		$headers = $this->mail->getHeaders();
247		$headers->removeHeader('Content-type');
248		$headers->addHeaderLine(
249			'Content-type: multipart/mixed; boundary="'.$body->getMime()->boundary().'"'
250		);
251	}
252
253	/**
254	 * Get the Zend Message object
255	 *
256	 * @return \Zend\Mail\Message
257	 */
258	function getMessage() {
259		return $this->mail;
260	}
261
262	function send($recipients, $type = 'mail')
263	{
264		global $tikilib, $prefs;
265		$logslib = TikiLib::lib('logs');
266
267		$this->mail->getHeaders()->removeHeader('to');
268		foreach ((array) $recipients as $to) {
269			try {
270				$this->mail->addTo($to);
271			} catch (Zend\Mail\Exception\InvalidArgumentException $e) {
272				$title = 'mail error';
273				$error = $e->getMessage();
274				$this->errors[] = $error;
275				$error = ' [' . $error . ']';
276				$logslib->add_log($title, $to . '/' . $this->mail->getSubject() . $error);
277			}
278		}
279
280		if ($prefs['zend_mail_handler'] == 'smtp' && $prefs['zend_mail_queue'] == 'y') {
281			$query = "INSERT INTO `tiki_mail_queue` (message) VALUES (?)";
282			$bindvars = [serialize($this->mail)];
283			$tikilib->query($query, $bindvars, -1, 0);
284			$title = 'mail';
285		} else {
286			try {
287				tiki_send_email($this->mail);
288				$title = 'mail';
289				$error = '';
290			} catch (Zend\Mail\Exception\ExceptionInterface $e) {
291				$title = 'mail error';
292				$error = $e->getMessage();
293				$this->errors[] = $error;
294				$error = ' [' . $error . ']';
295			}
296
297			if ($title == 'mail error' || $prefs['log_mail'] == 'y') {
298				foreach ($recipients as $u) {
299					$logslib->add_log($title, $u . '/' . $this->mail->getSubject() . $error);
300				}
301			}
302		}
303		return $title == 'mail';
304	}
305
306	protected function convertBodyToMime($text)
307	{
308		$textPart = new Zend\Mime\Part($text);
309		$textPart->setType(Zend\Mime\Mime::TYPE_TEXT);
310		$newBody = new Zend\Mime\Message();
311		$newBody->addPart($textPart);
312		$this->mail->setBody($newBody);
313	}
314
315	function addAttachment($data, $filename, $mimetype)
316	{
317		$body = $this->mail->getBody();
318		if (! ($body instanceof \Zend\Mime\Message)) {
319			$this->convertBodyToMime($body);
320			$body = $this->mail->getBody();
321		}
322
323		$attachment = new Zend\Mime\Part($data);
324		$attachment->setFileName($filename);
325		$attachment->setType($mimetype);
326		$attachment->setEncoding(Zend\Mime\Mime::ENCODING_BASE64);
327		$attachment->setDisposition(Zend\Mime\Mime::DISPOSITION_INLINE);
328		$body->addPart($attachment);
329	}
330
331	/**
332	 *	scramble an email with a method
333	 *
334	 * @param string $email email address to be scrambled
335	 * @param string $method unicode or y: each character is replaced with the unicode value
336	 *                       strtr: mr@tw.org -> mr AT tw DOT org
337	 *                       x: mr@tw.org -> mr@xxxxxx
338	 *
339	 * @return string scrambled email
340	 */
341	static function scrambleEmail($email, $method = 'unicode')
342	{
343		switch ($method) {
344			case 'strtr':
345				$trans = [	"@" => tra("(AT)"),
346							"." => tra("(DOT)")
347				];
348				return strtr($email, $trans);
349			case 'x':
350				$encoded = $email;
351				for ($i = strpos($email, "@") + 1, $istrlen_email = strlen($email); $i < $istrlen_email; $i++) {
352					if ($encoded[$i] != ".") {
353						$encoded[$i] = 'x';
354					}
355				}
356				return $encoded;
357			case 'unicode':
358			case 'y':// for previous compatibility
359				$encoded = '';
360				for ($i = 0, $istrlen_email = strlen($email); $i < $istrlen_email; $i++) {
361					$encoded .= '&#' . ord($email[$i]) . ';';
362				}
363				return $encoded;
364			case 'n':
365			default:
366				return $email;
367		}
368	}
369
370	private function collectCss()
371	{
372		static $css;
373		if ($css) {
374			return $css;
375		}
376
377		$cachelib = TikiLib::lib('cache');
378		if ($css = $cachelib->getCached('email_css')) {
379			return $css;
380		}
381
382		$headerlib = TikiLib::lib('header');
383		$files = $headerlib->get_css_files();
384		$contents = array_map(function ($file) {
385			if ($file{0} == '/') {
386				return file_get_contents($file);
387			} elseif (substr($file, 0, 4) == 'http') {
388				return TikiLib::lib('tiki')->httprequest($file);
389			} else {
390				if (strpos($file, 'themes/') === 0) {   // only use the tiki base and current theme files
391					return file_get_contents(TIKI_PATH . '/' . $file);
392				}
393			}
394		}, $files);
395
396		$css = implode("\n\n", array_filter($contents));
397		$cachelib->cacheItem('email_css', $css);
398		return $css;
399	}
400
401	private function applyStyle($html)
402	{
403		$html = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />' . $html;
404		$css = $this->collectCss();
405		$processor = new \TijsVerkoyen\CssToInlineStyles\CssToInlineStyles();
406		$html = $processor->convert($html, $css);
407		return $html;
408	}
409}
410
411/**
412 * Format text, sender and date for a plain text email reply
413 * - Split into 75 char long lines prepended with >
414 *
415 * @param $text		email text to be quoted
416 * @param $from		email from name/address to be quoted
417 * @param $date		date of mail to be quoted
418 * @return string	text ready for replying in a plain text email
419 */
420function format_email_reply(&$text, $from, $date)
421{
422	$lines = preg_split('/[\n\r]+/', wordwrap($text));
423
424	for ($i = 0, $icount_lines = count($lines); $i < $icount_lines; $i++) {
425		$lines[$i] = '> ' . $lines[$i] . "\n";
426	}
427	$str = ! empty($from) ? $from . ' wrote' : '';
428	$str .= ! empty($date) ? ' on ' . $date : '';
429	$str = "\n\n\n" . $str . "\n" . implode($lines);
430
431	return $str;
432}
433
434/**
435 * Attempt to close any unclosed HTML tags
436 * Needs to work with what's inside the BODY
437 * originally from http://snipplr.com/view/3618/close-tags-in-a-htmlsnippet/
438 *
439 * @param $html			html input
440 * @return string		corrected html out
441 */
442function closetags($html)
443{
444	#put all opened tags into an array
445	preg_match_all("#<([a-z]+)( .*)?(?!/)>#iU", $html, $result);
446	$openedtags = $result[1];
447
448	#put all closed tags into an array
449	preg_match_all("#</([a-z]+)>#iU", $html, $result);
450	$closedtags = $result[1];
451	$len_opened = count($openedtags);
452
453	# all tags are closed
454	if (count($closedtags) == $len_opened) {
455		return $html;
456	}
457	$openedtags = array_reverse($openedtags);
458
459	# close tags
460	for ($i = 0; $i < $len_opened; $i++) {
461		if (! in_array($openedtags[$i], $closedtags)) {
462			$html .= "</" . $openedtags[$i] . ">";
463		} else {
464			unset($closedtags[array_search($openedtags[$i], $closedtags)]);
465		}
466	}
467	return $html;
468}
469
470