GaucheでSMTPクライアントを書いてみる(1)

ネットワークプログラムをまともに書いたこともなければ知識もないので、SMTPを題材にしてプログラムを書いてみる。

プログラムの概要

最低限の動作をするSMTPクライアントのプログラムはだいたい次のようになるはず。

(use gauche.net) ; ソケットを使うため

(call-with-client-socket (make-client-socket 'inet host port)
  (lambda (in out)
    ;                                 receive 220
    ; send EHLO 送り手側のドメイン -> receive 250
    ; send MAIL FROM: アドレス     -> receive 250
    ; send RCPT TO: アドレス       -> receive 250
    ;(必要なら send RCPT TO: ... を繰り返す)
    ; send DATA                    -> receive 354
    ; send メールの各行(行頭のピリオドはエスケープする)
    ; send .                       -> receive 250
    ; send QUIT                    -> receive 221
  ))

実装する……

すぐに書けるかと思ったら、まったく思い通りにならず、書くのにやたらと時間がかかった。一応動くようになったけど、ほんとにちゃんと動作しているのか怪しい。こういうプログラムに対する適切なテストの仕方が知りたい。

できあがったプログラム

メインの手続きはsend-mailで、これを呼びだせばメールを送ることができる。

(use gauche.net)

(define (send-mail host port source mail-from mail-to . options)
  (let-keywords options ((mail-tos '())
                         (helo-domain (sys-gethostname)))
    (call-with-client-socket (make-client-socket 'inet host port)
      (lambda (in out)
        (send-and-receive in out #f 220)
        (send-and-receive in out (format #f "EHLO ~a" helo-domain) 250)
        (send-and-receive in out (format #f "MAIL FROM: <~a>" mail-from) 250)
  
        (let1 recipients (cons mail-to mail-tos)
          (for-each 
            (lambda (rcpt)
              (send-and-receive in out (format #f "RCPT TO: <~a>" rcpt) 250))
            recipients))
  
        (send-and-receive in out "DATA" 354)
        (send-body-of-mail out source)
        (send-and-receive in out "." 250)
        (send-and-receive in out "QUIT" 221)
      ))))

(define (display-log . args)
  (apply format #t args))

(define (send-and-receive in out command expected-code)
  (when command
    (display-log "\n->~s\n" command)
    (send-line out command))
  (receive (code lines) (receive-response in)
    (display-log "~s<-~s\n" expected-code lines)
    (when (not (= code expected-code))
      (errorf "smtp error: expected-code ~a\n~a -> ~s"
              expected-code command lines))
    lines))

(define (receive-response in)
  (let* ((lines (read-response in))
         (code (get-code lines)))
    (values code lines)))

(define (read-response in)
  (do ((lines '() (cons line lines))
       (line (read-line in) (read-line in)))
      ((not (continuation-line? line))
       (reverse (cons line lines)))
    ;no-body
  ))

(define (get-code lines)
  (string->number (substring (car lines) 0 3)))
  ;; コードは各桁ごとに意味があるみたいなので、
  ;; 数値に変換せず文字列のままの作りにした方が良いかもしれない。

(define (continuation-line? line)
  (and (> (string-length line) 3)
       (char=? (string-ref line 3) #\-)))

(define (send-line out line)
  (display line out)
  (display "\r\n" out))

(define (send-body-of-mail out source)
  (define (send-additional-period-if-need out line)
    (when (and (> (string-size line) 0)
          (char=? #\. (string-ref line 0)))
      (display "." out)))

  (port-for-each
    (lambda (line)
      (send-additional-period-if-need out line)
      (send-line out line))
    (lambda () (read-line source))))

次のような感じでsend-mailを呼べばメールを送ることができる。

(send-mail "somemailserver.zzz.co.jp" 25
  (open-input-string
     "From: <from@aaa.bbb.ccc.co.jp>\n\
      To: <to@xxx.yyy.zzz.co.jp>\n\
      Subject: test mail\n\
      \n\
      This is Test Mail!\n\
      \n\
      .\n\
      ...\n\
      ...\n\
      .\n")
    "from@aaa.bbb.ccc.co.jp"
    "to@xxx.yyy.zzz.co.jp"
    :mail-tos '("other1@xxx.yyy.zzz.co.jp" "other2@xxx.yyy.zzz.co.jp"))

いくつか注意。

  • SMTPの標準のポート番号は25番。認証なんかをおこなうSMTPサーバは587番を使うみたいだけど、このプラグラムは認証を扱っていない。
  • 引数sourceは入力ポートで、メールの内容(ヘッダ含む)が入っていること。ヘッダと本文の間には空白行を一行入れる。
    • 入力ポートのリストも扱えるようにすると、ヘッダは文字列ポートで与えて、本文はファイルポートで与えるみたいなことができて便利かもしれない。単にわかりにくくなるだけかもしれない。
  • SMTPサーバが、EHLOで提示したサーバが実際に存在するかどうかをチェックしている場合がある。その場合はオプション:helo-domainでちゃんとした(公に確認できる)サーバ名を与える。

予定

SMTPクライアントをSSL/TLSに対応させることについて。