Golang + Google Workspace (G Suite) + Gmail API + 独自ドメインでサーバーからメールを送る

やりたいこと

Google Workspace を使うことで独自ドメインで Gmail を使えるようになったので、
Gmail API を使ってサーバーから独自ドメインでメールを送る。
言語は Go 言語で書く。

最初に

色々試して、そしてものすごくハマったので、
やりたいことに対して必要じゃない作業がある可能性がある。

丸2日「googleapi: Error 400: Precondition check failed., failedPrecondition」というエラーから永遠に抜け出せなかった。

使用するサービスと予めやっておくこと

本記事ではドメインを psychedelicnekopunch.com で進める

  • AWS Route 53
  • Google Admin (Google Workspace)
    • ユーザーの作成
      • system@psychedelicnekopunch.com
      • customer@psychedelicnekopunch.com
      • noreply@psychedelicnekopunch.com
  • Google Cloud Platform
    • system@psychedelicnekopunch.com で進める

Google Cloud Platform

プロジェクトを作成する

  • 右上「」クリック → 左メニュー「 IAM と管理」 → 「リソースの管理」
  • 「プロジェクトを作成」
  • 「プロジェクト名」は適当につける
  • 作成したプロジェクトに移動

IAM でユーザー権限を設定する

  • 追加」
  • Google Admin で作成したユーザーを追加
    • system@xxx
      • 「ロールを選択」 → 「Project」 → 「オーナー」
    • customer@xxx 、 noreply@xxx
      • 「ロールを選択」 → 「Project」 → 「閲覧者」

Gmail を有効にする

  • 右上「」クリック → 左メニュー「 API とサービス」 → 「ライブラリ」
    • API とサービス検索」 → 「gmail」と入力
      • 「Gmail API」が出てくるのでクリック
        • 「有効にする」をクリック

OAuth 同意画面の設定

  • 右上「」クリック → 左メニュー「 API とサービス」 → 「 OAuth 同意画面」
    • 「内部」
      • アプリ名: 適当に
      • ユーザーサポートメール: customer@psychedelicnekopunch.com
      • 承認済みドメイン: ← 本記事ではやらなくていいと思う
      • デベロッパーの連絡先情報: system@psychedelicnekopunch.com
      • 「保存して次へ」
    • スコープ
      • 「スコープの追加または削除」 → 「スコープの手動追加」

サービスアカウントを作成する

  • 右上「」クリック → 左メニュー「 IAM と管理」 → 「サービスアカウント」
    • サービスアカウントを追加」
  • 1 サービス アカウントの詳細
    • サービスアカウント名: 適当につける
    • 「作成」
  • 以下省略してもいいかも
    • 2 このサービス アカウントにプロジェクトへのアクセスを許可する (省略可)
      • 「ロールを選択」 → 「Project」 → 「閲覧者」
      • 「続行」
    • 3 ユーザーにこのサービス アカウントへのアクセスを許可 (省略可)
      • サービスアカウントユーザーロール
        • noreply@psychedelicnekopunch.com
      • サービスアカウント管理者ロール
        • system@psychedelicnekopunch.com
      • 「完了」

サービスアカウントの詳細を編集する

  • 上部「 編集」クリック
  • 一意の ID (クライアント ID) をコピーしておく
    • Google Admin で使う
  • サービス アカウントのステータス
    • 「ドメイン全体の委任を表示」
      • 「G Suite ドメイン全体の委任を有効にする」にチェック
  • キー
    • 「鍵を追加」 → 「新しい鍵を作成」 → キーのタイプ「JSON」 → 「作成」
      • json ファイルがダウンロードされるので、この json ファイルをサーバーにアップロードしておく。
  • 最後に「保存」を忘れずに

Google Admin (Google Workspace)

サービスアカウントのクライアント ID の登録

  • 右上「」クリック → 左メニュー「セキュリティ」 → 「API の制御」
    • 「ドメイン全体の委任」 → 「ドメイン全体の委任を管理」
      • API クライアント → 「新しく追加」
        • クライアント ID : Google Cloud Platform でコピーしたサービスアカウントの「一意の ID (クライアント ID)」 をペースト
        • OAuth スコープ: 「https://mail.google.com/」
        • 「承認」

Golang で書く Gmail API を使用したサンプルコード

アップロードした JSON ファイルを使って認証された gmail.Service 型を取得して、
各 API を呼び出すんだけど、 google.golang.org/api/gmail/v1 に書いてあるサンプルコードでの認証方法がどれも「googleapi: Error 400: Precondition check failed., failedPrecondition」エラーが出て動かなかった。

Go + GmailAPIでサービスアカウントからメールを送信する の認証方法でうまくいった。

ファイル構成

// /var/www/html/psychedelicnekopunch/go-sample

/infrastructure
	├ Config.go
	└ Gmail.go

main.go
psychedelicnekopunch-xxxxxxxxxx.json // サービスアカウントで生成した JSON ファイル

infrastructure/Config.go

package infrastructure

type Config struct {
	AbsolutePath string
	Environment string
}

func NewConfig() *Config {

	c := new(Config)

	c.AbsolutePath = "/var/www/html/psychedelicnekopunch/go-sample"

	// production, staging, development
	c.Environment = "production"

	return c
}

infrastructure/Gmail.go

package infrastructure

import (
	"bytes"
	"encoding/base64"
	"fmt"
	"io/ioutil"
	"net/mail"
	"net/smtp"
	"strings"

	"golang.org/x/net/context"
	"golang.org/x/oauth2/google"
	"google.golang.org/api/option"
	"google.golang.org/api/gmail/v1"
)


type GMail struct {
	Environment string
	From mail.Address
	Service *gmail.Service
}


func NewGMail() *GMail {
	c := NewConfig()
	return newGMail(c.Environment, c)
}


func newGMail(environment string, c *Config) *GMail {

	from := mail.Address{ Name: "サイケデリックねこパンチ", Address: "noreply@psychedelicnekopunch.com" }

	// アップロードした JSON ファイル
	json, err := ioutil.ReadFile(c.AbsolutePath + "/psychedelicnekopunch-xxxxxxxxxx.json")
	if err != nil {
		panic(err)
	}

	config, err := google.JWTConfigFromJSON(json, "https://mail.google.com/")
	if err != nil {
		panic(err)
	}

	config.Subject = from.Address
	ctx := context.Background()
	tokenSource := config.TokenSource(ctx)

	service, err := gmail.NewService(ctx, option.WithTokenSource(tokenSource))
	if err != nil {
		panic(err)
	}

	return &GMail{
		Environment: environment,
		From: from,
		Service: service,
	}
}


func (m *GMail) writeString(b *bytes.Buffer, s string) *bytes.Buffer {
	_, err := b.WriteString(s)
	if err != nil {
		fmt.Print(err.Error())
	}
	return b
}


/**
 * go で utf8 メールを送信
 * https://qiita.com/yamasaki-masahide/items/a9f8b43eeeaddbfb6b44
 *
 * サンプルコードほぼ丸パクリ
 */

// サブジェクトを MIME エンコードする
func (m *GMail) encodeSubject(subject string) string {
	// UTF8 文字列を指定文字数で分割する
	b := bytes.NewBuffer([]byte(""))
	strs := []string{}
	length := 13
	for k, c := range strings.Split(subject, "") {
		b.WriteString(c)
		if k%length == length-1 {
			strs = append(strs, b.String())
			b.Reset()
		}
	}
	if b.Len() > 0 {
		strs = append(strs, b.String())
	}
	// MIME エンコードする
	b2 := bytes.NewBuffer([]byte(""))
	b2.WriteString("Subject:")
	for _, line := range strs {
		b2.WriteString(" =?utf-8?B?")
		b2.WriteString(base64.StdEncoding.EncodeToString([]byte(line)))
		b2.WriteString("?=\r\n")
	}
	return b2.String()
}


// 本文を 76 バイト毎に CRLF を挿入して返す
func (m *GMail) encodeBody(body string) string {
	b := bytes.NewBufferString(body)
	s := base64.StdEncoding.EncodeToString(b.Bytes())
	b2 := bytes.NewBuffer([]byte(""))
	for k, c := range strings.Split(s, "") {
		b2.WriteString(c)
		if k % 76 == 75 {
			b2.WriteString("\r\n")
		}
	}
	return b2.String()

}


/**
 * メールを送る
 */
func (m *GMail) Send(to string, subject string, body string) (err error) {

	msg := bytes.NewBuffer([]byte(""))
	msg = m.writeString(msg, "From: " + m.From.String() + "\r\n")
	msg = m.writeString(msg, "To: " + to + "\r\n")
	// msg = m.writeString(msg, "Bcc: " + m.From.String() + "\r\n")
	msg = m.writeString(msg, m.encodeSubject(subject))
	msg = m.writeString(msg, "MIME-Version: 1.0\r\n")
	msg = m.writeString(msg, "Content-Type: text/plain; charset=\"utf-8\"\r\n")
	msg = m.writeString(msg, "Content-Transfer-Encoding: base64\r\n")
	msg = m.writeString(msg, "\r\n")
	msg = m.writeString(msg, m.encodeBody(body))

	message := new(gmail.Message)
	message.Raw = base64.StdEncoding.EncodeToString(msg.Bytes())

	_, err = m.Service.Users.Messages.Send("me", message).Do()
	return err
}

main.go

package main

import (
	"fmt"
	"github.com/psychedelicnekopunch/go-sample/infrastructure"
)

func main() {

	mail := infrastructure.NewGMail()
	msg := `0123456789
メール
送信
TEST

寿限無(じゅげむ) 寿限無(じゅげむ) 五劫(ごこう)のすりきれ 海砂利(かいじゃり)水魚(すいぎょ)の水行末(すいぎょうまつ) 雲来末(うんらいまつ) 風来末(ふうらいまつ) 食(く)う寝(ね)るところに 住(す)むところ やぶらこうじの ぶらこうじ パイポ パイポ パイポの シューリンガン シューリンガンの グーリンダイ グーリンダイの ポンポコピーのポンポコナの 長久命(ちょうきゅうめい)の長助(ちょうすけ) パブロ・ディエゴ・ホセ・フランシスコ・デ・パウラ・ホアン・ネポムセーノ・マリア・デ・ロス・レメディオス・クリスピーン・クリスピアーノ・デ・ラ・サンティシマ・トリニダード・ルイス・イ・ピカソ`

	// mail.Send(送信先メールアドレス, 件名, 本文)
	err := mail.Send("info@sample.com", "メール送信 TEST 1234", msg)
	if err != nil {
		fmt.Print(err.Error())
		return
	}
	fmt.Print("success")
}