プログラマーのメモ書き

伊勢在住のプログラマーが気になることを気ままにメモったブログです

修正UTF-7 のエンコード/デコード処理について

はじめに

メインのメールとして、さくらインターネットのレンタルサーバーのメールを使っています。

今までは POP でローカルにメールを取ってきて処理していたのですが、最近、さすがに不便になってきたので、 IMAP で処理しようと思い立ちました。 幸い、さくらのメールは、 Webメール としてもアクセスできるので、問題なさそうです。

と思ったのですが、試し始めると大きな問題が。
というのも、さくらインターネットの Webメールでは、メールのフォルダへの振り分けができないのです。 いろんなメールが来るので、それをいちいち手作業で振り分けているのも、さすがにつらいものがあります。

いろいろと調べてみると、さくらインターネットの場合、 IMAP サーバ として Courier IMAP を使っており、 MDA (Mail Delivery Agent) として maildrop というがメールの受信と振り分けをやってくれているようです(maildropに関するさくら側の情報はないので、推測です)。 で、その設定ファイル .mailfilter を書いてやれば、一応振り分けはできるとのことです(こちらのさくらインターネットのヘルプでmailfilterについて触れてます)。

それなら、簡単だと思いつつ、Webメールのインターフェースから適当にフォルダを作って、

f:id:junichim:20190817104104p:plain

SSHでログインしてフォルダ名を確認すると、

[myuser@xxxxxxx ~/MailBox/account/maildir]$ ls -laF
total 84
drwx------  17 myuser  users  1024 Aug 17 10:37 ./
drwx------   5 myuser  users   512 Aug 16 15:57 .&MEIwRDBGMEgwSg-/
drwx------   6 myuser  users   512 Jul 11 16:04 .&Yy8wilIGMFEwxjC5MMg-/
drwx------   6 myuser  users   512 Jul 11 16:07 .&Yy8wilIGMFEwxjC5MMg-.&Txp5Pv8S-/
drwx------   6 myuser  users   512 Aug 10 18:23 .&Yy8wilIGMFEwxjC5MMg-.company1/
drwx------   5 myuser  users   512 Aug 10 19:33 ../
drwx------   6 myuser  users   512 Jul 11 15:37 .Archive/
drwx------   6 myuser  users   512 Jul 11 15:41 .Draft/
drwx------   6 myuser  users   512 Jul 11 15:37 .Junk E-mail/
drwx------   6 myuser  users   512 Aug  1 22:49 .Sent/
drwx------   6 myuser  users   512 Jul 11 15:41 .Trash/
drwx------   6 myuser  users   512 Jul 13 00:02 .spam/

のように、一瞬文字化けか?と思うようなわけわからん記号のフォルダが存在しています。

調べてみると、これは、日本語などのマルチバイト文字をエンコードしたフォルダ名とのこと。 このエンコード方法が IMAP で決められていて、それが『修正UTF-7』という方法になるんだそうです。

まだまだ知らないことだらけですね。

.mailfilter を書くにあたり、どれがどのフォルダに該当するのかわからないのは、はなはだ不便です。 が、ちょっとネットを見てみても、入力文字列を 修正UTF-7 にエンコードしてくれるサービスが見つかりません。
多くの方、困ってないのかな?

ということで、仕方ないので、今年のお盆休みの課題として、 修正UTF-7 にエンコード/デコードしてくれるツールを作りましたので、メモ残しておきます。

修正UTF-7

まずは、 修正UTF-7 の規格について調べます。

ゆっくり読むと、それほど難しくはないようです。

まとめると、

  • & は &- にエンコードする
  • & 以外のASCII印字可能文字はそのままとする
  • それ以外の文字は、UTF-16BE(ビッグエンディアン)でエンコード後、修正Base64でエンコードして、前に & 後ろに - を付加する

修正Base64は、

  • Base64で変換する
  • / (スラッシュ)を , (カンマ)で置き換える
  • 4文字に満たない場合も = (等号)でパディングしない

とのことです。

では、実装してみましょう

実装

今回は WSL 上の node.js (v10.16.0) で作業しました。

変換処理のメイン部分は下記の記事を参考にしました。

JavaScript で Modified UTF-7 にエンコード&デコード - Qiita

ただ、自分で、Base64 や UTF-16BE へのエンコーディングは避ける方針とし、

  • UTF-16BEへのエンコーディングは、 iconv-lite
  • base64 へのエンコーディングは、 Buffer

を使いました。

エンコード処理

エンコード処理の主な部分について触れておきます。

function encode(str) {
        var encoded = str
            .replace(/&/g, "&-")
            .replace(/[\u0000-\u0019\u007f-\uffff]+/g, modifiedBase64Encode);

        //console.log(encoded)
        return encoded;
}

最初に & を &- に変換しています。この変換を行っても、いわゆるASCII印字可能文字が増えるだけなので、修正Base64でのエンコード対象外になり、後段の処理に影響は及ぼしません。
次に、正規表現でASCII印字可能文字範囲以外が連続して出てくる箇所を見つけて、修正base64でエンコードするという処理になります。ASCII 印字可能文字の範囲が 0x20 ~ 0x7e までなので、そこを外した範囲指定になります。

ちなみにこれって、node.js(JavaScript)の文字列の内部のエンコーディングが、UTF-16だから成立しているのだと思います。 たとえば、

mor@DESKTOP-H6IEJF9:/tmp/mutf7$ node
> "あ".charCodeAt(0).toString(16)
'3042'

とすれば、『あ』に対応する UTF-16のコード U+3042 が表示されるのがわかります。

修正base64の処理はこんな感じになります。

function modifiedBase64Encode(str) {
        const buf = iconv.encode(str, "utf16-be");
        //console.log(str);
        //console.log(buf);
        return "&"+ buf.toString("base64").replace(/\//g, ",").replace(/=/g, "") + "-";
}

正規表現で変換対象として文字列を、UTF-16BEでエンコード後、さらにbase64で変換し、修正base64に合うように、文字を置換しておきます。

一連の処理の結果、正しい変換結果になっているかどうかは、さくらインターネットのWebメールで実際にフォルダ名を設定して確認しておきます。

デコード処理は基本的にこれと逆の処理をすればよいことになるので、省略しておきます。興味があれば、ソースをご覧ください。

ツールとして整理

基本の変換処理は実装できたので、ツールとして仕立てます。
ツールとして仕立てようとすると、引数をうまく処理する必要があります。 そのあたりの引数処理を行うために commander というライブラリを使いました。

いやー、便利なもんですね。

できたもの

最終的には、こんな感じになりました。

#!/usr/bin/env node
/**
 * 修正UTF-7 エンコード・デコード関数
 *
 * https://qiita.com/tkooler_lufar/items/66b01f1a88c9e018f76e
 * https://smdn.jp/programming/netfx/tips/modified_utf7/
 */

'use strict';

const iconv = require('iconv-lite');
const program = require('commander');

// コマンドライン引数処理
program.version('0.0.1')
       .usage('文字列')
       .description("与えられた文字列について、修正UTF7によるエンコード/デコードを行います")
       .option("-d, --decode","修正UTF7文字列をデコードします");

program.parse(process.argv);
//console.log("optons: " + program.opts());

var args = program.args
if (!args || args.length == 0) {
    console.log("no target string");
    process.exit(1);
    return;
}
//console.log("args: " + program.args);

if (program.decode) {
        var decoded = decode(args[0]);
        console.log(decoded);
        return;
}
var encoded = encode(args[0])
console.log(encoded)
return;

/////////////////////////////////////
// 以下、関数

function encode(str) {
        var encoded = str
            .replace(/&/g, "&-")
            .replace(/[\u0000-\u0019\u007f-\uffff]+/g, modifiedBase64Encode);

        //console.log(encoded)
        return encoded;
}
function decode(str) {
        var decoded = str
            .replace(/&([A-Za-z0-9\+\,]+)-/g, modifiedBase64Decode)
            .replace(/&-/g, "&");

        //console.log(decoded)
        return decoded;
}

function modifiedBase64Encode(str) {
        const buf = iconv.encode(str, "utf16-be");
        //console.log(str);
        //console.log(buf);
        return "&"+ buf.toString("base64").replace(/\//g, ",").replace(/=/g, "") + "-";
}
function modifiedBase64Decode(str, substr) {
        const buf = Buffer.from(substr.replace(/,/g, "/"), "base64")
        //console.log(str);
        //console.log(substr);
        //console.log(buf);
        const decoded = iconv.decode(buf, "utf16-be");
        //console.log(decoded);
        return decoded;
}

動作確認

動かしてみます。

オプションを指定しないときは、エンコードします。

mor@DESKTOP-H6IEJF9:/tmp/mutf7$ node index.js あいうえお
&MEIwRDBGMEgwSg-
mor@DESKTOP-H6IEJF9:/tmp/mutf7$ node index.js 日本語
&ZeVnLIqe-

サロゲートペアの文字(下記の例だと、𩸽(ほっけ)、𠮷)が入っていても大丈夫です。

mor@DESKTOP-H6IEJF9:/tmp/mutf7$ node index.js 𩸽定食𠮷野家
&2GfePVuamN,YQt+3kc5btg-

デコードもできます。

mor@DESKTOP-H6IEJF9:/tmp/mutf7$ node index.js -d "&MEIwRDBGMEgwSg-"
あいうえお

一応、 Github にもあげておきます。

github.com

余談

以下余談です。

その1:紆余曲折

すんなりと上記の実装が書けたわけではなく、いろいろと紆余曲折がありました。

最初、 node.js (javascript) の場合、base64に変換するのが、 Buffer を使えばできるとのことだったので、

var buf = Buffer.from("対象文字列")
var res = buf.toString("base64");

としてました。 変換結果を確認すると、違ってます。

何だろう?と思い調べてみると、 Buffer#from は文字コードを指定しないと、UTF-8でエンコードするそうです。文字列の内部表現がUTF-16なので、てっきりUTF-16だと思い込んでしまいました。

じゃあ、UTF-16BEを指定すればいいやと思いきや、Bufferは UTF-16BE には対応していないそうです(UTF-16LEはあるんですけどね)。

はじめてのNode.js:Node.js内でバイナリデータを扱うための「Buffer」クラス | OSDN Magazine

困ったなと思ってあれこれ見てたとき、ふと、ツールを作る前に iconv で修正UTF-7に変換すればいいやん、と考えていたことを思い出しました。 ただ、iconv の場合、下記に示すように、修正UTF-7には対応してなかったんですが、UTF-16はいけます。

mor@DESKTOP-H6IEJF9:/tmp/mutf7$ iconv --list | grep -i utf
ISO-10646/UTF-8/
ISO-10646/UTF8/
UTF-7//
UTF-8//
UTF-16//
UTF-16BE//
UTF-16LE//
UTF-32//
UTF-32BE//
UTF-32LE//
UTF7//
UTF8//
UTF16//
UTF16BE//
UTF16LE//
UTF32//
UTF32BE//
UTF32LE//
mor@DESKTOP-H6IEJF9:/tmp/mutf7$

ということで、 node.js から iconv を使うためのライブラリを探してみると

mor@DESKTOP-H6IEJF9:/tmp/mutf7$ npm search iconv
NAME                      | DESCRIPTION          | AUTHOR          | DATE       | VERSION  | KEYWORDS
iconv                     | Text recoding in…    | =bnoordhuis     | 2019-03-31 | 2.3.4    |
iconv-lite                | Convert character…   | =ashtuchkin     | 2019-06-26 | 0.5.0    | iconv convert charset icu
codepage                  | pure-JS library to…  | =sheetjs        | 2018-07-08 | 1.14.0   | codepage iconv convert strings
・・・

いろいろとありました。
この中の、 iconv-lite がよさそうだったので、今回はこれを使うことにしました。

その2:さくらのWebメールとサロゲートペア

さくらインターネットのwebメールでは、サロゲートペアにあたる文字が含まれるフォルダは正しく処理されませんでした。

例えば、『𩸽定食』というフォルダを作成すると

f:id:junichim:20190817113734p:plain

フォルダは作られるんだけど、画面上は文字化けしています。

f:id:junichim:20190817113949p:plain

ちなみに、直接該当アカウントのmaildir を見ると

[myuser@xxxxxxx ~/MailBox/account/maildir]$ ls -laF
total 88
drwx------  18 myuser  users  512 Aug 17 11:37 ./
drwx------   5 myuser  users  512 Aug 17 11:37 .&2GfePVuamN8-/
drwx------   5 myuser  users  512 Aug 16 15:57 .&MEIwRDBGMEgwSg-/
drwx------   6 myuser  users  512 Jul 11 16:04 .&Yy8wilIGMFEwxjC5MMg-/
drwx------   6 myuser  users  512 Jul 11 16:07 .&Yy8wilIGMFEwxjC5MMg-.&Txp5Pv8S-/
drwx------   6 myuser  users  512 Aug 10 18:23 .&Yy8wilIGMFEwxjC5MMg-.company1/
drwx------   5 myuser  users  512 Aug 10 19:33 ../

のように、正しく作られています(.&2GfePVuamN8- が𩸽定食のフォルダに該当しています)。

また、中身を見ようとしても、『読み込み中』が表示されて、そこで処理が止まってしまいます。このフォルダ名の変更、削除もできません。

いくつかのサロゲートペアの文字で試しても同様の結果になりましたが、サロゲートペアの文字一般についても同じ問題が起きるのかまでは調査していません。
もしくは、サロゲートペアの問題なのか、それとも別の問題があるのかまで特定したわけでもありません。

ですが、もし、Webメールのフォルダを操作していて不思議な挙動があれば、サロゲートペアを疑ってみるのもいいかもしれません。

ちなみに、この状態でフォルダを削除するには、SSHでログインして、 ~/Mailbox/メールアドレス名/maildir 以下にある、該当フォルダを直接削除すれば可能です。 ただ、フォルダ名は修正UTF7でエンコードされているので、間違って他のフォルダを消さないように注意してください。

(参考) JavaScript における文字コードと「文字数」の数え方 | blog.jxck.io