python で SMTP 認証を行ってメールを送信する

呼称: Snakemail
目的: コマンドラインで使える「ちょっとした」メール送信クライアントを作成する
特徴: 任意のファイルを添付できる(たぶん)、SMTP 認証に対応している
用例: テストスクリプトに組み込んで、実行結果のログをメールに添付して送信する
備考: 大雑把なエラー制御のみで、詳細な SMTP 通信の仕組みを私が分かっていないので注意!

「ちょっとした」用途でコマンドライン(スクリプト)からメールを送信したいことがあります。そんなメール送信クライアントを作ってみました。sendmail コマンドを使えば良いのでは?と思うのは最もですが、任意の SMTP サーバを指定できないという唯一の欠点があります(私がやり方を知らないだけだったらツッコミください)。

同様のツールで id:kyagi さんが RubyRingmail を作っていましたが、これは SMTP 認証に未対応ですよね?(^ ^;;

さらに、SMTP 認証を用いると gmailSMTP サーバ経由でメールを送信できます。外部のメールサーバへの接続を許可している環境であれば、事実上、メールは自由に送信できますね。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import getopt
import os
import sys
import smtplib
from email import message_from_string
from email import Encoders
from email.MIMEBase import MIMEBase
from email.MIMEText import MIMEText
from email.MIMEMultipart import MIMEMultipart
from email.Header import Header
from email.Utils import formatdate
from mimetypes import guess_type
from getpass import getpass

class Mail():
    """supported text/plain and base64 encoded attachment"""
    def __init__(self, args):
        self.server    = args[0]
        self.port      = args[1]
        self.from_addr = args[2]
        self.to_addr   = args[3]
        self.subject   = args[4]
        self.body      = args[5]
        self.attach    = args[6]
        self.opt_flag  = args[7]
        self.msg       = None
        self.auth_user = None
        self.auth_pass = None
    
    def create_message(self):
        if self.attach:
            self.create_multipart_message()
        else:
            self.create_text_message()
        if self.opt_flag['debug'] == 'on':
            print '-'*36 + ' from here'
            print self.msg
            print '-'*36 + ' up here'
    
    def create_base_header(self):
        self.msg['Subject'] = Header(self.subject, 'utf-8')
        self.msg['From'] = self.from_addr
        rcpt_to = self.to_addr[0]
        for addr in self.to_addr[1:]:
            rcpt_to += ', ' + addr
        self.msg['To'] = rcpt_to
        self.msg['Date'] = formatdate()

    def create_text_message(self):
        self.msg = MIMEText(self.body, 'plain', 'utf-8')
        self.create_base_header()
   
    def create_multipart_message(self):
        # base header and body
        self.msg = MIMEMultipart('mixed')
        self.create_base_header()
        text_msg = MIMEText(self.body, 'plain', 'utf-8')
        self.msg.attach(text_msg)
        
        # attachements
        for att in self.attach:
            # get mime-type by checking file extension 
            content_type = guess_type(att)[0]
            main_type, sub_type = content_type.split('/', 1)
            
            # add attached file with base64 encode
            sub_part = MIMEBase(main_type, sub_type)
            sub_part['Content-ID'] = att
            sub_part.set_payload(open(att).read())
            Encoders.encode_base64(sub_part)
            sub_part.add_header('Content-Type', content_type, name=att)
            self.msg.attach(sub_part)
        
    def send(self):
        if self.opt_flag['auth'] == 'on':
            self.auth_user = raw_input('smtp-auth user: ')
            self.auth_pass = getpass('smtp-auth passwd: ')
        
        try:
            s = smtplib.SMTP(self.server, self.port)
        except Exception, err:
            raise SnakeErrorConnectMailServer, (
                    err, self.server, self.port)
        try:
            s.ehlo()
            s.starttls()
            s.ehlo()
            if self.auth_user and self.auth_pass:
               s.login(self.auth_user, self.auth_pass)
            s.sendmail(self.from_addr, self.to_addr, 
                        self.msg.as_string())
        except Exception, err:
            raise SnakeErrorSMTP, err
        finally:
            s.close()

class ParseOption:
    def __init__(self, args):
        self.values = self.parse(args)

    def parse(self, args):
        """parse command line arguments"""
        try:
            opts, ttt = getopt.getopt(args, 'm:p:f:t:s:b:a:Avh',
                    ['server=', 'port=', 'from=', 'to=', 
                     'subject=', 'body=', 'attach='])
        except getopt.error, err:
            raise SnakeErrorUsage, err
        
        # initial values
        server     = 'localhost'
        port       = 25
        from_addr  = os.getenv('USER') + '@' + os.getenv('HOSTNAME')
        to_addr    = []
        subject    = ''
        body       = ''
        attach     = []
        opt_flag   = {'auth':'off', 'debug':'off'}
        for opt, val in opts:
            if opt == '-h' or opt == '--help':
                raise SnakeErrorUsage
            if opt == '-A' or opt == '--Auth':
                opt_flag['auth'] = 'on'
                continue
            if opt == '-v' or opt == '--verbose':
                opt_flag['debug'] = 'on'
                continue
            if opt == '-m' or opt == '--server':
                server = val
                continue
            if opt == '-p' or opt == '--port':
                port = val
                continue
            if opt == '-f' or opt == '--from':
                from_addr = val
                continue
            if opt == '-t' or opt == '--to':
                to_addr.append(val)
                continue
            if opt == '-s' or opt == '--subject':
                subject = val
                continue
            if opt == '-b' or opt == '--body':
                body = val
                continue
            if opt == '-a' or opt == '--attach':
                attach.append(val)
                continue
      
        if not to_addr:
            raise SnakeErrorNotMailTo
        
        return (server, port, from_addr, to_addr, 
                subject, body, attach, opt_flag)

class SnakeError(Exception):
    """Base class for all exceptions"""
    def __init__(self, msg):
        print 'Exception: %s' % (self.__class__.__name__)
        if msg:
            sys.stderr.write('%s\n' % (msg))

class SnakeErrorUsage(SnakeError):
    def __init__(self, msg=None):
        SnakeError.__init__(self, msg)
        usage()

class SnakeErrorNotMailTo(SnakeError):
    def __init__(self, msg=None):
        SnakeError.__init__(self, msg)
        print 'you have to give "To Address"'
        usage()

class SnakeErrorConnectMailServer(SnakeError):
    def __init__(self, *args):
        SnakeError.__init__(self, args[0])
        print 'mail server: %s' % (args[1])
        print '       port: %s' % (args[2])
    
class SnakeErrorSMTP(SnakeError):
    def __init__(self, msg=None):
        SnakeError.__init__(self, msg)

def usage():
    print ('Usage: python %s -m mail_server -p port_number '
           '-f from_address -t to_address -s subject -b body '
           '-a attach -A -v -h\n'
           '   -A: enable SMTP-Authentication\n'
           '   -v: enable DEBUG mode\n'
           '   -h: display usage' % (sys.argv[0]))

def main():
    """Script Main"""
    try:
        opt = ParseOption(sys.argv[1:])
        m = Mail(opt.values)
        m.create_message()
        m.send()
    except SnakeError, err:
        sys.exit(1)

if __name__ == '__main__':
    main()

実行結果。

$ python snakemail.py -A -m smtp.gmail.com -p 587 -t xxx@gmail.com \
                       -f yyy -s gmail_test -b body_test -a ttt.txt -v
 ------------------------------------ from here
From nobody Sun Jul  5 14:09:25 2009
Content-Type: multipart/mixed; boundary="===============1881580230==" 
MIME-Version: 1.0                  
Subject: =?utf-8?q?gmail=5Ftest?=
From: yyy@localhost.localdomain
To: xxx@gmail.com                             
Date: Sun, 05 Jul 2009 05:09:25 -0000

 --===============1881580230==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64

Ym9keV90ZXN0

 --===============1881580230==
Content-Type: text/plain
MIME-Version: 1.0
Content-ID: ttt.txt
Content-Transfer-Encoding: base64
Content-Type: text/plain; name="ttt.txt"

YWJjCg==

 --===============1881580230==--
 ------------------------------------ up here
smtp-auth user: 
smtp-auth passwd: 

リファレンス:
7.1 email -- 電子メールと MIME 処理のためのパッケージ
Pythonでメールを送信したい人のためのサンプル集