F# で Gmail の大掃除をする

## はじめに
**【2022年末に投稿した記事が乱雑だったので、大幅に手直ししました。】**

本記事は、放置しすぎてWebでは手に負えなくなった大量の Gmail を IMAP 経由で大掃除した手順を備忘録としてまとめたものです。

## 概要
* ツールには **F# Interactive** と **S22.Imap.Core** を使う。
* Gmail の仕様上、POP では目的達成が困難。**IMAP** で接続する。
* ヘッダの読み取りには時間がかかるので、クライアントを複数用意して**並列で処理**する。
* サーバーから一定期間（ここでは１年分）のヘッダを読み取り、**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）

## 準備
1. Gmail の設定から IMAP を有効にする。
1. Googleアカウントのアプリパスワードを取得する。

## IMAP 用ライブラリの選定
nuget で IMAP のライブラリを検索し、使いやすそうな **S22.Imap.Core** を採用しました。

https://www.nuget.org/packages/S22.Imap.Core

公式ドキュメントは[**ここから**](https://smiley22.github.io/S22.Imap/Documentation/)。



## 作業
**<font color=red>【以降、Gmail の操作については自己責任でお願いいたします。】</font>**

早速作業に取りかかります。
#### １）F# Interactive (fsi) の起動とライブラリ等の準備
`dotnet fsi`で **F# Interactive** を起動。（.NET SDK のインストールが必要。）
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/a148ebd3-cf22-0565-9ff5-3ca110b1f4b9.png" width=560px>
S22.Imap.Core の他にクリップボード用のライブラリ **TextCopy** も使用します。これで、**メール一覧の確認に Excel を使う**ことができます。詳細は後述します。

さらに、正規表現や JSON への変換も準備しておきます。
```FSharp:F# Interactive
#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
;;
```
<font color=red>**（注意）**
.NET Core や .NET 5 以降では、既定でサポートされるエンコード方式が限定されており、日本語メールでよく使われる ISO-2022-JP などは対応していません。
.NET SDK のインストール後、次の１行を**一度実行**しておけば、システムは ISO-2022-JP を含む多くのエンコード方式に対応するようになります。</font>
```FSharp:F# Interactive
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);;
```
#### ２）クライアントの作成と接続
まず、IMAP クライアントを作成する関数`createClient`を用意します。
呼び出しと同時に接続からログインまで完了します。
```FSharp:F# Interactive
let createClient () : ImapClient =
    new ImapClient("imap.gmail.com", 993, "(Gmail アドレス)", "(アプリパスワード)", AuthMethod.Login, true)
;;
```
#### ３）Gmail のラベル操作
メール操作に入る前に、メールを整理するための「ラベル」について確認します。
##### ＜ラベル一覧の表示＞
`ImapClient.ListMailboxes`メソッドは **Gmail の「ラベル」にも対応します。**
```FSharp:F# Interactive
// ラベル一覧の表示
let client: ImapClient = createClient()
client.ListMailboxes() |> Seq.iter (printfn "%s")
client.Logout()
client.Dispose()
;;
```
実行してみます。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/46b2972b-5fd0-a127-ab9a-1b3496b50b1d.png width=560px>

**「INBOX」** は Gmail 上の **「受信トレイ」** に対応しますが、実際はラベルとして扱われており、他のラベルと同じように操作できます。
「受信トレイ」のラベルを外したものを Gmail では **「アーカイブ」** として扱っています。
**以後、操作対象のメールは「INBOX（受信トレイ）」にあることを前提にします。**

**<font color=red>"[Gmail]/～" は「ラベル」とは別物で核となる領域なので、外部からの操作は避け、自分が用意したラベルでメールを整理します。</font>**

##### ＜「アーカイブ」されたメールについて＞
**安全のため「アーカイブ」されたメールは操作対象にしません。
必要ならば Gmail アプリ上で検索し、必要に応じて手動で受信トレイに移動しておきます。**

Gmail の検索ボックスで
`after:2020/1/1 before:2021/1/1 -in:spam -in:trash -is:sent -in:drafts -in:inbox`
と検索すれば、2020年のメールでアーカイブされたものが確認できます。
スマホで無意識にアーカイブしてしまったメールも見つかったりします。
##### ＜ラベルの作成＞
`ImapClient.CreateMailbox`メソッドがラベル作成に対応します。
また、ラベル名を **"/"** で連結することにより階層化できます。
```FSharp:F# Interactive
// ラベルの新規作成テスト
let client: ImapClient = createClient()
client.CreateMailbox("TEST")
client.CreateMailbox("TEST/Test1")
client.CreateMailbox("TEST/Test1/test11")
client.CreateMailbox("ﾃｽﾄ")
client.Logout()
client.Dispose()
;;
```
実行後、ラベルが作られています。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/c231297b-7755-3945-abad-ef6928becc62.png width=270px>

ラベル名の表示は大文字・小文字を反映していますが、内部では区別していないようです。
#### ４）メールの検索とUID（ユニークID）の取得
`ImapClient.Search`メソッドを使ってメールを検索します。
このメソッドは *mailbox* オプションでラベル名を渡すこともできます。
省略すると「INBOX（受信トレイ）」に対して検索します。

多様な検索条件を指示することができますが、検索結果は UID のコレクションとして返されます。
この UID を使ってメールを移動させます。

ここでは、指定した年に受信した（正確にはその年に自分宛に送信された）メールの UID を取得するための関数`getUidsInYear`を作成します。

```FSharp:F# Interactive
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秒程度でした。）
```FSharp:F# Interactive
2021 |> getUidsInYear |> Seq.length;;
2022 |> getUidsInYear |> Seq.length;;
```
Gmail がゴミ屋敷になっていました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/4a36f3fa-bde7-82b4-b534-45d4b05483bf.png" width=560px>
#### ５）ヘッダの取得とメール一覧の作成
##### ＜メール一覧の作成準備＞
`ImapClient.GetMessages`メソッドに UID のコレクションを渡すと個々のメールヘッダのコレクションを受け取ることができます。
サーバーから受け取るメールヘッダの形式は`System.Net.Mail.MailMessage`です。

メールの整理に必要な項目（ **UID・件名・アドレス・表示名・日時** ）を`type myHeader`として記述し、サーバーからのデータを関数`toMyHeader`で加工します。

なお、試行段階で 件名（subject）の末尾にタブ文字がついているメールが複数見つかったので`String.Trim`で余分な空白文字を消しています。
```FSharp:F# Interactive
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 の取得よりもかなり長い時間を要します。

試行段階では１つのクライアントで接続し、約7,100件のヘッダを読み取った時点でホストからタイムアウトで切断されました。（時間を測定しなかったのだが、おそらく１時間程？）
そこで、**複数クライアントで同時接続し、並行して読むことにします。**

ということで、非同期で動作するようにヘッダ取得関数`getMyHeaders`を書きます。
UID のコレクションを渡して、`myHeader`のリストを返します。
```FSharp:F# Interactive
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`で簡単に複数クライアントの同時接続ができます。
```FSharp:F# Interactive
let headersInYear (yyyy: int) =
    getUidsInYear yyyy
    |> Seq.splitInto 10
    |> Seq.map getMyHeaders
    |> Async.Parallel
    |> Async.RunSynchronously
    |> List.concat
;;

// 実行してみる
let headers = headersInYear 2020;;
```
上記コードのとおり
1. 指定した年の UID を取得
1. UID を10グループに分割
1. 各グループが並行して一覧を作成し
1. 結合して返す

という流れです。

クライアントの数は10にしました。[**公式には同時接続数の上限が15**](https://support.google.com/mail/answer/7126229?hl=ja#zippy=%2C%E5%90%8C%E6%99%82%E6%8E%A5%E7%B6%9A%E6%95%B0%E3%81%8C%E5%A4%9A%E3%81%99%E3%81%8E%E3%81%BE%E3%81%99%E3%82%A8%E3%83%A9%E3%83%BC)とされていますが、10を超えるとエラーを返す回数が増えたように感じました。
10クライアントでは比較的安定して動きましたが、サーバーの状態や回線の影響で、たまに時間がかかったりエラーを吐くこともあります。その点はご理解ください。

下が実行画面です。3分半で8,635件のヘッダを処理し、メール一覧`headers`を作成しました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/ef713ae8-22bb-14c1-fdf6-1728385389e6.png" width=560px>
#### ６）メール一覧を JSON ファイルにする
メール一覧をファイルに保存できた方が便利なので、JSON 形式で保存できるようにします。
```FSharp:F# Interactive
// メール一覧を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`をデスクトップに読み書きする例です。
```FSharp:F# Interactive
let fileName = @"C:\Users\User\Desktop\gmail_2020.json";;
// ファイルに保存
headers |> headersToJson fileName;;
// ファイルを開く
let headers = headersFromJson fileName;;
```
保存した JSON ファイルの先頭2項目分を確認してみます。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/97a2cb2e-41fb-7946-8ac4-7a3feecd2a56.png width=560px>
うまく保存できています。
#### ７）メール一覧を Excel で確認する。
メールを整理する前に目視で検討できるように、メールの一覧を Excel で見てみます。
ここでは、nuget で公開されている **[TextCopy](https://www.nuget.org/packages/TextCopy)** を使って、クリップボードでデータを渡します。 

関数`headersToExcel`は、メール一覧をタブ区切りの文字列に整形し、クリップボードにコピーします。
```FSharp:F# Interactive
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 を使って、事前に整理するメールを考えておくと作業が楽です。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/53776327-bdcc-84a7-e8cd-3ab441b9f0a4.png width=560px>

#### ８）メールを「移動」する（ラベル付けをする）
`ImapClient.MoveMessages`メソッドでメールを「移動」します。
「移動」といってもメールボックスやフォルダ間での**移動ということではなく**、実際は、UID で指定したメールに対して**目的のラベルを追加するだけ**です。

`MoveMessages`もオプションで移動元を与えることができますが、混乱を避けるため、既定の「INBOX」を移動元にしてメールを移動します。

ここでは、事前に作成したラベル "MyTrash" にメールを移動する関数`moveToMyTrash`を作成しました。
（ラベル名も引数にして、呼び出しの際に渡してもよいでしょう。）
```FSharp:F# Interactive
// "INBOX" から "MyTrash" にメールを移動する
let moveToMyTrash (uids: seq<uint>) =
    let client: ImapClient = createClient()
    client.MoveMessages(uids, "MyTrash")
    client.Logout()
    client.Dispose()
;;
```
移動作業の詳細は、次の項にまとめます。
#### ９）移動作業の具体例
Gmail の[検索演算子](https://support.google.com/mail/answer/7190?hl=ja)も相当に強力ですが、F# のコレクション関数や正規表現の組み合わせは、柔軟な絞り込みを簡潔に表現できます。
スクリプトを書いて、一気にラベルで分類することも可能です。

例として、メールの一覧から「条件」に合致するメールを "MyTrash" に移動する関数`deleteMessage`を書いてみました。
移動したメール一覧をクリップボードにコピーし、Excel でも確認できるようにしました。

この関数は、一覧から移動分を除いたものを返します。
```FSharp:F# Interactive
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" に集めてみます。
```FSharp:F# Interactive
// アドレスに "noreply", "no-reply", "info" を含むものを移動し、headers を更新
// ざっくりとした正規表現なので "information" などにもヒットする...
let headers = headers |> deleteMessage (fun h -> Regex.IsMatch(h.address, "no-?reply|info"));;
```
3,053通（赤丸部分）を約35秒で移動完了です。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/27ebf19a-a399-77cc-c470-56248c99c7ec.png width=560px>
3000通を超えているので必要なメールも紛れているかもしれません。
このようなときに Excel にペーストして移動内容の確認作業もできます。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/68a6a2f2-3b35-a116-ca51-c57b39112938.png width=560px>
アプリ側で確認すると、しっかり移動できています。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/8a79c354-16f6-9cb3-7493-2871e97bb2fe.png width=560px>
関数が返した移動数とアプリ側の行数が一致しています。

試行当初は数が一致せず、検証に時間を要しましたが、**「同一日の同じ件名」はスレッドにまとめられ１行とカウントされた**ことが原因でした。
Gmail の設定で**スレッド表示を解除すると行数が一致**しました。

ラベルでまとめたら、一括選択して削除できます。
## その他注意事項
* <B>Gmail アプリで複数のメールアカウントを使っている場合などは検証していません。</B>

* <B>作業が終わったら、アプリパスワードを Googleアカウントから早めに抹消してください。</B>

* <B>Gmail の UID（INBOX の UID）は受信トレイから出し入れすると変化するようです。</B>
F# 側でメール操作をしている途中にアプリ側で受信トレイに関わるメール移動操作をしたら、メール一覧を作成し直した方が無難です。