EvernoteからObsidianに移行した話

ふと思い立ってEvernoteの中身をローカルに保存することにしました。

方法についていろいろ調べていたのですが、EvernoteをエクスポートしてObsidianに取り込むというのが良さそう。

以前からクラウドにデータを保存することのリスクを感じていて、特にEvernoteに貯めるようなデータは数十年単位で参照可能になっていることを期待していますから、ローカルにMarkdownでそのまま保存できるObsidianはまさに目的通りのツールでしょう。

今まではVSCodeをMarkdownエディタかつ簡易的なビューアとして使っていましたが、良い機会なので本格的にObsidianへ移行しようと思います。

ついでにEvernote以外にもGoogle KeepやGoogleカレンダーのデータもObsidianに取り込んで、ローカルのライフログを構築しましょう。

Evernote -> Obsidian

Evernoteのアプリをインストールしてローカルに同期して、*.enex にエクスポートするだけやろ!って思っていたのですが。

無料版だと最大50個のノート、最大1冊のノートブックという制限があるらしい。幸いなことに7日間の無料トライアルが使えたので、この間に全部エクスポートしてしまいましょう。

AIによる補足

2026年8月時点でもEvernote Freeは「最大50ノート・1ノートブック」です。ただし公式の説明では、すでに上限を超えている既存ユーザーでも、既存ノートの閲覧・編集・共有・エクスポート・削除は可能とされています。

自分の環境では既存データをまともに扱えず困ったのですが、少なくとも「Freeにしたら既存データそのものが見られなくなる」というのが現在の公式仕様ではありません。

ただ、全てのノートブックを一括でエクスポートすることはできないらしく、50以上あったノートブックを1つずつ右クリックしてエクスポートするのはだいぶ面倒であった。。

AIによる補足

Evernoteの公式ヘルプでも、デスクトップアプリから一度にエクスポートできるのは「100ノート、または1ノートブック」となっています。大量にノートブックがある場合は、結局ノートブック単位での作業になる。

エクスポートしてしまえば、ObsidianのImporterプラグインからフォルダ指定で一括インポート可能なので早い。

AIによる補足

ImporterはEvernoteのENEXをMarkdownへ変換してくれるので、この用途にはかなり便利です。 ただし大量移行の場合は、ENEXの原本を消さずに残しておくのと、変換前後でノート数をざっくり確認しておくのがおすすめ。変換処理を何段階も通すと、後から「このノートどこ行った?」となった時に元データがないと詰みます。

Google Keep -> Obsidian

Google TakeoutからGoogle Keepを選択してアーカイブリクエストをします。容量によるのかもしれないけど、ものの数分でリンクが作成されてダウンロードできるようになる。

AIによる補足

Googleの公式説明では、Keepのエクスポートには本文だけでなく、リスト項目、画像・音声・描画などの添付、色、固定・アーカイブ状態、ラベルなども含まれます。

Importerプラグインからインポートできるけど、JSONの構造が単純なので自作のスクリプトで処理したほうが柔軟かもしれない。

JSONはこういうフォーマット。

{
  "color": "DEFAULT",
  "isTrashed": false,
  "isPinned": false,
  "isArchived": false,
  "annotations": [...],
  "textContent": "...",
  "title": "タイトル",
  "userEditedTimestampUsec": 1786096084982000,
  "createdTimestampUsec": 1783644710236000,
  "textContentHtml": "..."
}

自作の方がよい、と言っても自分で作ろうとするとやっぱり手間で、しかしAIと相談しながら仕様を詰めて実装までやってもらえば1時間もかからず高品質なスクリプトを作ってくれる。ほんとよい時代になりましたね。

仕様書とPythonスクリプトはこちら。

Googleカレンダー -> Obsidian

Googleカレンダーは 設定 -> インポート / エクスポート からエクスポートできる。

AIによる補足

全カレンダーをまとめてエクスポートするとZIPがダウンロードされ、その中にカレンダーごとの .ics が入る 個別のカレンダーは .ics として直接エクスポートできる ICSはiCalendar形式なので、開始・終了日時だけでなく、繰り返し予定、参加者、場所、説明なども持てる。

こちらもImporterプラグインからインポートできるけど、やっぱり比較的扱いやすいフォーマットなので自作のスクリプトで処理することにしましょう。

仕様書とPythonスクリプトはこちら。

文書の整理

データ移行自体は機械的にできるので比較的簡単なのですが、移行したデータを整理するのが一番大変かもしれない。

添付ファイルの整理

まず機械的にできるところから。

Local Images Plus

埋め込みデータがローカルファイルだと思っていたらリモートのURLを参照していることがあります。これは見た目では分からないので罠かもしれない。

こちらのプラグインでリモートの埋め込み参照をローカルにダウンロードしてリンクを更新してくれます。

AIによる補足

Web上の画像だけでなく、Markdownの埋め込みリンクやbase64画像のローカル化にも対応しており、MD5による重複排除の仕組みもあります。 Vault全体を一括処理するとノートそのものを書き換えるので、実行前にVaultを丸ごとバックアップしておくのが安全です。これは以下の添付ファイル整理系プラグインも同様。

Obsidianは参照先リンクのパスが多少間違っていても解決できてしまう場合があり、便利ですがミスに気付かないことがあります。

このプラグインで添付ファイルの参照が正しいかどうかチェックし、リンクを書き換え、添付ファイルを適切なフォルダに配置しなおしてくれます。

Check Vault Consistency でリンク切れや不正なリンクをチェックしてくれるので、エラーが無くなるまで確認しましょう。

Unique attachments

Local Images PlusでダウンロードしたファイルについてはMD5を使ってuniqueな名前を付けてくれますが、既にローカルにあるファイルについては別途整理が必要になります。

Evernoteの問題なのか、unknown_file みたいな名前のファイルが大量にできてしまい、整理してると意図せず上書きされてしまったり参照がおかしくなったりして困るんですよね。

このプラグインで添付ファイルをMD5を使ったuniqueな名前に書き換えてくれます。

例えばこんな感じ。

5be2a494d8c98092d803718d29fe14c0.png

ただこのプラグイン、使用には注意が必要です。

  • Consistent Attachments and Linksと同時に大規模な書き換えを走らせるのは避けた方がよい。使用前にConsistent Attachments and Linksは無効にしておくのがお勧め
  • 大量のファイルを処理すると時間がかかる(1ファイルあたり数秒)が通常画面には進捗が表示されない。コンソールには処理したファイルがログに出るので、Ctrl+Shift+I で開発者ツールを開いて確認できる
  • 処理対象の拡張子を確認しておく。自分の場合は png,jpg,jpeg,gif,pdf,bmp,svg あたりを対象にした。(場合によっては.doc, .docx, .rtfなどのドキュメント関連も必要になるかも)
  • Rename only active attachments がうまく動かない(別記事で触れる予定)
  • 当時コードを見た範囲では、ファイルのリネーム処理を await していない箇所もあった(実用上は特に問題にならなかった)

大量のファイルをインポートした後、1回だけ全体に適用するという使い方がよいと思います。(もっと安定して高機能なプラグインがあるかもしれない)

旅行関連メモの整理

何しろEvernoteの思想(?)として持て囃されていたのが「雑にデータをぶっ込んでおいても検索で探せる!」というものだったので、旅行関連のメモは地域名でノートブックを作って予約情報やら行きたいお店のメモやら日記やらを全部ぶち込んでて、後から見返すと大変なカオスになっていました。

今は本当に便利なもので、MarkdownファイルをAIに渡して整理してもらえば一発できれいにやってもらえます。

最終的にこういうプロンプトになりました(これは整理してもらったもので、実際はもっとゴチャゴチャ説明していますが)。

添付したZIP内のMarkdownを、情報を失わない範囲で整理してください。

目的は、散在しているスポット情報を後から参照しやすいMarkdownに再構成することです。

【分類】
大きなジャンルごとに1ファイルにしてください。
例:
- 食事
- 観光・レジャー
- 交通
- 宿泊
- 買い物
- 通信・作業環境

食事については、1ファイル内をさらに
「ラーメン」「うどん」「居酒屋」「海鮮」などのセクションに分けてください。

【統合】
同じ店・施設が複数文書に登場する場合は1件に統合してください。
「行きたい」「訪問済み」「wishlist」などの状態は区別せず、同じスポットとして扱ってください。
重複を統合した旨を本文に書く必要はありません。

【リンク】
店や施設そのものへ直接アクセスできるURLがある場合は残してください。
食べログ、公式サイト、Google Maps等は残して構いません。

SNS投稿やブログ記事から店名を抽出した場合、
元のSNS・ブログURLは原則として残さないでください。
ただし、その記事自体を保存する意味がある場合は例外としてください。

【本文】
Web記事を丸ごと保存するのではなく、
スポットを特定するのに必要な情報や、
個人的なメモ・感想だけを簡潔に残してください。

【添付ファイル】
Markdownから参照されている attachments 以下のファイルは維持してください。
参照されていない添付ファイルは無理に残さなくて構いません。

内容を推測して存在しない店名や情報を補完しないでください。
判断できないものは無理に分類せず、その旨が分かる形にしてください。

最後に整理後のZIPを作成してください。

旅行の日記もあったので、こちらは別途まとめてもらいました。

日記の長さにもよりますが、昔の自分は謎に長文日記を書いていたので、日ごとに2、3点のハイライトをまとめてもらうようにしました。これ結構おもしろいのでやってみるとよいです。

【ルール】

- 1日につき1セクション
- 同じ日の文書が複数ある場合は統合
- 内容が重複している部分は自然に1つにまとめる
- 重複を統合したことは書かない
- 日付別まとめには各日2〜3件程度の要点を書く
- 本文は原文をできるだけ保持する
- 要約によって本文を短くしない
- 元資料に存在しない日付や出来事を作らない
- 内容がない日付を勝手に補完しない
- YAML / properties / タグは削除
- 先頭の不要な空行を削除
- Markdownの見出し階層を整理

AIによる補足

この「本文は原文をできるだけ保持する」「要約によって本文を短くしない」は結構重要。 単に「整理して」と頼むと、AIは良かれと思って内容まで要約してしまうことがあります。アーカイブ用途では、構造は変えてよいが原文は消さないという境界を明示した方がよい。

↑そうなのか笑

その他雑多なメモの整理

何しろEvernoteの思想(?)として持て囃されていたのが「雑にデータをぶっ込んでおいても検索で探せる!」というものだったので(略)

1つずつメモを読んで分類するのは大変すぎますが、これもAIに頼めばいい感じにやってくれます。

元のプロンプト?はこういう感じでした。

1日1セクションにまとめる
年ごとに1ファイルとする
重複した内容はマージする
文章として意味をなさない断片的な情報はtrashへ
健康関連はhealthへ
個人の意見や感想があれば日記として扱う
800文字以上でテーマ性のある文章はwritingsへ

一時的なメモやWebのコピーなど長期的なデータとして意味のないものが多すぎる一方、短い内容でも日記的な文章が入っていたりと機械的に分けるのが難しく、AIに内容を読んで判断してもらうのがベストだと思います。

また、個人的な事情により健康に関する話題は日記とは別に分けておきたいため特筆事項としました。

改良版プロンプトとして作ってもらったものはこちらです。なげーな。

添付したZIP内のMarkdownを、日記アーカイブとして再整理してください。

最も重要なのは、文字数ではなく本文の意味を読んで分類することです。

【日記】
日記として扱える文書は、年ごとに1つのMarkdownへ統合してください。

形式:

# 2024 日記

## 2024-01-01

本文...

## 2024-01-02

本文...

同じ日付に複数文書がある場合は1セクションに統合してください。

内容が重複している場合は自然にマージし、
「重複を統合しました」などの注記は不要です。

元文書内に見出しがある場合は、
日付見出しの子になるように見出しレベルを調整してください。

【日記として扱う基準】
以下のいずれかが含まれていれば、短文でも原則として日記として扱ってください。

- その日に起きた出来事
- 自分がしたこと
- 自分の感想
- 自分の考え
- 感情
- 後から振り返る意味がある個人的記録

単なる情報メモに見えても、
本人の意見や感想が含まれている場合は日記を優先してください。

【trash】
以下のように、個人的な文章として意味をなさない文書は
trash ディレクトリへ移動してください。

- URLだけ
- Webクリップの断片
- パスワードやアカウント情報
- 製品型番やスペックだけ
- イベントの時刻表だけ
- スケジュールだけ
- 名前の羅列
- ゲーム攻略データ
- コピーした資料だけ

ただし、これらと日記本文が混在している場合は、
日記部分を残し、明らかに不要な情報だけ除去してください。

【health】
体調、病気、症状、治療、薬、検査など
健康に関する記録は health ディレクトリへ移動してください。
文章が短くてもhealthを優先してください。

【writings】
800文字以上あり、
日記ではなく特定テーマについてまとまって書かれた文章は
writings ディレクトリへ移動してください。

例:
- エッセイ
- 手紙
- レビュー
- 考察
- 長文の推しへの文章
- 特定テーマを振り返った文章

単に長い日記はwritingsにしないでください。
「その日の出来事」が中心なら日記のままとしてください。

最後に以下の構成でZIPを作成してください。

2020_日記.md
2021_日記.md
...
health/
writings/
trash/

また、分類結果を確認できる簡単な「分類概要.md」も作成してください。

改めてプロンプトを見ると、ある程度の汎用性はありますが自分のメモの傾向がわりと出てますね。。

一旦Markdownを全部読ませて、分類する大項目について詰めたほうがいいかもしれないですね。

AIによる補足

大量のファイルをいきなり「整理して」と投げるより、

  1. まず内容を読ませて分類案だけ出してもらう
  2. 分類基準を人間側で修正する
  3. その基準で実際にファイルを生成してもらう
  4. 最後に件数やリンク切れを機械的に検証する

という二段階、三段階のやり方の方が事故は少ないと思います。

特に大量ファイルでは、AIによる意味ベースの判断と、スクリプトによる件数・重複・リンク検証を組み合わせるのが相性よいです。

まとめ

データの移行から大量の文書のまとめまで、AIのおかげでずいぶん作業が楽になりましたね。手作業でやってたら1週間はかかるような作業が1日で終わってしまった。

そしてかつてEvernoteが流行していた時代の「何でもデータを入れて後から検索する」という使い方から、「大量のデータをAIに整理してもらう」という使い方へのパラダイムシフトを強く感じます。

Obsidianにまとめたデータ自体も、必要に応じてAIから使えるわけで。時代は進歩してますなー。

AIによる補足

ここでいう「AIに使う」は、必ずしもモデルそのものを追加学習するという意味ではありません。ローカルのMarkdownを検索して関連部分だけをAIに渡す、いわゆるRAG的な使い方でも十分に「自分の過去データをAIから参照できる」状態を作れます。 そして何より、元データがプレーンなMarkdownとして手元に残っていれば、特定のクラウドサービスやAIサービスがなくなっても別のツールへ持っていける。この点が今回Obsidianへ移行した一番大きなメリットかもしれません。

PythonとJavaScript で空の配列の真偽値が違う件について

Python

>>> bool([])
False

JavaScript

> Boolean([])
true

どっちが良いかと言われると難しいなあ。

どちらにせよ、配列そのものを真偽値チェックに使うのはバグの温床になるから止めたほうがいいね。lengthをチェックするとか変数定義をチェックするとかにした方がいい。

fetchにタイムアウトとリトライの処理を付ける

axios(+ axios-retry)だとタイムアウトとリトライの処理があるけど、fetchには何もないらしい。まじか。

手続き型言語なら普通にタイマーでキャンセル処理を登録してリトライはforループとかで、、

for(1 .. retry_num){
  if (timer(fetch, timeout) == 0){
    break
  }
}

とこんな感じのコードで済むんだけど、javascriptは何かと面倒だな。
色々調べてこんな感じになった。

function fetch_retry(url, params={}) {
    const {retry=5, timeout_ms=1000, options={}} = params;

    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeout_ms);
    options["signal"] = controller.signal;

    return fetch(url, options)
    .catch((error) => {
        if (retry === 1) throw error;
        return fetch_retry(url, {retry : retry-1, timeout_ms : timeout_ms, options : options});
    })
    .finally(() => clearTimeout(timer));
}

こんな感じで使いたい。

fetch_retry(url)
fetch_retry(url, {timeout_ms:10000})

fetchを中断させるには AbortController().abort() を使うらしい。
おそらくこれを実行するとfetchのoptionに指定したsignalが送られてコネクションを閉じるんだと思う。

Promise.race()を使って、指定した時間でrejectを実行するPromiseと同時に動かすという方法もあるようだけど、これだと処理的にはタイムアウトするけどコネクションはそのままになってしまう。
まあ放っておけばkeep-aliveでcloseするだろうから(keepalive有効になってないことなんてないよね?)大きな問題ではなさそうだけど
fetch自体に備わってるabort処理を使う方が素直な実装でしょう。Promise.race()も使わずにすむし。

で、前のエントリーに合わせてStreams対応させれば。。もうちょっとだ。

参考:

fetchでReadableStreamを使う

var ts = function(){
  dt = new Date();
  year = dt.getFullYear();
  month = (dt.getMonth()+1).toString().padStart(2, '0');
  day = dt.getDate().toString().padStart(2, '0');
  hour = dt.getHours().toString().padStart(2, '0');
  min  = dt.getMinutes().toString().padStart(2, '0');
  sec  = dt.getSeconds().toString().padStart(2, '0');
  ms = dt.getMilliseconds();

  str = year + "-" + month + "-" + day + " " + hour + ":" + min + ":" + sec + "." + ms
  return str
}

var url = "<big data URL>"

fetch(url)
  .then((response) => response.body.getReader())
  .then((reader) => {
    var charsReceived = 0;

    function processText({ done, value }) {
      if (done) {
        console.log("Stream complete");
        return;
      }
      charsReceived += value.length;
      console.log( ts() + " " + value.length + " bytes received. total size=" + charsReceived + " bytes");

      return reader.read().then(processText);
    }
    return reader.read().then(processText);
  });

実行結果

2023-09-09 11:34:19.427 16384 bytes received. total size=16384 bytes
2023-09-09 11:34:19.519 32768 bytes received. total size=49152 bytes
2023-09-09 11:34:19.522 32274 bytes received. total size=81426 bytes
2023-09-09 11:34:19.614 65536 bytes received. total size=146962 bytes
2023-09-09 11:34:19.614 32491 bytes received. total size=179453 bytes
2023-09-09 11:34:19.618 16098 bytes received. total size=195551 bytes
2023-09-09 11:34:19.708 65536 bytes received. total size=261087 bytes
2023-09-09 11:34:19.709 36951 bytes received. total size=298038 bytes
Stream complete

とりあえず動いた。

参考:

疑問点

  • そもそもPythonのdatetimeっぽい時刻表示にするためにあんな泥臭いことやらないといけない?strftimeとかないの?
  • printf()あるいはformat()みたいな文字列フォーマットはないの?
  • reader.read() が完了する(fulfilledになる)タイミングはどこで決まってる?変更できるのかな?
  • 再起じゃなくてループにはできないのかな?awaitしないとwhileループで書けない?

javascript慣れてないからお作法というか文化的なものが分からんなあ。。

追記

async版。こっちの方が分かりやすいな。Promise分かんなすぎ。

(async () => {
  var charsReceived = 0;
  const response = await fetch(url);
  const reader = response.body.getReader();
  while (true) {
    const {value, done} = await reader.read();
    if (done) break;
    charsReceived += value.length;
    console.log( ts() + " " + value.length + " bytes received. total size=" + charsReceived + " bytes");
  }
  console.log('Stream complete');
})()


あとちなみに、valueはバイト列(Uint8Array)として渡されるので、テキストとして扱う場合はTextDecoder()でデコードするか
TextDecoderStream()をTransformStreamとして指定すればよいらしい。

const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();

わぁーおしゃれ!

追記2

最初は一番下のreturn reader.read().then(processText)の中にprocessTextの定義を埋め込んでたんだけど、そうすると途中でPromiseが切れる(という言い方が適切なのかは分からない)というか
.thenで繋いだときに読み込みが終わる前に実行されてしまうので分けて定義しないといけないみたい。

GmailのSMTPでOAuth2.0を使う in Perl

こちらの記事の続き。

kkobayashi-a.hatenablog.com

IMAPに比べてSMTPでOAuth 2.0の認証をするサンプルコードについてはほとんど情報がありませんでしたが、唯一こちらが参考になりました。

www.perlmonks.org

Gmail用のAuthen::SASLオブジェクトをAuthen::SASL::Perl::XOAUTH2として定義していて、これをそのまま使えば良さそう。

package Authen::SASL::Perl::XOAUTH2 ;

use strict ;
use warnings ;

our $VERSION = "0.01c" ;
our @ISA = qw( Authen::SASL::Perl ) ;

my %secflags = ( ) ;

sub _order { 1 }

sub _secflags {
  shift ;
  scalar grep { $secflags{$_} } @_ ;
}

sub mechanism {
    # SMTP->auth may call mechanism again with arg $mechanisms
    #            but that means something is not right
    if ( defined $_[1] ) { die "XOAUTH2 not supported by host\n" } ;
    return 'XOAUTH2' ;
} ;

my @tokens = qw( user auth access_token ) ;

sub client_start {
    # Create authorization string:
    # "user=" {User} "^Aauth=Bearer " {Access Token} "^A^A"
    my $self = shift ;
    $self->{ error } = undef ;
    $self->{ need_step } = 0 ;
    return
        'user=' .
        $self->_call( $tokens[0] ) .
        "\001auth=" .
        $self->_call( $tokens[1] ) .
        " " .
        $self->_call( $tokens[2] ) .
        "\001\001" ;
}

1 ;

他に使う機会もないし、スクリプトにそのまま埋め込めばいいかと思っていたけど
これを使うNet::SMTPS(Net::SMTP)内部でreuireする処理があるので
実ファイルとして@INCのパスの通ったディレクトリに保存する必要がありました。

Net::SMTPSから直接送信

とりあえずシンプルな例としてNet::SMTPSから直接叩いてメールを送るサンプルです。

最もシンプルな例としてはAuthen::SASLオブジェクト経由ではなく
直接 Net::SMTPS->command()で認証コマンドを実行することになりますが、
勉強も兼ねて公開されているAuthen::SASL::Perl::XOAUTH2を活かす方向で行きます。

use strict;
use warnings;
use utf8;
use Encode qw /encode/;

use Net::SMTPS;
use Authen::SASL qw/Perl/;
use Email::MIME;

my $USER_MAIL = 'kobayashi01234@gmail.com';
my $access_token = '[my access token]';

my $email = Email::MIME->create( header => [
  From    => $USER_MAIL,
  To      => $USER_MAIL,
  Subject => 'test mail',
  ],
  attributes => {
    content_type => 'text/plain',
    charset      => 'UTF-8',
    encoding     => '8bit',
  },
  body => encode('utf8', "テストメール"),
);
my $msg_string = $email->as_string;

my $sasl = Authen::SASL->new(
  mechanism => 'XOAUTH2',
  callback => {
    user => $USER_MAIL,
    auth => 'Bearer',
    access_token => $access_token,
  }
);

my $smtp = Net::SMTPS->new(
  'smtp.gmail.com',
  Port  => 587,
  doSSL => 'starttls',
  Debug => 1
);

$smtp->auth($sasl) or die "Can't authenticate:" . $smtp->message();
$smtp->mail($USER_MAIL);
$smtp->recipient($USER_MAIL);
$smtp->data();
$smtp->datasend($msg_string);
$smtp->dataend();

実行結果

Net::SMTPS=GLOB(0x80009c5f0)<<< 220 smtp.gmail.com ESMTP
Net::SMTPS=GLOB(0x80009c5f0)>>> EHLO localhost.localdomain
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-smtp.gmail.com at your service, [39.111.129.226]
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-SIZE 35882577
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-8BITMIME
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-STARTTLS
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-ENHANCEDSTATUSCODES
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-PIPELINING
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-CHUNKING
Net::SMTPS=GLOB(0x80009c5f0)<<< 250 SMTPUTF8
Net::SMTPS=GLOB(0x80009c5f0)>>> STARTTLS
Net::SMTPS=GLOB(0x80009c5f0)<<< 220 2.0.0 Ready to start TLS
Net::SMTPS=GLOB(0x80009c5f0)>>> EHLO localhost.localdomain
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-smtp.gmail.com at your service, [39.111.129.226]
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-SIZE 35882577
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-8BITMIME
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-AUTH LOGIN PLAIN XOAUTH2 PLAIN-CLIENTTOKEN OAUTHBEARER XOAUTH
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-ENHANCEDSTATUSCODES
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-PIPELINING
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-CHUNKING
Net::SMTPS=GLOB(0x80009c5f0)<<< 250 SMTPUTF8
Net::SMTPS=GLOB(0x80009c5f0)>>> AUTH XOAUTH2 XXXXXXXXXX
Net::SMTPS=GLOB(0x80009c5f0)<<< 235 2.7.0 Accepted
Net::SMTPS=GLOB(0x80009c5f0)>>> MAIL FROM:<kobayashi01234@gmail.com>
Net::SMTPS=GLOB(0x80009c5f0)<<< 250 2.1.0 OK
Net::SMTPS=GLOB(0x80009c5f0)>>> RCPT TO:<kobayashi01234@gmail.com>
Net::SMTPS=GLOB(0x80009c5f0)<<< 250 2.1.5 OK
Net::SMTPS=GLOB(0x80009c5f0)>>> DATA
Net::SMTPS=GLOB(0x80009c5f0)<<< 354  Go ahead
Net::SMTPS=GLOB(0x80009c5f0)>>> From: kobayashi01234@gmail.com
Net::SMTPS=GLOB(0x80009c5f0)>>> To: kobayashi01234@gmail.com
Net::SMTPS=GLOB(0x80009c5f0)>>> Subject: test mail
Net::SMTPS=GLOB(0x80009c5f0)>>> Date: Wed, 15 Jun 2022 16:35:50 +0900
Net::SMTPS=GLOB(0x80009c5f0)>>> MIME-Version: 1.0
Net::SMTPS=GLOB(0x80009c5f0)>>> Content-Type: text/plain; charset=UTF-8
Net::SMTPS=GLOB(0x80009c5f0)>>> Content-Transfer-Encoding: 8bit
Net::SMTPS=GLOB(0x80009c5f0)>>>
Net::SMTPS=GLOB(0x80009c5f0)>>> テストメール
Net::SMTPS=GLOB(0x80009c5f0)>>> .
Net::SMTPS=GLOB(0x80009c5f0)<<< 250 2.0.0 OK  1655278555
Net::SMTPS=GLOB(0x80009c5f0)>>> QUIT
Net::SMTPS=GLOB(0x80009c5f0)<<< 221 2.0.0 closing connection

いい感じですね!

Email::Senderから送信

ようやく最終目標であるEmail::Senderから送る方法を考えます。
Net::SMTP(S)をそのまま使ってもいいですが、Email::Senderがいい感じにラップしてくれるので
モダンなPerlコードはこれを使うみたいです。

Email::Senderを使うにはGmailの認証に対応したEmail::Sender::Transportが必要になりますが
うまい具合に指定する方法が見つからなかったので、強引にEmail::Sender::Transport::SMTPを上書き(継承)した
Email::Sender::Transport::SMTP::Gmailクラスを作成します。

sendmail()の処理では_smtp_client()関数からsmtpオブジェクトの生成や認証を行うのですが、
コンストラクタで認証済みのNet::SMTPオブジェクトをセットし、それをそのまま返すようにしています。

package Email::Sender::Transport::SMTP::Gmail;

use strict;
use warnings;
use base qw(Email::Sender::Transport::SMTP);

sub new{
  my $this = shift;
  my $class = ref $this || $this;
  return bless {_smtps_client => $_[0]}, $class;
}

sub _smtp_client{
  return $_[0]->{_smtps_client};
}

1;

package main;

use strict;
use warnings;
use utf8;
use Encode qw /encode/;

use Net::SMTPS;
use Authen::SASL qw/Perl/;
use Email::MIME;
use Email::Sender::Simple qw(sendmail);

my $USER_MAIL = 'kobayashi01234@gmail.com';
my $access_token = '[my access token]';

my $email = Email::MIME->create( header => [
  From    => $USER_MAIL,
  To      => $USER_MAIL,
  Subject => 'test mail',
  ],
  attributes => {
    content_type => 'text/plain',
    charset      => 'UTF-8',
    encoding     => '8bit',
  },
  body => encode('utf8', "テストメール"),
);
my $msg_string = $email->as_string;

my $sasl = Authen::SASL->new(
  mechanism => 'XOAUTH2',
  callback => {
    user => $USER_MAIL,
    auth => 'Bearer',
    access_token => $access_token,
  }
);

my $smtp = Net::SMTPS->new(
  'smtp.gmail.com',
  Port  => 587,
  doSSL => 'starttls',
  Debug => 1
);
$smtp->auth($sasl) or die "Can't authenticate: " . $smtp->message();

my $sender = Email::Sender::Transport::SMTP::Gmail->new($smtp);

sendmail($email, {transport => $sender});

GmailのIMAPでOAuth2.0を使う in Perl

GmailのIMAPログインが失敗するようになってしまった。

Google アカウントが5月30日にセキュリティ強化、Gmailの外部メールアプリ利用などが使えなくなる可能性 - ケータイ Watch

これのことらしい。困りますね。

use strict;
use warnings;
use utf8;

use IO::Socket::SSL;
use Net::IMAP::Client;

my $imap = Net::IMAP::Client->new(
  server => 'imap.gmail.com',
  port   => 993,
  ssl    => 1,
  user   => '[user gmail address]',
  pass   => '[password]',
);
die "Could not connect to IMAP server" unless $imap;

$imap->login or die('Login failed: ' . $imap->last_error);
$ perl gmail_test.pl
Login failed: [AUTHENTICATIONFAILED] Invalid credentials (Failure) at gmail_test.pl line 17.

ユーザーID+パスワードではなく、OAuth2.0を使ってログインする必要があるそうです。
しかしOAuthとかすっかり忘れてしまった。
自分の過去記事でもまとめてるけど、何しろ10年くらい前の話(!)なので勉強し直しですな。。

大まかな手順としては以下のような感じ。

  1. Googleのproject(application)を作成
  2. OAuth2.0用のClient IDを作成
  3. ↑のClient IDから使用するGmailのアカウントでOAuth2.0認証してアクセストークンを取得
  4. アクセストークンを使ってIMAP認証

とりあえず公式ドキュメントのこの辺を読んでおけばよさそう。

動くPerlのコードサンプルとしてはこちらが参考になりました。

lowreal.net

しかしやっぱりWeb周りに関してはPerlよりPythonの方が簡単だなあ。Googleが提供してるライブラリもPerlはないし干されている。

project作成

Google API Consoleを開いてプロジェクトを作成する。
色々と項目があって混乱するけど、実際やることはほとんどない。

  1. project作成
  2. APIs & Servicesを選択
  3. OAuth consent screenを設定
    • Scopesは何も指定しない
    • Publishing status を productionにする
  4. CredentialsからOAuth 2.0 Client IDを作成
    • Application typeはDesktop app
  5. client_secret.json をダウンロード
メモ

Gmail APIを有効にする必要があるかな?と思ったけど特にAPIの有効化やscopeの設定は必要ない。
今回はAPIを使うわけではなく認証するだけ(実際の処理はIMAP/SMTPで行う)だからなのかな?

また、ScopeにGmail API(https://mail.google.com/)を指定するとpublishing statusをproductionにする際verificationが必要になる。verificationには色々手順が必要らしく面倒そう。
support.google.com

しかしpublishing statusがtestのままだと7日ごとにrefresh tokenの取り直し(OAuth2.0の再認証?)が必要になるのでやっぱり面倒そう。

ということで、scopeを空にしてpublishing statusをproductionにする。これだとverificationが不要なので楽だと思う。

Client IDデータ(client_secret.json)

ダウンロードしたclient_secret.jsonはこういう感じのデータになってるので、これをそのまま使います。

redirect_urisはlocalhostになっていますが、"urn:ietf:wg:oauth:2.0:oob"に書き換えておきます。

{
  "installed": {
    "client_id": "XXXXXXXX",
    "project_id": "YYYYYYYY",
    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
    "token_uri": "https://oauth2.googleapis.com/token",
    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
    "client_secret": "ZZZZZZZZ",
    "redirect_uris": [
      "localhost"
    ]
  }
}

redirect URIの詳細な説明はこちらのドキュメントに載ってます。

github.com

GoogleのOAuth2.0認証

というわけで、参考サイトのドキュメントやサンプルコードを眺めつつOAuth2.0の認証とアクセストークンのrefreshを実装してみました。

use strict;
use warnings;
use utf8;
use Getopt::Std;

use JSON::XS;
use Path::Class qw/file/;
use URI;
use LWP::UserAgent;

my $opt = {};
getopts("r:", $opt);

my $client_secret = JSON::XS->new->utf8->decode(scalar file('client_secret.json')->slurp)->{installed};

if($opt->{r}){
  refresh($client_secret, $opt->{r});
}
else{
  auth($client_secret);
}

sub auth{
  my $client_secret = shift;

  my $auth_uri = URI->new($client_secret->{auth_uri});
  $auth_uri->query_form(
    response_type => 'code',
    client_id     => $client_secret->{client_id},
    redirect_uri  => $client_secret->{redirect_uris}->[0],
    scope         => 'https://mail.google.com/',
  );

  print "To authorize token, visit this url and follow the directions:\n$auth_uri\n";
  print "Enter verification code: ";

  my $authorization_code = <>;
  $authorization_code =~ tr/\x0A\x0D//d;

  my $ua = LWP::UserAgent->new();
  my $token_uri = URI->new($client_secret->{token_uri});
  my $res = $ua->post($token_uri, {
    client_id     => $client_secret->{client_id},
    client_secret => $client_secret->{client_secret},
    code          => $authorization_code,
    redirect_uri  => $client_secret->{redirect_uris}[0],
    grant_type    => 'authorization_code',
  });

  my $res_text = $res->decoded_content;
  my $res_json = JSON::XS->new->utf8->decode($res_text);

  print "\n";
  print "Refresh Token: $res_json->{refresh_token}\n";
  print "Access Token: $res_json->{access_token}\n";
  print "Access Token Expiration Seconds: $res_json->{expires_in}\n";
}

sub refresh{
  my ($client_secret, $refresh_token) = @_;
  my $ua = LWP::UserAgent->new();
  my $token_uri = URI->new($client_secret->{token_uri});
  my $res = $ua->post($token_uri, {
    client_id     => $client_secret->{client_id},
    client_secret => $client_secret->{client_secret},
    refresh_token => $refresh_token,
    grant_type    => 'refresh_token',
  });
  
  my $res_text = $res->decoded_content;
  my $res_json = JSON::XS->new->utf8->decode($res_text);

  print "\n";
  print "Access Token: $res_json->{access_token}\n";
  print "Access Token Expiration Seconds: $res_json->{expires_in}\n";
}

メール受信(IMAP)

use strict;
use warnings;
use utf8;

use MIME::Base64;
use IO::Socket::SSL;
use Net::IMAP::Client;
use Data::Dumper;
use Time::Out qw /timeout/;

my $imap = Net::IMAP::Client->new(
  server => 'imap.gmail.com',
  port   => 993,
  ssl    => 1,
  timeout => 10,
);
die "Could not connect to IMAP server" unless $imap;

my $access_token = '[access token]';
my $user = '[user mail]';
my $auth_string = sprintf("user=%s\001auth=Bearer %s\001\001", $user, $access_token);

my $capability = $imap->capability;

my $ok;
timeout 5 => sub {
  ($ok) = $imap->_tell_imap('AUTHENTICATE' => 'XOAUTH2 ' . encode_base64($auth_string, ''));
};
if ($@){
  die("AUTHENTICATE timed out");
}
unless($ok){
  die('Login failed: ' . $imap->last_error);
}
$imap->select('INBOX');

Net::IMAP::Client->_tell_imap()というのが生のIMAPコマンドを投げる内部関数らしいので、これを使ってOAuthの認証情報を送ります。

ちなみにオリジナルのNet::IMAP::Client->loginはこんな感じ。

sub login {
    my ($self, $user, $pass) = @_;
    $user ||= $self->{user};
    $pass ||= $self->{pass};
    $self->{user} = $user;
    $self->{pass} = $pass;
    _string_quote($user);
    _string_quote($pass);
    my ($ok) = $self->_tell_imap(LOGIN => "$user $pass");
    return $ok;
}

このAUTHENTICATEコマンドで間違ったアクセストークンを投げるとエラーを返すと思っていたけど、
実際は応答がなくなってしまう(ハングする)のでTime::Outを使って強制的にタイムアウトさせてる。
コンストラクタにtimeoutオプションがあるけど、これはTCP connectのときのタイムアウトっぽくてコマンドのタイムアウトはしなかった。

メール送信(SMTP)

SMTPの場合もOAuth2.0のやり方が書いてあるけどPerlでどう実装するかは要調査。
developers.google.com

とりあえず暫定対応としてユーザーとパスワードでSMTP AUTHができる別なメールサービスを使うことにした。
今後もこういうGoogle独自の認証が増えてくると面倒なので、シンプルなSMTP/IMAPが使えるメールサービスを契約しておくというのは現実的にアリかもしれない。

追記

できた。
kkobayashi-a.hatenablog.com

WindowsでPycURLを使う

PycURL – A Python Interface To The cURL library — PycURL 7.45.1 documentation

PythonでHTTP関係の処理をするならRequestsなのですが、PycURLはlibcurlベースで高速だったり細かい処理ができたりして便利なこともあるので使ってみます。

残念ながらWindows用のバイナリは公式に存在しないそうです。かといってソースからbuildするのもなあ。

検索してみるとこちらのサイトでバイナリを配布してるようなので使ってみます。

Python Extension Packages for Windows - Christoph Gohlke

自分の環境はこれなので "pycurl‑7.45.1‑cp38‑cp38‑win_amd64.whl" をダウンロードします。

$ python -VV
Python 3.8.6 (tags/v3.8.6:db45529, Sep 23 2020, 15:52:53) [MSC v.1927 64 bit (AMD64)]
$ pip install pycurl-7.45.1-cp38-cp38-win_amd64.whl

とりあえず動くサンプル。

from datetime import datetime, timedelta
import pycurl
from io import BytesIO

def report(curl):
    print("Performance report:")
    print("-----------------------------------------------------------------------")
    print("EFFECTIVE_URL      : {}".format(curl.getinfo(pycurl.EFFECTIVE_URL)))
    print("RESPONSE_CODE      : {}".format(curl.getinfo(pycurl.RESPONSE_CODE)))
    print("SIZE_DOWNLOAD      : {}".format(curl.getinfo(pycurl.SIZE_DOWNLOAD)))

    print("NAMELOOKUP_TIME    : {}".format(curl.getinfo(pycurl.NAMELOOKUP_TIME)))
    print("CONNECT_TIME       : {} {}".format(curl.getinfo(pycurl.CONNECT_TIME), curl.getinfo(pycurl.CONNECT_TIME)-curl.getinfo(pycurl.NAMELOOKUP_TIME)))
    # APPCONNECT : ssl_handshake_done
    print("APPCONNECT_TIME    : {} {}".format(curl.getinfo(pycurl.APPCONNECT_TIME), curl.getinfo(pycurl.APPCONNECT_TIME)-curl.getinfo(pycurl.CONNECT_TIME)))
    # Time to HTTP GET done
    # https://curl.se/libcurl/c/CURLINFO_PRETRANSFER_TIME.html
    print("PRETRANSFER_TIME   : {} {}".format(curl.getinfo(pycurl.PRETRANSFER_TIME), curl.getinfo(pycurl.PRETRANSFER_TIME)-curl.getinfo(pycurl.APPCONNECT_TIME)))
    # STARTTRANSFER : TTFB(time to first byte)
    print("STARTTRANSFER_TIME : {} {}".format(curl.getinfo(pycurl.STARTTRANSFER_TIME), curl.getinfo(pycurl.STARTTRANSFER_TIME)-curl.getinfo(pycurl.PRETRANSFER_TIME)))
    print("TOTAL_TIME         : {} {}".format(curl.getinfo(pycurl.TOTAL_TIME), curl.getinfo(pycurl.TOTAL_TIME)-curl.getinfo(pycurl.STARTTRANSFER_TIME)))
    print("REDIRECT_TIME      : {}".format(curl.getinfo(pycurl.REDIRECT_TIME)))
    print()

def _curl_debug(type, data):
    # CURLINFO_TEXT = 0,
    # CURLINFO_HEADER_IN,    /* 1 */
    # CURLINFO_HEADER_OUT,   /* 2 */
    # CURLINFO_DATA_IN,      /* 3 */
    # CURLINFO_DATA_OUT,     /* 4 */
    # CURLINFO_SSL_DATA_IN,  /* 5 */
    # CURLINFO_SSL_DATA_OUT, /* 6 */

    type_str = ('*', '<', '>', '{', '}', '<<', '>>')
    msg = None
    if type == 3 or type == 4:
        msg = "[{} bytes data]".format(len(data))
    else:
        msg = data.decode('utf-8').strip()

    print("{} {} {}".format(datetime.now(), type_str[type], msg))

buffer = BytesIO()
curl = pycurl.Curl()
curl.setopt(pycurl.URL, 'http://pycurl.io/docs/latest/index.html')
curl.setopt(pycurl.WRITEDATA, buffer)
curl.setopt(pycurl.FOLLOWLOCATION, True)
curl.setopt(pycurl.VERBOSE, True)
curl.setopt(pycurl.DEBUGFUNCTION, _curl_debug)
curl.perform()
print()
report(curl)
curl.close()
$ python a.py
2022-05-06 09:09:49.740667 * Trying 192.30.252.154:80...
2022-05-06 09:09:49.914677 * Connected to pycurl.io (192.30.252.154) port 80 (#0)
2022-05-06 09:09:49.914677 > GET /docs/latest/index.html HTTP/1.1
Host: pycurl.io
User-Agent: PycURL/7.45.1 libcurl/7.80.0 Schannel zlib/1.2.11 zstd/1.5.2 c-ares/1.18.1 libssh2/1.10.0
Accept: */*
2022-05-06 09:09:50.093687 * Mark bundle as not supporting multiuse
2022-05-06 09:09:50.094688 < HTTP/1.1 200 OK
2022-05-06 09:09:50.094688 < Server: GitHub.com
2022-05-06 09:09:50.094688 < Date: Fri, 06 May 2022 00:09:49 GMT
2022-05-06 09:09:50.094688 < Content-Type: text/html; charset=utf-8
2022-05-06 09:09:50.094688 < Content-Length: 22758
2022-05-06 09:09:50.094688 < Vary: Accept-Encoding
2022-05-06 09:09:50.094688 < Last-Modified: Sun, 13 Mar 2022 07:25:32 GMT
2022-05-06 09:09:50.094688 < Vary: Accept-Encoding
2022-05-06 09:09:50.094688 < Access-Control-Allow-Origin: *
2022-05-06 09:09:50.094688 < ETag: "622d9c6c-58e6"
2022-05-06 09:09:50.094688 < expires: Fri, 06 May 2022 00:19:49 GMT
2022-05-06 09:09:50.094688 < Cache-Control: max-age=600
2022-05-06 09:09:50.094688 < Accept-Ranges: bytes
2022-05-06 09:09:50.094688 < x-proxy-cache: MISS
2022-05-06 09:09:50.094688 < X-GitHub-Request-Id: E3E7:3DA7:4CD618:744035:6274674D
2022-05-06 09:09:50.094688 <
2022-05-06 09:09:50.094688 { [984 bytes data]
2022-05-06 09:09:50.094688 { [12924 bytes data]
2022-05-06 09:09:50.268697 { [5744 bytes data]
2022-05-06 09:09:50.268697 { [3106 bytes data]
2022-05-06 09:09:50.268697 * Connection #0 to host pycurl.io left intact

Performance report:
-----------------------------------------------------------------------
EFFECTIVE_URL      : http://pycurl.io/docs/latest/index.html
RESPONSE_CODE      : 200
SIZE_DOWNLOAD      : 22758.0
NAMELOOKUP_TIME    : 0.003169
CONNECT_TIME       : 0.177756 0.174587
APPCONNECT_TIME    : 0.0 -0.177756
PRETRANSFER_TIME   : 0.17809 0.17809
STARTTRANSFER_TIME : 0.356996 0.17890599999999998
TOTAL_TIME         : 0.531489 0.174493
REDIRECT_TIME      : 0.0

いいね。