Completed
Pull Request — development (#3105)
by John
13:01
created

Email_Parse   D

Complexity

Total Complexity 169

Size/Duplication

Total Lines 1341
Duplicated Lines 5.07 %

Coupling/Cohesion

Components 1
Dependencies 1

Test Coverage

Coverage 57.75%

Importance

Changes 0
Metric Value
wmc 169
lcom 1
cbo 1
dl 68
loc 1341
ccs 231
cts 400
cp 0.5775
rs 4
c 0
b 0
f 0

29 Methods

Rating   Name   Duplication   Size   Complexity  
A read_data() 0 18 4
A _readFailed() 0 17 3
B _query_load_email() 0 24 2
A read_email() 0 15 2
A _split_headers() 0 20 3
C _parse_headers() 0 37 7
B _parse_content_headers() 8 38 5
B _parse_content_header_parameters() 0 28 5
F _parse_body() 10 173 36
B _process_DSN() 8 38 5
C _process_attachments() 2 28 8
A _boundary_split() 0 23 3
C _decode_header() 0 73 15
B _decode_body() 0 37 5
A _check_dsn() 0 10 2
A get_failed_dest() 0 16 3
A load_returnpath() 0 15 3
A load_subject() 0 13 2
A load_key() 0 21 4
B _load_key_from_headers() 8 33 6
A _load_key_from_body() 6 22 3
A _load_key_details() 0 11 2
B load_address() 20 40 6
B load_ip() 0 28 6
D load_spam() 6 25 9
A _parse_ip() 0 19 3
B _parse_address() 0 43 6
B _decode_string() 0 20 5
B _charset_convert() 0 35 6

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like Email_Parse often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Email_Parse, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * Class to parse and email in to its header and body parts for use in posting
5
 *
6
 * @name      ElkArte Forum
7
 * @copyright ElkArte Forum contributors
8
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause
9
 *
10
 * @version 2.0 dev
11
 *
12
 */
13
14
/**
15
 * Class to parse and email in to its header and body parts for use in posting
16
 *
17
 * What it does:
18
 *
19
 * - Can read from a supplied string, stdin or from the failed email database
20
 * - Parses and decodes headers, return them in a named array $headers
21
 * - Parses, decodes and translates message body returns body and plain_body sections
22
 * - Parses and decodes attachments returns attachments and inline_files
23
 *
24
 * Load class
25
 * Initiate as
26
 *  - $email_message = new EmailParse();
27
 *
28
 * Make the call, loads data and performs all need parsing
29
 * - $email_message->read_email(true); // Read data and parse it, prefer html section
30
 *
31
 * Just load data:
32
 * - $email_message->read_data(); // load data from stdin
33
 * - $email_message->read_data($data); // load data from a supplied string
34
 *
35
 * Get some email details:
36
 * - $email_message->headers // All the headers in an array
37
 * - $email_message->body // The decoded / translated message
38
 * - $email_message->raw_message // The entire message w/headers as read
39
 * - $email_message->plain_body // The plain text version of the message
40
 * - $email_message->attachments // Any attachments with key = filename
41
 *
42
 * Optional functions:
43
 * - $email_message->load_address(); // Returns array with to/from/cc addresses
44
 * - $email_message->load_key(); // Returns the security key is found, also sets
45
 * message_key, message_type and message_id
46
 * - $email_message->load_spam(); // Returns boolean on if spam headers are set
47
 * - $email_message->load_ip(); // Set ip origin of the email if available
48
 * - $email_message->load_returnpath(); // Load the message return path
49
 *
50
 * @package Maillist
51
 */
52
class Email_Parse
53
{
54
	/**
55
	 * The full message section (headers, body, etc) we are working on
56
	 * @var string
57
	 */
58
	public $raw_message = null;
59
60
	/**
61
	 * Attachments found after the message
62
	 * @var string[]
63
	 */
64
	public $attachments = array();
65
66
	/**
67
	 * Attachments that we designated as inline with the text
68
	 * @var string[]
69
	 */
70
	public $inline_files = array();
71
72
	/**
73
	 * Parsed and decoded message body, may be plain text or html
74
	 * @var string
75
	 */
76
	public $body = null;
77
78
	/**
79
	 * Parsed and decoded message body, only plain text version
80
	 * @var string
81
	 */
82
	public $plain_body = null;
83
84
	/**
85
	 * All of the parsed message headers
86
	 * @var mixed[]
87
	 */
88
	public $headers = array();
89
90
	/**
91
	 * Full security key
92
	 * @var string
93
	 */
94
	public $message_key_id = null;
95
96
	/**
97
	 * Message hex-code
98
	 * @var string
99
	 */
100
	public $message_key = null;
101
102
	/**
103
	 * Message type of the key p, m or t
104
	 * @var string
105
	 */
106
	public $message_type = null;
107
108
	/**
109
	 * If an html was found in the message
110
	 * @var boolean
111
	 */
112
	public $html_found = false;
113
114
	/**
115
	 * If any positive spam headers were found in the message
116
	 * @var boolean
117
	 */
118
	public $spam_found = false;
119
120
	/**
121
	 * Message id of the key
122
	 * @var int
123
	 */
124
	public $message_id = null;
125
126
	/**
127
	 * Holds the return path as set in the email header
128
	 * @var string
129
	 */
130
	public $return_path = null;
131
132
	/**
133
	 * Holds the message subject
134
	 * @var string
135
	 */
136
	public $subject = null;
137
138
	/**
139
	 * Holds the email to from & cc emails and names
140
	 * @var mixed[]
141
	 */
142
	public $email = array();
143
144
	/**
145
	 * Holds the sending ip of the email
146
	 * @var string|boolean
147
	 */
148
	public $ip = false;
149
150
	/**
151
	 * If the file was converted to utf8
152
	 * @var boolean
153
	 */
154
	public $_converted_utf8 = false;
155
156
	/**
157
	 * Whether the message is a DSN (Delivery Status Notification - aka "bounce"),
158
	 * indicating failed delivery
159
	 * @var boolean
160
	 */
161
	public $_is_dsn = false;
162
163
	/**
164
	 * Holds the field/value/type report codes from DSN messages
165
	 * Accessible as [$field]['type'] and [$field]['value']
166
	 * @var mixed[]
167
	 */
168
	public $_dsn = null;
169
170
	/**
171
	 * Holds the current email address, to, from, cc
172
	 * @var string
173
	 */
174
	private $_email_address = null;
175
176
	/**
177
	 * Holds the current email name
178
	 * @var string
179
	 */
180
	private $_email_name = null;
181
182
	/**
183
	 * Holds each boundary section of the message
184
	 * @var string[]
185
	 */
186
	private $_boundary_section = array();
187
188
	/**
189
	 * The total number of boundary sections
190
	 * @var int
191
	 */
192
	private $_boundary_section_count = 0;
193
194
	/**
195
	 * The message header block
196
	 * @var string
197
	 */
198
	private $_header_block = null;
199
200
	/**
201
	 * Loads an email message from stdin, file or from a supplied string
202
	 *
203
	 * @param string $data optional, if supplied must be a full headers+body email string
204
	 * @param string $location optional, used for debug
205
	 * @throws Elk_Exception
206
	 */
207 1
	public function read_data($data = '', $location = '')
208
	{
209
		// Supplied a string of data, simply use it
210 1
		if ($data !== null)
211
		{
212 1
			$this->raw_message = !empty($data) ? $data : false;
0 ignored issues
show
Documentation Bug introduced by
It seems like !empty($data) ? $data : false can also be of type false. However, the property $raw_message is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
213
		}
214
		// Not running from the CLI, must be from the ACP
215
		elseif (!defined('STDIN'))
216
		{
217
			$this->_readFailed($location);
218
		}
219
		// Load file data straight from the pipe
220
		else
221
		{
222
			$this->raw_message = file_get_contents('php://stdin');
223
		}
224 1
	}
225
226
	/**
227
	 * Load a message for parsing by reading it from the DB or from a debug file
228
	 *
229
	 * - Must have admin permissions
230
	 *
231
	 * @param string $location
232
	 * @throws Elk_Exception
233
	 */
234
	private function _readFailed($location)
235
	{
236
		// Called from the ACP, you must have approve permissions
237
		if (isset($_POST['item']))
238
		{
239
			isAllowedTo(array('admin_forum', 'approve_emails'));
240
241
			// Read in the file from the failed log table
242
			$this->raw_message = $this->_query_load_email($_POST['item']);
243
		}
244
		// Debugging file, just used for testing
245
		elseif (file_exists($location . '/elk-test.eml'))
246
		{
247
			isAllowedTo('admin_forum');
248
			$this->raw_message = file_get_contents($location . '/elk-test.eml');
249
		}
250
	}
251
252
	/**
253
	 * Loads an email message from the database
254
	 *
255
	 * @param int $id id of the email to retrieve from the failed log
256
	 *
257
	 * @return string
258
	 */
259
	private function _query_load_email($id)
260
	{
261
		$db = database();
262
263
		// Nothing to load then
264
		if (empty($id))
265
		{
266
			return '';
267
		}
268
269
		$request = $db->query('', '
270
			SELECT message
271
			FROM {db_prefix}postby_emails_error
272
			WHERE id_email = {int:id}
273
			LIMIT 1',
274
			array(
275
				'id' => $id
276
			)
277
		);
278
		list ($message) = $db->fetch_row($request);
279
		$db->free_result($request);
280
281
		return $message;
282
	}
283
284
	/**
285
	 * Main email routine, calls the needed functions to parse the data so that
286
	 * its available.
287
	 *
288
	 * What it does:
289
	 *
290
	 * - read/load data
291
	 * - split headers from the body
292
	 * - break header string in to individual header keys
293
	 * - determine content type and character encoding
294
	 * - convert message body's
295
	 *
296
	 * @param boolean $html - flag to determine if we are saving html or not
297
	 * @param string $data - full header+message string
298
	 * @param string $location - optional, used for debug
299
	 * @throws Elk_Exception
300
	 */
301 1
	public function read_email($html = false, $data = '', $location = '')
302
	{
303
		// Main, will read, split, parse, decode an email
304 1
		$this->read_data($data, $location);
305
306 1
		if ($this->raw_message)
307
		{
308 1
			$this->_split_headers();
309 1
			$this->_parse_headers();
310 1
			$this->_parse_content_headers();
311 1
			$this->_parse_body($html);
312 1
			$this->load_subject();
313 1
			$this->_is_dsn = $this->_check_dsn();
314
		}
315 1
	}
316
317
	/**
318
	 * Separate the email message headers from the message body
319
	 *
320
	 * The header is separated from the body by
321
	 *  - 1 the first empty line or
322
	 *  - 2 a line that does not start with a tab, a field name followed by a colon or a space
323
	 */
324 1
	private function _split_headers()
325
	{
326 1
		$this->_header_block = '';
327 1
		$match = array();
328
329
		// Do we even start with a header in this boundary section?
330 1
		if (!preg_match('~^[\w-]+:[ ].*?\r?\n~i', $this->raw_message))
331
		{
332 1
			return;
333
		}
334
335
		// The header block ends based on condition (1) or (2)
336 1
		if (!preg_match('~^(.*?)\r?\n(?:\r?\n|(?!(\t|[\w-]+:|[ ])))(.*)~s', $this->raw_message, $match))
337
		{
338
			return;
339
		}
340
341 1
		$this->_header_block = $match[1];
342 1
		$this->body = $match[3];
343 1
	}
344
345
	/**
346
	 * Takes the header block created with _split_headers and separates it
347
	 * in to header keys => value pairs
348
	 */
349 1
	private function _parse_headers()
350
	{
351
		// Remove windows style \r's
352 1
		$this->_header_block = str_replace("\r\n", "\n", $this->_header_block);
353
354
		// unfolding multi-line headers, a CRLF immediately followed by a LWSP-char is equivalent to the LWSP-char
355 1
		$this->_header_block = preg_replace("~\n(\t| )+~", ' ', $this->_header_block);
356
357
		// Build the array of headers
358 1
		$headers = explode("\n", trim($this->_header_block));
359 1
		foreach ($headers as $header)
360
		{
361 1
			$pos = strpos($header, ':');
362 1
			$header_key = substr($header, 0, $pos);
363 1
			$pos++;
364
365
			// Invalid, empty or generally malformed header
366 1
			if (!$header_key || $pos === strlen($header) || ($header[$pos] !== ' ' && $header[$pos] !== "\t"))
367
			{
368 1
				continue;
369
			}
370
371
			// The header key (standardized) and value
372 1
			$header_value = substr($header, $pos + 1);
373 1
			$header_key = strtolower(trim($header_key));
374
375
			// Decode and add it in to our headers array
376 1
			if (!isset($this->headers[$header_key]))
377
			{
378 1
				$this->headers[$header_key] = $this->_decode_header($header_value);
379
			}
380
			else
381
			{
382 1
				$this->headers[$header_key] .= ' ' . $this->_decode_header($header_value);
383
			}
384
		}
385 1
	}
386
387
	/**
388
	 * Content headers need to be set so we can properly decode the message body.
389
	 *
390
	 * What it does:
391
	 *
392
	 * - Content headers often use the optional parameter value syntax which need to be
393
	 * specially processed.
394
	 * - Parses or sets defaults for the following:
395
	 * content-type, content-disposition, content-transfer-encoding
396
	 */
397 1
	private function _parse_content_headers()
398
	{
399
		// What kind of message content do we have
400 1
		if (isset($this->headers['content-type']))
401
		{
402 1
			$this->_parse_content_header_parameters($this->headers['content-type'], 'content-type');
403 1 View Code Duplication
			if (empty($this->headers['x-parameters']['content-type']['charset']))
404
			{
405 1
				$this->headers['x-parameters']['content-type']['charset'] = 'UTF-8';
406
			}
407
		}
408 View Code Duplication
		else
409
		{
410
			// No content header given so we assume plain text
411 1
			$this->headers['content-type'] = 'text/plain';
412 1
			$this->headers['x-parameters']['content-type']['charset'] = 'UTF-8';
413
		}
414
415
		// Any special content or assume standard inline
416 1
		if (isset($this->headers['content-disposition']))
417
		{
418
			$this->_parse_content_header_parameters($this->headers['content-disposition'], 'content-disposition');
419
		}
420
		else
421
		{
422 1
			$this->headers['content-disposition'] = 'inline';
423
		}
424
425
		// How this message been encoded, utf8, quoted printable, other??, if none given assume standard 7bit
426 1
		if (isset($this->headers['content-transfer-encoding']))
427
		{
428 1
			$this->_parse_content_header_parameters($this->headers['content-transfer-encoding'], 'content-transfer-encoding');
429
		}
430
		else
431
		{
432 1
			$this->headers['content-transfer-encoding'] = '7bit';
433
		}
434 1
	}
435
436
	/**
437
	 * Checks if a given header has any optional parameter values
438
	 *
439
	 * A header like Content-type: text/plain; charset=iso-8859-1 will become
440
	 * - headers[Content-type] = text/plain
441
	 * - headers['x-parameters'][charset] = iso-8859-1
442
	 *
443
	 * If parameters are found, sets the primary value to the given key and the additional
444
	 * values are placed to our catch all x-parameters key. Done this way to prevent
445
	 * overwriting a primary header key with a secondary one
446
	 *
447
	 * @param string $value
448
	 * @param string $key
449
	 */
450 1
	private function _parse_content_header_parameters($value, $key)
451
	{
452 1
		$matches = array();
453
454
		// Does the header key contain parameter values?
455 1
		$pos = strpos($value, ';');
456 1
		if ($pos !== false)
457
		{
458
			// Assign the primary value to the key
459 1
			$this->headers[$key] = strtolower(trim(substr($value, 0, $pos)));
460
461
			// Place any parameter values in the x-parameters key
462 1
			$parameters = ltrim(substr($value, $pos + 1));
463 1
			if (!empty($parameters) && preg_match_all('~([A-Za-z-]+)="?(.*?)"?\s*(?:;|$)~', $parameters, $matches))
464
			{
465 1
				$count = count($matches[0]);
466 1
				for ($i = 0; $i < $count; $i++)
467
				{
468 1
					$this->headers['x-parameters'][$key][strtolower($matches[1][$i])] = $matches[2][$i];
469
				}
470
			}
471
		}
472
		// No parameters associated with this header
473
		else
474
		{
475 1
			$this->headers[$key] = strtolower(trim($value));
476
		}
477 1
	}
478
479
	/**
480
	 * Based on the the message content type, determine how to best proceed
481
	 *
482
	 * @param boolean $html
483
	 */
484 1
	private function _parse_body($html = false)
485
	{
486
		// based on the content type for this body, determine what do do
487 1
		switch ($this->headers['content-type'])
488
		{
489
			// The text/plain content type is the generic subtype for plain text. It is the default specified by RFC 822.
490 1
			case 'text/plain':
491 1
				$this->body = $this->_decode_string($this->body, $this->headers['content-transfer-encoding'], $this->headers['x-parameters']['content-type']['charset']);
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->_decode_string($t...tent-type']['charset']) can also be of type array<integer,string>. However, the property $body is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
492 1
				$this->plain_body = $this->body;
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->body can also be of type array<integer,string>. However, the property $plain_body is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
493 1
				break;
494
			// The text/html content type is an Internet Media Type as well as a MIME content type.
495 1 View Code Duplication
			case 'text/html':
496 1
				$this->html_found = true;
497 1
				$this->body = $this->_decode_string($this->body, $this->headers['content-transfer-encoding'], $this->headers['x-parameters']['content-type']['charset']);
498 1
				break;
499
			// We don't process the following, noted here so people know why
500
			//
501
			// multipart/digest - used to send collections of plain-text messages
502
			// multipart/byteranges - defined as a part of the HTTP message protocol. It includes two or more parts,
503
			// each with its own Content-Type and Content-Range fields
504
			// multipart/form-data - intended to allow information providers to express file upload requests uniformly
505
			// text/enriched - Uses a very limited set of formatting commands all with <command name></command name>
506
			// text/richtext - Obsolete version of the above
507
			//
508 1
			case 'multipart/digest':
509 1
			case 'multipart/byteranges':
510 1
			case 'multipart/form-data':
511 1
			case 'text/enriched':
512 1
			case 'text/richtext':
513
				break;
514
			// The following are considered multi part messages, as such they *should* contain several sections each
515
			// representing the same message in various ways such as plain text (mandatory), html section, and
516
			// encoded section such as quoted printable as well as attachments both as files and inline
517
			//
518
			// multipart/alternative - the same information is presented in different body parts in different forms.
519
			// The body parts are ordered by increasing complexity and accuracy
520
			// multipart/mixed -  used when the body parts are independent and need to be bundled in a particular order
521
			// multipart/parallel - display all of the parts simultaneously on hardware and software that can do so (image with audio)
522
			// multipart/related - used for compound documents, those messages in which the separate body parts are intended to work
523
			// together to provide the full meaning of the message
524
			// multipart/report - defined for returning delivery status reports, with optional included messages
525
			// multipart/signed -provides a security framework for MIME parts
526
			// multipart/encrypted - as above provides a security framework for MIME parts
527
			// message/rfc822 - used to enclose a complete message within a message
528
			//
529 1
			case 'multipart/alternative':
530
			case 'multipart/mixed':
531
			case 'multipart/parallel':
532
			case 'multipart/related':
533
			case 'multipart/report':
534
			case 'multipart/signed':
535
			case 'multipart/encrypted':
536
			case 'message/rfc822':
537 1
				if (!isset($this->headers['x-parameters']['content-type']['boundary']))
538
				{
539
					// No boundary's but presented as multipart?, then we must have a incomplete message
540
					$this->body = '';
541
					return;
542
				}
543
544
				// Break up the message on the boundary --sections, each boundary section will have its
545
				// own Content Type and Encoding and we will process each as such
546 1
				$this->_boundary_split($this->headers['x-parameters']['content-type']['boundary'], $html);
547
548
				// Some multi-part messages ... are singletons :P
549 1
				if ($this->_boundary_section_count === 1)
550
				{
551
					$this->body = $this->_boundary_section[0]->body;
552
					$this->headers['x-parameters'] = $this->_boundary_section[0]->headers['x-parameters'];
553
				}
554
				// We found multiple sections, lets go through each
555 1
				elseif ($this->_boundary_section_count > 1)
556
				{
557 1
					$html_ids = array();
558 1
					$text_ids = array();
559 1
					$this->body = '';
560 1
					$this->plain_body = empty($this->plain_body) ? '' : $this->plain_body;
561 1
					$bypass = array('application/pgp-encrypted', 'application/pgp-signature', 'application/pgp-keys');
562
563
					// Go through each boundary section
564 1
					for ($i = 0; $i < $this->_boundary_section_count; $i++)
565
					{
566
						// Stuff we can't or don't want to process
567 1
						if (in_array($this->_boundary_section[$i]->headers['content-type'], $bypass))
568
						{
569
							continue;
570
						}
571
						// HTML sections
572 1
						elseif ($this->_boundary_section[$i]->headers['content-type'] === 'text/html')
573
						{
574 1
							$html_ids[] = $i;
575
						}
576
						// Plain section
577 1
						elseif ($this->_boundary_section[$i]->headers['content-type'] === 'text/plain')
578
						{
579 1
							$text_ids[] = $i;
580
						}
581
						// Message is a DSN (Delivery Status Notification)
582
						elseif ($this->_boundary_section[$i]->headers['content-type'] === 'message/delivery-status')
583
						{
584
							$this->_process_DSN($i);
585
						}
586
587
						// Attachments, we love em
588 1
						$this->_process_attachments($i);
589
					}
590
591
					// We always return a plain text version for use
592 1
					if (!empty($text_ids))
593
					{
594 1
						foreach ($text_ids as $id)
595
						{
596 1
							$this->plain_body .= $this->_boundary_section[$id]->body;
597
						}
598
					}
599
					elseif (!empty($html_ids))
600
					{
601
						// This should never run as emails should always have a plain text section to be valid, still ...
602
						foreach ($html_ids as $id)
603
						{
604
							$this->plain_body .= $this->_boundary_section[$id]->body;
605
						}
606
607
						$this->plain_body = str_ireplace('<p>', "\n\n", $this->plain_body);
608
						$this->plain_body = str_ireplace(array('<br />', '<br>', '</p>', '</div>'), "\n", $this->plain_body);
609
						$this->plain_body = strip_tags($this->plain_body);
610
					}
611 1
					$this->plain_body = $this->_decode_body($this->plain_body);
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->_decode_body($this->plain_body) can also be of type array<integer,string>. However, the property $plain_body is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
612
613
					// If they want the html section, and its available,  we need to set it
614 1
					if ($html && !empty($html_ids))
615
					{
616 1
						$this->html_found = true;
617 1
						$text_ids = $html_ids;
618
					}
619
620 1
					if (!empty($text_ids))
621
					{
622
						// For all the chosen sections
623 1
						foreach ($text_ids as $id)
624
						{
625 1
							$this->body .= $this->_boundary_section[$id]->body;
626
627
							// A section may have its own attachments if it had is own unique boundary sections
628
							// so we need to check and add them in as needed
629 1
							foreach ($this->_boundary_section[$id]->attachments as $key => $value)
630
							{
631
								$this->attachments[$key] = $value;
632
							}
633
634 1
							foreach ($this->_boundary_section[$id]->inline_files as $key => $value)
635
							{
636 1
								$this->inline_files[$key] = $value;
637
							}
638
						}
639 1
						$this->body = $this->_decode_body($this->body);
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->_decode_body($this->body) can also be of type array<integer,string>. However, the property $body is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
640
641
						// Return the right set of x-parameters and content type for the body we are returning
642 1 View Code Duplication
						if (isset($this->_boundary_section[$text_ids[0]]->headers['x-parameters']))
643
						{
644 1
							$this->headers['x-parameters'] = $this->_boundary_section[$text_ids[0]]->headers['x-parameters'];
645
						}
646
647 1
						$this->headers['content-type'] = $this->_boundary_section[$text_ids[0]]->headers['content-type'];
648
					}
649
				}
650 1
				break;
651 View Code Duplication
			default:
652
				// deal with all the rest (e.g. image/xyx) the standard way
653
				$this->body = $this->_decode_string($this->body, $this->headers['content-transfer-encoding'], $this->headers['x-parameters']['content-type']['charset']);
654
				break;
655
		}
656 1
	}
657
658
	/**
659
	 * If the boundary is a failed email response, set the DSN flag for the admin
660
	 *
661
	 * @param int $i The section being worked
662
	 */
663
	private function _process_DSN($i)
664
	{
665
		// These sections often have extra blank lines, so cannot be counted on to be
666
		// fully accessible in ->headers. The "body" of this section contains values
667
		// formatted by FIELD: [TYPE;] VALUE
668
		$dsn_body = array();
669
		foreach (explode("\n", str_replace("\r\n", "\n", $this->_boundary_section[$i]->body)) as $line)
670
		{
671
			$type = '';
672
			list($field, $rest) = explode(':', $line);
673
674 View Code Duplication
			if (strpos($line, ';'))
675
			{
676
				list ($type, $val) = explode(';', $rest);
677
			}
678
			else
679
			{
680
				$val = $rest;
681
			}
682
683
			$dsn_body[trim(strtolower($field))] = array('type' => trim($type), 'value' => trim($val));
684
		}
685
686
		switch ($dsn_body['action']['value'])
687
		{
688
			case 'delayed':
689
				// Remove this if we don't want to flag delayed delivery addresses as "dirty"
690
				// May be caused by temporary net failures, e.g. DNS outage
691
				// Lack of break is intentional
692
			case 'failed':
693
				// The email failed to be delivered.
694
				$this->_is_dsn = true;
695
				$this->_dsn = array('headers' => $this->_boundary_section[$i]->headers, 'body' => $dsn_body);
0 ignored issues
show
Documentation Bug introduced by
It seems like array('headers' => $this...s, 'body' => $dsn_body) of type array<string,?,{"headers":"?","body":"array"}> is incompatible with the declared type array<integer,*> of property $_dsn.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
696
				break;
697
			default:
698
				$this->_is_dsn = false;
699
		}
700
	}
701
702
	/**
703
	 * If the boundary section is "attachment" or "inline", process and save the data
704
	 *
705
	 * - Data is saved in ->attachments or ->inline_files
706
	 *
707
	 * @param int $i The section being worked
708
	 */
709 1
	private function _process_attachments($i)
710
	{
711 1
		if ($this->_boundary_section[$i]->headers['content-disposition'] === 'attachment' || $this->_boundary_section[$i]->headers['content-disposition'] === 'inline' || isset($this->_boundary_section[$i]->headers['content-id']))
712
		{
713
			// Get the attachments file name
714 1
			if (isset($this->_boundary_section[$i]->headers['x-parameters']['content-disposition']['filename']))
715
			{
716
				$file_name = $this->_boundary_section[$i]->headers['x-parameters']['content-disposition']['filename'];
717
			}
718 1 View Code Duplication
			elseif (isset($this->_boundary_section[$i]->headers['x-parameters']['content-type']['name']))
719
			{
720
				$file_name = $this->_boundary_section[$i]->headers['x-parameters']['content-type']['name'];
721
			}
722
			else
723
			{
724 1
				return;
725
			}
726
727
			// Load the attachment data
728
			$this->attachments[$file_name] = $this->_boundary_section[$i]->body;
729
730
			// Inline attachments are a bit more complicated.
731
			if (isset($this->_boundary_section[$i]->headers['content-id']) && $this->_boundary_section[$i]->headers['content-disposition'] === 'inline')
732
			{
733
				$this->inline_files[$file_name] = trim($this->_boundary_section[$i]->headers['content-id'], ' <>');
734
			}
735
		}
736
	}
737
738
	/**
739
	 * Split up multipart messages and process each section separately
740
	 * as its own email object
741
	 *
742
	 * @param string $boundary
743
	 * @param boolean $html - flag to indicate html content
744
	 */
745 1
	private function _boundary_split($boundary, $html)
746
	{
747
		// Split this message up on its boundary sections
748 1
		$parts = explode('--' . $boundary, $this->body);
749 1
		foreach ($parts as $part)
750
		{
751 1
			$part = trim($part);
752
753
			// Nothing?
754 1
			if (empty($part))
755
			{
756
				continue;
757
			}
758
759
			// Parse this section just like its was a separate email
760 1
			$this->_boundary_section[$this->_boundary_section_count] = new Email_Parse();
761 1
			$this->_boundary_section[$this->_boundary_section_count]->read_email($html, $part);
762
763 1
			$this->plain_body .= $this->_boundary_section[$this->_boundary_section_count]->plain_body;
764
765 1
			$this->_boundary_section_count++;
766
		}
767 1
	}
768
769
	/**
770
	 * Converts a header string to ascii/UTF8
771
	 *
772
	 * What it does:
773
	 *
774
	 * - Headers, mostly subject and names may be encoded as quoted printable or base64
775
	 * to allow for non ascii characters in those fields.
776
	 * - This encoding is separate from the message body encoding and must be
777
	 * determined since this encoding is not directly specified by the headers themselves
778
	 *
779
	 * @param string $val
780
	 * @param bool $strict
781
	 * @return string
782
	 */
783 1
	private function _decode_header($val, $strict = false)
784
	{
785
		// Check if this header even needs to be decoded.
786 1
		if (strpos($val, '=?') === false || strpos($val, '?=') === false)
787
		{
788 1
			return trim($val);
789
		}
790
791
		// If iconv mime is available just use it and be done
792
		if (function_exists('iconv_mime_decode'))
793
		{
794
			return iconv_mime_decode($val, $strict ? 1 : 2, 'UTF-8');
795
		}
796
797
		// The RFC 2047-3 defines an encoded-word as a sequence of characters that
798
		// begins with "=?", ends with "?=", and has two "?"s in between. After the first question mark
799
		// is the name of the character encoding being used; after the second question mark
800
		// is the manner in which it's being encoded into plain ASCII (Q=quoted printable, B=base64);
801
		// and after the third question mark is the text itself.
802
		// Subject: =?iso-8859-1?Q?=A1Hola,_se=F1or!?=
803
		$matches = array();
804
		if (preg_match_all('~(.*?)(=\?([^?]+)\?(Q|B)\?([^?]*)\?=)([^=\(]*)~i', $val, $matches))
805
		{
806
			$decoded = '';
807
			for ($i = 0, $num = count($matches[4]); $i < $num; $i++)
808
			{
809
				// [1]leading text, [2]=? to ?=, [3]character set, [4]Q or B, [5]the encoded text [6]trailing text
810
				$leading_text = $matches[1][$i];
811
				$encoded_charset = $matches[3][$i];
812
				$encoded_type = strtolower($matches[4][$i]);
813
				$encoded_text = $matches[5][$i];
814
				$trailing_text = $matches[6][$i];
815
816
				if ($strict)
817
				{
818
					// Technically the encoded word can only be by itself or in a cname
819
					$check = trim($leading_text);
820
					if ($i === 0 && !empty($check) && $check[0] !== '(')
821
					{
822
						$decoded .= $matches[0][$i];
823
						continue;
824
					}
825
				}
826
827
				// Decode and convert our string
828
				if ($encoded_type === 'q')
829
				{
830
					$decoded_text = $this->_decode_string(str_replace('_', ' ', $encoded_text), 'quoted-printable', $encoded_charset);
831
				}
832
				elseif ($encoded_type === 'b')
833
				{
834
					$decoded_text = $this->_decode_string($encoded_text, 'base64', $encoded_charset);
835
				}
836
837
				// Add back in anything after the closing ?=
838
				if (!empty($encoded_text))
839
				{
840
					$decoded_text .= $trailing_text;
0 ignored issues
show
Bug introduced by
The variable $decoded_text does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
841
				}
842
843
				// Add back in the leading text to the now decoded value
844
				if (!empty($leading_text))
845
				{
846
					$decoded_text = $leading_text . $decoded_text;
847
				}
848
849
				$decoded .= $decoded_text;
850
			}
851
			$val = $decoded;
852
		}
853
854
		return trim($val);
855
	}
856
857
	/**
858
	 * Checks the body text to see if it may need to be further decoded
859
	 *
860
	 * What it does:
861
	 *
862
	 * - Sadly whats in the body text is not always what the header claims, or the
863
	 * header is just wrong. Copy/paste in to email from other apps etc.
864
	 * This does an extra check for quoted printable DNA and if found decodes the
865
	 * message as such.
866
	 *
867
	 * @param string $val
868
	 * @return string
869
	 */
870 1
	private function _decode_body($val)
871
	{
872
		// The encoding tag can be missing in the headers or just wrong
873 1
		if (preg_match('~(?:=C2|=A0|=D2|=D4|=96){1}~s', $val))
874
		{
875
			// Remove /r/n to be just /n
876
			$val = preg_replace('~(=0D=0A)~', "\n", $val);
877
878
			// utf8 non breaking space which does not decode right
879
			$val = preg_replace('~(=C2=A0)~', ' ', $val);
880
881
			// Smart quotes they will decode to black diamonds or other, but if
882
			// UTF-8 these may be valid non smart quotes
883
			if ($this->headers['x-parameters']['content-type']['charset'] !== 'UTF-8')
884
			{
885
				$val = str_replace('=D4', "'", $val);
886
				$val = str_replace('=D5', "'", $val);
887
				$val = str_replace('=D2', '"', $val);
888
				$val = str_replace('=D3', '"', $val);
889
				$val = str_replace('=A0', '', $val);
890
			}
891
			$val = $this->_decode_string($val, 'quoted-printable');
892
		}
893
		// Lines end in the tell tail quoted printable ... wrap and decode
894 1
		elseif (preg_match('~\s=[\r?\n]{1}~s', $val))
895
		{
896
			$val = preg_replace('~\s=[\r?\n]{1}~', ' ', $val);
897
			$val = $this->_decode_string($val, 'quoted-printable');
898
		}
899
		// Lines end in = but not ==
900 1
		elseif (preg_match('~((?<!=)=[\r?\n])~s', $val))
901
		{
902
			$val = $this->_decode_string($val, 'quoted-printable');
903
		}
904
905 1
		return $val;
906
	}
907
908
	/**
909
	 * Checks the message components to determine if the message is a DSN
910
	 *
911
	 * What it does:
912
	 *
913
	 * - Checks the content of the message, looking for headers and values that
914
	 * correlate with the message being a DSN. _parse_body checks for the existence
915
	 * of a "message/delivery-status" header
916
	 * - As many, many daemons and providers do not adhere to the RFC 3464
917
	 * standard, this function will hold the "special cases"
918
	 *
919
	 * @return boolean|null
920
	 */
921 1
	private function _check_dsn()
922
	{
923
		// If we already know it's a DSN, bug out
924 1
		if ($this->_is_dsn)
925
		{
926
			return true;
927
		}
928
929
		/** Add non-header-based detection **/
930 1
	}
931
932
	/**
933
	 * Tries to find the original intended recipient that failed to deliver
934
	 *
935
	 * What it does:
936
	 *
937
	 * - Checks the headers of a DSN for the various ways that the intended recipient
938
	 *   Might have been included in the DSN headers
939
	 *
940
	 * @return string or null
941
	 */
942
	public function get_failed_dest()
943
	{
944
		/** Body->Original-Recipient Header **/
945
		if (isset($this->_dsn['body']['original-recipient']['value']))
946
		{
947
			return $this->_dsn['body']['original-recipient']['value'];
948
		}
949
950
		/** Body->Final-recipient Header **/
951
		if (isset($this->_dsn['body']['final-recipient']['value']))
952
		{
953
			return $this->_dsn['body']['final-recipient']['value'];
954
		}
955
956
		return null;
957
	}
958
959
	/**
960
	 * Find the message return_path and well return it
961
	 *
962
	 * @return string or null
963
	 */
964
	public function load_returnpath()
965
	{
966
		$matches = array();
967
968
		// Fetch the return path
969
		if (isset($this->headers['return-path']))
970
		{
971
			if (preg_match('~(.*?)<(.*?)>~', $this->headers['return-path'], $matches))
972
			{
973
				$this->return_path = trim($matches[2]);
974
			}
975
		}
976
977
		return $this->return_path;
978
	}
979
980
	/**
981
	 * Returns the decoded subject of the email
982
	 *
983
	 * - Makes sure the subject header is set, if not sets it to ''
984
	 *
985
	 * @return string or null
986
	 */
987 1
	public function load_subject()
988
	{
989
		// Account for those no-subject emails
990 1
		if (!isset($this->headers['subject']))
991
		{
992 1
			$this->headers['subject'] = '';
993
		}
994
995
		// Change it to a readable form ...
996 1
		$this->subject = htmlspecialchars($this->_decode_header($this->headers['subject']), ENT_COMPAT, 'UTF-8');
997
998 1
		return (string) $this->subject;
999
	}
1000
1001
	/**
1002
	 * Check for the message security key in common headers, in-reply-to and references
1003
	 *
1004
	 * - If the key is not found in the header, will search the message body
1005
	 * - If the key is still not found will search the entire input stream
1006
	 * - returns the found key or false.  If found will also save it in the in-reply-to header
1007
	 *
1008
	 * @param string $key optional
1009
	 * @return string of key or false on failure
1010
	 */
1011 1
	public function load_key($key = '')
1012
	{
1013 1
		$regex_key = '~(([a-z0-9]{32})\-(p|t|m)(\d+))~i';
1014 1
		$match = array();
1015
1016
		// Supplied a key, lets check it
1017 1
		if (!empty($key))
1018
		{
1019
			preg_match($regex_key, $key, $match);
1020
		}
1021
		// Otherwise we play find the key
1022
		else
1023
		{
1024 1
			if (!$this->_load_key_from_headers($regex_key))
1025
			{
1026 1
				$this->_load_key_from_body();
1027
			}
1028
		}
1029
1030 1
		return !empty($this->message_key_id) ? $this->message_key_id : false;
1031
	}
1032
1033
	/**
1034
	 * Searches the most common locations for the security key
1035
	 *
1036
	 * - Normal return location would be in the in-reply-to header
1037
	 * - Common for it to be shifted to a reference header
1038
	 *
1039
	 * @param string $regex_key
1040
	 *
1041
	 * @return bool is the security key is found or not
1042
	 */
1043 1
	private function _load_key_from_headers($regex_key)
1044
	{
1045 1
		$found_key = false;
1046
1047
		// Check our reply_to_msg_id based on in-reply-to and references, the key *should* be there.
1048 1
		if (empty($this->headers['in-reply-to']) || preg_match($regex_key, $this->headers['in-reply-to'], $match) === 0)
1049
		{
1050
			// Check if references are set, sometimes email clients thread from there
1051 1
			if (!empty($this->headers['references']))
1052
			{
1053
				// Maybe our security key is in the references
1054 1
				$refs = explode(' ', $this->headers['references']);
1055 1
				foreach ($refs as $ref)
1056
				{
1057 1 View Code Duplication
					if (preg_match($regex_key, $ref, $match))
1058
					{
1059
						// Found the key in the ref, set the in-reply-to
1060
						$this->headers['in-reply-to'] = $match[1];
1061
						$this->_load_key_details($match);
1062
						$found_key = true;
1063 1
						break;
1064
					}
1065
				}
1066
			}
1067
		}
1068
		else
1069
		{
1070
			$this->_load_key_details($match);
1071
			$found_key = true;
1072
		}
1073
1074 1
		return $found_key;
1075
	}
1076
1077
	/**
1078
	 * Searches the message body or the raw email in search of the key
1079
	 *
1080
	 * - Not found in the headers, so lets search the body for the [key]
1081
	 * as we insert that on outbound email just for this
1082
	 */
1083 1
	private function _load_key_from_body()
1084
	{
1085 1
		$regex_key = '~\[(([a-z0-9]{32})\-(p|t|m)(\d+))\]~i';
1086 1
		$found_key = false;
1087
1088
		// Check the message body
1089 1
		if (preg_match($regex_key, $this->body, $match) === 1)
1090
		{
1091 1
			$this->headers['in-reply-to'] = $match[1];
1092 1
			$this->_load_key_details($match);
1093 1
			$found_key = true;
1094
		}
1095
		// Grrr ... check everything!
1096 View Code Duplication
		elseif (preg_match($regex_key, $this->raw_message, $match) === 1)
1097
		{
1098
			$this->headers['in-reply-to'] = $match[1];
1099
			$this->_load_key_details($match);
1100
			$found_key = true;
1101
		}
1102
1103 1
		return $found_key;
1104
	}
1105
1106
	/**
1107
	 * Loads found key details for use in other functions
1108
	 *
1109
	 * @param string[] $match from regex 1=>full, 2=>key, 3=>p|t|m, 4=>12345
1110
	 */
1111 1
	private function _load_key_details($match)
1112
	{
1113 1
		if (!empty($match[1]))
1114
		{
1115
			// 1=>7738c27ae6c431495ad26587f30e2121-m29557, 2=>7738c27ae6c431495ad26587f30e2121, 3=>m, 4=>29557
1116 1
			$this->message_key_id = $match[1];
1117 1
			$this->message_key = $match[2];
1118 1
			$this->message_type = $match[3];
1119 1
			$this->message_id = (int) $match[4];
1120
		}
1121 1
	}
1122
1123
	/**
1124
	 * Loads in the most email from, to and cc address
1125
	 *
1126
	 * - will attempt to return the name and address for fields "name:" <email>
1127
	 * - will become email['to'] = email and email['to_name'] = name
1128
	 *
1129
	 * @return array of addresses
1130
	 */
1131 1
	public function load_address()
1132
	{
1133 1
		$this->email['to'] = array();
1134 1
		$this->email['from'] = array();
1135 1
		$this->email['cc'] = array();
1136
1137
		// Fetch the "From" email and if possibly the senders common name
1138 1
		if (isset($this->headers['from']))
1139
		{
1140 1
			$this->_parse_address($this->headers['from']);
1141 1
			$this->email['from'] = $this->_email_address;
1142 1
			$this->email['from_name'] = $this->_email_name;
1143
		}
1144
1145
		// Fetch the "To" email and if possible the recipients common name
1146 1 View Code Duplication
		if (isset($this->headers['to']))
1147
		{
1148 1
			$to_addresses = explode(',', $this->headers['to']);
1149 1
			for ($i = 0, $num = count($to_addresses); $i < $num; $i++)
1150
			{
1151 1
				$this->_parse_address($to_addresses[$i]);
1152 1
				$this->email['to'][$i] = $this->_email_address;
1153 1
				$this->email['to_name'][$i] = $this->_email_name;
1154
			}
1155
		}
1156
1157
		// Fetch the "cc" address if there is one and once again the real name as well
1158 1 View Code Duplication
		if (isset($this->headers['cc']))
1159
		{
1160
			$cc_addresses = explode(',', $this->headers['cc']);
1161
			for ($i = 0, $num = count($cc_addresses); $i < $num; $i++)
1162
			{
1163
				$this->_parse_address($cc_addresses[$i]);
1164
				$this->email['cc'][$i] = $this->_email_address;
1165
				$this->email['cc_name'][$i] = $this->_email_name;
1166
			}
1167
		}
1168
1169 1
		return $this->email;
1170
	}
1171
1172
	/**
1173
	 * Finds the message sending ip and returns it
1174
	 *
1175
	 * - will look in various header fields where the ip may reside
1176
	 * - returns false if it can't find a valid IP4
1177
	 *
1178
	 * @return string|boolean on fail
1179
	 */
1180 1
	public function load_ip()
1181
	{
1182 1
		$this->ip = false;
1183
1184
		// The sending IP can be useful in spam prevention and making a post
1185 1
		if (isset($this->headers['x-posted-by']))
1186
		{
1187
			$this->ip = $this->_parse_ip($this->headers['x-posted-by']);
1188
		}
1189 1
		elseif (isset($this->headers['x-originating-ip']))
1190
		{
1191
			$this->ip = $this->_parse_ip($this->headers['x-originating-ip']);
1192
		}
1193 1
		elseif (isset($this->headers['x-senderip']))
1194
		{
1195
			$this->ip = $this->_parse_ip($this->headers['x-senderip']);
1196
		}
1197 1
		elseif (isset($this->headers['x-mdremoteip']))
1198
		{
1199
			$this->ip = $this->_parse_ip($this->headers['x-mdremoteip']);
1200
		}
1201 1
		elseif (isset($this->headers['received']))
1202
		{
1203 1
			$this->ip = $this->_parse_ip($this->headers['received']);
1204
		}
1205
1206 1
		return $this->ip;
1207
	}
1208
1209
	/**
1210
	 * Finds if any spam headers have been positively set and returns that flag
1211
	 *
1212
	 * - will look in various header fields where the spam status may reside
1213
	 *
1214
	 * @return boolean on fail
1215
	 */
1216 1
	public function load_spam()
1217
	{
1218
		// SpamAssassin (and others like rspamd)
1219 1
		if (isset($this->headers['x-spam-flag']) && strtolower(substr($this->headers['x-spam-flag'], 0, 3)) === 'yes')
1220
		{
1221 1
			$this->spam_found = true;
1222
		}
1223
		// SpamStopper and other variants
1224 View Code Duplication
		elseif (isset($this->headers['x-spam-status']) && strtolower(substr($this->headers['x-spam-status'], 0, 3)) === 'yes')
1225
		{
1226
			$this->spam_found = true;
1227
		}
1228
		// j-chkmail --  hi = likely spam lo = suspect ...
1229 View Code Duplication
		elseif (isset($this->headers['x-j-chkmail-status']) && strtolower(substr($this->headers['x-j-chkmail-status'], 0, 2)) === 'hi')
1230
		{
1231
			$this->spam_found = true;
1232
		}
1233
		// Nucleus Mailscanner
1234 View Code Duplication
		elseif (isset($this->headers['x-nucleus-mailscanner']) && strtolower($this->headers['x-nucleus-mailscanner']) !== 'found to be clean')
1235
		{
1236
			$this->spam_found = true;
1237
		}
1238
1239 1
		return $this->spam_found;
1240
	}
1241
1242
	/**
1243
	 * Validates that the ip is a valid ip4 address
1244
	 *
1245
	 * @param string|null $string
1246
	 * @return string
1247
	 */
1248 1
	private function _parse_ip($string)
1249
	{
1250 1
		if (preg_match('~\[?([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\]?~', $string, $matches) !== 1)
1251
		{
1252
			return '';
1253
		}
1254
1255 1
		$string = trim($matches[0], '[] ');
1256
1257
		// Validate it matches an ip4 standard
1258 1
		if (filter_var($string, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false)
1259
		{
1260 1
			return $string;
1261
		}
1262
		else
1263
		{
1264
			return '';
1265
		}
1266
	}
1267
1268
	/**
1269
	 * Take an email address and parse out the email address and email name
1270
	 *
1271
	 * @param string $val
1272
	 */
1273 1
	private function _parse_address($val)
1274
	{
1275 1
		$this->_email_address = '';
1276 1
		$this->_email_name = '';
1277
1278 1
		if (preg_match('~(.*?)<(.*?)>~', $val, $matches))
1279
		{
1280
			// The email address, remove spaces and (comments)
1281 1
			$this->_email_address = trim(str_replace(' ', '', $matches[2]));
1282 1
			$this->_email_address = preg_replace('~\(.*?\)~', '', $this->_email_address);
1283
1284
			// Perhaps a common name as well "name:" <email>
1285 1
			if (!empty($matches[1]))
1286
			{
1287 1
				$matches[1] = $this->_decode_header($matches[1]);
1288 1
				if ($matches[1][0] === '"' && substr($matches[1], -1) === '"')
1289
				{
1290 1
					$this->_email_name = substr($matches[1], 1, -1);
1291
				}
1292
				else
1293
				{
1294 1
					$this->_email_name = $matches[1];
1295
				}
1296
			}
1297
			else
1298
			{
1299 1
				$this->_email_name = $this->_email_address;
1300
			}
1301
1302
			// Check the validity of the common name, if not sure set it to email user.
1303 1
			if (!preg_match('~^\w+~', $this->_email_name))
1304
			{
1305 1
				$this->_email_name = substr($this->_email_address, 0, strpos($this->_email_address, '@'));
1306
			}
1307
		}
1308
		else
1309
		{
1310
			// Just an sad lonely email address, so we use it as is
1311
			$this->_email_address = trim(str_replace(' ', '', $val));
1312
			$this->_email_address = preg_replace('~\(.*?\)~', '', $this->_email_address);
1313
			$this->_email_name = substr($this->_email_address, 0, strpos($this->_email_address, '@'));
1314
		}
1315 1
	}
1316
1317
	/**
1318
	 * Decodes base64 or quoted-printable strings
1319
	 * Converts from one character set to utf-8
1320
	 *
1321
	 * @param string $string
1322
	 * @param string $encoding
1323
	 * @param string $charset
1324
	 *
1325
	 * @return bool|null|string|string[]
1326
	 */
1327 1
	private function _decode_string($string, $encoding, $charset = '')
1328
	{
1329
		// Decode if its quoted printable or base64 encoded
1330 1
		if ($encoding === 'quoted-printable')
1331
		{
1332
			$string = quoted_printable_decode(preg_replace('~=\r?\n~', '', $string));
1333
		}
1334 1
		elseif ($encoding === 'base64')
1335
		{
1336
			$string = base64_decode($string);
1337
		}
1338
1339
		// Convert this to utf-8 if needed.
1340 1
		if (!empty($charset) && $charset !== 'UTF-8')
1341
		{
1342
			$string = $this->_charset_convert($string, strtoupper($charset), 'UTF-8');
1343
		}
1344
1345 1
		return $string;
1346
	}
1347
1348
	/**
1349
	 * Pick the best possible function to convert a strings character set, if any exist
1350
	 *
1351
	 * @param string $string
1352
	 * @param string $from
1353
	 * @param string $to
1354
	 *
1355
	 * @return null|string|string[]
1356
	 */
1357
	private function _charset_convert($string, $from, $to)
1358
	{
1359
		// Lets assume we have one of the functions available to us
1360
		$this->_converted_utf8 = true;
1361
		$string_save = $string;
1362
1363
		// Use iconv if its available
1364
		if (function_exists('iconv'))
1365
		{
1366
			$string = @iconv($from, $to . '//TRANSLIT//IGNORE', $string);
1367
		}
1368
1369
		// No iconv or a false response from it
1370
		if (!function_exists('iconv') || ($string === false))
1371
		{
1372
			if (function_exists('mb_convert_encoding'))
1373
			{
1374
				// Replace unknown characters with a space
1375
				@ini_set('mbstring.substitute_character', '32');
1376
				$string = @mb_convert_encoding($string, $to, $from);
1377
			}
1378
			elseif (function_exists('recode_string'))
1379
			{
1380
				$string = @recode_string($from . '..' . $to, $string);
1381
			}
1382
			else
1383
			{
1384
				$this->_converted_utf8 = false;
1385
			}
1386
		}
1387
1388
		unset($string_save);
1389
1390
		return $string;
1391
	}
1392
}
1393