以前、PowerShellを使ってSMTPでGmailを送信したことがありました。
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
目次
環境
- Windows10 x64
- PowerShell v5.0
- AE.Net.Mail 1.7.10
- andyedinborough/aenetmail: C# POP/IMAP Mail Client
- 理由は後述しますが、標準の
System.Net.Mail.MailMessage
だと扱いづらい部分があったため、この外部ライブラリを使いました
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. 必要な認証情報の種類を調べる
で、以下を選択し、必要な認証情報
をクリック2.OAuth 2.0 クライアント ID を作成する
- 名前: 任意 (
MyPowerShellGmailSender
など) クライアントIDの作成
をクリック
- 名前: 任意 (
3.OAuth 2.0 同意画面を設定する
- メールアドレス: 自分のメールアドレス
- ユーザーに表示するサービス名: 任意(
MyPowerShellGmailSender Auth
など) 次へ
をクリック
4.認証情報をダウンロードする
client_id.json
ファイルを、PowerShell用スクリプトと同じディレクトリへ置く完了
をクリック
OAuth2.0での認証部分の作成
参考サイトだと、Auth-Google
関数の部分になります。
処理の流れは
- 未認証の場合、ブラウザ経由で認証し、トークンを取得
- 認証済&有効期限内の場合、トークンを再利用
- 認証済&有効期間超過の場合、トークンをリフレッシュ
でした。以下の記事と同じ流れでした。
Google API OAuth2.0のアクセストークン&リフレッシュトークン取得手順メモ - Qiita
また、参考サイトではトークンなどはグローバル変数に入れていましたが、今回はPython版のgoogle-api-python-client
に合わせて、ローカルのJSONファイルとして保存することにします。
実装した時に悩んだ部分は以下の通りです。
JSONファイルの読み書き
ConvertFrom-Json
やConvertTo-Json
を使います。
PowerShell で JSON をファイル入出力 する - tech.guitarrapc.cóm
なお、読み込んだJSONオブジェクトの各項目は
$json = Get-Content $SECRET_FILE -Encoding UTF8 -Raw | ConvertFrom-Json $auth = $json.installed
のように、<オブジェクト名>.<項目名>
でアクセスします。
IEで認可した時のcodeを取得
参考サイトだと、ブラウザに表示されているcode
をコピー&ペーストする形でした。
良い方法がないか調べたところ、
- PowerShellからCOM経由でIEを操作可能
- codeはWebページの
title
に設定され、IEで取得可能
と、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にあるパラメータの意味について
以下が参考になりました。
- response_type
- approval_promptとaccess_type
- Google OAuth2 Web Server Profileでのリフレッシュトークン
- リフレッシュトークンが必要な場合には、この2つのパラメータを設定する
メール送信部分の作成
参考サイトだとSend-GoogleMailMessage
関数になります。
処理の流れは、
- アクセストークンを取得
System.Net.Mail.MailMessage
クラスを使ってメールのヘッダやボディを作成- リフレクションで非公開メソッドを使って、
MailMessage
をバイト列へ変換 System.Convert.ToBase64String()
を使って、Base64エンコードした文字列を取得- エンドポイントへPOST
でした。
.NETでBase64エンコードする場合、System.Convert.ToBase64String()
を使いますが、
- メソッドの引数がバイト列のため、
System.Net.Mail.MailMessage
をそのまま渡せない - バイト列にするために、参考サイトではリフレクションで非公開メソッドを使っている
- URLセーフなBase64ではないため、Gmail APIの仕様を満たせない
という点が悩ましかったです。
それらを解消する方法を調べたところ、以下の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モジュールの読込
今回、
は別の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
ディレクトリの中にnet40
とnet45
の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.
のように、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-ModuleMember –Function 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:元はドイツ語ですが、英語に翻訳するとだいぶ見やすくなります