IMAP4と日本語のメールボックス

少し前に Yahoo!メールが IMAP4 から読めるようになったので、Python の標準ライブラリに収録されている imaplib を試しているが日本語で問題が発生した。

普通のメールソフトでフォルダに相当するものを IMAP4 はメールボックスと呼んでいる。メールボックスの名前として使える文字は ASCII の一部だけで、残りは Modified UTF-7 という方法で符号化しなければならない。しかし現在の Python にはこの実装がついてこない。

事案5305: imaplib は内部メールボックス名をサポートすべき」という文書から

  • Twisted がすでに Modified UTF-7 を実装しているが、
  • それには軽度なバグがある(ということを日本人らしき人が指摘している)

ということがわかった。自分は Python3 の実装が欲しかったので、前述のバグの改修も含めて移植してみた。

# Python 3.2.2
import codecs
import base64

def fix_base64(s):
    pad = b'=' * (((~len(s)) + 1) & 3)
    return s + pad

def modified_unbase64(s):
    s = fix_base64(s)
    return base64.b64decode(s, b'+,').decode('utf_16_be')

def modified_base64(s):
    enc = base64.b64encode(s.encode('utf_16_be'), b'+,')
    return enc.rstrip(b'=')

def decoder(s, errors=None):
    assert isinstance(s, bytes) or isinstance(s, memoryview)

    r = []
    decode = []

    for i in bytes(s):
        if i == ord(b'&') and not decode:
            decode.append(i)
        elif i == ord(b'-') and decode:
            if len(decode) == 1:
                r.append('&')
            else:
                r.append(modified_unbase64(bytes(decode[1:])))
            del decode[:]
        elif decode:
            decode.append(i)
        else:
            r.append(chr(i))

    if decode:
        r.append(modified_unbase64(bytes(r[1:])))
    return ''.join(r), len(s)

def encoder(s, erros=None):
    ret = bytearray()
    _in = []
    
    for c in s:
        if '\x20' <= c <= '\x7E':
            if _in:
                ret.extend(b'&' + modified_base64(''.join(_in)) + b'-')
                del _in[:]
            if c == '&':
                ret += b'&-'
            else:
                ret += c.encode()
        else:
            _in.append(c)

    if _in:
        ret.extend(b'&' + modified_base64(''.join(_in)) + b'-')

    return bytes(ret), len(ret)

class StreamReader(codecs.StreamReader):
    def decode(self, s, errors='strict'):
        return decoder(s)

class StreamWriter(codecs.StreamWriter):
    def encode(self, s, errors='strict'):
        return encoder(s)

_codecInfo = codecs.CodecInfo(encoder, decoder, StreamReader, StreamWriter)

def imap4_utf_7(name):
    if name == 'imap4-utf-7':
        return _codecInfo

codecs.register(imap4_utf_7)

以上のコードを実行すると、かなり簡易ではあるものの一応 imap4-utf-7 というエンコーディングが使用可能になる。以下は適当なメールボックスを作って削除する例。

import imaplib
import getpass
import re
import sys

def test():
    conn = imaplib.IMAP4_SSL('imap.mail.yahoo.co.jp')
    password = getpass.getpass('password:')
    try:
        conn.login('/* ここにアカウント名を入れる */', password)
        create_mailbox(conn, 'やっほーyahoo')
        list_mailbox(conn)
        print('-' * 20)
        delete_mailbox(conn, 'やっほーyahoo')
        list_mailbox(conn)
    except conn.error as err:
        print('IMAP Error:', err, file=sys.stderr)
    finally:
        conn.logout()

def list_mailbox(conn):
    re_response = re.compile(br'\((?P<flags>.*?)\) "(?P<delimiter>.*)" (?P<name>.*)')
    typ, lines = conn.list()
    for i in lines:
        m = re_response.match(i)
        if m:
            flags, delim, name = m.groups()
            if name.startswith(b'"') and name.endswith(b'"'):
                name = name[1:-1]
            print('-', name.decode('imap4-utf-7'))
        else:
            print('Response Parsing Error:', i, file=sys.stderr)

def create_mailbox(conn, name):
    conn.create(name.encode('imap4-utf-7'))

def delete_mailbox(conn, name):
    conn.delete(name.encode('imap4-utf-7'))


if __name__ == '__main__':
    test()

""" 結果:
- Bulk Mail
- Draft
- Inbox
- Sent
- Trash
- やっほーyahoo
- 保存
--------------------
- Bulk Mail
- Draft
- Inbox
- Sent
- Trash
- 保存
"""