PHP7.1未満お断り！最新PHPメーラーライブラリ Genkgo/Mail を試してみる

# はじめに

PHPのメーラーライブラリといえば

- [PHPMailer/PHPMailer: The classic email sending library for PHP](https://github.com/PHPMailer/PHPMailer)

こいつが定番でしたが，脆弱性がたびたび見つかったりとかインタフェースがレガシー感満載とかだったりで，正直あまり進んで使う気にはなれませんでした。そんな気持ちでたまたまGitHubを巡回していたら，とてもいい感じのライブラリと巡り会えたので紹介させていただきます。

- [genkgo/mail: Library to send e-mails over different transports and protocols using immutable messages and streams](https://github.com/genkgo/mail)

特長として以下のようなものが紹介されています。

- SMTPサーバもしくはPHPの[`mail`](http://php.net/manual/ja/function.mail.php)関数どちらでも送れる
- HTMLパートからプレーンテキストパートを自動生成する
- SMTPサーバへの接続が失敗したときに，指定回数接続を自動リトライする
- SMTPサーバへの送信が失敗したときに，自動でキューに追加する
- ASCII，Base64，QuotedPrintableの中から最適なものを自動選択してエンコードする
- リソースを使っているストリームとコネクション以外はすべてイミュータブルオブジェクト
- [Intl](http://php.net/manual/ja/book.intl.php)エクステンションだけは使うが，それ以外には何も依存しない

ソースコード本体もかなり美しく抽象化されています。

# 基本的な利用方法

1. `new FormattedMessageFactory()` でファクトリクラスを生成
2. `->with*()` で本文や添付ファイルなどを付与してボディを組み立てていく
3. `->createMessage()` でボディを完成
4. `->with*()` で送信先や件名などのヘッダーを付与していってメッセージ全体を完成
5. `new SmtpTransport()` または `new PhpMailTransport()` でトランスポータークラスを生成
6. 作成したメッセージをトランスポーターの `->send()` に投げて送信

## 1. ファクトリクラスの生成

```php
use Genkgo\Mail\FormattedMessageFactory;

$factory = new FormattedMessageFactory();
```

ここは特に説明不要。

## 2. ボディの組み立て

### 文章の追加

HTMLとテキストの2種類があります。HTMLからテキストも同時に自動生成する方法がおすすめです。

```php:HTMLパートの追加&#32;（テキストパートの自動生成あり）
$factory = $factory->withHtml('<html><body><p>Hello World</p></body></html>');
```

```php:HTMLパートの追加&#32;（テキストパートの自動生成無し）
$factory = $factory->withHtmlAndNoGeneratedAlternativeText('<html><body><p>Hello World</p></body></html>');
```

```php:テキストパートの追加
$factory = $factory->withAlternativeText('Hello World');
```

### 添付ファイルの追加

`FileAttachment` を使う方法と `ResourceAttachment` を使う方法があります。 `FileAttachment` のみContent-Typeの自動判定に対応しています。

```php:ファイル名から&#32;(Content-Typeが自明な場合は自分で指定)
use Genkgo\Mail\Mime\FileAttachment;
use Genkgo\Mail\Header\ContentType;

$factory = $factory->withAttachment(new FileAttachment(
    __DIR__ . '/example.jpg',                // 第1引数 $filename は fopen() するためのパス
    new ContentType('image/jpeg'),           // 第2引数 $charset は省略すると指定無し
    'example.jpg'                            // 第3引数 $attachmentName は省略すると basename($filename) になる
));

$factory = $factory->withAttachment(new FileAttachment(
    __DIR__ . '/example.txt',
    new ContentType('text/plain', 'UTF-8')   // 第2引数 $charset は省略すると "text/*" の場合 UTF-8 がデフォルト
));
```

```php:ファイル名から&#32;(Content-Typeが不明な場合はfinfoモジュールによる自動判定)
use Genkgo\Mail\Mime\FileAttachment;

$factory = $factory->withAttachment(FileAttachment::fromUnknownFileType(
    __DIR__ . '/example.jpg',                // 第1引数 $filename は fopen() するためのパス
    'example.jpg'                            // 第2引数 $attachmentName は省略すると basename($filename) になる
));                                          // $charset は "binary" と判定された場合は追加されない

$factory = $factory->withAttachment(FileAttachment::fromUnknownFileType(
    __DIR__ . '/example.txt'
));                                          // $charset は自動判定で追加される
```

```php:リソースから&#32;(Content-Typeが自明な場合は自分で指定)
use Genkgo\Mail\Mime\ResourceAttachment;
use Genkgo\Mail\Header\ContentType;

$factory = $factory->withAttachment(new ResourceAttachment(
    fopen(__DIR__ . '/example.jpg', 'rb'),   // 第1引数 $resource
    'example.jpg',                           // 第2引数 $filename は名前がややこしいが ↑ の $attachmentName の意味
    new ContentType('image/jpeg')            // 第3引数 $contentType
));
```

```php:リソースから&#32;(Content-Typeが不明な場合はfinfoを使って自分で調べるかapplication/octet-streamにしておく)
use Genkgo\Mail\Mime\ResourceAttachment;
use Genkgo\Mail\Header\ContentType;

$factory = $factory->withAttachment(new ResourceAttachment(
    fopen(__DIR__ . '/example.jpg', 'rb'),   // 第1引数 $resource
    'example.jpg',                           // 第2引数 $filename は名前がややこしいが ↑ の $attachmentName の意味
    ContentType::unknown()                   // 第3引数 $contentType には new ContentType('application/octet-stream') のショートハンドを渡している
));
```

```php:文字列から&#32;(リソースからの場合とほぼ同じ，バイナリデータはphp&#58;//memoryに書き込まれる)
use Genkgo\Mail\Mime\ResourceAttachment;
use Genkgo\Mail\Header\ContentType;

$factory = $factory->withAttachment(ResourceAttachment::fromString(
    file_get_contents(__DIR__ . '/example.jpg'),   // 第1引数 $string はバイナリデータ
    'example.jpg',                                 // 第2引数 $filename は名前がややこしいが ↑ の $attachmentName の意味
    new ContentType('image/jpeg')                  // 第3引数 $contentType
));
```

### 埋め込み画像の追加

HTMLメール本文から参照するための画像を埋め込みます。第1引数にはストリームオブジェクトを渡します。バイナリデータの場合はほぼBase64エンコード1択なので `Base64EncodedStream` で問題無いと思います。ファイルパスには未対応のようです。

```php:リソースから
use Genkgo\Mail\Mime\EmbeddedImage;
use Genkgo\Mail\Stream\Base64EncodedStream;
use Genkgo\Mail\Header\ContentType;
use Genkgo\Mail\Header\ContentID;

$factory = $factory->withEmbeddedImage(new EmbeddedImage(
    new Base64EncodedStream(fopen(__DIR__ . '/example.jpg', 'rb')),  // 第1引数 $stream
    'example.jpg',                                                   // 第2引数 $filename は名前がややこしいが ↑ の $attachmentName の意味
    new ContentType('image/jpeg'),                                   // 第3引数 $contentType
    new ContentID('<unique-image-id%3CXXXXX%3E@example.com>')        // 第4引数 $contentID はRFC2392の形式に従って， "<URLエンコードされたユニークな識別子@送信元>" で記述する
));
```

埋め込んだ画像は以下の形式でHTMLメール本文から参照することができます。

```html
<img src="cid:unique-image-id%3CXXXXX%3E@example.com">
```

せめて `<>` でくくるのとURLエンコードぐらいは自動でやってほしい感。あと作った `EmbeddedImage` オブジェクトから `->getCid()` でContent-IDを一発で取ったりしたい。ちょっとこのあたりのサポートがまだ弱く，なんだかんだ自前で書く部分が多いので，今後の開発に期待します。

## 3. ボディの完成

```php
$message = $factory->createMessage();
```

ここも特に説明不要。

## 4. ヘッダーの追加とメッセージの完成

以下の3つのオペレーションがあります。基本的に `->withHeader()` だけで事足りると思います。

- `->withHeader()` … ヘッダーの追加 （存在した場合は上書き）
- `->withAddedHeader()` … ヘッダーの追加 （存在した場合も追加）
- `->withoutHeader()` … ヘッダーの削除 （存在しなかった場合は無視）

静的なヘルパーメソッドを使うのが一番簡単です。

```php:静的なヘルパーメソッドを使って組み立てる
use Genkgo\Mail\Header\From;
use Genkgo\Mail\Header\To;
use Genkgo\Mail\Header\Subject;

$message = $message
    ->withHeader(From::fromAddress('mur@example.com', '三浦'))
    ->withHeader(new Subject('Hello World'))
    ->withHeader(To::fromArray([['kmr@example.com', '木村'], ['szk@example.com', '鈴木']]));
```

```php:全部new演算子で丁寧に組み立てる
use Genkgo\Mail\Header\From;
use Genkgo\Mail\Header\To;
use Genkgo\Mail\Header\Subject;
use Genkgo\Mail\Address;
use Genkgo\Mail\AddressList;

$message = $message
    ->withHeader(new From(new Address(new EmailAddress('mur@example.com', '三浦')))
    ->withHeader(new Subject('Hello World'))
    ->withHeader(new To(new AddressList([
        new Address(new EmailAddress('kmr@example.com'), '木村'),
        new Address(new EmailAddress('szk@example.com'), '鈴木'),
    ])));
```

あまり出番は無いですがこういうこともできます。

```php:エンコードされた&#32;"名前&#32;&lt;メールアドレス&gt;"&#32;形式の文字列をパースさせる
use Genkgo\Mail\Header\From;
use Genkgo\Mail\Header\To;
use Genkgo\Mail\Header\Subject;
use Genkgo\Mail\Address;
use Genkgo\Mail\AddressList;

$message = $message
    ->withHeader(new From(Address::fromString('=?UTF-8?B?5LiJ5rWm?= <mur@example.com>'))
    ->withHeader(new Subject('Hello World'))
    ->withHeader(new To(AddressList::fromString('=?UTF-8?B?5pyo5p2R?= <kmr@example.com>,=?UTF-8?B?6Yi05pyo?= <szk@example.com>')));
```

他にも `CC` `BCC` `ReplyTo` `Sender` `Date` `MessageID` とかお馴染みのヘッダーがありますが紹介は割愛。

- `CC` `BCC` `ReplyTo` は `To` とほとんど同じ扱いで， `AddressList` を必要とします。
- `Sender` `Date` `MessageID` はこのあと紹介する方法で自動付与できます。

## 5. トランスポータークラスの生成

- SMTPサーバを経由させる場合は `SmtpTransport` クラスを使用します。
- PHPの[`mail`](http://php.net/manual/ja/function.mail.php)関数を利用する場合は `PhpMailTransport` クラスを使用します。

メールプロトコルまわりあまり詳しくないのでちゃんと説明できない…

### SMTPサーバを経由させる

```php:シンプルな例
use Genkgo\Mail\Transport\SmtpTransport;
use Genkgo\Mail\Protocol\Smtp\ClientFactory;
use Genkgo\Mail\Transport\EnvelopeFactory;

$transport = new SmtpTransport(
    ClientFactory::fromString('smtp://user:pass@host/')->newClient(), // 第1引数 $client にはSMPTPクライアントを渡す（このようにスキームを含む文字列形式で書くのが最も簡単）
    EnvelopeFactory::useExtractedHeader()                             // 第2引数 $envelopeFactory にはエンベロープのファクトリクラスを渡す
);                                                                    // ::useExtractedHeader() 以外の例が無いのでとりあえずこれを渡しておけばいいと思う（適当
```


```php:HELOの代わりにEHLOでネゴシエーション
use Genkgo\Mail\Transport\SmtpTransport;
use Genkgo\Mail\Protocol\Smtp\ClientFactory;
use Genkgo\Mail\Transport\EnvelopeFactory;

$transport = new SmtpTransport(
    ClientFactory::fromString('smtp://user:pass@host/')->withEhlo('example.com')->newClient(),　// ->withEhlo() では自分のドメインを指定
    EnvelopeFactory::useExtractedHeader()
);
```

### PHPの[`mail`](http://php.net/manual/ja/function.mail.php)関数を利用する

```php:シンプルな例
use Genkgo\Mail\Transport\PhpMailTransport;
use Genkgo\Mail\Transport\EnvelopeFactory;

$transport = new PhpMailTransport(EnvelopeFactory::useExtractedHeader()); // 第1引数 $envelopeFactory にはエンベロープのファクトリクラスを渡す
```

```php:ログファイルに記録する
use Genkgo\Mail\Transport\PhpMailTransport;
use Genkgo\Mail\Transport\EnvelopeFactory;

$transport = new SmtpTransport(EnvelopeFactory::useExtractedHeader(), ['-X /path/to/logfile']); // 第2引数 $parameters には sendmail に渡す -f 以外のパラメータを指定する （-f は自動付与される）
```

### 作成したトランスポーターに Sender，Date，Message-ID を自動付与させる

```php:Sender，Date，Message-IDを自動付与させる
use Genkgo\Mail\Transport\InjectStandardHeadersTransport;

$transport = new InjectStandardHeadersTransport($transport, 'example.com'); // 第1引数にトランスポーター，第2引数に自分のドメインを指定
```

## 6. 送信！

```php
$transport->send($message);
```

例外が飛ばなければ送信成功です。（返り値は`void`）
発生しうる例外は [mail/src/Exception at master · genkgo/mail](https://github.com/genkgo/mail/tree/master/src/Exception) に一覧があります。

とりあえずすべての例外を捕捉したい，というときは

```php
use Genkgo\Mail\Exception\AbstractException as GenkgoMailException;

try {

} catch (GenkgoMailException $e) {

}
```

という感じで全体を括っておきましょう。

# 発展: 失敗時の対応

`InjectStandardHeadersTransport` ですでにトランスポーターをデコレートするパターンについては紹介していますが，この他に失敗の対応に関わるものとして以下の2つがあります。

- `RetryIfFailedTransport`
- `QueueIfFailedTransport`

## 失敗時にリトライ

```php:Sender，Date，Message-IDを自動付与させる
use Genkgo\Mail\Transport\QueueIfFailedTransport;

$transport = new QueueIfFailedTransport($transport, 5); // 第1引数にトランスポーター，第2引数にリトライ回数を指定
```

試行間隔は **0秒** です。指数的に試行間隔を増やしたいなどのニーズがあれば，これに相当する処理は自分で書くべきでしょう。

## 失敗時にキューイング

キューには以下のものがあります。`ArrayObjectQueue`以外は**外部プロセスとして起動し，一定時間ごとに`QueueProcessor::process()`をコールする**イメージです。[`sleep`](http://php.net/manual/ja/function.sleep.php)関数を挟んだ無限ループ，および定期的なcron実行によって運用します。

- `RedisQueue` … ストレージとしては一番優れているがRedisおよび[`predis/predis`](https://github.com/nrk/predis)が必要
- `FilesystemQueue` … 気軽に使える
- `ArrayObjectQueue` … [ReactPHP](https://github.com/reactphp/react)や[Swoole](https://github.com/swoole/swoole-src)など，変数が永続的に保持されるWebサーバで動かしているときだけ使える

### エンキュー処理

```php:RedisQueueを使う
use Genkgo\Mail\Transport\QueueIfFailedTransport;
use Genkgo\Mail\Queue\RedisQueue;
use Predis\Client;

$redis = new Client('tcp://127.0.0.1:6379');
$queue = new RedisQueue($redis, 'GenkgoMailQueue'); // 第1引数にRedisクライアント，第2引数にRPUSH/LPOPの際に使用するキーを指定
$transport = new QueueIfFailedTransport([$transport], [$queue]); // 第1引数にトランスポーターの配列，第2引数にキューの配列を指定
```

```php:FilesystemQueueを使う
use Genkgo\Mail\Transport\QueueIfFailedTransport;
use Genkgo\Mail\Queue\FilesystemQueue;

$queue = new FilesystemQueue(__DIR__ . '/GenkgoMailQueue'); // 第1引数にディレクトリ，第2引数に作成されるEMLファイルのパーミッションを指定（デフォルトは0750）
$transport = new QueueIfFailedTransport([$transport], [$queue]); // 第1引数にトランスポーターの配列，第2引数にキューの配列を指定
```


### デキュー処理

```php:デーモンプロセスとして動かす
use Genkgo\Mail\Queue\QueueProcessor;

$processor = new QueueProcessor($transport, [$queue]); // 第1引数にトランスポーター，第2引数にキューの配列を指定

while (true) {
    $processor->process();
    sleep(30);
}
```

# 注意

**今日初めてGitHub上で見たソースを追って脳内デバッグしながら書いた記事です。**信憑性に難があります。ぜひ実際に動作させておかしいところとか教えてください。まだドキュメントがあまり整備されていないので書くだけで疲れてしまった…