POP3クライアントのモジュールを書いてみた

さて、今回はPOP3クライアントのモジュールを書いてみようと思います。前回、半泣きでテストを書いてみたので、それに合わせてつらつらと書いていけば良いはずです。

ところでPOP3では、三つの状態(AUTHORIZATION, TRANSACTION, UPDATE)が規定されています。「クライアント側だから考慮しなくていいかなぁ」と迷ったものの、そういう仕様を知ってしまった以上は実装してみたいと思います。趣味のYAGNI破りです。

では、前回ざっと決めたデザイン(オブジェクトを使う、POP3サーバーとの入出力は関数で包んじゃう、など)に沿ってコードを書いていきます。いつも通り、コンパイルエラーの山と戦うのですが、経験値が上がってきたおかげで以前ほど、苦しまなくなってきました。

で、できたのがこんなコード。

type stat =
  | Authrization
  | Transaction
  | Update
  | Close

type cmd =
  | User of string
  | Pass of string
  | Quit
  | Stat
  | List of int option
  | Retr of int
  | Dele of int
  | Noop
  | Rset
  | Top of int * int

type res =
  | OneLine
  | MultiLine

exception Error_response

type cmd_info_rec = {str:string; res:res; trans:stat option}

let cmd_info stat cmd =
  let sp = Printf.sprintf in
  let otos = function Some i -> sp " %d" i | None -> "" in
  match stat with
  | Authrization -> (
      match cmd with
      | User s -> {str = sp "USER %s" s; res = OneLine; trans = None}
      | Pass s -> {str = sp "PASS %s" s; res = OneLine; trans = Some Transaction}
      | Quit ->   {str = "QUIT"; res = OneLine; trans = Some Close}
      | x -> invalid_arg "stat is Authrization"
    )
  | Transaction -> (
      match cmd with
      | Stat ->   {str = "STAT"; res = OneLine; trans = None}
      | List o -> {str = "LIST" ^ (otos o); res = MultiLine; trans = None}
      | Retr i -> {str = sp "RETR %d" i; res = MultiLine; trans = None}
      | Dele i -> {str = sp "DELE %d" i; res = OneLine; trans = None}
      | Noop ->   {str = "NOOP"; res = OneLine; trans = None}
      | Rset ->   {str = "RSET"; res = MultiLine; trans = None}
      | Top (i, j) -> {str = sp "TOP %d %d" i j; res = MultiLine; trans = None}
      | Quit ->   {str = "QUIT"; res = OneLine; trans = Some Update}
      | x -> invalid_arg "stat is Transaction"
    )
  | x -> invalid_arg "stat is neither Authrization nor Transaction"

let recieve_response infun resmode =
  let re = Str.regexp (if resmode = OneLine then "\r\n" else "\r\n.\r\n") in
  let bufsize = 1024 in
  let resbuf = Buffer.create bufsize in
  let tmpbuf = String.create bufsize in
  let rec recv () =
    let rlen = infun tmpbuf 0 bufsize in (
      try
        let pos = Str.search_forward re tmpbuf 0 in
        Buffer.add_substring resbuf tmpbuf 0 pos;
        Buffer.contents resbuf
      with
      | Not_found ->
          Buffer.add_substring resbuf tmpbuf 0 rlen; recv ()
    ) in
  recv ()

let parse_and_check_response resbuf =
  let list = Str.split (Str.regexp "\r\n") resbuf in
  match list with
  | [] -> failwith "invalid response";
  | hd::tl -> (
      let res = Str.bounded_split (Str.regexp " ") hd 2 in
      match (List.hd res) with
      | "+OK" -> String.concat " " (List.tl res)::tl
      | "-ERR" -> raise Error_response
      | _ -> failwith "invalid response"
    )

class pop3_client ~infun ~outfun = object (self)

  val mutable stat = Authrization

  val infuc = infun
  val outfun = outfun

  method private execmd cmd =
    let ci = cmd_info stat cmd in
    let sendstr = ci.str ^ "\r\n" in
    outfun sendstr 0 (String.length sendstr);
    let resbuf = recieve_response infun ci.res in
    let reslist = parse_and_check_response resbuf in (
      match ci.trans with
      | None -> ()
      | Some s -> ignore (stat <- s)
    );
    reslist

  method auth ~user ~pass =
    ignore (recieve_response infun OneLine);
    ignore (self#execmd (User user));
    ignore (self#execmd (Pass pass));
    ()

  method quit () = ignore (self#execmd Quit)
  method stat () = self#execmd Stat
  method list ?(msgno) () = self#execmd (List msgno)
  method retr ~msgno = self#execmd (Retr msgno)
  method dele ~msgno = ignore (self#execmd (Dele msgno))
  method noop () = ignore (self#execmd Noop)
  method rset () = ignore (self#execmd Rset)
  method top ~msgno ~line  = self#execmd (Top (msgno, line))
end

今回悩んだのは、共通処理をletによる関数定義にすべきか、privateメソッドにすべきか、というところでした。 他のライブラリのコードを適当に眺めて見たんですが、どうもポリシーが読み取れなかった。

それはさておき、改めてコードを見てみると、サービス精神のかけらも無いインターフェースにひるみますねぇ。このモジュールを使ったプログラムが、list や retr メソッドのレスポンスを涙ぐましく処理している姿が目に浮かびます… 「一つメソッドを叩けば、各メッセージについてヘッダーの連想配列と本文のタプルをリストにして返してくれる」、そんなやさしいメソッドがあってしかるべきですが、力尽きてきたのでこの辺で…

あと、本当はOCamlシグネチャという機能を活用して、モジュールに対するアクセスを制限しなきゃならないはず。recieve_responseとかの関数は、外から呼ばれるとまずいのですから。これも力尽きたのを理由に省略…

というわけで、いくつかの手抜きをやり過ごしつつ実際に動かしてみましょう。そもそもの目的が「Gmailにアクセス」だったのでそれをやってみます。

open Unix
open Ssl
open Pop3

type conn_info = {host:string; port:int; cafile:string}
type user_info = {user:string; password:string}

let ci = {host = "pop.gmail.com"; port = 995;
          cafile = "/usr/ports/security/ca-roots/files/ca-root.crt"}
let ui = {user = "hogehoge"; password = "hogepass"}

let ssl_conn info =
  Ssl.init () ;
  let hostent =
      try gethostbyname info.host with
      | Not_found -> failwith "gethostbyname" in
  let sockaddr = ADDR_INET((hostent.h_addr_list.(0)), info.port) in
  let ctx = Ssl.create_context Ssl.SSLv23 Ssl.Client_context in
  Ssl.load_verify_locations ctx info.cafile "";
  let ssl = Ssl.open_connection_with_context ctx sockaddr in
  Ssl.verify ssl;
  ssl

let main () =
  let ssl = ssl_conn ci in
  let p = print_endline in
  let p_hr () = print_endline "--------------------------" in

  p "connecting...";
  p_hr ();

  let pop3 = new pop3_client ~infun:(Ssl.read ssl) ~outfun:(Ssl.write ssl) in
  pop3#auth ~user:(ui.user) ~pass:(ui.password);

  p "mail list";
  p_hr ();
  p (String.concat "\n" (pop3#list ()));
  p_hr ();

  let rec loop () =
    p "please enter msgno > ";
    try
      let msgno = read_int () in
      p_hr ();
      p (String.concat "\n" (pop3#retr ~msgno:msgno));
      p_hr ();
      loop ()
    with _ -> () in
  loop ();

  pop3#rset ();
  pop3#quit ();
  Ssl.shutdown_connection ssl
;;

let () = main ()

SSL接続のところは以前のコードの流用です。で、取得した接続を、Ssl.read/writeに部分適用して入出力用の関数を作成し、pop3_clientに渡してあげています。これを以下のようにコンパイルしてみます。

ocamlbuild -ocamlc 'ocamlfind c -package str,ssl -linkpkg' mailan.byte

そして、おもむろに./mailan.byteと叩いてみると…

mitsu@garlic$ ./mailan.byte
connecting...
--------------------------
mail list
--------------------------
1 messages (1633 bytes)
1 1633
--------------------------
please enter msgno >
1
--------------------------
message follows
Delivered-To: hogehoge@gmail.com
Received: by 10.20.30.40 with SMTP id 28314098way;
        Mon, 2 Jun 2008 07:30:29 -0700 (PDT)
From: hogehoge@hotmail.com
To: hogehoge@gmail.com
Subject: test mail
Date: Mon, 2 Jun 2008 14:29:12 +0000
Content-Type: text/plain; charset="iso-2022-jp"
MIME-Version: 1.0
X-OriginalArrivalTime: 02 Jun 2008 14:29:13.0438 (UTC) FILETIME=[078997E0:01C8C4BD]

hello, world

_________________________________________________________________
Live Search CO?^8!:w$GEl5~$NET?4It$,3DI=<($K!*%I%3$r$_$F$_$k!)
http://maps.live.com/
--------------------------
please enter msgno >
q
mitsu@garlic$

一応、成功です(出力内容は一部編集してます)。

メッセージの一覧が表示され、その後メッセージ番号を入力すると、当該メールのヘッダと本文が表示されました(LISTとRETRコマンドのレスポンスを表示しているだけとも言う…)。

メールに付与している広告文が文字化けしていますが、iso-2022-jpUTF-8(私の開発環境)の文字コード変換が必要っぽいです。OCamlでは、camomileというライブラリをつかうのが定番のようですが、それはまた別の話…