💌

Laravel から Gmail API (OAuth) でメールを送信する

2024/08/07に公開

背景

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