Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
55
Help us understand the problem. What are the problem?
@richmikan@github

シェルスクリプトでメール送信、「添付ファイルも一緒に」編

シェルスクリプトから単純なメール(日本語)を送る方法は以前に紹介しましたので、もうできますね。

今回は、さらに添付ファイルを付けて送りましょう。この技法さえ習得すれば、毎日生成するログファイルを、定時にcronで自動的にメール送信することもできますし、或いはatコマンドを併用し、予め用意しておいた本文と添付ファイルを指定日時に予約送信するなんてこともできます。

必要なのはたったこれだけ

添付ファイルを送るとはいえ、必要なものは添付ファイル無しの時と同じで、次の3つだけです。

  • メールを送信できるホスト (これ最重要)
  • Bourneシェル(素のshでも、bashでもdashでもkshでもzshでも)
  • sendmailコマンド(大抵は /usr/sbin あたりに入ってます)

これだけあればOK。つまりPOSIX(UNIXで必須のもの)以外で必要なものはsendmailコマンドを備えた主要MTA(sendmail、Postfix、qmail、…)だけです。(nkfすら不要)

読み進めていくと、base64コマンドを用いている箇所がありますが、POSIXの範囲で自作していますので特に要りません。base64コマンドが無い環境では、補足に記したコードをコピーしてシェルにペーストすれば、シェル関数版base64(ただしエンコードのみ)が即使用可能です。

技術解説

手順概要

まずは作業の流れを大まかに示しておきます。

  1. 本文や各添付ファイルをそれぞれBase64エンコードする
    • 本文はエンコード前に、CR+LF改行にしておく
  2. 本文や各添付ファイルの手前にマルチパート内ヘッダーをつける
    • Content-Type: */*ヘッダー(添付ファイルの場合は、ここにname属性を付記してファイル名を指定)
    • Content-Transfer-Encoding: base64ヘッダー
    • 添付ファイルには更に、Content-Disposition: attachmentヘッダー(ここにfilename属性を付記してもう一度ファイル名を指定)
    • ヘッダーセクションの後、本文・ファイルの手前に空行を1行置く
  3. その各本文・添付ファイルの前後行に境界文字列行--HOGEHOGEを置く
    • HOGEHOGEは、本文や添付ファイルと被らないランダムな文字列にしておく(メールの場合はデータをBase64化するので実は何でもよい)
    • 一番最後の境界文字列だけは、行末にハイフン2文字を付け足して--HOGEHOGE--とする
  4. Content-Type: multipart/mixed; boundary="HOGEHOGE"を付けながら、メールヘッダーを作る
    • Content-Type以外は単純な日本語メールと同じ。From:やTo:、Subject:ヘッダーを同様に書く。
  5. あとはsendjpmailコマンドに流し込むだけ。
    • もし、From:やTo:、Subject:ヘッダーに日本語文字が一切含まれていなければsendmail -i -tに流し込んでもよい。

チュートリアル

それではこの手順に従い、具体的にメール送信をしてみましょう。UTF-8で書いた本文用のテキストファイル(msg.txt)と何らかのJPEG画像(img.jpg)、それから何らかのZIPファイル(arc.zip)を用意しておいてください。

1. 本文や添付ファイルをBase64エンコード

まずは、メールで送りたい本文や添付ファイルを全て、それぞれBase64エンコードします。UTF-8テキストも添付ファイルも8ビットバイナリーデータであり、SMTPサーバーを通過できる保証がないためです。

実際のコードはこんな感じです。本文1つと添付ファイル2つ(JPGE画像ファイル、ZIP圧縮ファイル)をそれぞれBase64エンコードしています。

本文と添付ファイルをBase64エンコード
$ CR=$(printf '\r')            # ←本文テキストは
$ cat msg.txt               |  #  Base64化の前に
> sed "/^\$/s/\$/$CR/"      |  #  CR+LF改行に
> sed "/[^$CR]\$/s/\$/$CR/" |  #  しなければならない
> base64                    > msg.txt.base64
$ cat img.jpg | base64      > img.jpg.base64
$ cat arc.zip | base64      > arc.zip.base64
$ 

本文(msg.txt)だけ、base64コマンドに流し込む前にsedコマンドを使って各行末にCRコードを付加しています。これは、メールテキストの仕様で改行コードはCR+LFと定められているからです。Base64エンコードしない箇所は最後に流し込む先であるsendmailコマンドが自動的にCRコードを付加してくれますので不要(むしろ付加されるので付けない方がよい)ですが、エンコードされてしまった中身には付加できないのでここでしなければならないのです。

2. マルチパート内ヘッダーをつける

今作った各Base64エンコードファイルの手前に、適宜ヘッダーを付加します。付加するものは

  • Content-Transfer-Encoding: base64
  • Content-Type: */*(添付ファイルの場合は、ここにname属性を付記してファイル名を指定)
  • 添付ファイルには更に、Content-Disposition: attachmentヘッダー(ここにfilename属性を付記してもう一度ファイル名を指定)

です。

Content-Type*/*の部分には、それぞれに応じて適切な名称を付けます。公式な名前はIANAのMedia Typeページに記載されています。この例で出てくるUTF-8テキストファイルはtext/plain; charset="UTF-8"、JPEGファイルはimage/jpeg、ZIPファイルはapplication/zipです。

また、添付ファイルである2つはさらに、Content-Type: */*ヘッダーにname属性としてファイル名付記し、さらにContent-Disposition: attachmentヘッダーを追加したうえでfilename属性として同じファイル名を付記してください。具体的には次のコードを見て理解してください。ちなみに、2箇所に同じファイル名を付記するのは受け取る側のプログラムの作られた時期によってどちらを見るか分からないためです。

マルチパート内ヘッダーを付加
$ echo 'Content-Transfer-Encoding: base64'         >  msg.txt.part
$ echo 'Content-Type: text/plain; charset="UTF-8"' >> msg.txt.part
$ echo ''                                          >> msg.txt.part
$ cat msg.txt.base64                               >> msg.txt.part
$ echo 'Content-Transfer-Encoding: base64'                   >  img.jpg.part
$ echo 'Content-Type: image/jpeg; name="img.jpg"'            >> img.jpg.part
$ echo 'Content-Disposition: attachment; filename="img.jpg"' >> img.jpg.part
$ echo ''                                                    >> img.jpg.part
$ cat img.jpg.base64                                         >> img.jpg.part
$ echo 'Content-Transfer-Encoding: base64'                   >  arc.zip.part
$ echo 'Content-Type: image/jpeg; name="arc.zip"'            >> arc.zip.part
$ echo 'Content-Disposition: attachment; filename="arc.zip"' >> arc.zip.part
$ echo ''                                                    >> arc.zip.part
$ cat arc.zip.base64                                         >> arc.zip.part
$ 

ところで、もしファイル名に日本語文字列を含んでいたらどうするかについて補足しておきますが、その時は件名等と同じルールでBase64エンコードしておきます。ここでの説明は割愛しますが、添付ファイルなしメール送信手順のstep(3/3)を参照してください。

3. 各本文・添付ファイルの前後行に境界文字列行--HOGEHOGEを置く

本文や添付ファイルに基づいた各パートファイルができたところで、今度はそれらを繋ぎ合わせてマルチパートファイルを作っていきます。

具体的には、境界文字列で挟みながら各パートファイルを付けたしていきます。境界文字列は--HOGEHOGEのように、行頭がハイフン2つで始まる文字列で、各パートファイルの中身と被らないようにランダムな文字を作らなくてはなりません。しかしメールデータの場合は全てBase64エンコードしているので、理論的にどんな文字列でも大丈夫です。なぜならBase64エンコードされたデータの中にはハイフンが出現しないからです。なので、このチュートリアルではそのままHOGEHOGEでいきます。

具体的なコードは次のとおりです。

各パートデータを境界文字列で挟みながら結合
$ echo '--HOGEHOGE'   >  body.txt
$ cat msg.txt.part    >> body.txt
$ echo '--HOGEHOGE'   >> body.txt
$ cat img.jpg.part    >> body.txt
$ echo '--HOGEHOGE'   >> body.txt
$ cat arc.zip.part    >> body.txt
$ echo '--HOGEHOGE--' >> body.txt # 行末に"-"が2つ付いていることに注意!
$ 

ここで注意!境界文字列のうち、一番最後の文字列だけは、うしろにハイフン-を2つ付けてください。そういう仕様です。無くても動く実装がほとんどだとは思いますが、正しく開けない実装もあるかもしれません。

4. Content-Type: multipart/mixed; boundary="HOGEHOGE"を加えてメールヘッダーを作成

いろいろなテンポラリーファイルを作ってきましたがこれが最後です。メールヘッダーファイルを作ります。From:やTo:、Subject:などを適宜付けるのは添付ファイル無しの時と同じですが、一箇所だけ違うのはそれらに加えてContent-Type: multipart/mixed; boundary="HOGEHOGE"というヘッダーを付けること。HOGEHOGEの部分には先程の手順で使った境界文字列のハイフンを除いた部分を記述しますが、今回のチュートリアルではそのままHOGEHOGEとしているのでこのままでOKです。

ではコードを書いてみましょう。実際に試す場合はメールアドレスは実在のものを書いてください。From:ヘッダーも含めていい加減なものを書くとspamフィルターに引っかかりやすくなってしまいます。

ヘッダー部分を作る(Content-Type;multipart/mixedを付けて)
$ cat <<MAILHEADER > header.txt
From: わたし <MY-ADDRESS@YOUR-DOMAIN.OCM>
To: あなた <YOUR-ADDRESS@YOUR-DOMAIN.OCM>
Subject: 添付ファイル付メール送ります
Content-Type: multipart/mixed; boundary="HOGEHOGE"

MAILHEADER
$ 

ヘッダーの最後は、その後に続くボディー部分との境界を示すためのものですので忘れないようにしてください。

5. sendjpmailコマンドに流し込み、メール送信

あとはヘッダー部とボディーを結合しながら、拙作の日本語対応メール送信ラッパーsendjpmailに流し込めば完了です。sendjpmailはヘッダー部にある日本語(UTF-8)文字列をBase64に変換しながら送ってくれるコマンドですので、もしヘッダー部に一切日本語文字列が含まれないのであれば次に示す例のように直接sendmailコマンドに流し込んで構いません。

メール送信!
# From:やTo:、Subject:等に日本語文字を含めている場合
$ cat header.txt body.txt | sendjpmail
$ 

# From:やTo:、Subject:等ヘッダー部に一切日本語文字が出てこないならこちらもOK
$ cat header.txt body.txt | sendmail -i -t
$ 

どうですか、無事届きましたか?仕組みがわかれば難しくないですよね?

コマンドも作りました

いくら難しくないとはいえやることはいっぱいありますし、テンポラリーファイルもたくさん作っていて始末も面倒です。そこで、MIMEマルチパートを作る処理をコマンド化したmime-makeを用意しました。

使い方

これを使うと添付ファイル送信はだいぶ簡単になります。先程のチュートリアルで用いた本文用(msg.txt)、JPEG画像(img.jpg)、ZIPファイル(arc.zip)を同じように送る場合、下記のようなワンライナーでできてしまいます。

先程のチュートリアルと同じメールを送信
$ mime-make --wh -M msg.txt -A img.jpg -A arc.zip                 |
> awk 'BEGIN{print "From: わたし <MY-ADDRESS@YOUR-DOMAIN.OCM>";   #
>            print "To: あなた <YOUR-ADDRESS@YOUR-DOMAIN.OCM>";   #
>            print "Subject: 添付ファイル付メール送ります";    }  #
>      {print;                                                 }' |
> sendjpmail
$ 

簡単に解説しておきます。

最初のmime-makeコマンドで、本文と各添付ファイルをBase64化してヘッダーを付けて--HOGEHOGEから--HOGEHOGE--で挟むという作業をしています。-Mオプション(massage)が本文ファイル指定用、-Aオプション(attachment)が添付ファイル指定用ですが、さらに--wh(with-header)というオプションを付けるとContent-Type: multipart/mixed; boundary="HOGEHOGE"の部分まで含めて生成してくれます。

次の行のAWKで、そのテキストデータの手前に各種ヘッダーを付加し(mime-makeコマンドがボディー部との境界の空行を付けているためこのAWKコマンドでは付けない)、出来上がったものを最後のsendjpmailに流し込んでおしまいです。

これなら簡単ですよね?

補足

POSIX原理主義に基づくbase64コマンド(エンコードのみ)

次のコードをコピー&ペーストしておけば、base64コマンドのエンコード方向がシェル関数として使えるようになります。

シェル関数版base64コマンド(エンコードのみ)
# Base64 Encoder ($1:width a line)
base64 () {
  w=''
  case "${1:-}" in
    --wrap=*) printf '%s\n' "$1" | grep -q '^--wrap=[0-9]\{1,\}$' && {
                w=${1#--wrap=}
              }
              ;;
  esac
  case "$w" in '') w=76;; esac
  od -A n -t x1 -v                                                         |
  awk 'BEGIN{OFS=""; ORS="";                                               #
             x2o["0"]="0000"; x2o["1"]="0001"; x2o["2"]="0010";            #
             x2o["3"]="0011"; x2o["4"]="0100"; x2o["5"]="0101";            #
             x2o["6"]="0110"; x2o["7"]="0111"; x2o["8"]="1000";            #
             x2o["9"]="1001"; x2o["a"]="1010"; x2o["b"]="1011";            #
             x2o["c"]="1100"; x2o["d"]="1101"; x2o["e"]="1110";            #
             x2o["f"]="1111";                                              #
             x2o["A"]="1010"; x2o["B"]="1011"; x2o["C"]="1100";            #
             x2o["D"]="1101"; x2o["E"]="1110"; x2o["F"]="1111";         }  #
       {     l=length($0);                                                 #
             for(i=1;i<=l;i++){print x2o[substr($0,i,1)];}                 #
             printf("\n");                                              }' |
  awk 'BEGIN{s="";                                                      }  #
       {     buf=buf $0;                                                   #
             l=length(buf);                                                #
             if(l<6){next;}                                                #
             u=int(l/6)*6;                                                 #
             for(p=1;p<u;p+=6){print substr(buf,p,6);}                     #
             buf=substr(buf,p);                                         }  #
       END  {if(length(buf)>0){print substr(buf "00000",1,6);}          }' |
  awk 'BEGIN{ORS=""; w='$w';                                               #
             o2b6["000000"]="A"; o2b6["000001"]="B"; o2b6["000010"]="C";   #
             o2b6["000011"]="D"; o2b6["000100"]="E"; o2b6["000101"]="F";   #
             o2b6["000110"]="G"; o2b6["000111"]="H"; o2b6["001000"]="I";   #
             o2b6["001001"]="J"; o2b6["001010"]="K"; o2b6["001011"]="L";   #
             o2b6["001100"]="M"; o2b6["001101"]="N"; o2b6["001110"]="O";   #
             o2b6["001111"]="P"; o2b6["010000"]="Q"; o2b6["010001"]="R";   #
             o2b6["010010"]="S"; o2b6["010011"]="T"; o2b6["010100"]="U";   #
             o2b6["010101"]="V"; o2b6["010110"]="W"; o2b6["010111"]="X";   #
             o2b6["011000"]="Y"; o2b6["011001"]="Z"; o2b6["011010"]="a";   #
             o2b6["011011"]="b"; o2b6["011100"]="c"; o2b6["011101"]="d";   #
             o2b6["011110"]="e"; o2b6["011111"]="f"; o2b6["100000"]="g";   #
             o2b6["100001"]="h"; o2b6["100010"]="i"; o2b6["100011"]="j";   #
             o2b6["100100"]="k"; o2b6["100101"]="l"; o2b6["100110"]="m";   #
             o2b6["100111"]="n"; o2b6["101000"]="o"; o2b6["101001"]="p";   #
             o2b6["101010"]="q"; o2b6["101011"]="r"; o2b6["101100"]="s";   #
             o2b6["101101"]="t"; o2b6["101110"]="u"; o2b6["101111"]="v";   #
             o2b6["110000"]="w"; o2b6["110001"]="x"; o2b6["110010"]="y";   #
             o2b6["110011"]="z"; o2b6["110100"]="0"; o2b6["110101"]="1";   #
             o2b6["110110"]="2"; o2b6["110111"]="3"; o2b6["111000"]="4";   #
             o2b6["111001"]="5"; o2b6["111010"]="6"; o2b6["111011"]="7";   #
             o2b6["111100"]="8"; o2b6["111101"]="9"; o2b6["111110"]="+";   #
             o2b6["111111"]="/";                                           #
             if (getline) {print o2b6[$0];n=1;}                         }  #
       n==w {printf("\n")  ; n=0;                                       }  #
       {     print o2b6[$0]; n++;                                       }  #
       END  {if(NR>0){printf("%s\n",substr("===",1,(4-(NR%4))%4));}     }'
}

これはPOSIX原理主義の実力を示すためのものですので、既にbase64コマンドがある環境では意味がありません。デコードができなくなってしまいます。いや、デコードまで対応したbase64コマンドもちゃんと作っていますけどね。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
55
Help us understand the problem. What are the problem?