1<?php
2
3namespace RainLoop\Providers\Filters;
4
5class SieveStorage implements \RainLoop\Providers\Filters\FiltersInterface
6{
7	const NEW_LINE = "\r\n";
8
9	const SIEVE_FILE_NAME = 'rainloop.user';
10	const SIEVE_FILE_NAME_RAW = 'rainloop.user.raw';
11
12	/**
13	 * @var \MailSo\Log\Logger
14	 */
15	private $oLogger;
16
17	/**
18	 * @var \RainLoop\Plugins\Manager
19	 */
20	private $oPlugins;
21
22	/**
23	 * @var \RainLoop\Application
24	 */
25	private $oConfig;
26
27	/**
28	 * @var bool
29	 */
30	private $bUtf8FolderName;
31
32	/**
33	 * @return void
34	 */
35	public function __construct($oPlugins, $oConfig)
36	{
37		$this->oLogger = null;
38
39		$this->oPlugins = $oPlugins;
40		$this->oConfig = $oConfig;
41
42		$this->bUtf8FolderName = !!$this->oConfig->Get('labs', 'sieve_utf8_folder_name', true);
43	}
44
45	/**
46	 * @param \RainLoop\Model\Account $oAccount
47	 * @param bool $bAllowRaw = false
48	 *
49	 * @return array
50	 */
51	public function Load($oAccount, $bAllowRaw = false)
52	{
53		$sRaw = '';
54
55		$bBasicIsActive = false;
56		$bRawIsActive = false;
57
58		$aModules = array();
59		$aFilters = array();
60
61		$oSieveClient = \MailSo\Sieve\ManageSieveClient::NewInstance()->SetLogger($this->oLogger);
62		$oSieveClient->SetTimeOuts(10, (int) $this->oConfig->Get('labs', 'sieve_timeout', 10));
63
64		if ($oAccount->SieveConnectAndLoginHelper($this->oPlugins, $oSieveClient, $this->oConfig))
65		{
66			$aModules = $oSieveClient->Modules();
67			$aList = $oSieveClient->ListScripts();
68
69			if (\is_array($aList) && 0 < \count($aList))
70			{
71				if (isset($aList[self::SIEVE_FILE_NAME]))
72				{
73					$bBasicIsActive = !!$aList[self::SIEVE_FILE_NAME];
74					$sS = $oSieveClient->GetScript(self::SIEVE_FILE_NAME);
75					if ($sS)
76					{
77						$aFilters = $this->fileStringToCollection($sS);
78					}
79				}
80
81				if ($bAllowRaw && isset($aList[self::SIEVE_FILE_NAME_RAW]))
82				{
83					$bRawIsActive = !!$aList[self::SIEVE_FILE_NAME_RAW];
84					$sRaw = \trim($oSieveClient->GetScript(self::SIEVE_FILE_NAME_RAW));
85				}
86			}
87
88			$oSieveClient->LogoutAndDisconnect();
89		}
90
91		return array(
92			'RawIsAllow' => $bAllowRaw,
93			'RawIsActive' => $bRawIsActive,
94			'Raw' => $bAllowRaw ? $sRaw : '',
95			'Filters' => !$bBasicIsActive && !$bRawIsActive ? array() : $aFilters,
96			'Capa' => $bAllowRaw ? $aModules : array(),
97			'Modules' => array(
98				'redirect' => \in_array('fileinto', $aModules),
99				'regex' => \in_array('regex', $aModules),
100				'relational' => \in_array('relational', $aModules),
101				'date' => \in_array('date', $aModules),
102				'moveto' => \in_array('fileinto', $aModules),
103				'reject' => \in_array('reject', $aModules),
104				'vacation' => \in_array('vacation', $aModules),
105				'markasread' => \in_array('imap4flags', $aModules)
106			)
107		);
108	}
109
110	/**
111	 * @param \RainLoop\Model\Account $oAccount
112	 * @param array $aFilters
113	 * @param string $sRaw = ''
114	 * @param bool $bRawIsActive = false
115	 *
116	 * @return bool
117	 */
118	public function Save($oAccount, $aFilters, $sRaw = '', $bRawIsActive = false)
119	{
120		$oSieveClient = \MailSo\Sieve\ManageSieveClient::NewInstance()->SetLogger($this->oLogger);
121		$oSieveClient->SetTimeOuts(10, (int) \RainLoop\Api::Config()->Get('labs', 'sieve_timeout', 10));
122
123		if ($oAccount->SieveConnectAndLoginHelper($this->oPlugins, $oSieveClient, $this->oConfig))
124		{
125			$aList = $oSieveClient->ListScripts();
126
127			if ($bRawIsActive)
128			{
129				if (!empty($sRaw))
130				{
131					$oSieveClient->PutScript(self::SIEVE_FILE_NAME_RAW, $sRaw);
132					$oSieveClient->SetActiveScript(self::SIEVE_FILE_NAME_RAW);
133				}
134				else if (isset($aList[self::SIEVE_FILE_NAME_RAW]))
135				{
136					$oSieveClient->DeleteScript(self::SIEVE_FILE_NAME_RAW);
137				}
138			}
139			else
140			{
141				$sUserFilter = $this->collectionToFileString($aFilters);
142
143				if (!empty($sUserFilter))
144				{
145					$oSieveClient->PutScript(self::SIEVE_FILE_NAME, $sUserFilter);
146					$oSieveClient->SetActiveScript(self::SIEVE_FILE_NAME);
147				}
148				else if (isset($aList[self::SIEVE_FILE_NAME]))
149				{
150					$oSieveClient->DeleteScript(self::SIEVE_FILE_NAME);
151				}
152			}
153
154			$oSieveClient->LogoutAndDisconnect();
155
156			return true;
157		}
158
159		return false;
160	}
161
162	/**
163	 * @param \RainLoop\Providers\Filters\Classes\FilterCondition $oCondition
164	 *
165	 * @return string
166	 */
167	private function conditionToSieveScript($oCondition, &$aCapa)
168	{
169		$sResult = '';
170		$sTypeWord = '';
171		$bTrue = true;
172
173		$sValue = \trim($oCondition->Value());
174		$sValueSecond = \trim($oCondition->ValueSecond());
175
176		if (0 < \strlen($sValue) ||
177			(0 < \strlen($sValue) && 0 < \strlen($sValueSecond) &&
178				\RainLoop\Providers\Filters\Enumerations\ConditionField::HEADER === $oCondition->Field()))
179		{
180			switch ($oCondition->Type())
181			{
182				case \RainLoop\Providers\Filters\Enumerations\ConditionType::OVER:
183					$sTypeWord = ':over';
184					break;
185				case \RainLoop\Providers\Filters\Enumerations\ConditionType::UNDER:
186					$sTypeWord = ':under';
187					break;
188				case \RainLoop\Providers\Filters\Enumerations\ConditionType::NOT_EQUAL_TO:
189					$sResult .= 'not ';
190				case \RainLoop\Providers\Filters\Enumerations\ConditionType::EQUAL_TO:
191					$sTypeWord = ':is';
192					break;
193				case \RainLoop\Providers\Filters\Enumerations\ConditionType::NOT_CONTAINS:
194					$sResult .= 'not ';
195				case \RainLoop\Providers\Filters\Enumerations\ConditionType::CONTAINS:
196					$sTypeWord = ':contains';
197					break;
198				case \RainLoop\Providers\Filters\Enumerations\ConditionType::REGEX:
199					$sTypeWord = ':regex';
200					$aCapa['regex'] = true;
201					break;
202				default:
203					$bTrue = false;
204					$sResult = '/* @Error: unknown type value */ false';
205					break;
206			}
207
208			switch ($oCondition->Field())
209			{
210				case \RainLoop\Providers\Filters\Enumerations\ConditionField::FROM:
211					$sResult .= 'header '.$sTypeWord.' ["From"]';
212					break;
213				case \RainLoop\Providers\Filters\Enumerations\ConditionField::RECIPIENT:
214					$sResult .= 'header '.$sTypeWord.' ["To", "CC"]';
215					break;
216				case \RainLoop\Providers\Filters\Enumerations\ConditionField::SUBJECT:
217					$sResult .= 'header '.$sTypeWord.' ["Subject"]';
218					break;
219				case \RainLoop\Providers\Filters\Enumerations\ConditionField::HEADER:
220					$sResult .= 'header '.$sTypeWord.' ["'.$this->quote($sValueSecond).'"]';
221					break;
222				case \RainLoop\Providers\Filters\Enumerations\ConditionField::SIZE:
223					$sResult .= 'size '.$sTypeWord;
224					break;
225				default:
226					$bTrue = false;
227					$sResult = '/* @Error: unknown field value */ false';
228					break;
229			}
230
231			if ($bTrue)
232			{
233				if (\in_array($oCondition->Field(), array(
234					\RainLoop\Providers\Filters\Enumerations\ConditionField::FROM,
235					\RainLoop\Providers\Filters\Enumerations\ConditionField::RECIPIENT
236				)) && false !== \strpos($sValue, ','))
237				{
238					$self = $this;
239					$aValue = \array_map(function ($sValue) use ($self) {
240						return '"'.$self->quote(\trim($sValue)).'"';
241					}, \explode(',', $sValue));
242
243					$sResult .= ' ['.\trim(\implode(', ', $aValue)).']';
244				}
245				else if (\RainLoop\Providers\Filters\Enumerations\ConditionField::SIZE === $oCondition->Field())
246				{
247					$sResult .= ' '.$this->quote($sValue);
248				}
249				else
250				{
251					$sResult .= ' "'.$this->quote($sValue).'"';
252				}
253
254				$sResult = \MailSo\Base\Utils::StripSpaces($sResult);
255			}
256		}
257		else
258		{
259			$sResult = '/* @Error: empty condition value */ false';
260		}
261
262		return $sResult;
263	}
264
265	/**
266	 * @param \RainLoop\Providers\Filters\Classes\Filter $oFilter
267	 * @param array $aCapa
268	 *
269	 * @return string
270	 */
271	private function filterToSieveScript($oFilter, &$aCapa)
272	{
273		$sNL = \RainLoop\Providers\Filters\SieveStorage::NEW_LINE;
274		$sTab = '    ';
275
276		$bAll = false;
277		$aResult = array();
278
279		// Conditions
280		$aConditions = $oFilter->Conditions();
281		if (\is_array($aConditions))
282		{
283			if (1 < \count($aConditions))
284			{
285				if (\RainLoop\Providers\Filters\Enumerations\ConditionsType::ANY ===
286					$oFilter->ConditionsType())
287				{
288					$aResult[] = 'if anyof(';
289
290					$bTrim = false;
291					foreach ($aConditions as $oCond)
292					{
293						$bTrim = true;
294						$sCons = $this->conditionToSieveScript($oCond, $aCapa);
295						if (!empty($sCons))
296						{
297							$aResult[] = $sTab.$sCons.',';
298						}
299					}
300					if ($bTrim)
301					{
302						$aResult[\count($aResult) - 1] = \rtrim($aResult[\count($aResult) - 1], ',');
303					}
304
305					$aResult[] = ')';
306				}
307				else
308				{
309					$aResult[] = 'if allof(';
310					foreach ($aConditions as $oCond)
311					{
312						$aResult[] = $sTab.$this->conditionToSieveScript($oCond, $aCapa).',';
313					}
314
315					$aResult[\count($aResult) - 1] = \rtrim($aResult[\count($aResult) - 1], ',');
316					$aResult[] = ')';
317				}
318			}
319			else if (1 === \count($aConditions))
320			{
321				$aResult[] = 'if '.$this->conditionToSieveScript($aConditions[0], $aCapa).'';
322			}
323			else
324			{
325				$bAll = true;
326			}
327		}
328
329		// actions
330		if (!$bAll)
331		{
332			$aResult[] = '{';
333		}
334		else
335		{
336			$sTab = '';
337		}
338
339		if ($oFilter->MarkAsRead() && \in_array($oFilter->ActionType(), array(
340			\RainLoop\Providers\Filters\Enumerations\ActionType::NONE,
341			\RainLoop\Providers\Filters\Enumerations\ActionType::MOVE_TO,
342			\RainLoop\Providers\Filters\Enumerations\ActionType::FORWARD
343		)))
344		{
345			$aCapa['imap4flags'] = true;
346			$aResult[] = $sTab.'addflag "\\\\Seen";';
347		}
348
349		switch ($oFilter->ActionType())
350		{
351			case \RainLoop\Providers\Filters\Enumerations\ActionType::NONE:
352				$aResult[] = $sTab.'stop;';
353				break;
354			case \RainLoop\Providers\Filters\Enumerations\ActionType::DISCARD:
355				$aResult[] = $sTab.'discard;';
356				$aResult[] = $sTab.'stop;';
357				break;
358			case \RainLoop\Providers\Filters\Enumerations\ActionType::VACATION:
359				$sValue = \trim($oFilter->ActionValue());
360				$sValueSecond = \trim($oFilter->ActionValueSecond());
361				$sValueThird = \trim($oFilter->ActionValueThird());
362				$sValueFourth = \trim($oFilter->ActionValueFourth());
363				if (0 < \strlen($sValue))
364				{
365					$aCapa['vacation'] = true;
366
367					$iDays = 1;
368					$sSubject = '';
369					if (0 < \strlen($sValueSecond))
370					{
371						$sSubject = ':subject "'.
372							$this->quote(\MailSo\Base\Utils::StripSpaces($sValueSecond)).'" ';
373					}
374
375					if (0 < \strlen($sValueThird) && \is_numeric($sValueThird) && 1 < (int) $sValueThird)
376					{
377						$iDays = (int) $sValueThird;
378					}
379
380					$sAddresses = '';
381					if (0 < \strlen($sValueFourth))
382					{
383						$self = $this;
384
385						$aAddresses = \explode(',', $sValueFourth);
386						$aAddresses = \array_filter(\array_map(function ($sEmail) use ($self) {
387							$sEmail = \trim($sEmail);
388							return 0 < \strlen($sEmail) ? '"'.$self->quote($sEmail).'"' : '';
389						}, $aAddresses), 'strlen');
390
391						if (0 < \count($aAddresses))
392						{
393							$sAddresses = ':addresses ['.\implode(', ', $aAddresses).'] ';
394						}
395					}
396
397					$aResult[] = $sTab.'vacation :days '.$iDays.' '.$sAddresses.$sSubject.'"'.$this->quote($sValue).'";';
398					if ($oFilter->Stop())
399					{
400						$aResult[] = $sTab.'stop;';
401					}
402				}
403				else
404				{
405					$aResult[] = $sTab.'# @Error (vacation): empty action value';
406				}
407				break;
408			case \RainLoop\Providers\Filters\Enumerations\ActionType::REJECT:
409				$sValue = \trim($oFilter->ActionValue());
410				if (0 < \strlen($sValue))
411				{
412					$aCapa['reject'] = true;
413
414					$aResult[] = $sTab.'reject "'.$this->quote($sValue).'";';
415					$aResult[] = $sTab.'stop;';
416				}
417				else
418				{
419					$aResult[] = $sTab.'# @Error (reject): empty action value';
420				}
421				break;
422			case \RainLoop\Providers\Filters\Enumerations\ActionType::FORWARD:
423				$sValue = $oFilter->ActionValue();
424				if (0 < \strlen($sValue))
425				{
426					if ($oFilter->Keep())
427					{
428						$aCapa['fileinto'] = true;
429						$aResult[] = $sTab.'fileinto "INBOX";';
430					}
431
432					$aResult[] = $sTab.'redirect "'.$this->quote($sValue).'";';
433					$aResult[] = $sTab.'stop;';
434				}
435				else
436				{
437					$aResult[] = $sTab.'# @Error (redirect): empty action value';
438				}
439				break;
440			case \RainLoop\Providers\Filters\Enumerations\ActionType::MOVE_TO:
441				$sValue = $oFilter->ActionValue();
442				if (0 < \strlen($sValue))
443				{
444					$sFolderName = $sValue; // utf7-imap
445					if ($this->bUtf8FolderName) // to utf-8
446					{
447						$sFolderName = \MailSo\Base\Utils::ConvertEncoding($sFolderName,
448							\MailSo\Base\Enumerations\Charset::UTF_7_IMAP,
449							\MailSo\Base\Enumerations\Charset::UTF_8);
450					}
451
452					$aCapa['fileinto'] = true;
453					$aResult[] = $sTab.'fileinto "'.$this->quote($sFolderName).'";';
454					$aResult[] = $sTab.'stop;';
455				}
456				else
457				{
458					$aResult[] = $sTab.'# @Error (fileinto): empty action value';
459				}
460				break;
461		}
462
463		if (!$bAll)
464		{
465			$aResult[] = '}';
466		}
467
468		return \implode($sNL, $aResult);
469	}
470
471	/**
472	 * @param array $aFilters
473	 *
474	 * @return string
475	 */
476	private function collectionToFileString($aFilters)
477	{
478		$sNL = \RainLoop\Providers\Filters\SieveStorage::NEW_LINE;
479
480		$aCapa = array();
481		$aParts = array();
482
483		$aParts[] = '# This is RainLoop Webmail sieve script.';
484		$aParts[] = '# Please don\'t change anything here.';
485		$aParts[] = '# RAINLOOP:SIEVE';
486		$aParts[] = '';
487
488		foreach ($aFilters as /* @var $oItem \RainLoop\Providers\Filters\Classes\Filter */ $oItem)
489		{
490			$aData = array();
491			$aData[] = '/*';
492			$aData[] = 'BEGIN:FILTER:'.$oItem->ID();
493			$aData[] = 'BEGIN:HEADER';
494			$aData[] = \chunk_split(\base64_encode($oItem->serializeToJson()), 74, $sNL).'END:HEADER';
495			$aData[] = '*/';
496			$aData[] = $oItem->Enabled() ? '' : '/* @Filter is disabled ';
497			$aData[] = $this->filterToSieveScript($oItem, $aCapa);
498			$aData[] = $oItem->Enabled() ? '' : '*/';
499			$aData[] = '/* END:FILTER */';
500			$aData[] = '';
501
502			$aParts[] = \implode($sNL, $aData);
503		}
504
505		$aCapa = \array_keys($aCapa);
506		$sCapa = 0 < \count($aCapa) ? $sNL.'require '.
507			\str_replace('","', '", "', \json_encode($aCapa)).';'.$sNL : '';
508
509		return $sCapa.$sNL.\implode($sNL, $aParts).$sNL;
510	}
511
512	/**
513	 * @param string $sFileString
514	 *
515	 * @return array
516	 */
517	private function fileStringToCollection($sFileString)
518	{
519		$aResult = array();
520		if (!empty($sFileString) && false !== \strpos($sFileString, 'RAINLOOP:SIEVE'))
521		{
522			$aMatch = array();
523			if (\preg_match_all('/BEGIN:FILTER(.+?)BEGIN:HEADER(.+?)END:HEADER/s', $sFileString, $aMatch) &&
524				isset($aMatch[2]) && \is_array($aMatch[2]))
525			{
526				foreach ($aMatch[2] as $sEncodedLine)
527				{
528					if (!empty($sEncodedLine))
529					{
530						$sDecodedLine = \base64_decode(\preg_replace('/[\s]+/', '', $sEncodedLine));
531						if (!empty($sDecodedLine))
532						{
533							$oItem = new \RainLoop\Providers\Filters\Classes\Filter();
534							if ($oItem && $oItem->unserializeFromJson($sDecodedLine))
535							{
536								$aResult[] = $oItem;
537							}
538						}
539					}
540				}
541			}
542		}
543
544		return $aResult;
545	}
546
547	/**
548	 * @param string $sValue
549	 *
550	 * @return string
551	 */
552	public function quote($sValue)
553	{
554		return \str_replace(array('\\', '"'), array('\\\\', '\\"'), \trim($sValue));
555	}
556
557	/**
558	 * @param \MailSo\Log\Logger $oLogger
559	 */
560	public function SetLogger($oLogger)
561	{
562		$this->oLogger = $oLogger instanceof \MailSo\Log\Logger ? $oLogger : null;
563	}
564}
565