Python

Python の標準ライブラリで新着メールだけを受信してDBに保存する

More than 1 year has passed since last update.

これは ユニキャストアドベントカレンダー の5日目の記事です。
世間的には6日目だけど5日目です。
2日目の Python の標準ライブラリでメールをデコードする の続きになります。

前回と前々回で、Python の標準ライブラリでメールを受信できることを確認しました。
今回はもう一歩進んで「新着メールのみ」受信するプログラムを書きます。

新着メールをチェックする仕組み (POP3)

POP3 では IMAP とは異なり、サーバ側で既読・未読状態を保持してくれません。
代わりに、メッセージにユニークなID番号がつけられており、この番号をクライアント側の受信済みメールとサーバ上のメールとの間で照合することで、受信済みかどうかを確認することができます。
POP3 ではこのユニークなIDを UIDL コマンドで取得することができます。

Python では poplib.POP3 クラスの uidl() メソッドで取得できます。

クライアント側で UIDL を記憶する

ユニークIDをサーバ側とクライアント側とで照合するには、クライアント側に UIDL コマンドの結果を保存しておく必要があります。
前回fetchmail() 関数らへんを改造して、受信したメールのUIDL番号、差出人、送信日、件名、本文を SQLite データベースに保存するようにしてみます。
ちなみに Python では SQLite も標準ライブラリでサポートしています。

import sqlite3
import poplib
import email
from email.header import decode_header
from email.utils import parsedate_to_datetime

# msg から name ヘッダを取得
def get_header(msg, name):
    header = ''
    if msg[name]:
        for tup in decode_header(str(msg[name])):
            if type(tup[0]) is bytes:
                charset = tup[1]
                if charset:
                    header += tup[0].decode(tup[1])
                else:
                    header += tup[0].decode()
            elif type(tup[0]) is str:
                header += tup[0]
    return header


# msg から本文を取得
def get_content(msg):
    charset = msg.get_content_charset()
    payload = msg.get_payload(decode=True)
    try:
        if payload:
            if charset:
                return payload.decode(charset)
            else:
                return payload.decode()
        else:
            return ""
    except:
        return payload # デコードできない場合は生データにフォールバック


# 指定した番号のメッセージを受信する
def fetchmail(cli, msg_no):
    content = cli.retr(msg_no)[1]
    uidl = cli.uidl(msg_no).decode().split(' ')[-1]
    msg = email.message_from_bytes(b'\r\n'.join(content))
    from_ = get_header(msg, 'From')
    date_hdr = get_header(msg, 'Date')
    if date_hdr:
        date = parsedate_to_datetime(date_hdr)
    else:
        date = None
    subject = get_header(msg, 'Subject')
    content = get_content(msg)
    return (uidl, subject, content, from_, date)

fetchmail()uidl() メソッドを呼び出す処理を追加しました。
UIDLコマンドは <メッセージ番号> <UIDL番号> という形式で返ってくるため、空白で split() して切り出します。
切り出したUIDL番号を SQLite データベースに保存しておき、次の受信時にサーバ側のUIDL番号と照合することで、受信済みかどうかをチェックすることができるようになります。

上記は一通のメールに対して uidl() を呼び出していますが、 uidl() を引数なしで呼び出すと、サーバ上の全メールのUIDL番号のリストが返ってきます。
このリストと、SQLite データベースに入れておいたUIDL番号のリストと照合すれば、未受信のメールのUIDL番号およびメッセージ番号がわかります。
この照合処理は、Pythonの set オブジェクトの集合演算を使うと簡単に実装できます。

# 新着メールのUIDL番号のリストを返す
def find_newmail(cli, db):
    uidl = cli.uidl()[1]
    remote_uidl = list(map(lambda elm: elm.decode().split(' '), cli.uidl()[1]))
    c = db.cursor()
    res = c.execute('SELECT uidl FROM mail').fetchall()
    local_uidl = map(lambda tup: tup[0], res)
    # サーバ上のUIDL番号のセットと、受信済みメールのUIDL番号のセットとの差集合をとる
    new_uidl = set(map(lambda elm: elm[-1], remote_uidl)) - set(local_uidl)
    return list(filter(lambda elm: elm[1] in new_uidl, remote_uidl))

余談ですが、Pythonista はあまり lambda をつかわず、リスト内包表記の方を好むと聞きます。
筆者は他の言語から入ったのでリスト処理を lambda で書いてしまいがちです。

上記の fetchmail()find_newmail() を組み合わせれば、

  • 新着メールだけを受信
  • 受信内容をデータベースに保存

する処理を以下のように書けます。

def receive_all(cli, db):
    newmail = find_newmail(cli, db)
    count = len(newmail)
    c = db.cursor()
    for mail in newmail:
        msg = fetchmail(cli, mail[0])
        c.execute("""
            INSERT INTO mail (uidl, subject, content, sender, sent_at)
            VALUES (?, ?, ?, ?, ?)
            """, msg)
        print('Date: %s, From: %s, Subject: %s' % (msg[4], msg[3], msg[1]))
        db.commit()
    c.close()

呼び出し方は以下のようになります。

def setup(db):
    c = db.cursor()
    c.execute('CREATE TABLE mail (uidl text, subject text, content text, sender text, sent_at timestamp, created_at default current_timestamp)')
    db.commit()
    c.close()

db = sqlite3.connect('mail.db')
setup(db)

cli = poplib.POP3_SSL('mail.example.com')
cli.user('jsaito@example.com')
cli.pass_('password')

receive_all(cli, db)
cli.quit()

実行すると、メールを延々と受信し続けますが、Ctrl+Cで抜けてから再度実行しても、受信済みのメールは受信せずに、続きから受信するように動作します。
(文字コードの取り扱いが雑なので受信中にエラーになるかも。)

まとめ

Python の標準ライブラリで POP3 の UIDL を用いた新着メール受信のサンプルを例示しました。

だんだんメールソフトっぽい動きになってきました。
次回は新着通知をポップアップ表示したり、内容に特定のキーワードが入っていたらチャットに通知したりする方法を見ていきたいと思います。