Laravel から Gmail API (OAuth) でメールを送信する
背景
GoogleWorkspaceのヘルプにあるとおり、アプリケーションから Gmail のメールサーバに SMTP 接続してメール送信することが、2024年秋頃をもって不可能になります。
代替手段として、OAuth、つまり Gmail API を利用してメールを送信する方法が提示されています。
弊社でも、相当数のアプリケーションの修正が必要になりました。
Laravel 用のライブラリもあるにはあるのですが(例:Iagofelicio/laravel-gmail-oauth2)、
PHP や Laravel のバージョンが古いままのプロジェクトもあったため
ひとまず、自力実装してみることにしました。
前置き: Manager と Driver
config/mail.php を見ると、
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
標準で11種類の Mailer、つまりメール送信機構が選択できるようになっています。
標準的なSTMP送信機構の他、MailGun や SES などの API を使う機構、
デバッグ用として、実際にはメールは送らずログファイルに出力する機構があります。
Laravel のコードを読み慣れている人ならすぐにピンと来ると思いますが、
Laravel では、 Manager クラスがこうした複数の機構(Driver)を管理して抽象化し、
拡張できるようになっている機能が多数あります。
上記の SMTP、MailGun などひとつひとつの機構が、Driver であり、
これら Driver の管理をしているのが Manager です。
メールのほか、認証(Auth) や ロギング(Log) や ストレージ(Filesystem) なども、同じように Manager と Driver で構成されています。
そして、Manager クラスの extend() メソッドを利用して、独自の Driver を追加できるようになっています。
(config/mail.php では driver という名称は使われず、mailer となっていますが、Laravel 6.x までは driver という名称でした)
メールの場合 Manager クラスにあたるのは Illuminate\Mail\MailManager です。
各 Driver (Mailer) に対応するクラスは、
smtp ... Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport
ses ... Illuminate\Mail\Transport\SesTransport
log ... Illuminate\Mail\Transport\LogTransport
等で、TransportInterface (Symfony\Component\Mailer\Transport\TransportInterface) よって抽象化されています。
※厳密には、Laravel 7.x 以降、メールの Driver は Mailer (送信機構の種類) と Transport (具体的な送信処理) の2つの概念に分割され、
Mailer がそれぞれ内部に Transport をもつ構造になっています。
つまり、2つの SMTP サーバを使い分けたい時、2つの Mailer を用意し、どちらも SmtpTransport を使用する、ということができるようになっています。
大まかな実装方針
まず、独自 Mailer 用のクラス (Transport) を作ります。
TransportInterface を実装しなければいけないので、
send() メソッドと __toString() メソッドが必要です。
namespace App\Modules;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mime\RawMessage;
class GmailTransport extends TransportInterface
{
public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage
{
// TODO 送信処理
}
public function __toString(): string
{
return 'gmail';
}
}
次に、これを使用した、メールドライバを登録します。
AppServiceProvider(または適当な Provider クラス)で、下記のように登録します。
public function boot()
{
app('mail.manager')->extend('gmail', static function ($config) {
return new GmailTransport($config);
});
}
先程の Transport クラスを用いて、'gmail' という名前で登録しました。
これで、config/mail.php の 'default' ないし、
.env の MAIL_MAILER で、'gmail' と指定すれば、
GmailTransport クラスがメール送信に用いられるようになります。
あとは、GmailTransport の send() メソッドを実装していくだけです。
Gmail API の認証方法: OAuth or サービスアカウント
ところで、Gmail API をはじめとする Google Cloud の API には大きく2種類の認証方法があります。
OAuth
ひとつめは OAuth。
Google アカウントをもつユーザにブラウザからログインさせ、
リダイレクトバック時に付与された認証コードをもとに、アクセストークンを取得する方法です。
同時にリフレッシュトークンを取得できるため、これをDB等に保持しておけば、
アクセストークンの期限が切れても、プログラムからAPIを叩き続けることができます。
事前準備が比較的簡単な反面、
ユーザを誘導するためのリダイレクト用エンドポイントと、認証成功時等のリダイレクト先URLを用意しなければならないこと、
またメール送信元となる Google アカウントでログインを実行させなければならないこと、
アクセストークンやリフレッシュトークンをDBで管理し、トークンの更新を実装しなければいけないことなどが難点です。
サービスアカウント
もうひとつ、「サービスアカウント」を使った認証方法があります。
サービスアカウントは、Google Cloud のプロジェクトに作成できる、プログラムから使用するためのユーザ(IAM)のことで、
キーをファイルとしてダウンロードし、プログラムに組み込めば、
OAuth のような認証作業なしでAPIを叩くことができます。
Map API や Vision API のような、特定のユーザに紐づかないAPIであればこれが便利なのですが、
今回はメール送信元となる Google アカウントにアクセスしなければいけないため、少々複雑です。
具体的には、Google Workspace 管理コンソール 側の設定で、該当サービスアカウントに対し、「ドメイン全体の委任」を設定する作業が必要です。
サービスアカウントおよびそのアクセスキーに、強い権限を与えることになるため、セキュリティ面からも十分な考慮が必要です。
Laravel からサービスアカウントで Gmail API 送信
今回は、サービスアカウントを使用して実装してみました。
サービスアカウントを作成し、Google Workspace の管理コンソールの「ドメイン全体の委任」で、スコープ "https://www.googleapis.com/auth/gmail.send" を与えておきます。
そして、GCPコンソールから JSON 形式のキーをダウンロードし、アプリ内に配置します。
Composer で google/apiclient をインストールしておきます。
Laravel 9.x 以降の場合
# .env
MAIL_MAILER=gmail # ここを変更
GMAIL_FROM_ADDRESS="xxx@example.com" # GoogleWorkspace に存在するアドレス
GMAIL_SERVICE_ACCOUNT_KEY="xxx/xxx.json" # キーファイルのパス
# config/mail.php
'mailers' => [
//...
// 下記を追記
'gmail' => [
'from_address' => env('GMAIL_FROM_ADDRESS'),
'service_account_key' => env('GMAIL_SERVICE_ACCOUNT_KEY'),
],
],
# AppServiceProvider
use App\Modules\GmailTransport;
public function boot()
{
app('mail.manager')->extend('gmail', static function ($config) {
return new GmailTransport($config);
});
}
namespace App\Modules;
use Google\Client;
use Google\Service\Gmail;
use Google\Service\Gmail\Message;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
class GmailTransport extends AbstractTransport
{
protected $config;
protected $client;
public function __construct(array $config)
{
$this->config = $config;
$this->client = new Client();
$this->client->setApplicationName(config('app.name'));
$this->client->setAuthConfig(base_path($this->config['service_account_key']));
$this->client->setScopes([Gmail::GMAIL_SEND]);
$this->client->setSubject($this->config['from_address']);
parent::__construct();
}
protected function doSend(SentMessage $message): void
{
$service = new Gmail($this->client);
$gmail_message = new Message();
$gmail_message->setRaw(base64_encode($message->getOriginalMessage()->toString()));
$service->users_messages->send($this->config['from_address'], $gmail_message);
}
public function __toString(): string
{
return 'gmail';
}
}
インターフェイスに従い send() メソッドを直接実装しても良かったのですが、
イベント関連の処理等が組み込まれている AbstractTransport を継承し、
送信処理の中核部分にあたる doSend() を実装しました。
Laravel 7.x, 8.x の場合
Laravel 8 までは Symfony Mailer ではなく Swift Mailer が使用されており、
Transport が実装すべき Interface も変わっています。
# .env
MAIL_MAILER=gmail # ここを変更
GMAIL_FROM_ADDRESS="xxx@example.com" # GoogleWorkspace に存在するアドレス
GMAIL_SERVICE_ACCOUNT_KEY="xxx/xxx.json" # キーファイルのパス
# config/mail.php
'mailers' => [
//...
// 下記を追記
'gmail' => [
'from_address' => env('GMAIL_FROM_ADDRESS'),
'service_account_key' => env('GMAIL_SERVICE_ACCOUNT_KEY'),
],
],
# AppServiceProvider
use App\Modules\GmailTransport;
public function boot()
{
app('mail.manager')->extend('gmail', static function ($config) {
return new GmailTransport($config);
});
}
namespace App\Modules;
use Google\Client;
use Google\Service\Gmail;
use Google\Service\Gmail\Message;
use Illuminate\Mail\Transport\Transport;
use Swift_Mime_SimpleMessage;
class GmailTransport extends Transport
{
protected $config;
protected $client;
public function __construct(array $config)
{
$this->config = $config;
$this->client = new Client();
$this->client->setApplicationName(config('app.name'));
$this->client->setAuthConfig(base_path($this->config['service_account_key']));
$this->client->setScopes([Gmail::GMAIL_SEND]);
$this->client->setSubject($this->config['from_address']);
}
public function send(Swift_Mime_SimpleMessage $message, &$failedRecipients = null)
{
$this->beforeSendPerformed($message);
$service = new Gmail($this->client);
$gmail_message = new Message();
$gmail_message->setRaw(base64_encode($message->toString()));
$service->users_messages->send($this->config['from_address'], $gmail_message);
$this->sendPerformed($message);
return $this->numberOfRecipients($message);
}
}
Laravel 6.x 以前の場合
Laravel 6 までは Mailer と Transport の分離がされておらず、
Driver という概念のみで構成されていました。
また、それを管理するクラスも、MailManaer ではなく TransportManager だったため、
構造が大幅に変わっています。
# .env
MAIL_DRIVER=gmail # ここを変更
GMAIL_FROM_ADDRESS="xxx@example.com" # GoogleWorkspace に存在するアドレス
GMAIL_SERVICE_ACCOUNT_KEY="xxx/xxx.json" # キーファイルのパス
# config/mail.php
// 下記を追記
'gmail' => [
'from_address' => env('GMAIL_FROM_ADDRESS'),
'service_account_key' => env('GMAIL_SERVICE_ACCOUNT_KEY'),
],
# AppServiceProvider
use App\Modules\GmailTransport;
public function boot()
{
app('swift.transport')->extend('gmail', static function ($app) {
return $app->make(GmailTransport::class);
});
}
namespace App\Modules;
use Google\Client;
use Google\Service\Gmail;
use Google\Service\Gmail\Message;
use Illuminate\Mail\Transport\Transport;
use Swift_Mime_SimpleMessage;
class GmailTransport extends Transport
{
public function send(Swift_Mime_SimpleMessage $message, &$failedRecipients = null)
{
$this->beforeSendPerformed($message);
$client = new Client();
$client->setApplicationName(config('app.name'));
$client->setAuthConfig(base_path(config('gmail.service_account_key')));
$client->setScopes([Gmail::GMAIL_SEND]);
$client->setSubject(config('gmail.from_address'));
$service = new Gmail($client);
$gmail_message = new Message();
$gmail_message->setRaw(base64_encode($message->toString()));
$service->users_messages->send(config('gmail.from_address'), $gmail_message);
$this->sendPerformed($message);
return $this->numberOfRecipients($message);
}
}
ライブラリ化
Laravel のバージョンに合わせた対応ができるよう、ライブラリ化しました。
ad5jp/laravel-gmail
Laravel 6〜9 の間でメール関連の実装が大幅に変わっており、
各バージョンへの対応に苦労しました。
Discussion