Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the ability to parse multipart MIME email strings #255

Open
wants to merge 9 commits into
base: 2.26.x
Choose a base branch
from
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"laminas/laminas-servicemanager": "^3.22.1",
"phpunit/phpunit": "^10.4.2",
"psalm/plugin-phpunit": "^0.18.4",
"smalot/pdfparser": "^2.7",
"symfony/process": "^6.3.4 || ^7.0.0",
"vimeo/psalm": "^5.15"
},
Expand Down
76 changes: 64 additions & 12 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 37 additions & 19 deletions docs/book/message/attachments.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@

## Basic multipart content

The following example creates an email with two parts, HTML content and an
image.
The following example creates an email with two parts, HTML content and an image.

```php
use Laminas\Mail\Message;
Expand Down Expand Up @@ -37,14 +36,11 @@
$contentTypeHeader->setType('multipart/related');
```

Note that the above code requires us to manually specify the message content
type; laminas-mime does not automatically select the multipart type for us, nor
does laminas-mail populate it by default.

## multipart/alternative content

One of the most common email types sent by web applications is
`multipart/alternative` messages with both text and HTML parts.
`multipart/alternative` messages containing both plain text and HTML parts.
Below, you'll find an example of how to programmatically create one.

```php
use Laminas\Mail\Message;
Expand All @@ -67,22 +63,17 @@

$message = new Message();
$message->setBody($body);

$contentTypeHeader = $message->getHeaders()->get('Content-Type');
$contentTypeHeader->setType('multipart/alternative');
```

The only differences from the first example are:

- We have text and HTML parts instead of an HTML and image part.
- The `Content-Type` header is now `multipart/alternative`.
- The message's `Content-Type` header is automatically set to [`multipart/mixed`][multipart-content-type].

## multipart/alternative emails with attachments

Another common task is creating `multipart/alternative` emails where the HTML
content refers to assets attachments (images, CSS, etc.).

To accomplish this, we need to:
Another common task is creating `multipart/alternative` emails where one of the parts contains assets, such as images, and CSS, etc.
To accomplish this, we need to complete the following steps:

- Create a `Laminas\Mime\Part` instance containing our `multipart/alternative`
message.
Expand All @@ -94,6 +85,14 @@
The following example creates a MIME message with three parts: text and HTML
alternative versions of an email, and an image attachment.

**Note:** The message part order is important for email clients to properly display the correct version of the content. For more information, refer to the quote below, from [section 7.2.3 The Multipart/alternative subtype of RFC 1341][multipart-content-type]:

> In general, user agents that compose multipart/alternative entities should place the body parts in increasing order of preference, that is, with the preferred format last. For fancy text, the sending user agent should put the plainest format first and the richest format last. Receiving user agents should pick and display the last format they are capable of displaying. In the case where one of the alternatives is itself of type "multipart" and contains unrecognized sub-parts, the user agent may choose either to show that alternative, an earlier alternative, or both.
>
> NOTE: From an implementor's perspective, it might seem more sensible to reverse this ordering, and have the plainest alternative last. However, placing the plainest alternative first is the friendliest possible option when mutlipart/alternative entities are viewed using a non-MIME- compliant mail reader. While this approach does impose some burden on compliant mail readers, interoperability with older mail readers was deemed to be more important in this case.
>
> It may be the case that some user agents, if they can recognize more than one of the formats, will prefer to offer the user the choice of which format to view. This makes sense, for example, if mail includes both a nicely-formatted image version and an easily-edited text version. What is most critical, however, is that the user not automatically be shown multiple versions of the same data. Either the user should be shown the last recognized version or should explicitly be given the choice.

```php
use Laminas\Mail\Message;
use Laminas\Mime\Message as MimeMessage;
Expand Down Expand Up @@ -138,13 +137,32 @@

## Setting custom MIME boundaries

In a multipart message, a MIME boundary for separating the different parts of
the message is normally generated at random. In some cases, however, you might
want to specify the MIME boundary that is used. This can be done by injecting a
new `Laminas\Mime\Mime` instance into the MIME message.
In a multipart message, [a MIME boundary][mime-boundary] for separating the different parts of
the message is normally generated at random, e.g., `000000000000d80dfc060ac6d232` or `Apple-Mail=_CEE98D34-7402-4263-858D-9820B6208C21`.

Check failure on line 141 in docs/book/message/attachments.md

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Documentation Linting [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integra...

Trailing spaces [Expected: 0 or 2; Actual: 1]
In some cases, however, you might want to specify the MIME boundary that is used. This can be done by injecting a new `Laminas\Mime\Mime` instance into the MIME message, as in the following example.

```php
use Laminas\Mime\Mime;

$mimeMessage->setMime(new Mime($customBoundary));
```

## Retrieving attachments

If you have created a multipart message with one or more attachments, whether programmatically

Check failure on line 152 in docs/book/message/attachments.md

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Documentation Linting [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integra...

Trailing spaces [Expected: 0 or 2; Actual: 1]
or via the `Message::fromString();` method, you can readily retrieve them by calling the `getAttachments()` method.
It will return an array of `\Laminas\Mime\Part` objects.

For example:

```php
// Instantiate a Message object from a .eml file.
$raw = file_get_contents(__DIR__ . '/mail_with_attachments.eml');
$message = Message::fromString($raw);

// Retrieve the email's attachments.
$attachments = $message->getAttachments();
```

[mime-boundary]: https://www.oreilly.com/library/view/programming-internet-email/9780596802585/ch03s04.html
[multipart-content-type]: https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html

Check failure on line 168 in docs/book/message/attachments.md

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Documentation Linting [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integra...

Files should end with a single newline character
109 changes: 105 additions & 4 deletions docs/book/message/intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@
$message->getHeaders()->addHeaderLine('X-API-Key', 'FOO-BAR-BAZ-BAT');
```

Sometimes you may want to provide HTML content, or multi-part content. To do
that, you'll first create a MIME message object, and then set it as the body of
your mail message object. When you do so, the `Message` class will automatically
set a "MIME-Version" header, as well as an appropriate "Content-Type" header.
Sometimes you may want to provide HTML content, or [multipart][multipart-content]
content. To do that, you'll first create a [MIME][mime-message] message object,

Check failure on line 86 in docs/book/message/intro.md

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Documentation Linting [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integra...

Trailing spaces [Expected: 0 or 2; Actual: 1]
and then set it as the body of your mail message object. When you do so, the `Message`

Check failure on line 87 in docs/book/message/intro.md

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Documentation Linting [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integra...

Trailing spaces [Expected: 0 or 2; Actual: 1]
class will automatically set a "MIME-Version" header, as well as an appropriate

Check failure on line 88 in docs/book/message/intro.md

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Documentation Linting [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integra...

Trailing spaces [Expected: 0 or 2; Actual: 1]
"Content-Type" header.

If you are interested in multipart emails or using attachments, read the chapter
on [Adding Attachments](attachments.md).
Expand Down Expand Up @@ -137,6 +138,90 @@
$transport->send($message);
```

### Create a Message from a raw email string

You can also create a Message object from a raw email string, one compliant with one or more of the applicable RFCs ([822](https://datatracker.ietf.org/doc/html/rfc822), [2045](https://datatracker.ietf.org/doc/html/rfc2045), [2046](https://datatracker.ietf.org/doc/html/rfc2046), [2047](https://datatracker.ietf.org/doc/html/rfc2047)), by using the static `fromString()` method.

```php
$rawEmail = <<<EOF
From: "[email protected]" <[email protected]>
Subject: test confirmation
To: [email protected], [email protected], [email protected],
[email protected], [email protected], [email protected]
Message-ID: <[email protected]>
Date: Thu, 15 Aug 2019 14:54:37 +0900
X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0;
attachmentreminder=0; deliveryformat=4
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:69.0) Gecko/20100101
Thunderbird/69.0
MIME-Version: 1.0
Content-Type: multipart/mixed;
boundary="------------26A45336F6C6196BD8BBA2A2"
Content-Language: en-US

This is a multi-part message in MIME format.
--------------26A45336F6C6196BD8BBA2A2
Content-Type: text/plain; charset=utf-8; format=flowed
Content-Transfer-Encoding: 7bit

testtest
testtest
testtest
testtest
testtest
testtest

--------------26A45336F6C6196BD8BBA2A2
Content-Type: text/plain; charset=UTF-8;
name="sha1hash.txt"
Content-Transfer-Encoding: base64
Content-Disposition: attachment;
filename="sha1hash.txt"

NzRjOGYwOWRmYTMwZWFjY2ZiMzkyYjEzMjMxNGZjNmI5NzhmMzI1YSAqZmxleC1jb25maXJt
LW1haWwuMS4xMC4wLnhwaQpjY2VlNGI0YWE0N2Y1MTNhYmNlMzQyY2UxZTJlYzJmZDk2MDBl
MzFiICpmbGV4LWNvbmZpcm0tbWFpbC4xLjExLjAueHBpCjA3MWU5ZTM3OGFkMDE3OWJmYWRi
MWJkYzY1MGE0OTQ1NGQyMDRhODMgKmZsZXgtY29uZmlybS1tYWlsLjEuMTIuMC54cGkKOWQ3
YWExNTM0MThlYThmYmM4YmU3YmE2ZjU0Y2U4YTFjYjdlZTQ2OCAqZmxleC1jb25maXJtLW1h
aWwuMS45LjkueHBpCjgxNjg1NjNjYjI3NmVhNGY5YTJiNjMwYjlhMjA3ZDkwZmIxMTg1NmUg
KmZsZXgtY29uZmlybS1tYWlsLnhwaQo=
--------------26A45336F6C6196BD8BBA2A2
Content-Type: application/json;
name="manifest.json"
Content-Transfer-Encoding: base64
Content-Disposition: attachment;
filename="manifest.json"

ewogICJtYW5pZmVzdF92ZXJzaW9uIjogMiwKICAiYXBwbGljYXRpb25zIjogewogICAgImdl
Y2tvIjogewogICAgICAiaWQiOiAiZmxleGlibGUtY29uZmlybS1tYWlsQGNsZWFyLWNvZGUu
Y29tIiwKICAgICAgInN0cmljdF9taW5fdmVyc2lvbiI6ICI2OC4wIgogICAgfQogIH0sCiAg
Im5hbWUiOiAiRmxleCBDb25maXJtIE1haWwiLAogICJkZXNjcmlwdGlvbiI6ICJDb25maXJt
IG1haWxhZGRyZXNzIGFuZCBhdHRhY2htZW50cyBiYXNlZCBvbiBmbGV4aWJsZSBydWxlcy4i
LAogICJ2ZXJzaW9uIjogIjIuMCIsCgogICJsZWdhY3kiOiB7CiAgICAidHlwZSI6ICJ4dWwi
LAogICAgIm9wdGlvbnMiOiB7CiAgICAgICJwYWdlIjogImNocm9tZTovL2NvbmZpcm0tbWFp
bC9jb250ZW50L3NldHRpbmcueHVsIiwKICAgICAgIm9wZW5faW5fdGFiIjogdHJ1ZQogICAg
fQogIH0KfQ==
--------------26A45336F6C6196BD8BBA2A2--
EOF;

$message = Message::fromString($rawEmail);
```

### Retrieve a Message's plain text and HTML body

Commonly, though not always, an email will contain one or both of a plain text and/or HTMl body.
To retrieve these directly, there are two methods available `getPlainTextBodyPart()` and

Check failure on line 213 in docs/book/message/intro.md

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Documentation Linting [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integra...

Trailing spaces [Expected: 0 or 2; Actual: 1]
`getHtmlBodyPart()`. For example:

```php
// Instantiate a Message object from a .eml file.
$raw = file_get_contents(__DIR__ . '/mail_with_attachments.eml');
$message = Message::fromString($raw);

echo $message->getPlainTextBodyPart();
echo $message->getHtmlBodyPart();
```

## Configuration Options

The `Message` class has no configuration options, and is instead a value object.
Expand Down Expand Up @@ -426,3 +511,19 @@
```

Serialize to string.

### fromString

Instantiates a `Message` object from a raw message string that is compliant with one or more of the applicable RFCs, including:

- [822](https://datatracker.ietf.org/doc/html/rfc822)
- [2045](https://datatracker.ietf.org/doc/html/rfc2045)
- [2046](https://datatracker.ietf.org/doc/html/rfc2046)
- [2047](https://datatracker.ietf.org/doc/html/rfc2047)

```php
fromString() : Laminas\Mail\Message
```

[multipart-content]: https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html
[mime-message]: https://en.wikipedia.org/wiki/MIME

Check failure on line 529 in docs/book/message/intro.md

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Documentation Linting [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integra...

Files should end with a single newline character
4 changes: 2 additions & 2 deletions src/Headers.php
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@
if (! is_array($headers) && ! $headers instanceof Traversable) {
throw new Exception\InvalidArgumentException(sprintf(
'Expected array or Traversable; received "%s"',
is_object($headers) ? $headers::class : gettype($headers)

Check failure on line 259 in src/Headers.php

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Psalm [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integration-action@v1, ...

TypeDoesNotContainType

src/Headers.php:259:17: TypeDoesNotContainType: Type never for $headers is never object (see https://psalm.dev/056)

Check failure on line 259 in src/Headers.php

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Psalm [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integration-action@v1, ...

MixedArgument

src/Headers.php:259:17: MixedArgument: Argument 2 of sprintf cannot be mixed|string, expecting float|int|object{__tostring()}|string (see https://psalm.dev/030)

Check failure on line 259 in src/Headers.php

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Psalm [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integration-action@v1, ...

RedundantCondition

src/Headers.php:259:57: RedundantCondition: Type never for $headers is never object (see https://psalm.dev/122)
));
}

Expand Down Expand Up @@ -296,9 +296,9 @@
throw new Exception\InvalidArgumentException(sprintf(
'%s expects its first argument to be a string; received "%s"',
__METHOD__,
is_object($headerFieldNameOrLine)

Check failure on line 299 in src/Headers.php

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Psalm [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integration-action@v1, ...

TypeDoesNotContainType

src/Headers.php:299:17: TypeDoesNotContainType: Type never for $headerFieldNameOrLine is never object (see https://psalm.dev/056)

Check failure on line 299 in src/Headers.php

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Psalm [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integration-action@v1, ...

MixedArgument

src/Headers.php:299:17: MixedArgument: Argument 3 of sprintf cannot be mixed|string, expecting float|int|object{__tostring()}|string (see https://psalm.dev/030)
? $headerFieldNameOrLine::class
: gettype($headerFieldNameOrLine)

Check failure on line 301 in src/Headers.php

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Psalm [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integration-action@v1, ...

RedundantCondition

src/Headers.php:301:19: RedundantCondition: Type never for $headerFieldNameOrLine is never object (see https://psalm.dev/122)
));
}

Expand Down Expand Up @@ -348,7 +348,7 @@
'%s requires a string or %s instance; received %s',
__METHOD__,
HeaderInterface::class,
is_object($instanceOrFieldName) ? $instanceOrFieldName::class : gettype($instanceOrFieldName)

Check failure on line 351 in src/Headers.php

View workflow job for this annotation

GitHub Actions / ci / QA Checks (Psalm [8.1, locked], ubuntu-latest, laminas/laminas-continuous-integration-action@v1, ...

TypeDoesNotContainType

src/Headers.php:351:17: TypeDoesNotContainType: Type never for $instanceOrFieldName is never object (see https://psalm.dev/056)
));
}

Expand Down Expand Up @@ -389,8 +389,8 @@
* Get all headers of a certain name/type
*
* @param string $name
* @return false|ArrayIterator|HeaderInterface Returns false if there is no headers with $name in this
* contain, an ArrayIterator if the header is a MultipleHeadersInterface instance and finally returns
* @return false|ArrayIterator|HeaderInterface Returns false if there are no headers with $name,
* an ArrayIterator if the header is a MultipleHeadersInterface instance, and finally returns
* HeaderInterface for the rest of cases.
*/
public function get($name)
Expand Down
Loading
Loading