1<?php
2// Copyright (C) 2010-2016 Combodo SARL
3//
4//   This file is part of iTop.
5//
6//   iTop is free software; you can redistribute it and/or modify
7//   it under the terms of the GNU Affero General Public License as published by
8//   the Free Software Foundation, either version 3 of the License, or
9//   (at your option) any later version.
10//
11//   iTop is distributed in the hope that it will be useful,
12//   but WITHOUT ANY WARRANTY; without even the implied warranty of
13//   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14//   GNU Affero General Public License for more details.
15//
16//   You should have received a copy of the GNU Affero General Public License
17//   along with iTop. If not, see <http://www.gnu.org/licenses/>
18
19
20/**
21 * Send an email (abstraction for synchronous/asynchronous modes)
22 *
23 * @copyright   Copyright (C) 2010-2016 Combodo SARL
24 * @license     http://opensource.org/licenses/AGPL-3.0
25 */
26
27require_once(APPROOT.'/lib/swiftmailer/lib/swift_required.php');
28
29Swift_Preferences::getInstance()->setCharset('UTF-8');
30
31
32define ('EMAIL_SEND_OK', 0);
33define ('EMAIL_SEND_PENDING', 1);
34define ('EMAIL_SEND_ERROR', 2);
35
36class EMail
37{
38	// Serialization formats
39	const ORIGINAL_FORMAT = 1; // Original format, consisting in serializing the whole object, inculding the Swift Mailer's object.
40							   // Did not work with attachements since their binary representation cannot be stored as a valid UTF-8 string
41	const FORMAT_V2 = 2; // New format, only the raw data are serialized (base64 encoded if needed)
42
43	protected static $m_oConfig = null;
44	protected $m_aData; // For storing data to serialize
45
46	public function LoadConfig($sConfigFile = ITOP_DEFAULT_CONFIG_FILE)
47	{
48		if (is_null(self::$m_oConfig))
49		{
50			self::$m_oConfig = new Config($sConfigFile);
51		}
52	}
53
54	protected $m_oMessage;
55
56	public function __construct()
57	{
58		$this->m_aData = array();
59		$this->m_oMessage = Swift_Message::newInstance();
60		$this->SetRecipientFrom(MetaModel::GetConfig()->Get('email_default_sender_address'), MetaModel::GetConfig()->Get('email_default_sender_label'));
61	}
62
63	/**
64	 * Custom serialization method
65	 * No longer use the brute force "serialize" method since
66	 * 1) It does not work with binary attachments (since they cannot be stored in a UTF-8 text field)
67	 * 2) The size tends to be quite big (sometimes ten times the size of the email)
68	 */
69	public function SerializeV2()
70	{
71		return serialize($this->m_aData);
72	}
73
74	/**
75	 * Custom de-serialization method
76	 * @param string $sSerializedMessage The serialized representation of the message
77	 */
78	static public function UnSerializeV2($sSerializedMessage)
79	{
80		$aData = unserialize($sSerializedMessage);
81		$oMessage = new Email();
82
83		if (array_key_exists('body', $aData))
84		{
85			$oMessage->SetBody($aData['body']['body'], $aData['body']['mimeType']);
86		}
87		if (array_key_exists('message_id', $aData))
88		{
89			$oMessage->SetMessageId($aData['message_id']);
90		}
91		if (array_key_exists('bcc', $aData))
92		{
93			$oMessage->SetRecipientBCC($aData['bcc']);
94		}
95		if (array_key_exists('cc', $aData))
96		{
97			$oMessage->SetRecipientCC($aData['cc']);
98		}
99		if (array_key_exists('from', $aData))
100		{
101			$oMessage->SetRecipientFrom($aData['from']['address'], $aData['from']['label']);
102		}
103		if (array_key_exists('reply_to', $aData))
104		{
105			$oMessage->SetRecipientReplyTo($aData['reply_to']);
106		}
107		if (array_key_exists('to', $aData))
108		{
109			$oMessage->SetRecipientTO($aData['to']);
110		}
111		if (array_key_exists('subject', $aData))
112		{
113			$oMessage->SetSubject($aData['subject']);
114		}
115
116
117		if (array_key_exists('headers', $aData))
118		{
119			foreach($aData['headers'] as $sKey => $sValue)
120			{
121				$oMessage->AddToHeader($sKey, $sValue);
122			}
123		}
124		if (array_key_exists('parts', $aData))
125		{
126			foreach($aData['parts'] as $aPart)
127			{
128				$oMessage->AddPart($aPart['text'], $aPart['mimeType']);
129			}
130		}
131		if (array_key_exists('attachments', $aData))
132		{
133			foreach($aData['attachments'] as $aAttachment)
134			{
135				$oMessage->AddAttachment(base64_decode($aAttachment['data']), $aAttachment['filename'], $aAttachment['mimeType']);
136			}
137		}
138		return $oMessage;
139	}
140
141  	protected function SendAsynchronous(&$aIssues, $oLog = null)
142	{
143		try
144		{
145			AsyncSendEmail::AddToQueue($this, $oLog);
146		}
147		catch(Exception $e)
148		{
149			$aIssues = array($e->GetMessage());
150			return EMAIL_SEND_ERROR;
151		}
152		$aIssues = array();
153		return EMAIL_SEND_PENDING;
154	}
155
156	protected function SendSynchronous(&$aIssues, $oLog = null)
157	{
158		// If the body of the message is in HTML, embed all images based on attachments
159		$this->EmbedInlineImages();
160
161		$this->LoadConfig();
162
163		$sTransport = self::$m_oConfig->Get('email_transport');
164		switch ($sTransport)
165		{
166		case 'SMTP':
167			$sHost = self::$m_oConfig->Get('email_transport_smtp.host');
168			$sPort = self::$m_oConfig->Get('email_transport_smtp.port');
169			$sEncryption = self::$m_oConfig->Get('email_transport_smtp.encryption');
170			$sUserName = self::$m_oConfig->Get('email_transport_smtp.username');
171			$sPassword = self::$m_oConfig->Get('email_transport_smtp.password');
172
173			$oTransport = Swift_SmtpTransport::newInstance($sHost, $sPort, $sEncryption);
174			if (strlen($sUserName) > 0)
175			{
176				$oTransport->setUsername($sUserName);
177				$oTransport->setPassword($sPassword);
178			}
179			break;
180
181		case 'Null':
182			$oTransport = Swift_NullTransport::newInstance();
183			break;
184
185		case 'LogFile':
186			$oTransport = Swift_LogFileTransport::newInstance();
187			$oTransport->setLogFile(APPROOT.'log/mail.log');
188			break;
189
190		case 'PHPMail':
191		default:
192			$oTransport = Swift_MailTransport::newInstance();
193		}
194
195		$oMailer = Swift_Mailer::newInstance($oTransport);
196
197		$aFailedRecipients = array();
198		$this->m_oMessage->setMaxLineLength(0);
199		$oKPI = new ExecutionKPI();
200		try
201		{
202			$iSent = $oMailer->send($this->m_oMessage, $aFailedRecipients);
203			if ($iSent === 0)
204			{
205				// Beware: it seems that $aFailedRecipients sometimes contains the recipients that actually received the message !!!
206				IssueLog::Warning('Email sending failed: Some recipients were invalid, aFailedRecipients contains: '.implode(', ', $aFailedRecipients));
207				$aIssues = array('Some recipients were invalid.');
208				$oKPI->ComputeStats('Email Sent', 'Error received');
209				return EMAIL_SEND_ERROR;
210			}
211			else
212			{
213				$aIssues = array();
214				$oKPI->ComputeStats('Email Sent', 'Succeded');
215				return EMAIL_SEND_OK;
216			}
217		}
218		catch (Exception $e)
219		{
220			$oKPI->ComputeStats('Email Sent', 'Error received');
221			throw $e;
222		}
223	}
224
225	/**
226	 * Reprocess the body of the message (if it is an HTML message)
227	 * to replace the URL of images based on attachments by a link
228	 * to an embedded image (i.e. cid:....)
229	 */
230	protected function EmbedInlineImages()
231	{
232		if ($this->m_aData['body']['mimeType'] == 'text/html')
233		{
234			$oDOMDoc = new DOMDocument();
235			$oDOMDoc->preserveWhitespace = true;
236			@$oDOMDoc->loadHTML('<?xml encoding="UTF-8"?>'.$this->m_aData['body']['body']); // For loading HTML chunks where the character set is not specified
237
238			$oXPath = new DOMXPath($oDOMDoc);
239			$sXPath = '//img[@'.InlineImage::DOM_ATTR_ID.']';
240			$oImagesList = $oXPath->query($sXPath);
241
242			if ($oImagesList->length != 0)
243			{
244				foreach($oImagesList as $oImg)
245				{
246					$iAttId = $oImg->getAttribute(InlineImage::DOM_ATTR_ID);
247					$oAttachment = MetaModel::GetObject('InlineImage', $iAttId, false, true /* Allow All Data */);
248					if ($oAttachment)
249					{
250						$sImageSecret = $oImg->getAttribute('data-img-secret');
251						$sAttachmentSecret = $oAttachment->Get('secret');
252						if ($sImageSecret !== $sAttachmentSecret)
253						{
254							// @see N°1921
255							// If copying from another iTop we could get an IMG pointing to an InlineImage with wrong secret
256							continue;
257						}
258
259						$oDoc = $oAttachment->Get('contents');
260						$oSwiftImage = new Swift_Image($oDoc->GetData(), $oDoc->GetFileName(), $oDoc->GetMimeType());
261						$sCid = $this->m_oMessage->embed($oSwiftImage);
262						$oImg->setAttribute('src', $sCid);
263					}
264				}
265			}
266			$sHtmlBody = $oDOMDoc->saveHTML();
267			$this->m_oMessage->setBody($sHtmlBody, 'text/html', 'UTF-8');
268		}
269	}
270
271	public function Send(&$aIssues, $bForceSynchronous = false, $oLog = null)
272	{
273		//select a default sender if none is provided.
274		if(empty($this->m_aData['from']['address']) && !empty($this->m_aData['to'])){
275			$this->SetRecipientFrom($this->m_aData['to']);
276		}
277
278		if ($bForceSynchronous)
279		{
280			return $this->SendSynchronous($aIssues, $oLog);
281		}
282		else
283		{
284			$bConfigASYNC = MetaModel::GetConfig()->Get('email_asynchronous');
285			if ($bConfigASYNC)
286			{
287				return $this->SendAsynchronous($aIssues, $oLog);
288			}
289			else
290			{
291				return $this->SendSynchronous($aIssues, $oLog);
292			}
293		}
294	}
295
296	public function AddToHeader($sKey, $sValue)
297	{
298		if (!array_key_exists('headers', $this->m_aData))
299		{
300			$this->m_aData['headers'] = array();
301		}
302		$this->m_aData['headers'][$sKey] = $sValue;
303
304		if (strlen($sValue) > 0)
305		{
306			$oHeaders = $this->m_oMessage->getHeaders();
307			switch(strtolower($sKey))
308			{
309				default:
310				$oHeaders->addTextHeader($sKey, $sValue);
311			}
312		}
313	}
314
315	public function SetMessageId($sId)
316	{
317		$this->m_aData['message_id'] = $sId;
318
319		// Note: Swift will add the angle brackets for you
320		// so let's remove the angle brackets if present, for historical reasons
321		$sId = str_replace(array('<', '>'), '', $sId);
322
323		$oMsgId = $this->m_oMessage->getHeaders()->get('Message-ID');
324		$oMsgId->SetId($sId);
325	}
326
327	public function SetReferences($sReferences)
328	{
329		$this->AddToHeader('References', $sReferences);
330	}
331
332	public function SetBody($sBody, $sMimeType = 'text/html', $sCustomStyles = null)
333	{
334		if (($sMimeType === 'text/html') && ($sCustomStyles !== null))
335		{
336			require_once(APPROOT.'lib/emogrifier/Classes/Emogrifier.php');
337			$emogrifier = new \Pelago\Emogrifier($sBody, $sCustomStyles);
338			$sBody = $emogrifier->emogrify(); // Adds html/body tags if not already present
339		}
340		$this->m_aData['body'] = array('body' => $sBody, 'mimeType' => $sMimeType);
341		$this->m_oMessage->setBody($sBody, $sMimeType);
342	}
343
344	public function AddPart($sText, $sMimeType = 'text/html')
345	{
346		if (!array_key_exists('parts', $this->m_aData))
347		{
348			$this->m_aData['parts'] = array();
349		}
350		$this->m_aData['parts'][] = array('text' => $sText, 'mimeType' => $sMimeType);
351		$this->m_oMessage->addPart($sText, $sMimeType);
352	}
353
354	public function AddAttachment($data, $sFileName, $sMimeType)
355	{
356		if (!array_key_exists('attachments', $this->m_aData))
357		{
358			$this->m_aData['attachments'] = array();
359		}
360		$this->m_aData['attachments'][] = array('data' => base64_encode($data), 'filename' => $sFileName, 'mimeType' => $sMimeType);
361		$this->m_oMessage->attach(Swift_Attachment::newInstance($data, $sFileName, $sMimeType));
362	}
363
364	public function SetSubject($sSubject)
365	{
366		$this->m_aData['subject'] = $sSubject;
367		$this->m_oMessage->setSubject($sSubject);
368	}
369
370	public function GetSubject()
371	{
372		return $this->m_oMessage->getSubject();
373	}
374
375	/**
376	 * Helper to transform and sanitize addresses
377	 * - get rid of empty addresses
378	 */
379	protected function AddressStringToArray($sAddressCSVList)
380	{
381		$aAddresses = array();
382		foreach(explode(',', $sAddressCSVList) as $sAddress)
383		{
384			$sAddress = trim($sAddress);
385			if (strlen($sAddress) > 0)
386			{
387				$aAddresses[] = $sAddress;
388			}
389		}
390		return $aAddresses;
391	}
392
393	public function SetRecipientTO($sAddress)
394	{
395		$this->m_aData['to'] = $sAddress;
396		if (!empty($sAddress))
397		{
398			$aAddresses = $this->AddressStringToArray($sAddress);
399			$this->m_oMessage->setTo($aAddresses);
400		}
401	}
402
403	public function GetRecipientTO($bAsString = false)
404	{
405		$aRes = $this->m_oMessage->getTo();
406		if ($aRes === null)
407		{
408			// There is no "To" header field
409			$aRes = array();
410		}
411		if ($bAsString)
412		{
413			$aStrings = array();
414			foreach ($aRes as $sEmail => $sName)
415			{
416				if (is_null($sName))
417				{
418					$aStrings[] = $sEmail;
419				}
420				else
421				{
422					$sName = str_replace(array('<', '>'), '', $sName);
423					$aStrings[] = "$sName <$sEmail>";
424				}
425			}
426			return implode(', ', $aStrings);
427		}
428		else
429		{
430			return $aRes;
431		}
432	}
433
434	public function SetRecipientCC($sAddress)
435	{
436		$this->m_aData['cc'] = $sAddress;
437		if (!empty($sAddress))
438		{
439			$aAddresses = $this->AddressStringToArray($sAddress);
440			$this->m_oMessage->setCc($aAddresses);
441		}
442	}
443
444	public function SetRecipientBCC($sAddress)
445	{
446		$this->m_aData['bcc'] = $sAddress;
447		if (!empty($sAddress))
448		{
449			$aAddresses = $this->AddressStringToArray($sAddress);
450			$this->m_oMessage->setBcc($aAddresses);
451		}
452	}
453
454	public function SetRecipientFrom($sAddress, $sLabel = '')
455	{
456		$this->m_aData['from'] = array('address' => $sAddress, 'label' => $sLabel);
457		if ($sLabel != '')
458		{
459			$this->m_oMessage->setFrom(array($sAddress => $sLabel));
460		}
461		else if (!empty($sAddress))
462		{
463			$this->m_oMessage->setFrom($sAddress);
464		}
465	}
466
467	public function SetRecipientReplyTo($sAddress)
468	{
469		$this->m_aData['reply_to'] = $sAddress;
470		if (!empty($sAddress))
471		{
472			$this->m_oMessage->setReplyTo($sAddress);
473		}
474	}
475
476}
477
478/////////////////////////////////////////////////////////////////////////////////////
479
480/**
481 * Extension to SwiftMailer: "debug" transport that pretends messages have been sent,
482 * but just log them to a file.
483 *
484 * @package Swift
485 * @author  Denis Flaven
486 */
487class Swift_Transport_LogFileTransport extends Swift_Transport_NullTransport
488{
489	protected $sLogFile;
490
491	/**
492	 * Sends the given message.
493	 *
494	 * @param Swift_Mime_Message $message
495	 * @param string[]           $failedRecipients An array of failures by-reference
496	 *
497	 * @return int     The number of sent emails
498	 */
499	public function send(Swift_Mime_Message $message, &$failedRecipients = null)
500	{
501		$hFile = @fopen($this->sLogFile, 'a');
502		if ($hFile)
503		{
504			$sTxt = "================== ".date('Y-m-d H:i:s')." ==================\n";
505			$sTxt .= $message->toString()."\n";
506
507			@fwrite($hFile, $sTxt);
508			@fclose($hFile);
509		}
510
511		return parent::send($message, $failedRecipients);
512	}
513
514	public function setLogFile($sFilename)
515	{
516		$this->sLogFile = $sFilename;
517	}
518}
519
520/**
521 * Pretends messages have been sent, but just log them to a file.
522 *
523 * @package Swift
524 * @author  Denis Flaven
525 */
526class Swift_LogFileTransport extends Swift_Transport_LogFileTransport
527{
528	/**
529	 * Create a new LogFileTransport.
530	 */
531	public function __construct()
532	{
533		call_user_func_array(
534		array($this, 'Swift_Transport_LogFileTransport::__construct'),
535		Swift_DependencyContainer::getInstance()
536		->createDependenciesFor('transport.null')
537		);
538	}
539
540	/**
541	 * Create a new LogFileTransport instance.
542	 *
543	 * @return Swift_LogFileTransport
544	 */
545	public static function newInstance()
546	{
547		return new self();
548	}
549}