Gmailで自動返信メールシステムを構築する方法
Gmail Labsで公開されている「返信定型文」と「フィルタ」機能を組み合わせて自動返信メールを実現することはできますが,いくつか制限があります。
- 自動返信メールの返信元アドレスに「+canned.response」が付く。
- 返信文にあいての名前などを動的に挿入することができない。
そこで,Google Apps Script を使って自動返信メールシステムを作成してみました。 Googleドキュメントのスプレッドシートをベースとし,その中にスクリプトや返信文書のテンプレートなどのシートを置くものとします。
スクリプトの作成
以下に,今回作成したスクリプトを示します。
/**
* 自動応答メールを送信する。
*
* Created by: Field Works, LLC / 合同会社フィールドワークス
* Reference: http://www.field-works.co.jp/
* Date: 2012/03/10
*/
var BCC = 'admin@example.co.jp';
// 書式変換
function format(templ, params) {
return templ.replace(/\${(.*?)}/g, function($0, $1) {
return (params[$1] && typeof(params[$1]) != "object") ?
params[$1].toString() : JSON.stringify(params[$1]);
});
}
// 「template」シートからメール本文を取得する。
function getMessage(ss, row, params) {
var templ = ss.getSheetByName('template');
var r = templ.getRange('A2:B99');
var subject = r.getCell(row, 1).getValue();
var body = r.getCell(row, 2).getValue();
return {subject: format(subject, params), body: format(body, params)};
}
// 送信結果を「log」シートに記録する。
function setResult(ss, params) {
var log = ss.getSheetByName('log');
var r = log.getRange('A2:H999');
// 空いている行を見つける。
for (var i = 1; i <= r.getNumRows(); ++i) {
if (r.getCell(i, 1).getValue() == "") {
break;
}
}
r.getCell(i, 1).setValue(params.TIMESTAMP);
r.getCell(i, 2).setValue(params.COMPANY);
r.getCell(i, 3).setValue(params.NAME);
r.getCell(i, 4).setValue(params.ADDRESS);
r.getCell(i, 5).setValue(params.TEL);
r.getCell(i, 6).setValue(params.COMMENT);
r.getCell(i, 7).setValue(params.RESULT);
r.getCell(i, 8).setValue(params.ERROR);
}
// メール送信
function sendEMail(to, subject, body, opt) {
try {
MailApp.sendEmail(to, subject, body, opt);
} catch (e) {
return {result: 'NG', message: e.message};
}
return {result: 'OK', message: ""};
}
// メール本文からパラメータを抽出する。
function extractMessage(body)
{
var lines = body.split("\n");
var name = "";
var company = "";
var address = "";
var tel = "";
var comment = "";
for (var i = 0; i < lines.length; ++i) {
var line = lines[i];
if (line.match(/^お名前: (.+)/)) {
name = RegExp.$1;
}
if (line.match(/^会社/団体名: (.+)/)) {
company = RegExp.$1;
}
if (line.match(/^メールアドレス: (.+)/)) {
address = RegExp.$1;
}
if (line.match(/^電話番号: (.+)/)) {
tel = RegExp.$1;
}
if (line.match(/^メッセージ: (.+)/)) {
comment = RegExp.$1;
while (lines[i+1] != "") {
comment += "\n";
comment += lines[i+1];
++i;
}
}
}
return {NAME: name, COMPANY: company, ADDRESS: address, TEL: tel,
TO: name + " <" + address + ">", COMMENT: comment}
}
// 新着メールに対して,自動応答メールを送信する。
function checkMail() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var threads = GmailApp.getInboxThreads();
for (var i = 0; i < threads.length; i++) {
Utilities.sleep(1000);
var msgs = threads[i].getMessages();
for (var j = 0; j < msgs.length; j++) {
var msg = msgs[j];
// 新着メールを確認する。
if (msg.isUnread() && msg.getTo() == "info+q@example.co.jp") {
// メール本文からパラメータを抽出する。
var params = extractMessage(msgs[j].getBody());
// タイムスタンプを加える。
params["TIMESTAMP"] = msgs[j].getDate();
// 応答メールのテンプレートを取得する。
var send = getMessage(ss, 1, params);
// 応答メールを送信する。
if (params.ADDRESS != "") {
var ret = sendEMail(params.TO, send.subject, send.body, {bcc: BCC});
// 送信結果を加える。
params["RESULT"] = ret.result;
params["ERROR"] = ret.message;
// 送信結果を記録する。
Logger.log(params);
setResult(ss, params);
}
// メールを既読にし,アーカイブへ移動させる。
msg.markRead();
threads[i].moveToArchive();
}
}
}
}
Gmailメッセージの取得
checkMail()では,Gmailの受信ボックスをチェックし自動返信対象となるメールの着信を確認します。
Gmailに届いたメールのスレッドやメッセージを取得するには,「GmailApp」オブジェクトを使います。 受信トレイのスレッドを取得するには,以下のメソッドを使用します。
var threads = GmailApp.getInboxThreads()
さらに,スレッドの中からメッセージの配列を取り出します。
var msgs = threads[i].getMessages();
メッセージ配列から取り出したGmailMessageオブジェクトが1通のメールメッセージに対応します。
var msg = msgs[j];
次に,Gmailに届いたメールの中から,自動返信の対象となるメッセージを探します。 今回は,未読かつ info+q@example.co.jp 宛に届いたメールという条件で振り分けています。
if (msg.isUnread() && msg.getTo() == "info+q@example.co.jp") {
自動返信処理が終わったメッセージは既読状態にしておかないと,なんどもメールを送信してしまいます。 処理の最後で既読状態にし,さらにアーカイブへ移動させています。 これは,受信トレイに残るメールを減らして次回以降の無駄な処理を省くためです。
msg.markRead(); threads[i].moveToArchive();
GmailMessageオブジェクトからは,getSubject()で件名,getBody()でメール本文のように様々なプロパティを取り出すことができます。 詳しくは,Google Apps Scriptのリファレンス・マニュアルを参照してください。
なお,スレッドを1件処理する度に1秒間のスリープを入れていますが, これはスリープを入れずに実行したところ「アクセスが頻繁すぎる」旨の警告が出されたためです。 実際には1秒は長すぎると思いますが,余裕を見て1秒に設定しています。
メール本文の解析
メール本文は,以下のフォーマットで送られてくるものとしています。
お名前: <名前>
メールアドレス: <メールアドレス>
会社/団体名: <会社名>
電話番号: <電話番号>
メッセージ: <メッセージ>
extractMessage(body)関数では,メール本文の内容を解析し必要な情報を取り出します。 解析結果は,NAME, COMPANY, ADDRESS, TEL, TO, COMMENT を属性としたオブジェクトとして返します。
返信文の組み立て
スプレッドシートの「template」シートを作成し,返信文のテンプレートを置きます。
「template」シートの1行目は見出しで,2行目に返信文の件名と本文を記述します。 件名や本文の中で「${NAME}」の様に記述すると,受信メールから抜き出した情報に置き換わります。
応答メールの送信と送信結果の記録
sendEMail(to, subject, body, opt)関数の中では,MailApp.sendEmail()を使ってメールを送信します。
setResult(ss, params)関数では,「log」シートに送信結果を書き込み記録として残します。
設定手順
スプレッドシートの作成
Googleドキュメントでスプレッドシートを作成し任意の名前をつける(ここでは「自動応答メール」とした)。
templateシートを作成し,1行目に見出し(Subject, Body)を記述する。
logシートを作成し,同じく1行目に見出し(タイムスタンプ, 会社/団体名, お名前, メールアドレス, 電話番号, ご質問/ご意見, 結果, エラーメッセージ)を記述する。
「ツール/スクリプトエディタ…」メニューを選択肢,スクリプトエディタを開く。 「コード」欄に上記のスクリプトを貼り付ける。
スクリプトの修正
スクリプト中のBCCの設定(不要の場合は,空文字列にすれば良いと思います)
var BCC = 'admin@example.co.jp';
や対象メールの判定条件
if (msg.isUnread() && msg.getTo() == "info+q@example.co.jp") {
を必要に応じて修正します。
また,メール本文の書式も異なると思いますので,extractMessage()の中身を修正します。
テンプレートの修正
メールの件名と本文を修正します。
トリガーの設定
最初に手動で「checkMail」を実行します。
以下のような警告が出るはずなので「Authorize」ボタンを押して認証を通します。 この作業は,スクリプトを修正する度に必要になります。
トリガーの設定
手動で実行して動作確認ができたら,トリガー実行の設定を行います。
「リソース/現在のスクリプトのトリガー…」メニューを選択して,下記のダイアログを表示させます。 15分間隔に設定しました。
参考
MacVimをコマンドラインから起動する方法
MacVimにはmvimコマンドが付属していて,ターミナルからMacVimを起動する事ができます。 香り屋版でもそのまま使えたので,便利に使っていました。
Lionになってから,MacVimが起動していない状態でmvimを実行すると,以下のようなエラーが出力されるようになりました。
$ 2012-08-08 09:06:18.768 MacVim[2088:407] ApplePersistenceIgnoreState:
Existing state will not be touched. New state will be written to
/var/folders/0h/_jh2qmy52b96v_f87h_y1jhr0000gn/T/org.vim.MacVim.savedState
特に実害はなかったのですが,回避策としてopenコマンドを使うことにしました。
$ open -a MacVim <ファイル名>
aliasを定義すれば,タイプ量を減らせます。
alias gvim='open -a MacVim'
mvimコマンドとの違いとして,復数のファイルを指定した場合の振る舞いが異なります。
mvimで起動した場合はタブを一つのみ作成しますが,openコマンドで起動した場合はファイル数分のタブを作成します。
nginx+SCGI+web2pyの構成をお試し中
nginx+uWSGI+web2pyの構成で3ヶ月ぐらい運用していたのですが,あるトラブルがありuWSGIをSCGIに置き換えて様子を見ています。
「あるトラブル」とは?
運用しているサイトは帳票開発ツールのデモサイトなので,応答として動的に生成したPDFを返すページがいくつかあります。 最初のうちは問題ないのですが,しばらく使っているとPDFを生成するページで応答が返らなくなり, Gateway Errorとなってしまうというものです。
プロセスの状態を確認すると,uwsgiのプロセスがCPU使用率100%の状態になっていて,いつまで経っても使用率は下がりません。 こうなると,uwsgiサービスを再起動するしかありませんでした。
色々調べたのですが原因が分からず,uWSGIを他の物に置き換えて様子を見ることにしました。 切り替えて2日ぐらい経過しましたが,今のところ問題は発生していません。
SCGIとは?
FastCGIのプロトコルを更に単純にした規格のようです。 nginx,web2py共にSCGIに対応していたので,使ってみることにしました。
nginxの設定を修正
ゲートウェイとしてSCGIを使用するように,設定ファイルを修正します。
$ sudo vim /etc/nginx/conf.d/web2py.conf
server {
listen 80;
server_name $hostname;
location ~* /(\w+)/static/ {
root /var/www/web2py/applications/;
}
location / {
include /etc/nginx/scgi_params;
scgi_pass 127.0.0.1:4000;
}
}
server {
listen 443;
server_name $hostname;
ssl on;
ssl_certificate /etc/nginx/ssl/rapid.crt;
ssl_certificate_key /etc/nginx/ssl/rapid.key;
keepalive_timeout 70;
location / {
include /etc/nginx/scgi_params;
scgi_param HTTPS yes;
scgi_pass 127.0.0.1:4000;
}
}
SCGIの設定
web2py付属のscgihandler.pyを動かすにはwsgitoolsが必要なので, まずはwsgitoolsをインストールします。 CentOSでは,pipコマンドがpip-pythonになっています。
$ sudo pip-python install wsgitools
次にこのサイトに掲載されている起動スクリプトをコピーして,/etc/init.d/scgihandler に置きました。
ただ,これはDebian用なので,CentOSでは動きません。 この記事の手順で,start-stop-daemonをビルドしました。
最後に起動スクリプトをサービスとして登録します。
$ sudo chmod +x /etc/init.d/scgihandler
$ sudo chkconfig —add scgihandler
参照サイト
書類送付状生成デモの改造
先日作成した書類送付状生成デモを少し改造してみました。
改造点は以下のとおりです。
- すべてをcontrollerで実装していたのを改め,controllerとviewに分離した。
- viewを切り替えて,PDFとHTML両方の出力形式に対応できるようにした。
Controller部
フォームに出力形式(PDFとHTML)を選択するラジオボタンを追加しました。 どちらを選択したかにより,リダイレクト先のURLを変えています。
web2pyでは,拡張子付きのURLが呼ばれると,最初に拡張子を抜いた名前のcontroller関数が呼ばれます。 次に拡張子付きの名前のviewファイルが探され,あればそのviewを用いてレンダリングが実行されます。
# coding: utf8
from gluon.contrib import simplejson
try:
from cStringIO import StringIO
except:
from StringIO import StringIO
import tools
def index():
def docinput(num):
return TR(TD(INPUT(_name='_no'+str(num), _size=5)),
TD(INPUT(_name='_document'+str(num), _size=60)),
TD(INPUT(_name='_count'+str(num), _size=5)))
form = FORM(
UL(LI(LABEL(u'日付'), INPUT(_name="date", _value="")),
FIELDSET(LEGEND(u'宛先'),
LI(LABEL(u'郵便番号'), INPUT(_name='to_post', _size=8)),
LI(LABEL(u'住所1'), INPUT(_name='to_address1', _size=30)),
LI(LABEL(u'住所2'), INPUT(_name='to_address2', _size=30)),
LI(LABEL(u'会社名'), INPUT(_name='to_company', _size=30)),
LI(LABEL(u'氏名'), INPUT(_name='to_name', _size=30))),
FIELDSET(LEGEND(u'差出人'),
LI(LABEL(u'郵便番号'), INPUT(_name='from_post', _size=8)),
LI(LABEL(u'住所1'), INPUT(_name='from_address1', _size=30)),
LI(LABEL(u'住所2'), INPUT(_name='from_address2', _size=30)),
LI(LABEL(u'会社名'), INPUT(_name='from_company', _size=30)),
LI(LABEL(u'氏名'), INPUT(_name='from_name', _size=30)),
LI(LABEL(u'連絡先1'), INPUT(_name='from_contact1', _size=30)),
LI(LABEL(u'連絡先2'), INPUT(_name='from_contact2', _size=30))),
FIELDSET(LEGEND(u'送付物'),
TABLE(
TR(TH(u'No.'), TH(u'書類名'), TH(u'部数')),
docinput(0), docinput(1), docinput(2), docinput(3),
docinput(4), docinput(5), docinput(6), docinput(7),
docinput(8), docinput(9), docinput(10))),
LI(LABEL(u'出力媒体'),
INPUT(_type="radio", _name="_media", _value="pdf", _checked="checked"), u"PDF ",
INPUT(_type="radio", _name="_media", _value="html"), u"HTML "),
LI(INPUT(_type='submit', _value='帳票出力'),
INPUT(_type='reset', _value='リセット'), _align='right'), _id="simple"))
if form.accepts(request.vars):
response.flash = u'入力を受け付けました'
context = form.vars
context['no'] = [form.vars['_no'+str(i)] for i in range(11)]
context['document'] = [form.vars['_document'+str(i)] for i in range(11)]
context['count'] = [form.vars['_count'+str(i)] for i in range(11)]
if form.vars['_media'] == "pdf":
redirect(URL('render.pdf', vars=context))
else:
redirect(URL('render.html', vars=context))
elif form.errors:
response.flash = u'入力に誤りがあります'
else:
response.flash = u'フォームに入力してください'
return dict(form=form)
def render():
return dict(request.vars)
View部
PDF用のviewファイルを views/cover/render.pdf に, HTML用のviewファイルを views/cover/render.htmlに置きます。
PDF用のviewでは,URLパラメータの内容を元にPDFレンダリング用のパラメータを作成し, Field Reports を呼び出しています。
URLの拡張子が'pdf'の場合は,Content-Typeの指定は特に必要ないようです。
Content-Dispositionでファイル名を指定すると,PDFファイルをダウンロードさせることができます。 ファイル名を指定しないと,ブラウザのウインドウにPDFを表示します。
{{
from field import reports
import os
import gluon.fileutils
settings = {
'template-root': os.path.join(gluon.fileutils.abspath('applications',
request.application), 'resources'),
}
params = {
'settings': settings,
'template': 'templates/sofu.pdf',
'context': response._vars,
}
reports.set_defaults({})
reports.set_log_level(3)
#response.headers['Content-Type'] = 'application/pdf'
#response.headers['Content-Disposition'] = 'attachment; filename="cover.pdf"'
=XML(reports.renders(params))
}
HTML用のviewは手抜きですが,単にURLパラメータの内容をダンプします。
{{extend 'layout.html'}}
<h2>書類送付状</h2>
{{=BEAUTIFY(response._vars)}}
まとめ
書類送付状生成デモを改造して,出力形式をPDFとHTMLで選択できるようにしました。 出力形式の切り替えは,二つのviewをURLにより区別することで実現しています。
今回は,PDF出力を行うページにHTML出力を追加しましたが, 逆に既存のHTML出力を行うページにPDF出力を追加することも可能です。 PDF帳票開発ツール Field Reports の入力パラメータは, LL言語用のWebフレームワークで標準的に用いられる辞書形式を基本としているので, 既存のWebサイトに最小の労力でPDF出力機能を追加することができます。