📧

Go で Gmail の未読メールを一括で既読に変更する

2024/04/12に公開

はじめに

はじめまして、バニッシュ・スタンダードの muship です。
以前所属していた SRE チームでは運用保守時に発生する手作業のシステム化を進めたりしてました。
ただ自分自身あまり自動化やシステム化という視点を持っていなかったので、Go の練習も兼ねてまず自身の作業を対象としてやってみました。

やりたいこと

「Gmail に溜まってる未読メールを一括で既読に変更したい」

個人的に未読メールが溜まってるのが好きじゃなく、定期的にボックスを確認しては既読にするを実行してます。
作業自体は大したことないにしろ毎日手作業でやってるのがめんどくさくなり、幸い自分宛のメールボックスのほとんどはプロモーションやメルマガだったのでこれらを一括で既読にしちゃおうと考えました。
Gmailのフィルタ機能を使えば似たようなことは実現可能ですが、結構簡単に作れそうなのでやってみました。

準備

Gmail API を使用するには Google Cloud Console での設定が必要なのでドキュメントに沿って登録します。

https://developers.google.com/gmail/api/quickstart/go?hl=ja

実装

以下のコードが全体像です。

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"
	"google.golang.org/api/gmail/v1"
	"google.golang.org/api/option"
	"log"
	"net/http"
	"os"
)

const (
	USER = "me"
)

func getClient(config *oauth2.Config) *http.Client {
	tokFile := "token.json"
	tok, err := tokenFromFile(tokFile)
	if err != nil {
		tok = getTokenFromWeb(config)
		saveToken(tokFile, tok)
	}
	return config.Client(context.Background(), tok)
}

func getTokenFromWeb(config *oauth2.Config) *oauth2.Token {
	authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)

	fmt.Printf("Go to the following link in your browser then type the "+
		"authorization code: \n%v\n", authURL)

	var authCode string
	if _, err := fmt.Scan(&authCode); err != nil {
		log.Fatalf("Unable to read authorization code: %v", err)
	}

	tok, err := config.Exchange(context.TODO(), authCode)
	if err != nil {
		log.Fatalf("Unable to retrieve token from web: %v", err)
	}
	return tok
}

func tokenFromFile(file string) (*oauth2.Token, error) {
	f, err := os.Open(file)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	tok := &oauth2.Token{}
	err = json.NewDecoder(f).Decode(tok)
	return tok, err
}

func saveToken(path string, token *oauth2.Token) {
	fmt.Printf("Saving credential file to: %s\n", path)
	f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
	if err != nil {
		log.Fatalf("Unable to cache oauth token: %v", err)
	}
	defer f.Close()
	json.NewEncoder(f).Encode(token)
}

func main() {
	ctx := context.Background()
	b, err := os.ReadFile("credentials.json")
	if err != nil {
		log.Fatalf("Unable to read client secret file: %v", err)
	}

	config, err := google.ConfigFromJSON(b, gmail.MailGoogleComScope)
	if err != nil {
		log.Fatalf("Unable to parse client secret file to config: %v", err)
	}
	client := getClient(config)

	srv, err := gmail.NewService(ctx, option.WithHTTPClient(client))
	if err != nil {
		log.Fatalf("Unable to retrieve Gmail client: %v", err)
	}

	ml, err := srv.Users.Messages.List(USER).Q("is:unread -is:important").Do()
	if err != nil {
		log.Fatalf("Failed to Retrieve Messages: %v", err)
	}
	if len(ml.Messages) == 0 {
		fmt.Println("Exiting because the message was not found.")
		return
	}

	var messageIdList []string
	for _, l := range ml.Messages {
		messageIdList = append(messageIdList, l.Id)
	}
	request := gmail.BatchModifyMessagesRequest{
		Ids:            messageIdList,
		RemoveLabelIds: []string{"UNREAD"},
	}
	err = srv.Users.Messages.BatchModify(USER, &request).Do()
	if err != nil {
		log.Fatalf("Failed to Update Messages: %v", err)
	}
	fmt.Println("Bulk update has been completed.")
}

解説

認証周り

アクセストークンを管理する token.json を作成する必要があります。

準備時に作成した credentials.json をもとに認証画面用の URL が作成されターミナルに表示されます。
URLにアクセスしユーザーのアクセスを許可すると localhost へリダイレクトされるので、URL から {hoge}の部分をコピーしてターミナルに貼り付ければ OK です。

http://localhost:3000/?state=state-token&code={hoge}&scope=https://mail.google.com/`
https://developers.google.com/gmail/api/quickstart/go?hl=ja#authorize_credentials_for_a_desktop_application

貼り付け後、ローカルにtoken.json ファイルが作成されます。
次回実行からこちらが参照されるようになるので有効期限が切れてない内はユーザー操作による認証がスキップできます。

条件に一致するメールの取得

今回は条件として未読かつ重要フラグがついていないメールを対象としてます。

重要フラグは Google が過去のユーザー操作やメールをチェックしてユーザーにとって重要かを判断してくれてるため、誤って重要なメールを既読に変更するのを防ぐために条件に加えました。

ml, err := srv.Users.Messages.List(USER).Q("is:unread -is:important").Do()

更新

未読メールには UNREAD ラベルが付与されているため、取得したメールIDに対してラベルを削除してあげることで既読に切り替えられます。

request := gmail.BatchModifyMessagesRequest{
Ids:            messageIdList,
RemoveLabelIds: []string{"UNREAD"},
}
err = srv.Users.Messages.BatchModify(USER, &request).Do()

https://developers.google.com/gmail/api/guides/labels?hl=ja#types_of_labels

おわりに

数日動かしてみた結果まだまだ改善点はありそうです。

  • 重要フラグがついていても更新対象となるメールが多いので、フィルタリング精度をあげる
  • ローカルの crontab で定期実行しているのをクラウドに移行したい
  • token の有効期限が切れた場合の更新処理の追加

1つの作業を自動化するだけでもこれだけ考えないといけないことがあるのは大変ですが、作業負担が軽減していく過程を体験していくのも面白かったのでこれからも色々作っていこうと思います。

Discussion