PowerShellでOAuthしてGmailAPIを叩く
 Author: 水卜

背景

https://thinkami.hatenablog.com/entry/2016/07/14/063045
こちらはpowershellでOAuth + Gmail APIの実行を行っているすんばらしい記事。
僕もどうあってもpowershellからGmail APIを叩かないといけない状況になったので、恐縮ながら人様のコードに以下の改修を施していきます。

  • タイトルの文字化けを直す
  • CC/BCC対応
  • 添付ファイル送れるようにする
  • refresh_tokenを持ててないときがある
  • パラメータとしてメールの情報を渡せるようにする

タイトルが文字化けする

$subject_encoded = ConvertTo-Base64Url "たいとる"
$msg.Subject = "=?utf-8?B?$($subject_encoded)?="

これで日本語も文字化けしない。
=?utf-8?B?<文字>?=
で文字を囲むと、この中の文字はutf-8でbase64デコードするものと解釈してくれる。

CC/BCC対応

こちらは簡単

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

refresh_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
                $refreshed_credential | Add-Member refresh_token $refresh_body.refresh_token -Force
            }

元記事様ではトークンをリフレッシュした後、戻ってきた認証情報をそのままcredential.jsonとして上書き保存している。
しかしここで戻ってきた認証情報にrefresh_tokenは含まれていない。
なのでもともと持っていたrefresh_tokenをリフレッシュ後の認証情報にAdd-Memberする。

添付ファイル送れるようにする

    if (!($atts[0] -eq "")) {
        foreach($att in $atts){
            $file_path = $att
            Write-Host $file_path
            $file_name = [System.IO.Path]::GetFileName($file_path)
            $file_bytes = [System.IO.File]::ReadAllBytes($file_path)
            $file_mime = "application/octet-stream"
            $attachment = New-Object AE.Net.Mail.Attachment($file_bytes, $file_mime, $file_name)
            $attachment.Headers.Add("Content-Disposition", "inline; filename=$($file_name)")
            $msg.Attachments.Add($attachment) 
        }
    }

添付ファイルのパスを配列として渡すようにしました。

パラメータとしてメール内容を渡せるようにする

これも簡単

Param(
    [parameter(mandatory=$true)][string]$from,
    [parameter(mandatory=$true)][string]$to,
    [string]$cc,
    [string]$bcc,
    [string]$title,
    [string]$body,
    [array]$atts
)

ソースコード

出来たコードはこちら
gmail_sender.ps1

Param(
    [parameter(mandatory=$true)][string]$from,
    [parameter(mandatory=$true)][string]$to,
    [string]$cc,
    [string]$bcc,
    [string]$title,
    [string]$body,
    [array]$atts
)

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($from, $to, $cc, $bcc, $title, $body, $atts){
    $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

    if (!([string]::IsNullOrEmpty($cc))) {
        $mail_cc = New-Object System.Net.Mail.MailAddress $cc
        $msg.Cc.Add($mail_cc)
    }
    if (!([string]::IsNullOrEmpty($bcc))) {
        $mail_bcc = New-Object System.Net.Mail.MailAddress $bcc
        $msg.Bcc.Add($mail_bcc)
    }
    Write-Host $from
    $mail_from = New-Object System.Net.Mail.MailAddress $from
    $mail_to = New-Object System.Net.Mail.MailAddress $to
    $msg.From = $mail_from
    $msg.To.Add($mail_to)

    Write-Host "Atts length: $($atts.length)"
    Write-Host "Atts: $($atts)"
    if (!($atts[0] -eq "")) {
        foreach($att in $atts){
            $file_path = $att
            Write-Host $file_path
            $file_name = [System.IO.Path]::GetFileName($file_path)
            $file_bytes = [System.IO.File]::ReadAllBytes($file_path)
            $file_mime = "application/octet-stream"
            $attachment = New-Object AE.Net.Mail.Attachment($file_bytes, $file_mime, $file_name)
            $attachment.Headers.Add("Content-Disposition", "inline; filename=$($file_name)")
            $msg.Attachments.Add($attachment) 
        }
    }

    $subject_encoded = ConvertTo-Base64Url $title
    $msg.Subject = "=?utf-8?B?$($subject_encoded)?="
    $msg.Body = $body

    $sw = New-Object System.IO.StringWriter
    $msg.Save($sw)
    $raw = ConvertTo-Base64Url $sw.ToString()
    $body = @{ "raw" = $raw; } | ConvertTo-Json
    Write-Host $sw.ToString()
    $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
}

echo "$from, $to, $cc, $bcc, $title, $body, $atts"
$atts_arr = $atts -split "," 
Send-Gmail $from $to $cc $bcc $title $body $atts_arr

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
        Write-Host $current_credential.access_token
        Write-Host $current_credential.token_type
        Write-Host $current_credential.expires_in
        Write-Host $current_credential.refresh_token
        Write-Host $current_credential.created_at
        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
                $refreshed_credential | Add-Member refresh_token $refresh_body.refresh_token -Force
            }
            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"
    Start-Process $auth_url
    $code = Read-Host "ブラウザに表示されている認証コードを入力してください。"
    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