風柳メモ

ソフトウェア・プログラミング関連の覚書が中心

メール処理でいろいろとはまる

ascii と iso-2022-jp 混在の Subject がうまくデコードされない

新刊.net のアラートメールの Subject は、

Subject: =?ISO-2022-JP?B?GyRCPzc0KRsoQg==?=.net =?ISO-2022-JP?B?GyRCJSIlaSE8JUgbKEI=?=

みたいになっているのだが(メーラ等で見ると "新刊.net アラート" と表示される)、これを Python 2.7.6 でデコードしようとすると、

>>> value = '=?ISO-2022-JP?B?GyRCPzc0KRsoQg==?=.net =?ISO-2022-JP?B?GyRCJSIlaSE8JUgbKEI=?='
>>> 
>>> from email.header import decode_header, make_header
>>> dc_list = decode_header(value)
>>> dc_list
[('\x1b$B?74)\x1b(B', 'iso-2022-jp')]
>>> print unicode(make_header(dc_list))
新刊
>>> 

のようになって、".net アラート" が消えてしまう。
iso-2022-jp→ascii→iso-2022-jpの文字列なので、dc_list((文字列, コード)のペアリスト)は3組でないとおかしいが、1組しかない。


${PYTHONHOME}/lib/python2.7/email/header.py を調べてみると、エンコードされている文字列を抜き出すための正規表現が、

# Match encoded-word strings in the form =?charset?q?Hello_World?=
ecre = re.compile(r'''
  =\?                   # literal =?
  (?P<charset>[^?]*?)   # non-greedy up to the next ? is the charset
  \?                    # literal ?
  (?P<encoding>[qb])    # either a "q" or a "b", case insensitive
  \?                    # literal ?
  (?P<encoded>.*?)      # non-greedy up to the next ?= is the encoded string
  \?=                   # literal ?=
  (?=[ \t]|$)           # whitespace or the end of the string
  ''', re.VERBOSE | re.IGNORECASE | re.MULTILINE)

のように定義されていた。


これだと、"=?ISO-2022-JP?B?<エンコード文字列>?="の直後は半角スペースもしくはタブでないといけないが、上記例だとすぐに".net"と続いているためにマッチせず、文字列の終端である"...KEI=?="の部分までが一連のものとみなされてしまう。
一方で、デコードの処理では、最初の"?="までしか処理されないため、その後の部分がとりこぼされてしまう。


試しに、

  (?=[ \t]|$)           # whitespace or the end of the string

の部分だけをコメントアウトして試してみると、

>>> dc_list = decode_header(value)
>>> dc_list
[('\x1b$B?74)\x1b(B', 'iso-2022-jp'), ('.net', None), ('\x1b$B%"%i!<%H\x1b(B', 'iso-2022-jp')]
>>> print unicode(make_header(dc_list))
新刊 .net アラート
>>>

のように、とりあえず ascii 部と iso-2022-jp 部とできちんと分割されているように見える。
ただし、

  • "新刊"と".net" の間に半角スペースが入っている。
    下記の __unicode__() によって付加される。
  • ".net"の後の半角スペースが消えてしまっている。
    decode_header() 内の "unenc = parts.pop(0).strip()" の箇所で消されている。
    ちなみに、".net"と"アラート"の間にある半角スペースは、__unicode__() によって付加されたもの。


${PYTHONHOME}/lib/python2.7/email/header.py を流し読みしていると、class Header 定義で、

    def __unicode__(self):
        """Helper for the built-in unicode function."""
        uchunks = []
        lastcs = None
        for s, charset in self._chunks:
            # We must preserve spaces between encoded and non-encoded word
            # boundaries, which means for us we need to add a space when we go
            # from a charset to None/us-ascii, or from None/us-ascii to a
            # charset.  Only do this for the second and subsequent chunks.
            nextcs = charset

のようなコメントがあった。
要は、Non/us-ascii とそれ以外のエンコードされた文字列が続いている場合には、間にスペースを挟むような仕様が前提となっている模様。

となると、そもそも新刊.netの Subject のエンコード方法がおかしい可能性もある、のか?
もっとも、多くのメーラで意図通りにデコードされているところを見ると、Python の email.header が余計なことをしている可能性も…悩むくらいなら、正しい仕様を調べろという話もあるが。

ちなみに、Python でエンコード→デコードすると、

>>> from email.Header import Header
>>> value = str(Header(u'新刊.net アラート', 'ISO-2022-JP'))
>>> value
'=?iso-2022-jp?b?GyRCPzc0KRsoQi5uZXQgGyRCJSIlaSE8JUgbKEI=?='
>>> 
>>> from email.header import decode_header, make_header
>>> dc_list = decode_header(value)
>>> dc_list
[('\x1b$B?74)\x1b(B.net \x1b$B%"%i!<%H\x1b(B', 'iso-2022-jp')]
>>> print unicode(make_header(dc_list))
新刊.net アラート
>>> 

こんな感じになる。
ペアは一組だけで、iso-2022-jp として一括で処理される。

追記

参考までに、上記問題に対応した ${PYTHONHOME}/lib/python2.7/email/header.py の差分(2行修正・3行追加)。

$ diff -cr header-orig.py ./header.py
*** header-orig.py      2014-01-02 22:30:22.084789888 +0900
--- ./header.py 2014-01-16 07:07:25.258016571 +0900
***************
*** 39,45 ****
    \?                    # literal ?
    (?P<encoded>.*?)      # non-greedy up to the next ?= is the encoded string
    \?=                   # literal ?=
!   (?=[ \t]|$)           # whitespace or the end of the string
    ''', re.VERBOSE | re.IGNORECASE | re.MULTILINE)

  # Field name regexp, including trailing colon, but not separating whitespace,
--- 39,45 ----
    \?                    # literal ?
    (?P<encoded>.*?)      # non-greedy up to the next ?= is the encoded string
    \?=                   # literal ?=
!   #(?=[ \t]|$)           # whitespace or the end of the string
    ''', re.VERBOSE | re.IGNORECASE | re.MULTILINE)

  # Field name regexp, including trailing colon, but not separating whitespace,
***************
*** 82,88 ****
              continue
          parts = ecre.split(line)
          while parts:
!             unenc = parts.pop(0).strip()
              if unenc:
                  # Should we continue a long line?
                  if decoded and decoded[-1][1] is None:
--- 82,88 ----
              continue
          parts = ecre.split(line)
          while parts:
!             unenc = parts.pop(0)
              if unenc:
                  # Should we continue a long line?
                  if decoded and decoded[-1][1] is None:
***************
*** 202,207 ****
--- 202,208 ----
      def __unicode__(self):
          """Helper for the built-in unicode function."""
          uchunks = []
+         '''
          lastcs = None
          for s, charset in self._chunks:
              # We must preserve spaces between encoded and non-encoded word
***************
*** 218,223 ****
--- 219,227 ----
                      uchunks.append(USPACE)
              lastcs = nextcs
              uchunks.append(unicode(s, str(charset)))
+         '''
+         for s, charset in self._chunks:
+             uchunks.append(unicode(s, str(charset)))
          return UEMPTYSTRING.join(uchunks)

      # Rich comparison operators for equality only.  BAW: does it make sense to

Python で送信→スマートフォンで受信したメール本文の最後に「�」(U+FFFD・REPLACEMENT CHARACTER)が表示されてしまう

PCのメールソフトだと別に普通に見えるのに、スマートフォン(Android:自分の場合は WX04K)で見ると、本文の最後に�が表示されてしまっている。
今のところ、Python で送信する際のエンコード済み文字列に、最後にEOF('\x1a')を付加することで改善されたので、様子見中。
正しい解決方法が知りたい。

2014/01/16 追記

だめだ、最後に EOF を付けると、今度は PC メールで受信したときに、そのまま EOF が表示されてしまう…うーむ。