Completed
Push — master ( 1be2e7...d38097 )
by Sam
23s
created

Mailer::encodeFileForEmail()   D

Complexity

Conditions 12
Paths 289

Size

Total Lines 57
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 12
eloc 34
nc 289
nop 4
dl 0
loc 57
rs 4.821
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace SilverStripe\Control\Email;
4
5
use InvalidArgumentException;
6
use SilverStripe\Control\HTTP;
7
use SilverStripe\Core\Convert;
8
use SilverStripe\Core\Object;
9
10
/**
11
 * Mailer objects are responsible for actually sending emails.
12
 * The default Mailer class will use PHP's mail() function.
13
 */
14
class Mailer extends Object
15
{
16
17
    /**
18
     * Default encoding type for messages. Available options are:
19
     * - quoted-printable
20
     * - base64
21
     *
22
     * @var string
23
     * @config
24
     */
25
    private static $default_message_encoding = 'quoted-printable';
26
27
    /**
28
     * Encoding type currently set
29
     *
30
     * @var string
31
     */
32
    protected $messageEncoding = null;
33
34
    /**
35
     * Email used for bounces
36
     *
37
     * @var string
38
     * @config
39
     */
40
    private static $default_bounce_email = null;
41
42
    /**
43
     * Email used for bounces
44
     *
45
     * @var string
46
     */
47
    protected $bounceEmail = null;
48
49
    /**
50
     * Email used for bounces
51
     *
52
     * @return string
53
     */
54
    public function getBounceEmail()
55
    {
56
        return $this->bounceEmail
57
            ?: (defined('BOUNCE_EMAIL') ? BOUNCE_EMAIL : null)
58
            ?: self::config()->default_bounce_email;
59
    }
60
61
    /**
62
     * Set the email used for bounces
63
     *
64
     * @param string $email
65
     */
66
    public function setBounceEmail($email)
67
    {
68
        $this->bounceEmail = $email;
69
    }
70
71
    /**
72
     * Get the encoding type used for plain text messages
73
     *
74
     * @return string
75
     */
76
    public function getMessageEncoding()
77
    {
78
        return $this->messageEncoding ?: static::config()->default_message_encoding;
79
    }
80
81
    /**
82
     * Sets encoding type for messages. Available options are:
83
     * - quoted-printable
84
     * - base64
85
     *
86
     * @param string $encoding
87
     */
88
    public function setMessageEncoding($encoding)
89
    {
90
        $this->messageEncoding = $encoding;
91
    }
92
93
    /**
94
     * Encode a message using the given encoding mechanism
95
     *
96
     * @param string $message
97
     * @param string $encoding
98
     * @return string Encoded $message
99
     */
100
    protected function encodeMessage($message, $encoding)
101
    {
102
        switch ($encoding) {
103
            case 'base64':
104
                return chunk_split(base64_encode($message), 60);
105
            case 'quoted-printable':
106
                return quoted_printable_encode($message);
107
            default:
108
                return $message;
109
        }
110
    }
111
112
    /**
113
     * Merge custom headers with default ones
114
     *
115
     * @param array $headers Default headers
116
     * @param array $customHeaders Custom headers
117
     * @return array Resulting message headers
118
     */
119
    protected function mergeCustomHeaders($headers, $customHeaders)
120
    {
121
        $headers["X-Mailer"] = X_MAILER;
122
        if (!isset($customHeaders["X-Priority"])) {
123
            $headers["X-Priority"]  = 3;
124
        }
125
126
        // Merge!
127
        $headers = array_merge($headers, $customHeaders);
128
129
        // Headers 'Cc' and 'Bcc' need to have the correct case
130
        foreach (array('Bcc', 'Cc') as $correctKey) {
131
            foreach ($headers as $key => $value) {
132
                if (strcmp($key, $correctKey) !== 0 && strcasecmp($key, $correctKey) === 0) {
133
                    $headers[$correctKey] = $value;
134
                    unset($headers[$key]);
135
                }
136
            }
137
        }
138
139
        return $headers;
140
    }
141
142
    /**
143
     * Send a plain-text email.
144
     *
145
     * @param string $to Email recipient
146
     * @param string $from Email from
147
     * @param string $subject Subject text
148
     * @param string $plainContent Plain text content
149
     * @param array $attachedFiles List of attached files
150
     * @param array $customHeaders List of custom headers
151
     * @return mixed Return false if failure, or list of arguments if success
152
     */
153
    public function sendPlain($to, $from, $subject, $plainContent, $attachedFiles = array(), $customHeaders = array())
154
    {
155
        // Prepare plain text body
156
        $fullBody = $this->encodeMessage($plainContent, $this->getMessageEncoding());
157
        $headers["Content-Type"] = "text/plain; charset=utf-8";
0 ignored issues
show
Coding Style Comprehensibility introduced by
$headers was never initialized. Although not strictly required by PHP, it is generally a good practice to add $headers = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
158
        $headers["Content-Transfer-Encoding"] = $this->getMessageEncoding();
159
160
        // Send prepared message
161
        return $this->sendPreparedMessage($to, $from, $subject, $attachedFiles, $customHeaders, $fullBody, $headers);
162
    }
163
164
165
    /**
166
     * Sends an email as a both HTML and plaintext
167
     *
168
     * @param string $to Email recipient
169
     * @param string $from Email from
170
     * @param string $subject Subject text
171
     * @param string $htmlContent HTML Content
172
     * @param array $attachedFiles List of attachments
173
     * @param array $customHeaders User specified headers
174
     * @param string $plainContent Plain text content. If omitted, will be generated from $htmlContent
175
     * @return mixed Return false if failure, or list of arguments if success
176
     */
177
    public function sendHTML(
178
        $to,
179
        $from,
180
        $subject,
181
        $htmlContent,
182
        $attachedFiles = array(),
183
        $customHeaders = array(),
184
        $plainContent = ''
185
    ) {
186
        // Prepare both Plain and HTML components and merge
187
        $plainPart = $this->preparePlainSubmessage($plainContent, $htmlContent);
188
        $htmlPart = $this->prepareHTMLSubmessage($htmlContent);
189
        list($fullBody, $headers) = $this->encodeMultipart(
190
            array($plainPart, $htmlPart),
191
            "multipart/alternative"
192
        );
193
194
        // Send prepared message
195
        return $this->sendPreparedMessage($to, $from, $subject, $attachedFiles, $customHeaders, $fullBody, $headers);
196
    }
197
198
    /**
199
     * Send an email of an arbitrary format
200
     *
201
     * @param string $to To
202
     * @param string $from From
203
     * @param string $subject Subject
204
     * @param array $attachedFiles List of attachments
205
     * @param array $customHeaders User specified headers
206
     * @param string $fullBody Prepared message
207
     * @param array $headers Prepared headers
208
     * @return mixed Return false if failure, or list of arguments if success
209
     */
210
    protected function sendPreparedMessage($to, $from, $subject, $attachedFiles, $customHeaders, $fullBody, $headers)
211
    {
212
        // If the subject line contains extended characters, we must encode the
213
        $subjectEncoded = "=?UTF-8?B?" . base64_encode($subject) . "?=";
214
        $to = $this->validEmailAddress($to);
215
        $from = $this->validEmailAddress($from);
216
217
        // Messages with attachments are handled differently
218
        if ($attachedFiles) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $attachedFiles of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
219
            list($fullBody, $headers) = $this->encodeAttachments($attachedFiles, $headers, $fullBody);
220
        }
221
222
        // Get bounce email
223
        $bounceAddress = $this->getBounceEmail() ?: $from;
224
        if (preg_match('/^([^<>]*)<([^<>]+)> *$/', $bounceAddress, $parts)) {
225
            $bounceAddress = $parts[2];
226
        }
227
228
        // Get headers
229
        $headers["From"] = $from;
230
        $headers = $this->mergeCustomHeaders($headers, $customHeaders);
231
        $headersEncoded = $this->processHeaders($headers);
232
233
        return $this->email($to, $subjectEncoded, $fullBody, $headersEncoded, $bounceAddress);
234
    }
235
236
    /**
237
     * Send the actual email
238
     *
239
     * @param string $to
240
     * @param string $subjectEncoded
241
     * @param string $fullBody
242
     * @param string $headersEncoded
243
     * @param string $bounceAddress
244
     * @return mixed Return false if failure, or list of arguments if success
245
     */
246
    protected function email($to, $subjectEncoded, $fullBody, $headersEncoded, $bounceAddress)
247
    {
248
        // Try it without the -f option if it fails
249
        $result = @mail($to, $subjectEncoded, $fullBody, $headersEncoded, escapeshellarg("-f$bounceAddress"));
250
        if (!$result) {
251
            $result = mail($to, $subjectEncoded, $fullBody, $headersEncoded);
252
        }
253
254
        if ($result) {
255
            return array($to, $subjectEncoded, $fullBody, $headersEncoded, $bounceAddress);
256
        }
257
258
        return false;
259
    }
260
261
    /**
262
     * Encode attachments into a message
263
     *
264
     * @param array $attachments
265
     * @param array $headers
266
     * @param string $body
267
     * @return array Array containing completed body followed by headers
268
     */
269
    protected function encodeAttachments($attachments, $headers, $body)
270
    {
271
        // The first part is the message itself
272
        $fullMessage = $this->processHeaders($headers, $body);
273
        $messageParts = array($fullMessage);
274
275
        // Include any specified attachments as additional parts
276
        foreach ($attachments as $file) {
277
            if (isset($file['tmp_name']) && isset($file['name'])) {
278
                $messageParts[] = $this->encodeFileForEmail($file['tmp_name'], $file['name']);
279
            } else {
280
                $messageParts[] = $this->encodeFileForEmail($file);
281
            }
282
        }
283
284
        // We further wrap all of this into another multipart block
285
        return $this->encodeMultipart($messageParts, "multipart/mixed");
286
    }
287
288
    /**
289
     * Generate the plainPart of a html message
290
     *
291
     * @param string $plainContent Plain body
292
     * @param string $htmlContent HTML message
293
     * @return string Encoded headers / message in a single block
294
     */
295
    protected function preparePlainSubmessage($plainContent, $htmlContent)
296
    {
297
        $plainEncoding = $this->getMessageEncoding();
298
299
        // Generate plain text version if not explicitly given
300
        if (!$plainContent) {
301
            $plainContent = Convert::xml2raw($htmlContent);
302
        }
303
304
        // Make the plain text part
305
        $headers["Content-Type"] = "text/plain; charset=utf-8";
0 ignored issues
show
Coding Style Comprehensibility introduced by
$headers was never initialized. Although not strictly required by PHP, it is generally a good practice to add $headers = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
306
        $headers["Content-Transfer-Encoding"] = $plainEncoding;
307
        $plainContentEncoded = $this->encodeMessage($plainContent, $plainEncoding);
0 ignored issues
show
Bug introduced by
It seems like $plainContent defined by \SilverStripe\Core\Convert::xml2raw($htmlContent) on line 301 can also be of type array; however, SilverStripe\Control\Email\Mailer::encodeMessage() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
308
309
        // Merge with headers
310
        return $this->processHeaders($headers, $plainContentEncoded);
311
    }
312
313
    /**
314
     * Generate the html part of a html message
315
     *
316
     * @param string $htmlContent HTML message
317
     * @return string Encoded headers / message in a single block
318
     */
319
    protected function prepareHTMLSubmessage($htmlContent)
320
    {
321
        // Add basic wrapper tags if the body tag hasn't been given
322
        if (stripos($htmlContent, '<body') === false) {
323
            $htmlContent =
324
                "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.0 Transitional//EN\">\n" .
325
                "<HTML><HEAD>\n" .
326
                "<META http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">\n" .
327
                "<STYLE type=\"text/css\"></STYLE>\n\n".
328
                "</HEAD>\n" .
329
                "<BODY bgColor=\"#ffffff\">\n" .
330
                    $htmlContent .
331
                "\n</BODY>\n" .
332
                "</HTML>";
333
        }
334
335
        // Make the HTML part
336
        $headers["Content-Type"] = "text/html; charset=utf-8";
0 ignored issues
show
Coding Style Comprehensibility introduced by
$headers was never initialized. Although not strictly required by PHP, it is generally a good practice to add $headers = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
337
        $headers["Content-Transfer-Encoding"] = $this->getMessageEncoding();
338
        $htmlContentEncoded = $this->encodeMessage($htmlContent, $this->getMessageEncoding());
339
340
        // Merge with headers
341
        return $this->processHeaders($headers, $htmlContentEncoded);
342
    }
343
344
    /**
345
     * Encode an array of parts using multipart
346
     *
347
     * @param array $parts List of parts
348
     * @param string $contentType Content-type of parts
349
     * @param array $headers Existing headers to include in response
350
     * @return array Array with two items, the body followed by headers
351
     */
352
    protected function encodeMultipart($parts, $contentType, $headers = array())
353
    {
354
        $separator = "----=_NextPart_" . preg_replace('/[^0-9]/', '', rand() * 10000000000);
355
356
        $headers["MIME-Version"] = "1.0";
357
        $headers["Content-Type"] = "$contentType; boundary=\"$separator\"";
358
        $headers["Content-Transfer-Encoding"] = "7bit";
359
360
        if ($contentType == "multipart/alternative") {
361
            // $baseMessage = "This is an encoded HTML message.  There are two parts: a plain text and an HTML message,
362
            // open whatever suits you better.";
363
            $baseMessage = "\nThis is a multi-part message in MIME format.";
364
        } else {
365
            // $baseMessage = "This is a message containing attachments.  The e-mail body is contained in the first
366
            // attachment";
367
            $baseMessage = "\nThis is a multi-part message in MIME format.";
368
        }
369
370
        $separator = "\n--$separator\n";
371
        $body = "$baseMessage\n" .
372
            $separator . implode("\n".$separator, $parts) . "\n" . trim($separator) . "--";
373
374
        return array($body, $headers);
375
    }
376
377
378
    /**
379
     * Add headers to the start of the message
380
     *
381
     * @param array $headers
382
     * @param string $body
383
     * @return string Resulting message body
384
     */
385
    protected function processHeaders($headers, $body = '')
386
    {
387
        $result = '';
388
        foreach ($headers as $key => $value) {
389
            $result .= "$key: $value\n";
390
        }
391
        if ($body) {
392
            $result .= "\n$body";
393
        }
394
395
        return $result;
396
    }
397
398
    /**
399
     * Encode the contents of a file for emailing, including headers
400
     *
401
     * $file can be an array, in which case it expects these members:
402
     *   'filename'        - the filename of the file
403
     *   'contents'        - the raw binary contents of the file as a string
404
     *  and can optionally include these members:
405
     *   'mimetype'        - the mimetype of the file (calculated from filename if missing)
406
     *   'contentLocation' - the 'Content-Location' header value for the file
407
     *
408
     * $file can also be a string, in which case it is assumed to be the filename
409
     *
410
     * h5. contentLocation
411
     *
412
     * Content Location is one of the two methods allowed for embedding images into an html email.
413
     * It's also the simplest, and best supported.
414
     *
415
     * Assume we have an email with this in the body:
416
     *
417
     *   <img src="http://example.com/image.gif" />
418
     *
419
     * To display the image, an email viewer would have to download the image from the web every time
420
     * it is displayed. Due to privacy issues, most viewers will not display any images unless
421
     * the user clicks 'Show images in this email'. Not optimal.
422
     *
423
     * However, we can also include a copy of this image as an attached file in the email.
424
     * By giving it a contentLocation of "http://example.com/image.gif" most email viewers
425
     * will use this attached copy instead of downloading it. Better,
426
     * most viewers will show it without a 'Show images in this email' conformation.
427
     *
428
     * Here is an example of passing this information through Email.php:
429
     *
430
     *   $email = new Email();
431
     *   $email->attachments[] = array(
432
     *     'filename' => BASE_PATH . "/themes/mytheme/images/header.gif",
433
     *     'contents' => file_get_contents(BASE_PATH . "/themes/mytheme/images/header.gif"),
434
     *     'mimetype' => 'image/gif',
435
     *     'contentLocation' => Director::absoluteBaseURL() . "/themes/mytheme/images/header.gif"
436
     *   );
437
     *
438
     * @param array|string $file
439
     * @param bool $destFileName
440
     * @param string $disposition
441
     * @param string $extraHeaders
442
     * @return string
443
     */
444
    protected function encodeFileForEmail($file, $destFileName = false, $disposition = null, $extraHeaders = "")
445
    {
446
        if (!$file) {
447
            throw new InvalidArgumentException("Not passed a filename and/or data");
448
        }
449
450
        if (is_string($file)) {
451
            $file = array('filename' => $file);
452
            $fh = fopen($file['filename'], "rb");
453
            if ($fh) {
454
                $file['contents'] = "";
455
                while (!feof($fh)) {
456
                    $file['contents'] .= fread($fh, 10000);
457
                }
458
                fclose($fh);
459
            }
460
        }
461
462
        // Build headers, including content type
463
        if (!$destFileName) {
464
            $base = basename($file['filename']);
465
        } else {
466
            $base = $destFileName;
467
        }
468
469
        $mimeType = !empty($file['mimetype']) ? $file['mimetype'] : HTTP::get_mime_type($file['filename']);
470
        if (!$mimeType) {
471
            $mimeType = "application/unknown";
472
        }
473
        if (empty($disposition)) {
474
            $disposition = isset($file['contentLocation']) ? 'inline' : 'attachment';
475
        }
476
477
        // Encode for emailing
478
        if (substr($mimeType, 0, 4) != 'text') {
479
            $encoding = "base64";
480
            $file['contents'] = chunk_split(base64_encode($file['contents']));
481
        } else {
482
            // This mime type is needed, otherwise some clients will show it as an inline attachment
483
            $mimeType = 'application/octet-stream';
484
            $encoding = "quoted-printable";
485
            $file['contents'] = quoted_printable_encode($file['contents']);
486
        }
487
488
        $headers =  "Content-type: $mimeType;\n\tname=\"$base\"\n".
489
                    "Content-Transfer-Encoding: $encoding\n".
490
                    "Content-Disposition: $disposition;\n\tfilename=\"$base\"\n";
491
492
        if (isset($file['contentLocation'])) {
493
            $headers .= 'Content-Location: ' . $file['contentLocation'] . "\n" ;
494
        }
495
496
        $headers .= $extraHeaders . "\n";
497
498
        // Return completed packet
499
        return $headers . $file['contents'];
500
    }
501
502
    /**
503
     * Cleans up emails which may be in 'Name <[email protected]>' format
504
     *
505
     * @param string $emailAddress
506
     * @return string
507
     */
508
    protected function validEmailAddress($emailAddress)
509
    {
510
        $emailAddress = trim($emailAddress);
511
        $openBracket = strpos($emailAddress, '<');
512
        $closeBracket = strpos($emailAddress, '>');
513
514
        // Unwrap email contained by braces
515
        if ($openBracket === 0 && $closeBracket !== false) {
516
            return substr($emailAddress, 1, $closeBracket - 1);
517
        }
518
519
        // Ensure name component cannot be mistaken for an email address
520
        if ($openBracket) {
521
            $emailAddress = str_replace('@', '', substr($emailAddress, 0, $openBracket))
522
                . substr($emailAddress, $openBracket);
523
        }
524
525
        return $emailAddress;
526
    }
527
}
528