🕌

GASでメール配信を自動化してみる

に公開

自動化すること

Google Apps Script(GAS)を使って以下の作業を自動化する

  1. 表データの加工(各表の結合)
  2. メール本文作成・下書き保存

※下書きに作成されたものを送信するのは人手でやる(ミスを防ぐため)

自動化にあたって気を付けること

  • フォルダ構成を崩さないこと(フォルダ名やファイルの置き場所を変えると、PC が認識できずエラーになる)

  • ファイル名・列名を必ずスクリプトに書いた内容に揃えること(でないとファイル・列を PC が認識できずエラーになる)

  • メール作成の際にネックになるのが、差出人を Google アカウントの Gmail アドレス以外に設定したい場合

    → 会社のメールソフトが Gmail であれば何も問題はないが、他のメールソフトを使用している場合、GAS 以外の自動化方法も検討すること(VBA、Power Automate、Pythonなど)

フォルダ構成

マイドライブ
└── 請求書メール自動化  # プロジェクトフォルダを作成、その中に作業で必要なファイルを集約
    ├── Input  # 生データを格納
    │   ├── Users.csv
    │   ├── Types.csv
    │   └── CreditCardLinks.csv
    ├── Output # 加工データを格納
    │   ├── Old # 加工データのバックアップを格納
    │   │   └── Data_YYYYMMDDHHMMSS.csv
    │   └── Data.csv
    ├── Templates # メール本文のテンプレートを格納
    │   ├── Template_CreditCard.txt
    │   └── Template_BankTransfer.txt
    └── Scripts # 自動化のスクリプトを格納
        └── MailCreate.gs  # Google Apps Scriptのスクリプト

各ファイルの詳細

Input/Users.csv

id name email type
1 山田花子 example1@gmail.com credit-card
2 加藤茂樹 example2@gmail.com credit-card
3 佐藤太郎 example3@gmail.com bank-transfer

Input/Types.csv

type due
credit-card 10 月 10 日
bank-transfer 10 月 15 日

Input/CreditCardLinks.csv

link
https://example.com/1
https://example.com/2

Output/Data.csv(スクリプトから自動生成されるファイル)

id name email type due link
1 山田花子 example1@gmail.com credit-card 10 月 10 日 https://example.com/1
2 加藤茂樹 example2@gmail.com credit-card 10 月 10 日 https://example.com/2
3 佐藤太郎 example3@gmail.com bank-transfer 10 月 15 日 nan

Templates/Template_CreditCard.txt

{name} 様

お世話になっております。○○でございます。

今月のお支払い方法についてご案内いたします。

お支払い方法: クレジットカード払い
お支払い期日: {due}

以下のリンクより決済をお願いします。

{link}

お支払いのほどよろしくお願いいたします。

○○

Templates/Template_BankTransfer.txt

{name} 様

お世話になっております。○○でございます。

今月のお支払い方法についてご案内いたします。

お支払い方法: 口座振込
お支払い期日: {due}

以下の口座にお振込みをお願いいたします。

銀行名: ○○
口座番号: ○○

お支払いのほどよろしくお願いいたします。

○○

Scripts/MailCreate.gs

処理の大まかな流れは以下

  1. 表データの加工(各表の結合)

    1. Input/Users.csvInput/Types.csv結合する。結合キーtype
    2. 1でできた大きなテーブルとInput/CreditCardLinks.csv結合する。Input/Users.csvtype列がcredit-cardという値となる行にだけ、Input/CreditCardLinks.csvlinks行の値を追記していく
    3. できた大きなテーブルをOutput/Data.csvとして出力する
  2. メール本文作成・下書き保存

    1. Output/Data.csvを読み込み、1 行ずつ処理していく
    2. 値を取り出し、type列の値がcredit-cardであればTemplates/Template_CreditCard.txtを、bank-transferであればTemplates/Template_BankTransfer.txtを読み込む(テンプレートの読み込み
    3. それぞれのテンプレートに値を埋め込みメール本文に貼り付ける
    4. 差出人をexample@gmail.comにする
    5. 宛先をemailに記載されている値にする
    6. メールを下書きに保存する
    7. 2-6を行数分だけ繰り返す

例外処理(何か不備が発生した際の処理)は以下

  1. 表データの加工(各表の結合)

    • 重大系(存在しない・壊れている)は即中断
    • データ品質系(type未定義・在庫不足・形式不正)は該当行だけスキップ/補正して処理継続+Output/Errors_YYYYMMDD_HHmmss.txt で可視化
  2. メール本文作成・下書き保存

    • 成功数 / スキップ数 / 失敗数のサマリをLoggerに必ず出力
    • 明細は Output/Errors_YYYYMMDD_HHmmss.txt に追記(誰に送れなかったかが一目で分かる)
/** =========================================================
 * 請求書メール作成自動化(Gmail API版のみ)
 * - DriveのルートはフォルダID直指定(迷子ゼロ)
 * - CSVは CP932(Shift_JIS) で入出力を統一(文字化け防止)
 * - Output/Data.csv を生成し、既存は Output/Old/Data_YYYYMMDDHHMMSS.csv へ退避
 * - Data.csv + テンプレから「下書き」を Gmail API で作成(From: エイリアス)
 * 前提:
 *   - Apps Script の「高度なGoogleサービス」で Gmail API をON
 *   - 差出人エイリアス(FROM_ADDRESS)を Gmail の「他のメールアドレスを追加」で登録・確認済み
 * ========================================================= */

/* ========= 設定(必ず置き換え) ========= */
const ROOT_FOLDER_ID = "9lXJtdyspOIP3S8INJ1pmG9YtGzQ9cITQ"; // https://drive.google.com/drive/folders/ の末尾ID
const FROM_ADDRESS   = "example@gmail.com";           // 差出人(Gmailでエイリアス登録・確認済みのこと)

/* ========= 固定値 ========= */
const ENCODING         = "Shift_JIS";           // = CP932
const FOLDER_INPUT     = "Input";
const FOLDER_OUTPUT    = "Output";
const FOLDER_ARCHIVE   = "Old";
const FOLDER_TEMPLATES = "Templates";

const FILE_DATA        = "Data.csv";
const FILE_USERS       = "Users.csv";
const FILE_TYPES       = "Types.csv";
const FILE_CC_LINKS    = "CreditCardLinks.csv";
const FILE_TPL_CC      = "Template_CreditCard.txt";
const FILE_TPL_BANK    = "Template_BankTransfer.txt";

/* ========= エントリーポイント ========= */
/** ワンボタン実行:データセット作成 → 下書き作成(Gmail API) */
function main() {
  buildMailDataset();        // Output/Data.csv を作成(既存は Old へ退避)
  createDraftsViaGmailAPI(); // Data.csv + テンプレから Gmail 下書きを作成(From: エイリアス)
}

/* ========= ステップ1:Data.csv を構築 ========= */
/**
 * Input の Users/Types/Links を突合し、Output/Data.csv を再構成する
 * - 既存の Data.csv は Old へ退避(タイムスタンプ付与)
 * - types.csv に存在しない type は due="" にしつつ エラーメッセージ記録
 * - links.csv のリンクは 重複配布しない(在庫切れになったら link="" + エラーメッセージ記録)
 * - エラーは Logger へ出力し、さらに Output/Errors_YYYYMMDD_HHmmss.txt を作成
 */
function buildMailDataset() {
  const rootFolder   = DriveApp.getFolderById(ROOT_FOLDER_ID);
  const inputFolder  = getRequiredSubfolder(rootFolder, FOLDER_INPUT);
  const outputFolder = getOrCreateSubfolder(rootFolder, FOLDER_OUTPUT);

  // 読み込み(CSV: CP932、スプレッドシート: displayValues)
  const usersTable = readTableCP932(inputFolder, FILE_USERS);     // 期待: id,name,email,type
  const typesTable = readTableCP932(inputFolder, FILE_TYPES);     // 期待: type,due
  const linksTable = readTableCP932(inputFolder, FILE_CC_LINKS);  // 期待: link(1列目)

  assertHasBody(usersTable, FILE_USERS);
  assertHasBody(typesTable, FILE_TYPES);

  const usersHeaderMap = parseHeader(usersTable[0]); // {id,name,email,type}
  const typesHeaderMap = parseHeader(typesTable[0]); // {type,due}
  assertHeader(usersHeaderMap, ["id","name","email","type"], FILE_USERS);
  assertHeader(typesHeaderMap, ["type","due"], FILE_TYPES);

  // ============== 追加: エラー蓄積用配列 =================
  /** @type {string[]} */
  const errors = [];

  // type → due のマップ
  /** @type {Record<string,string>} */
  const typeToDueMap = {};
  for (let i = 1; i < typesTable.length; i++) {
    const row = typesTable[i];
    const key = safe(row, typesHeaderMap.type);
    const val = safe(row, typesHeaderMap.due);
    if (key) typeToDueMap[key] = val;
  }

  // 出力(ヘッダ + 本文)
  const outputRows = [["id","name","email","type","due","link"]];

  // クレカ用リンク:重複配布をしない(在庫切れなら空欄+エラー)
  // linksTable[0] はヘッダ。以降が在庫。
  const availableLinkCount = Math.max(linksTable.length - 1, 0);
  let usedLinkCount = 0; // 消費したリンク数(在庫の先頭から順に割り当て)

  for (let i = 1; i < usersTable.length; i++) {
    const row   = usersTable[i];
    const id    = safe(row, usersHeaderMap.id);
    const name  = safe(row, usersHeaderMap.name);
    const email = safe(row, usersHeaderMap.email);
    const type  = safe(row, usersHeaderMap.type);

    // due 取得(types.csv に無い場合は空文字+エラー)
    let due = "";
    if (type in typeToDueMap) {
      due = typeToDueMap[type] || "";
    } else {
      errors.push(
        `[TYPE_NOT_FOUND] users(id=${id}, name=${name}, email=${email}) の type="${type}" が ${FILE_TYPES} に見つかりません。due を空欄で出力します。`
      );
    }

    // link 取得(credit-card のみ在庫を消費、在庫切れは空欄+エラー)
    let linkValue = "";
    if (type === "credit-card") {
      if (usedLinkCount < availableLinkCount) {
        // 在庫先頭から順に配布(重複なし)
        const linkRowIndex = 1 + usedLinkCount; // 1 はヘッダ行をスキップ
        linkValue = safe(linksTable[linkRowIndex], 0); // 先頭列が link
        usedLinkCount++;
      } else {
        errors.push(
          `[LINK_SHORTAGE] users(id=${id}, name=${name}, email=${email}) に割り当て可能な未使用リンクが ${FILE_CC_LINKS} に不足しています。link を空欄で出力します。`
        );
      }
    }

    outputRows.push([id, name, email, type, due, linkValue]);
  }

  // 既存 Data.csv を Old へ退避
  archiveIfExists(outputFolder, FILE_DATA);

  // 出力(CP932/Shift_JIS 明示)
  const csvText = outputRows.map(r => r.map(csvEscape).join(",")).join("\n");
  const csvBlob = Utilities.newBlob("", "text/csv", FILE_DATA).setDataFromString(csvText, ENCODING);
  outputFolder.createFile(csvBlob);

  // ============== 追加: エラー表示(ログ & テキストファイル) ==============
  if (errors.length > 0) {
    // Logger にも出す
    Logger.log("=== buildMailDataset: Errors ===\n" + errors.join("\n"));

    // Output/ にエラーファイルを作成
    const tz = (typeof Session !== "undefined" && Session.getScriptTimeZone)
      ? Session.getScriptTimeZone()
      : "Asia/Tokyo";
    const ts = Utilities.formatDate(new Date(), tz, "yyyyMMdd_HHmmss");
    const errorFileName = `Errors_${ts}.txt`;
    const errorBlob = Utilities.newBlob(errors.join("\n"), "text/plain", errorFileName);
    outputFolder.createFile(errorBlob);
  }
}

/* ========= ステップ2:下書きメール作成(Gmail API) ========= */
/**
 * Data.csv + テンプレから「下書き」を作成(Gmail API)
 * 件名:YYYY年MM月分のご請求について(実行日の年月、TZはスクリプト設定)
 * 前提:高度なGoogleサービスで Gmail API をON
 */
function createDraftsViaGmailAPI() {
  const rootFolder   = DriveApp.getFolderById(ROOT_FOLDER_ID);
  const outputFolder = getRequiredSubfolder(rootFolder, FOLDER_OUTPUT);
  const tplFolder    = getRequiredSubfolder(rootFolder, FOLDER_TEMPLATES);

  // 差出人エイリアスの存在確認(送信時整合のため)
  ensureAliasOrThrow(FROM_ADDRESS);

  // Data.csv(CP932)読込
  const dataFileIter = outputFolder.getFilesByName(FILE_DATA);
  if (!dataFileIter.hasNext()) throw new Error(`${FOLDER_OUTPUT}/${FILE_DATA} が見つかりません`);
  const rows = Utilities.parseCsv(dataFileIter.next().getBlob().getDataAsString(ENCODING));
  if (rows.length < 2) throw new Error("Data.csv にデータ行がありません");

  const headerMap = parseHeader(rows[0]); // id,name,email,type,due,link…

  // テンプレ(UTF-8優先→化け疑いでShift_JIS再トライ)
  const tplCredit = readTextSmart(tplFolder, FILE_TPL_CC);   // {name} {due} {link}
  const tplBank   = readTextSmart(tplFolder, FILE_TPL_BANK); // {name} {due}

  // 件名(スクリプトTZでゼロ埋め)
  const tz      = Session.getScriptTimeZone(); // 例: Asia/Tokyo
  const now     = new Date();
  const yyyy    = Utilities.formatDate(now, tz, "yyyy");
  const mm      = Utilities.formatDate(now, tz, "MM");
  const subject = `${yyyy}${mm}月分のご請求について`;

  // 下書き作成(From ヘッダをエイリアスに)
  let draftCount = 0;
  for (let i = 1; i < rows.length; i++) {
    const row   = rows[i];
    const to    = safe(row, headerMap.email);
    const name  = safe(row, headerMap.name);
    const type  = safe(row, headerMap.type);
    const due   = safe(row, headerMap.due);
    const link  = "link" in headerMap ? safe(row, headerMap.link) : "";
    if (!to) continue;

    const body =
      type === "credit-card"
        ? tplCredit.replace("{name}", name).replace("{due}", due).replace("{link}", link)
        : type === "bank-transfer"
          ? tplBank.replace("{name}", name).replace("{due}", due)
          : null;
    if (!body) continue;

    // プレーンテキストMIMEを組み立て → Base64URL → Drafts.create
    const mime = buildMimeMessage({ from: FROM_ADDRESS, to, subject, body });
    const raw  = toBase64Url(mime);
    Gmail.Users.Drafts.create({ message: { raw } }, "me");
    draftCount++;
  }

  Logger.log(`[DRAFT] 作成完了: ${draftCount}`);
}

/* ========= 汎用ヘルパー ========= */

/** 必須のサブフォルダ取得(なければエラー) */
function getRequiredSubfolder(parent, name) {
  const it = parent.getFoldersByName(name);
  if (!it.hasNext()) throw new Error(`サブフォルダ「${name}」が見つかりません(親: ${parent.getName()}`);
  return it.next();
}

/** サブフォルダ取得(なければ作成) */
function getOrCreateSubfolder(parent, name) {
  const it = parent.getFoldersByName(name);
  return it.hasNext() ? it.next() : parent.createFolder(name);
}

/** 既存ファイル filename を Old に Data_YYYYMMDDHHMMSS.csv として退避 */
function archiveIfExists(outputFolder, filename) {
  const it = outputFolder.getFilesByName(filename);
  if (!it.hasNext()) return;

  const archiveFolder = getOrCreateSubfolder(outputFolder, FOLDER_ARCHIVE);
  const ts = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyyMMddHHmmss");

  while (it.hasNext()) {
    const file = it.next();
    file.setName(`Data_${ts}.csv`);
    file.moveTo(archiveFolder);
  }
}

/** テーブル読み込み:CSVはCP932、GoogleシートはdisplayValues */
function readTableCP932(folder, name) {
  const it = folder.getFilesByName(name);
  if (!it.hasNext()) throw new Error(`${name} が見つかりません(フォルダ: ${folder.getName()}`);
  const file = it.next();
  const mime = file.getMimeType();

  if (mime === MimeType.GOOGLE_SHEETS || /spreadsheet/i.test(mime)) {
    const ss = SpreadsheetApp.openById(file.getId());
    const sh = ss.getSheets()[0];
    return sh.getDataRange().getDisplayValues();
  } else {
    const text = file.getBlob().getDataAsString(ENCODING); // CP932
    return Utilities.parseCsv(text);
  }
}

/** ヘッダ配列 → { colName: index } に変換(trim済み) */
function parseHeader(headerRow) {
  const map = {};
  headerRow.forEach((h, i) => { map[String(h).trim()] = i; });
  return map;
}

/** 必須ヘッダ検証(不足があればエラー) */
function assertHeader(headerMap, requiredKeys, sourceName) {
  const missing = requiredKeys.filter(k => !(k in headerMap));
  if (missing.length) throw new Error(`${sourceName} のヘッダーに不足: ${missing.join(", ")}`);
}

/** データ行存在検証(ヘッダーのみ/空はNG) */
function assertHasBody(rows, sourceName) {
  if (!rows || rows.length < 2) {
    throw new Error(`${sourceName} にデータ行がありません(ヘッダーのみ/空)`);
  }
}

/** 安全セル参照(欠損なら空文字) */
function safe(row, idx) {
  if (!row || idx == null || idx >= row.length) return "";
  return String(row[idx]);
}

/** CSVエスケープ(, " 改行を囲む) */
function csvEscape(v) {
  const s = v == null ? "" : String(v);
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}

/* ========= 文字化け対策付き:テンプレ読み込み ========= */
/**
 * テンプレートファイルを安全に読み込む。
 * - まず UTF-8 で読み、文字化け疑いがあれば Shift_JIS(CP932)で再トライ
 * - Googleドキュメントは DocumentApp でUTF-8化されたテキストを取得
 */
function readTextSmart(folder, name) {
  const it = folder.getFilesByName(name);
  if (!it.hasNext()) throw new Error(`${name} が見つかりません(フォルダ: ${folder.getName()}`);
  const file = it.next();
  const mime = file.getMimeType();

  // Googleドキュメント系は本文テキストを取得
  if (mime === MimeType.GOOGLE_DOCS) {
    return DocumentApp.openById(file.getId()).getBody().getText();
  }

  // txt等:UTF-8 → ダメなら Shift_JIS にフォールバック
  const blob = file.getBlob();
  let textUtf8 = blob.getDataAsString("UTF-8");
  if (!looksMojibake(textUtf8)) return textUtf8;

  // フォールバック:Shift_JIS(CP932)
  const textSJIS = blob.getDataAsString("Shift_JIS");
  return textSJIS;
}

/** 簡易な文字化け検知(U+FFFDの多数、半角カナ長連続) */
function looksMojibake(s) {
  if (!s) return false;
  const repl = (s.match(/\uFFFD/g) || []).length;
  if (repl >= 2) return true;                          // 置換文字が多い
  const suspiciousKana = /[\uFF61-\uFF9F]{8,}/.test(s); // 半角カナの不自然連続
  return suspiciousKana;
}

/* ========= 差出人エイリアス検証 & MIMEユーティリティ ========= */
/**
 * 差出人エイリアスがGmailに登録済みか検証する。
 * - 高度なサービス(Gmail API)が有効なら SendAs を参照
 * - 未有効/権限なしなら GmailApp.getAliases() にフォールバック
 * 該当がなければ Error を投げる
 */
function ensureAliasOrThrow(address) {
  if (!address) throw new Error("FROM_ADDRESS が未設定です。");

  // 1) 高度なサービス: Gmail.Users.Settings.SendAs
  try {
    const res = Gmail.Users.Settings.SendAs.list("me");
    const list = (res && res.sendAs) || [];
    const ok = list.some(sa =>
      sa.sendAsEmail === address &&
      (sa.isPrimary || sa.isDefault || sa.verificationStatus === "accepted" || sa.isEnabled)
    );
    if (ok) return;

    // 2) フォールバック: GmailApp.getAliases()
    const aliases = GmailApp.getAliases();
    const primary = Session.getActiveUser().getEmail();
    const ok2 = aliases.includes(address) || address === primary;
    if (!ok2) throw new Error(buildAliasErrorMessage(address, list, aliases, primary));
    return;
  } catch (e) {
    // 高度なサービス未有効などで上が落ちた場合
    const aliases = GmailApp.getAliases();
    const primary = Session.getActiveUser().getEmail();
    const ok = aliases.includes(address) || address === primary;
    if (!ok) throw new Error(buildAliasErrorMessage(address, [], aliases, primary));
  }
}

/** エイリアス検証のエラーメッセージ整形 */
function buildAliasErrorMessage(address, sendAsList, aliases, primary) {
  const sendAsStr = sendAsList && sendAsList.length
    ? sendAsList.map(sa => `${sa.sendAsEmail}${sa.isDefault ? " (default)" : ""}`).join(", ")
    : "(SendAs 取得不可/未有効)";
  const aliasStr = aliases && aliases.length ? aliases.join(", ") : "(エイリアスなし)";
  return [
    `差出人エイリアス '${address}' がGmailに登録されていません。`,
    `- プライマリ: ${primary}`,
    `- SendAs: ${sendAsStr}`,
    `- GmailApp.getAliases(): ${aliasStr}`,
    `Gmail設定 > アカウント > 「他のメールアドレスを追加」で '${address}' を登録・確認済みにしてください。`,
    `※ Apps Script の「高度なGoogleサービス」で Gmail API をONにすると SendAs を厳密に検証できます。`
  ].join("\n");
}

/**
 * シンプルなプレーンテキストMIMEを組み立てる(UTF-8)
 * @param {{from:string,to:string,subject:string,body:string}} p
 */
function buildMimeMessage(p) {
  const lines = [
    `From: ${p.from}`,
    `To: ${p.to}`,
    `Subject: ${encodeToMimeHeader(p.subject)}`, // 日本語件名をRFC 2047風に
    "MIME-Version: 1.0",
    "Content-Type: text/plain; charset=UTF-8",
    "",
    p.body || ""
  ];
  return lines.join("\r\n");
}

/** RFC 2047 風の簡易エンコード(日本語件名用)*/
function encodeToMimeHeader(str) {
  // UTF-8バイト → Base64(標準)→ "=?UTF-8?B?...?="
  const b64Std = Utilities.base64EncodeWebSafe(
    Utilities.newBlob(str, "text/plain", "").getBytes()
  ).replace(/-/g, "+").replace(/_/g, "/");
  return `=?UTF-8?B?${b64Std}?=`;
}

/** UTF-8バイト列を Base64URL にして '=' を除去(Gmail API raw 用)*/
function toBase64Url(str) {
  // MIME文字列をUTF-8バイト化(message/rfc822 としてBlob化)
  const bytes = Utilities.newBlob(str, "message/rfc822").getBytes();
  // Base64URL(WebSafe)に変換し、末尾の '=' を削除
  return Utilities.base64EncodeWebSafe(bytes).replace(/=+$/g, "");
}

動作確認

  1. (初回のみ)サービスに Gmail API を追加する
  2. 実行したい関数としてmainを選択して実行ボタンを押す
  3. 実行ログが現れて、処理が始まる
  4. 実行完了の表示が出てきたら、Gmail を開いて下書きにメールが作成されているか確認する


図1 GASの実行の様子


図2 Gmailに下書きが作成されている様子

感想

  • 普段使っているメールソフトが Outlook なのであれば、VBA で自動化した方が差出人の設定に手間取らなくて良いのではと感じた。
  • メールソフト依存を気にするのであれば、Python を使った方が良いと感じた。しかし、GAS や VBA を使うことによって、環境構築なしに自動化スクリプトが作れるのであれば、その方が気楽ではある。
  • どのメールは作成済みで、どのメールは作成できなかったかを、CSV で出力しても良いのかなと思った。

Discussion