はじめに
【2022年末に投稿した記事が乱雑だったので、大幅に手直ししました。】
本記事は、放置しすぎてWebでは手に負えなくなった大量の Gmail を IMAP 経由で大掃除した手順を備忘録としてまとめたものです。
概要
- ツールには F# Interactive と S22.Imap.Core を使う。
- Gmail の仕様上、POP では目的達成が困難。IMAP で接続する。
- ヘッダの読み取りには時間がかかるので、クライアントを複数用意して並列で処理する。
- サーバーから一定期間(ここでは1年分)のヘッダを読み取り、UID(ユニークID)・件名・アドレス・表示名・日時 に絞ったリストを作成する。
- 条件に合うメールを IMAP クライアントから Gmail 上の「ラベル」で整理する。
- その他、リストを JSON で保存や復元したり、Excel にペーストしたりしてみる。
動作確認環境
OS : Windows 11 Pro(Ver. 22H2)
.NET SDK : Ver. 7.0.101
F# Interactive : F# 7.0(12.4.0.0)
準備
- Gmail の設定から IMAP を有効にする。
- Googleアカウントのアプリパスワードを取得する。
IMAP 用ライブラリの選定
nuget で IMAP のライブラリを検索し、使いやすそうな S22.Imap.Core を採用しました。
公式ドキュメントはここから。
作業
【以降、Gmail の操作については自己責任でお願いいたします。】
早速作業に取りかかります。
1)F# Interactive (fsi) の起動とライブラリ等の準備
dotnet fsi
で F# Interactive を起動。(.NET SDK のインストールが必要。)
S22.Imap.Core の他にクリップボード用のライブラリ TextCopy も使用します。これで、メール一覧の確認に Excel を使うことができます。詳細は後述します。
さらに、正規表現や JSON への変換も準備しておきます。
#r "nuget: S22.Imap.Core"
open S22.Imap
#r "nuget: TextCopy"
open TextCopy
open System
open System.Net.Mail
open System.Text.Json
open System.Text.RegularExpressions
// 実行時の画面出力抑制(任意)
fsi.ShowDeclarationValues <- false
;;
(注意)
.NET Core や .NET 5 以降では、既定でサポートされるエンコード方式が限定されており、日本語メールでよく使われる ISO-2022-JP などは対応していません。
.NET SDK のインストール後、次の1行を一度実行しておけば、システムは ISO-2022-JP を含む多くのエンコード方式に対応するようになります。
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);;
2)クライアントの作成と接続
まず、IMAP クライアントを作成する関数createClient
を用意します。
呼び出しと同時に接続からログインまで完了します。
let createClient () : ImapClient =
new ImapClient("imap.gmail.com", 993, "(Gmail アドレス)", "(アプリパスワード)", AuthMethod.Login, true)
;;
3)Gmail のラベル操作
メール操作に入る前に、メールを整理するための「ラベル」について確認します。
<ラベル一覧の表示>
ImapClient.ListMailboxes
メソッドは Gmail の「ラベル」にも対応します。
// ラベル一覧の表示
let client: ImapClient = createClient()
client.ListMailboxes() |> Seq.iter (printfn "%s")
client.Logout()
client.Dispose()
;;
「INBOX」 は Gmail 上の 「受信トレイ」 に対応しますが、実際はラベルとして扱われており、他のラベルと同じように操作できます。
「受信トレイ」のラベルを外したものを Gmail では 「アーカイブ」 として扱っています。
以後、操作対象のメールは「INBOX(受信トレイ)」にあることを前提にします。
"[Gmail]/~" は「ラベル」とは別物で核となる領域なので、外部からの操作は避け、自分が用意したラベルでメールを整理します。
<「アーカイブ」されたメールについて>
安全のため「アーカイブ」されたメールは操作対象にしません。
必要ならば Gmail アプリ上で検索し、必要に応じて手動で受信トレイに移動しておきます。
Gmail の検索ボックスで
after:2020/1/1 before:2021/1/1 -in:spam -in:trash -is:sent -in:drafts -in:inbox
と検索すれば、2020年のメールでアーカイブされたものが確認できます。
スマホで無意識にアーカイブしてしまったメールも見つかったりします。
<ラベルの作成>
ImapClient.CreateMailbox
メソッドがラベル作成に対応します。
また、ラベル名を "/" で連結することにより階層化できます。
// ラベルの新規作成テスト
let client: ImapClient = createClient()
client.CreateMailbox("TEST")
client.CreateMailbox("TEST/Test1")
client.CreateMailbox("TEST/Test1/test11")
client.CreateMailbox("テスト")
client.Logout()
client.Dispose()
;;
ラベル名の表示は大文字・小文字を反映していますが、内部では区別していないようです。
4)メールの検索とUID(ユニークID)の取得
ImapClient.Search
メソッドを使ってメールを検索します。
このメソッドは mailbox オプションでラベル名を渡すこともできます。
省略すると「INBOX(受信トレイ)」に対して検索します。
多様な検索条件を指示することができますが、検索結果は UID のコレクションとして返されます。
この UID を使ってメールを移動させます。
ここでは、指定した年に受信した(正確にはその年に自分宛に送信された)メールの UID を取得するための関数getUidsInYear
を作成します。
let getUidsInYear (yyyy: int) : seq<uint> =
let dateSince: DateTime = DateTime(yyyy, 1, 1)
let dateBefore: DateTime = DateTime(yyyy + 1, 1, 1)
let client: ImapClient = createClient()
let uids: seq<uint> =
client.Search(
SearchCondition
.SentSince(dateSince)
.And(SearchCondition.SentBefore(dateBefore))
)
client.Logout()
client.Dispose()
uids
;;
関数の動作確認も兼ねて「受信トレイ」に残っている2021年と2022年の受信メール件数を確認してみます。
量にもよりますが UID は数秒で取得できます。(約12,000通のケースで4秒程度でした。)
2021 |> getUidsInYear |> Seq.length;;
2022 |> getUidsInYear |> Seq.length;;
5)ヘッダの取得とメール一覧の作成
<メール一覧の作成準備>
ImapClient.GetMessages
メソッドに UID のコレクションを渡すと個々のメールヘッダのコレクションを受け取ることができます。
サーバーから受け取るメールヘッダの形式はSystem.Net.Mail.MailMessage
です。
メールの整理に必要な項目( UID・件名・アドレス・表示名・日時 )をtype myHeader
として記述し、サーバーからのデータを関数toMyHeader
で加工します。
なお、試行段階で 件名(subject)の末尾にタブ文字がついているメールが複数見つかったのでString.Trim
で余分な空白文字を消しています。
type myHeader =
{ uid: uint
subject: string
address: string
displayname: string
date: DateTime }
let toMyHeader (uid: uint) (header: MailMessage) : myHeader =
{ uid = uid
subject = header.Subject.Trim()
address = header.From.Address
displayname = header.From.DisplayName
date = header.Headers["Date"] |> DateTime.Parse }
;;
<ヘッダの取得>
ヘッダ処理関数ができたので、サーバーからヘッダを取得しますが、UID の取得よりもかなり長い時間を要します。
試行段階では1つのクライアントで接続し、約7,100件のヘッダを読み取った時点でホストからタイムアウトで切断されました。(時間を測定しなかったのだが、おそらく1時間程?)
そこで、複数クライアントで同時接続し、並行して読むことにします。
ということで、非同期で動作するようにヘッダ取得関数getMyHeaders
を書きます。
UID のコレクションを渡して、myHeader
のリストを返します。
let getMyHeaders (uids: seq<uint>) : Async<myHeader list> =
async {
let client: ImapClient = createClient()
let headers: seq<MailMessage> =
client.GetMessages(uids, FetchOptions.HeadersOnly, false)
client.Logout()
client.Dispose()
return
(uids, headers)
||> Seq.map2 toMyHeader
|> Seq.toList
}
;;
部分的な関数ができあがったので、指定した年のメール一覧を返す関数headersInYear
にまとめます。
F# の場合Async.Parallel
~Async.RunSynchronously
で簡単に複数クライアントの同時接続ができます。
let headersInYear (yyyy: int) =
getUidsInYear yyyy
|> Seq.splitInto 10
|> Seq.map getMyHeaders
|> Async.Parallel
|> Async.RunSynchronously
|> List.concat
;;
// 実行してみる
let headers = headersInYear 2020;;
上記コードのとおり
- 指定した年の UID を取得
- UID を10グループに分割
- 各グループが並行して一覧を作成し
- 結合して返す
という流れです。
クライアントの数は10にしました。公式には同時接続数の上限が15とされていますが、10を超えるとエラーを返す回数が増えたように感じました。
10クライアントでは比較的安定して動きましたが、サーバーの状態や回線の影響で、たまに時間がかかったりエラーを吐くこともあります。その点はご理解ください。
下が実行画面です。3分半で8,635件のヘッダを処理し、メール一覧headers
を作成しました。
6)メール一覧を JSON ファイルにする
メール一覧をファイルに保存できた方が便利なので、JSON 形式で保存できるようにします。
// メール一覧をJSONファイルに保存
let headersToJson (path: string) (headers: myHeader list) =
let serializerOptions: JsonSerializerOptions =
new JsonSerializerOptions(
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
WriteIndented = true
)
let serializer: string = JsonSerializer.Serialize(headers, serializerOptions)
System.IO.File.WriteAllText(path, serializer)
// JSONファイルから読み込み
let headersFromJson (path: string) : myHeader list =
System.IO.File.ReadAllText path
|> JsonSerializer.Deserialize<myHeader list>
;;
先ほどのheaders
をデスクトップに読み書きする例です。
let fileName = @"C:\Users\User\Desktop\gmail_2020.json";;
// ファイルに保存
headers |> headersToJson fileName;;
// ファイルを開く
let headers = headersFromJson fileName;;
保存した JSON ファイルの先頭2項目分を確認してみます。
うまく保存できています。
7)メール一覧を Excel で確認する。
メールを整理する前に目視で検討できるように、メールの一覧を Excel で見てみます。
ここでは、nuget で公開されている TextCopy を使って、クリップボードでデータを渡します。
関数headersToExcel
は、メール一覧をタブ区切りの文字列に整形し、クリップボードにコピーします。
let headersToExcel (headers: myHeader list) =
headers
|> List.map (fun h -> $"{h.uid}\t{h.subject}\t{h.address}\t{h.displayname}\t{h.date}")
|> fun x -> String.Join("\n", x)
|> ClipboardService.SetText
;;
// メールの一覧をクリップボードに送ります。
headers |> headersToExcel;;
Excel を起動して、ペーストすると一覧を見ることができます。
もちろん、Google スプレッドシートでも構いません。
Excel を使って、事前に整理するメールを考えておくと作業が楽です。
8)メールを「移動」する(ラベル付けをする)
ImapClient.MoveMessages
メソッドでメールを「移動」します。
「移動」といってもメールボックスやフォルダ間での移動ということではなく、実際は、UID で指定したメールに対して目的のラベルを追加するだけです。
MoveMessages
もオプションで移動元を与えることができますが、混乱を避けるため、既定の「INBOX」を移動元にしてメールを移動します。
ここでは、事前に作成したラベル "MyTrash" にメールを移動する関数moveToMyTrash
を作成しました。
(ラベル名も引数にして、呼び出しの際に渡してもよいでしょう。)
// "INBOX" から "MyTrash" にメールを移動する
let moveToMyTrash (uids: seq<uint>) =
let client: ImapClient = createClient()
client.MoveMessages(uids, "MyTrash")
client.Logout()
client.Dispose()
;;
移動作業の詳細は、次の項にまとめます。
9)移動作業の具体例
Gmail の検索演算子も相当に強力ですが、F# のコレクション関数や正規表現の組み合わせは、柔軟な絞り込みを簡潔に表現できます。
スクリプトを書いて、一気にラベルで分類することも可能です。
例として、メールの一覧から「条件」に合致するメールを "MyTrash" に移動する関数deleteMessage
を書いてみました。
移動したメール一覧をクリップボードにコピーし、Excel でも確認できるようにしました。
この関数は、一覧から移動分を除いたものを返します。
let deleteMessage (isTrash: myHeader -> bool) (headers: myHeader list) : myHeader list =
let trash: myHeader list = headers |> List.filter isTrash
let nottrash: myHeader list = headers |> List.except trash
trash
|> List.map (fun h -> h.uid)
|> moveToMyTrash
trash |> headersToExcel
printfn $"削除候補{trash.Length}通を MyTrash に移動し、クリップボードにコピーしました。"
nottrash
;;
不要そうなメールを前項の "MyTrash" に集めてみます。
// アドレスに "noreply", "no-reply", "info" を含むものを移動し、headers を更新
// ざっくりとした正規表現なので "information" などにもヒットする...
let headers = headers |> deleteMessage (fun h -> Regex.IsMatch(h.address, "no-?reply|info"));;
3,053通(赤丸部分)を約35秒で移動完了です。
3000通を超えているので必要なメールも紛れているかもしれません。
このようなときに Excel にペーストして移動内容の確認作業もできます。
アプリ側で確認すると、しっかり移動できています。
関数が返した移動数とアプリ側の行数が一致しています。
試行当初は数が一致せず、検証に時間を要しましたが、「同一日の同じ件名」はスレッドにまとめられ1行とカウントされたことが原因でした。
Gmail の設定でスレッド表示を解除すると行数が一致しました。
ラベルでまとめたら、一括選択して削除できます。
その他注意事項
-
Gmail アプリで複数のメールアカウントを使っている場合などは検証していません。
-
作業が終わったら、アプリパスワードを Googleアカウントから早めに抹消してください。
-
Gmail の UID(INBOX の UID)は受信トレイから出し入れすると変化するようです。
F# 側でメール操作をしている途中にアプリ側で受信トレイに関わるメール移動操作をしたら、メール一覧を作成し直した方が無難です。