PowerShellを使って、Gmail APIからメールを送信する

以前、PowerShellを使ってSMTPGmailを送信したことがありました。
PowerShellを使って、SMTPでGmailを送信する - メモ的な思考的な

 
今回は、以下の記事を参考に、Gmail APIを使たGmailの送信を試してみました*1
Powershell: Googlemail (GMail) nativ mit Powershell verwalten - administrator.de

なお、自分が実装してみたのは送信部分だけですが、記事にはそれ以外の内容についても記載されています。

 
ちなみに、Gmail APIでは .NET向けにライブラリも用意されていますが、PowerShellでNuGetパッケージを扱うやり方が分からなかったため、今回は使用しませんでした。
.NET Quickstart  |  Gmail API  |  Google Developers

 
目次

 

環境

 

Gmail APIの有効化とclient_id.jsonファイルの取得

以前Pythonでやった時と同じ方法で作業します。
Python3 + google-api-python-clientで、Gmail APIを使ってメールを送信する - メモ的な思考的な

  • dotnetチュートリアルへ移動
  • Step1のa.にある、this wizardをクリック
  • Google Developers Console で Gmail API を有効にするアプリケーションの登録画面で、以下を選択し、同意して続行をクリック
    • 新機能のお知らせ: 任意
    • 利用規約の順守:はい
  • プロジェクトMy Projectを作成していますの表示後、しばらく待つと、API が有効化されましたの表示が出る

    • 認証情報に進むをクリック
  • プロジェクトへの認証情報の追加画面

    • 1. 必要な認証情報の種類を調べるで、以下を選択し、必要な認証情報をクリック
      • 使用する APIGmail API
      • API を呼び出す場所: その他のUI (Windows、CLIツールなど)
      • アクセスするデータの種類: ユーザーデータ
    • 2.OAuth 2.0 クライアント ID を作成する
      • 名前: 任意 (MyPowerShellGmailSender など)
      • クライアントIDの作成をクリック
    • 3.OAuth 2.0 同意画面を設定する
      • メールアドレス: 自分のメールアドレス
      • ユーザーに表示するサービス名: 任意(MyPowerShellGmailSender Auth など)
      • 次へ をクリック
    • 4.認証情報をダウンロードする

 

OAuth2.0での認証部分の作成

参考サイトだと、Auth-Google関数の部分になります。

処理の流れは

  • 未認証の場合、ブラウザ経由で認証し、トークンを取得
  • 認証済&有効期限内の場合、トークンを再利用
  • 認証済&有効期間超過の場合、トークンをリフレッシュ

でした。以下の記事と同じ流れでした。
Google API OAuth2.0のアクセストークン&リフレッシュトークン取得手順メモ - Qiita

また、参考サイトではトークンなどはグローバル変数に入れていましたが、今回はPython版のgoogle-api-python-clientに合わせて、ローカルのJSONファイルとして保存することにします。

 
実装した時に悩んだ部分は以下の通りです。

 

JSONファイルの読み書き

ConvertFrom-JsonConvertTo-Jsonを使います。
PowerShell で JSON をファイル入出力 する - tech.guitarrapc.cóm

 
なお、読み込んだJSONオブジェクトの各項目は

$json = Get-Content $SECRET_FILE -Encoding UTF8 -Raw | ConvertFrom-Json
$auth = $json.installed

のように、<オブジェクト名>.<項目名>でアクセスします。

 

IEで認可した時のcodeを取得

参考サイトだと、ブラウザに表示されているcodeをコピー&ペーストする形でした。

良い方法がないか調べたところ、

と、IEを使えば自動化できそうでした。

そこで、

$ie = New-Object -ComObject InternetExplorer.Application
$ie.Navigate($auth_url)
$ie.Visible = $true 

$code = ""
while($true){
    $title = $ie.Document.title
    if (($title) -and ($title.Contains("Success"))) {
        $code = $title.Replace("Success code=", "")
        break
    }
    Start-Sleep -s 1
}

$ie.Quit()
[System.Runtime.Interopservices.Marshal]::ReleaseComObject($ie)
Remove-Variable ie

としました。

 

PowerShellで既存のオブジェクトにプロパティを追加

OAuth2.0で取得したトークンの内容は

{
    "access_token":  "xxx",
    "token_type":  "Bearer",
    "expires_in":  3600,
    "refresh_token":  "xxx",
}

でした。有効期限切れまでの秒数(expires_in)はあるものの、有効期限の開始日時はありませんでした。

そのため、Add-Memberを使って

$credential | Add-Member created_at (Get-Date).ToString($DATE_FORMAT) -Force

のように、$credentialオブジェクトに created_atプロパティ(有効期限の開始日時)を追加し、有効期限を判定しやすくしました。
Add-Member - TechNet

 

OAuth2.0のURLにあるパラメータの意味について

以下が参考になりました。

 

メール送信部分の作成

参考サイトだとSend-GoogleMailMessage関数になります。

処理の流れは、

  • アクセストークンを取得
  • System.Net.Mail.MailMessageクラスを使ってメールのヘッダやボディを作成
  • リフレクションで非公開メソッドを使って、MailMessageバイト列へ変換
  • System.Convert.ToBase64String()を使って、Base64エンコードした文字列を取得
  • エンドポイントへPOST

でした。

 
.NETでBase64エンコードする場合、System.Convert.ToBase64String()を使いますが、

という点が悩ましかったです。

それらを解消する方法を調べたところ、以下のC#Gmail APIを使っているサイトが参考になりました。
Sending Email with the Gmail API in .NET / C# | Jason Pettys Blog

そのサイトでは

  • System.Convert.ToBase64String()に渡しやすい、AE.Net.Mailライブラリを使う
  • System.Convert.ToBase64String()で足りない部分は、自分で変換する

と実装しており、これで良さそうでした。

 
その他で悩んだ部分は以下の通りです。

 

PowerShellモジュールの読込

今回、

  • OAuth2.0での認証(google_credential.psm1)
  • メール送信(gmail_sender.ps1)

は別のPowerShellファイルとして用意しました。そのため、gmail_sender.ps1の中でgoogle_credential.psm1を読み込む必要があります。

方法としては

$path = Join-Path . "google_credential.psm1"
Import-Module -Name $path

のように、Import-Moduleを使うのが良さそうでした。
Importing a PowerShell Module

 

AE.Net.Mailライブラリ(dll)の読込

まずは、読み込むためのAE.Net.Mail.dllファイルを取得します。

C#プロジェクトとかであればNuGetを使いますが、今回は直接ダウンロードします。

以下のページのDownloadより、最新版のae.net.mail.1.7.10.nupkgをダウンロードします。
NuGet Gallery | AE.Net.Mail

拡張子をzipに変更・展開すると、libディレクトリの中にnet40net45の2ディレクトリがありました。

PowerShellで使っているバージョンを調べると、

PS > $PSVersionTable

Name                           Value
----                           -----
PSVersion                      5.0.10586.122
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0...}
BuildVersion                   10.0.10586.122
CLRVersion                     4.0.30319.42000
WSManStackVersion              3.0
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1

より、CLRVersionが4.0でした。

そのため、net40ディレクトリのAE.Net.Mail.dllスクリプトと同じディレクトリへとコピーします。

また、インターネットからダウンロードしたdllのため、今回はdllのプロパティよりブロックの解除をしておきます。
Powershell load dll got error: Add-Type : Could not load file or assembly 'WebDriver.dll' or one of its dependencies. Operation is not supported - Stack Overflow

 
あとは、

$dll = Join-Path . "AE.NET.Mail.dll"
Add-Type -Path $dll
$msg = New-Object AE.Net.Mail.MailMessage

のように、Add-Typeを使うことで、dllに含まれるクラスが利用可能になります。
Add-Type - TechNet

 

リクエストURLのuserIdパラメータについて

参考サイトでは、OAuth認証をしたユーザのメールアドレスを、System.Net.WebUtility.UrlEncode()エンコードした値を使っていました。
WindowsアプリケーションでHTMLデコード/エンコードを行うには?[4以降、C#、VB] - @IT

ただ、ドキュメントをよく読むと、

The user's email address. The special value me can be used to indicate the authenticated user.

Users.messages: send  |  Gmail API  |  Google Developers

のように、meという値も設定可能だったため、今回はこちらを使いました。

 

Bounceメールについて

正常に送信できたにもかかわらず、Bounceメールも同時に送信されました。

原因は、Reply-Toに値を設定していなかったためです。
GMail API Emails Bouncing - Stack Overflow

 

ソースコード全体

以下の通りとなりました。

google_credential.psm1

set CREDENTIAL_FILE (Join-Path . "credential.json")
set SECRET_FILE (Join-Path . "client_id.json")
set DATE_FORMAT "yyyy/MM/dd HH:mm:ss"
set GMAIL_SCOPE "https://www.googleapis.com/auth/gmail.send"

function Save-GoogleCredential($credential){
    $credential | Add-Member created_at (Get-Date).ToString($DATE_FORMAT) -Force
    $credential_file = Join-Path . $CREDENTIAL_FILE
    $credential | ConvertTo-Json | Out-File $CREDENTIAL_FILE -Encoding utf8
}

function Get-GoogleCredential(){
    if (-not(Test-Path $SECRET_FILE)) {
        Write-Host "Not found client_id.json file"
        return $null
    }

    $json = Get-Content $SECRET_FILE -Encoding UTF8 -Raw | ConvertFrom-Json
    $auth = $json.installed

    if (Test-Path $CREDENTIAL_FILE) {
        $current_credential = Get-Content $CREDENTIAL_FILE -Encoding UTF8 -Raw | ConvertFrom-Json
        if (-not ($current_credential.access_token -and $current_credential.token_type -and $current_credential.expires_in `
                  -and $current_credential.refresh_token -and $current_credential.created_at))
        {
            Write-Host "No credential file: $($CREDENTIAL_FILE)"
            return $null
        }

        $elapsed_seconds = ((Get-Date) - [DateTime]::ParseExact($current_credential.created_at, $DATE_FORMAT, $null)).TotalSeconds
        if ($elapsed_seconds -lt $current_credential.expires_in ) {
            Write-Host "Reuse access token..."
            return $current_credential
        }
        else{
            Write-Host "Refresh access token..."

            $refresh_body = @{
                "refresh_token" = $current_credential.refresh_token;
                "client_id" = $auth.client_id;
                "client_secret" = $auth.client_secret;
                "grant_type" = "refresh_token";
            }

            try {
                $refreshed_credential = Invoke-RestMethod -Method Post -Uri $auth.token_uri -Body $refresh_body
            }
            catch [System.Exception] {
                Write-Host $Error
                return $null
            }
            
            Save-GoogleCredential $refreshed_credential
            return $refreshed_credential
        }
    }

    Write-Host "New access token..."

    $gmail_scope = "https://www.googleapis.com/auth/gmail.send"
    $auth_url = "$($auth.auth_uri)?scope=$($GMAIL_SCOPE)"
    $auth_url += "&redirect_uri=$($auth.redirect_uris[0])"
    $auth_url += "&client_id=$($auth.client_id)"
    $auth_url += "&response_type=code&approval_prompt=force&access_type=offline"

    $ie = New-Object -ComObject InternetExplorer.Application
    $ie.Navigate($auth_url)
    $ie.Visible = $true 

    $code = ""
    while($true){
        $title = $ie.Document.title
        if (($title) -and ($title.Contains("Success"))) {
            $code = $title.Replace("Success code=", "")
            break
        }
        Start-Sleep -s 1
    }

    $ie.Quit()
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($ie)
    Remove-Variable ie

    try {
        $new_body = @{
            "client_id" = $auth.client_id;
            "client_secret" = $auth.client_secret;
            "redirect_uri" = $auth.redirect_uris[0];
            "grant_type" = "authorization_code";
            "code" = $code;
        }
        $new_credential = Invoke-RestMethod -Method Post -Uri $auth.token_uri -Body $new_body
    }
    catch [System.Exception] {
        Write-Host $Error
    }
    Save-GoogleCredential $new_credential
    return $new_credential
}

Export-ModuleMemberFunction Get-GoogleCredential

 
gmail_sender.ps1

function ConvertTo-Base64Url($str){
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($str)
    $b64str = [System.Convert]::ToBase64String($bytes)
    $without_plus = $b64str -replace '\+', '-'
    $without_slash = $without_plus -replace '/', '_'
    $without_equal = $without_slash -replace '=', ''

    return $without_equal
}

function Send-Gmail(){
    $mail = @{
        from = "example@gmail.com";
        to = "example+to@gmail.com";
    }

    $path = Join-Path . "google_credential.psm1"
    Import-Module -Name $path
    $credential = Get-GoogleCredential
    Write-Host $credential

    if (-not $credential){
        Write-Host "Not Authenticated."
        return
    }

    $dll = Join-Path . "AE.NET.Mail.dll"
    Add-Type -Path $dll
    $msg = New-Object AE.Net.Mail.MailMessage

    $from = New-Object System.Net.Mail.MailAddress $mail["from"]
    $msg.From = $from
    $to = New-Object System.Net.Mail.MailAddress $mail["to"]
    $msg.To.Add($to)
    $msg.ReplyTo.Add($from)

    $msg.Subject = "gmail api subject"
    $msg.Body = "body: ハロー Gmail API!"

    $sw = New-Object System.IO.StringWriter
    $msg.Save($sw)
    $raw = ConvertTo-Base64Url $sw.ToString()
    $body = @{ "raw" = $raw; } | ConvertTo-Json

    $user_id = "me"
    $uri = "https://www.googleapis.com/gmail/v1/users/$($user_id)/messages/send?access_token=$($credential.access_token)"

    try {
        $result = Invoke-RestMethod $uri -Method POST -ErrorAction Stop -Body $body -ContentType "application/json"
    }
    catch [System.Exception] {
        Write-Host $Error
        return
    }
    Write-Host $result
}


# エントリポイント
Send-Gmail

 
また、同じ内容でGitHubにも上げてあります。自分の理解用なので、いくつかコメントが入っています。
PowerShell_misc/gmail_api at master · thinkAmi/PowerShell_misc

*1:元はドイツ語ですが、英語に翻訳するとだいぶ見やすくなります