Google Apps Scriptで「最終版・抄録PDF」を申請者全員に一斉送信する


これで解決したい課題

  • 完成版(成形後)の抄録PDFを演題ごとに正確にひも付け、一括でメール送信したい
  • 誤送信は絶対に避けたい(ドライラン必須、同じファイルの二重送信防止)
  • フォルダ内に同じ演題のPDFがもし仮に複数あっても、最新だけを選んで送りたい
  • 送信履歴(いつ・どのファイルを送ったか)をシートに自動記録したい

全体の仕組み

  1. Google ドライブに「完成版PDFフォルダ」を用意(ID: FINAL_PDF_FOLDER_ID
  2. PDF名は <最終演題番号>(<受付番号>)_abstract_YYYYMMDD_HHMMSS.pdf(以前の記事を参考に進めていれば、このようになっています。)
  3. スプレッドシート「フォームの回答 1」から行ごとに以下を取得(下記のコードでは、「フォームの回答1」のタブ名からデータを取得しています。任意名に変更可能です。)
    • 宛先メールアドレス(必須)
    • 命名しなおした最終的な演題番号(必須:PDFひも付けのキー)
    • 宛名用:筆頭演者の姓/名、所属(任意)
  4. スクリプトがフォルダ内PDFを走査して**索引(インデックス)**を構築
    • 同じ最終演題番号のPDFが複数ある場合、タイムスタンプ(TS)が最大=最新の1件を採用
  5. 各行についてメールを組み立て、**DRY RUN(SAFE_MODE)**で確認 → 本番送信
  6. 送ったらシートにステータス/送信時刻/PDFのTS/ファイル名を記録
    • 同じTSのPDFは再送しない(差し替わった時だけ再送)

使う列(自動検出)

  • 必須
    • メール
    • 最終的な演題番号(または最終演題番号
  • 宛名に使う(任意)
    • 筆頭演者(発表者)の姓筆頭演者の名前筆頭演者の所属機関名
  • 送信履歴(無ければ自動で追加
    • 完成版送信ステータス / 完成版送信時刻 / 完成版PDF_TS / 完成版PDFファイル名

コードの全体(コピペ可能)

コードの全体を乗せます。


// ---- 安全運転スイッチ(本番で false に) ----
const SAFE_MODE = true;

// ---- 固有名詞(各自の情報に修正してください) ----
const ORG_NAME    = '〇〇学会';
const ORG_EMAIL   = 'contact@example.org';
const PROGRAM_URL = 'https://example.org/program';

// ---- 完成版PDFフォルダID(目的のGoogle DriveのフォルダのIDに、必ず差し替えてください) ----
const FINAL_PDF_FOLDER_ID = 'REPLACE_WITH_YOUR_FOLDER_ID';

/* ===== 件名・本文テンプレ ===== */
const FINAL_MAIL_SUBJECT = (finalId) =>
  `【${ORG_NAME}】演題採択のお知らせ(演題番号:${finalId})`;

const FINAL_MAIL_BODY = (finalId, pdfName) =>
`${ORG_NAME} 運営事務局でございます。
ご登録いただいた演題は、後述の演題番号にて採択されました。
抄録PDFを添付いたしましたので、改めて内容をご確認ください(${pdfName})。
もし誤り等の修正が必要な事項がございましたら、演題番号と合わせて、事務局まで早急にお知らせください。

演題番号:${finalId}

なお、発表日時は学会ホームページをご参照ください。
${PROGRAM_URL}

────────────────────
${ORG_NAME} 運営事務局
E-mail: ${ORG_EMAIL}
────────────────────
※本メールは自動送信です。`;

/* ===== メイン ===== */
function sendFinalAbstractEmails() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sh = ss.getSheetByName('フォームの回答 1');
  const values = sh.getDataRange().getValues();
  const H = values[0];

  // 必須列
  const emailCol    = findCol(H, /メール/);
  const finalIdCol  = findCol(H, /(最終的な演題番号|最終演題番号)/);

  // 宛名(任意)
  const lastNameCol = findCol(H, /筆頭演者(発表者)の姓/, true);
  const firstNameCol= findCol(H, /筆頭演者の名前/, true);
  const affilCol    = findCol(H, /筆頭演者の所属機関名/, true);

  // 送信履歴(無ければ1行目に追加)
  let statusCol = ensureCol(sh, H, '完成版送信ステータス');
  let timeCol   = ensureCol(sh, H, '完成版送信時刻');
  let tsCol     = ensureCol(sh, H, '完成版PDF_TS');         // 例: 20250919_175608
  let nameCol   = ensureCol(sh, H, '完成版PDFファイル名');   // 実際に送ったPDF名

  // Drive: 最終演題番号 → 最新PDF の索引を作る
  const pdfIndex = buildFinalPdfIndex_(FINAL_PDF_FOLDER_ID);

  // 各行を処理
  for (let r = 1; r < values.length; r++) {
    const row = values[r];
    const email   = safe(row[emailCol]);
    const finalId = safe(row[finalIdCol]);
    if (!email || !finalId) continue;

    const info = pdfIndex[finalId];
    if (!info) { Logger.log('PDF未発見: %s', finalId); continue; }

    // 既送チェック:同じTSは送らない
    const prevStatus = safe(row[statusCol]);
    const prevTs     = safe(row[tsCol]);
    const newTs      = info.ts;
    const alreadyUpToDate = (prevStatus === '送信済み' && prevTs === newTs);
    if (alreadyUpToDate) continue;

    // 宛名:「所属 氏名 先生」
    const fullName = `${safe(row[lastNameCol])} ${safe(row[firstNameCol])}`.trim();
    const affil    = safe(row[affilCol]);
    const recipientLine = (fullName || affil) ? `${affil} ${fullName} 先生\n\n` : '';

    const subject = FINAL_MAIL_SUBJECT(finalId);
    const body    = recipientLine + FINAL_MAIL_BODY(finalId, info.name);

    // ドライラン:ここで必ず止める(SAFE_MODE=trueの間は送られない)
    if (SAFE_MODE) {
      Logger.log('[DRY RUN] to=%s finalId=%s file=%s', email, finalId, info.name);
      continue;
    }

    // 実送信
    GmailApp.sendEmail(email, subject, body, {
      attachments: [info.file.getBlob()],
      name: `${ORG_NAME} 運営事務局`
    });

    // 履歴を記録
    sh.getRange(r+1, statusCol+1).setValue('送信済み');
    sh.getRange(r+1, timeCol+1).setValue(new Date());
    sh.getRange(r+1, tsCol+1).setValue(newTs);
    sh.getRange(r+1, nameCol+1).setValue(info.name);

    Utilities.sleep(500); // レート制限対策
  }
}

/* ===== Drive内のPDFを索引化:最終演題番号 → 最新1件 ===== */
function buildFinalPdfIndex_(folderId) {
  const folder = DriveApp.getFolderById(folderId);
  const it = folder.getFiles();
  const map = {};

  // 例: YIA7(e024XX)_abstract_20250919_175608.pdf
  const re = /^([A-Za-z0-9\-]+(?:\([^)]+\))?)_(?:[Aa]bstract)_(\d{8}_\d{6})\.pdf$/;

  while (it.hasNext()) {
    const f = it.next();
    const name = f.getName();
    const m = name.match(re);
    if (!m) continue;

    const idPart = m[1];                 // 例 "YIA7(e024XX)" / "S1-2"
    const coreId = idPart.replace(/\(.*$/, ''); // → "YIA7" / "S1-2"
    const ts     = m[2];                 // "YYYYMMDD_HHMMSS"

    // タイムスタンプの大きい方=最新を保持
    if (!map[coreId] || ts > map[coreId].ts) {
      map[coreId] = { file: f, ts, name };
    }
  }
  return map;
}

/* ===== ヘルパー ===== */
function findCol(H, regex, optional=false) {
  const i = H.findIndex(h => regex.test(String(h)));
  if (i === -1 && !optional) throw new Error('必要列が見つかりません: ' + regex);
  return i;
}
function ensureCol(sh, H, label) {
  let idx = H.indexOf(label);
  if (idx !== -1) return idx;
  idx = H.length;
  sh.getRange(1, idx+1).setValue(label);
  H.push(label);
  return idx;
}
function safe(v){ return (v == null) ? '' : String(v).trim(); }


コードのポイント解説

1) 誤送防止:SAFE_MODE

const SAFE_MODE = true; // trueの間は送信せず、ログだけ出す
  • ループ内で送信直前にガード: if (SAFE_MODE) { Logger.log('[DRY RUN] to=%s finalId=%s file=%s', email, finalId, info.name); continue; // 以降(実送信&履歴更新)へ進まない }
  • SAFE_MODE=false に切り替えたときだけ送られます。

2) PDFの正確なひも付け

  • フォルダ内を走査して正規表現で解析: ^([A-Za-z0-9\-]+(?:\([^)]+\))?)_(?:[Aa]bstract)_(\d{8}_\d{6})\.pdf$
    • 例:Y1A7(e024XX)_abstract_20250919_175608.pdf
    • 先頭グループから括弧以降を落として「最終演題番号」を抽出
    • 同一演題で複数あれば TS(YYYYMMDD_HHMMSS) 最大の1件=最新だけ採用

3) 二重送信の防止

  • 行に保存された 完成版PDF_TS と、今回選んだPDFのTSを比較
    • 同じなら スキップ(送らない)
    • 変わっていれば 再送(差し替わり)

4) 宛名と文面

  • 宛名は「所属 氏名 先生」で冒頭に差し込み:
const fullName = `${safe(row[lastNameCol])} ${safe(row[firstNameCol])}`.trim();
const affil    = safe(row[affilCol]);
const recipientLine = (fullName || affil) ? `${affil} ${fullName} 先生\n\n` : '';
const subject = FINAL_MAIL_SUBJECT(finalId);
const body    = recipientLine + FINAL_MAIL_BODY(finalId, info.name);
  • 件名・本文テンプレ(例):
const FINAL_MAIL_SUBJECT = (finalId) =>
  `【〇〇学会】演題採択のお知らせ(演題番号:${finalId})`;

const FINAL_MAIL_BODY = (finalId, pdfName) => 
`〇〇学会 運営事務局でございます。
ご登録いただいた演題は、後述の演題番号にて採択されました。
抄録PDFを添付いたしましたので、改めて内容をご確認ください(${pdfName})。
もし誤り等の修正が必要な事項がございましたら、演題番号と合わせて、事務局まで早急にお知らせください。

演題番号:${finalId}

なお、発表日時は学会ホームページをご参照ください。
https://example.org/program

────────────────────
〇〇学会 運営事務局
E-mail: contact@example.org
────────────────────
※本メールは自動送信です。`;

実行のながれ(DRY RUN → 本番)

  1. SAFE_MODE = true のまま「▶実行」
    • 実行ログに [DRY RUN] to=... finalId=... file=<選ばれたPDF名> が並ぶ(送信なし/履歴更新なし)
  2. PDFひも付けや宛名が期待通りか確認
  3. 問題なければ SAFE_MODE = false に変更して再実行 → 実送信
    • シートに「送信済み/送信時刻/PDF_TS/PDFファイル名」を記録

送られるメール(例)

件名
【〇〇学会】演題採択のお知らせ(演題番号:Y1A7)

本文

〇〇大学医学部 しろふわ 太郎 先生

〇〇学会 運営事務局でございます。
ご登録いただいた演題は、後述の演題番号にて採択されました。
抄録PDFを添付いたしましたので、改めて内容をご確認ください(Y1A7(e024XX)_abstract_20250919_175608.pdf)。
もし誤り等の修正が必要な事項がございましたら、演題番号と合わせて、事務局まで早急にお知らせください。

演題番号:Y1A7

なお、発表日時は学会ホームページをご参照ください。
https://example.org/program

────────────────────
〇〇学会 運営事務局
E-mail: contact@example.org
────────────────────
※本メールは自動送信です。

トラブルシューティング

  • 「PDF未発見: <演題番号>」が出る
    • 完成版PDFフォルダIDの誤り/PDF名が規則外/最終演題番号が空
  • 同じ人に再送されない
    • 既に同じTSで送信済み。PDFを差し替えたらファイル名のTSが更新されるか確認
  • 送信が急に止まる
    • Gmailの送信上限に注意。レート制限回避で Utilities.sleep(500) を挿入済み
  • 実行したのにメールが増えない
    • SAFE_MODE=true のままかも。ログに [DRY RUN] が出ているはず

セキュリティと拡張のヒント

  • より堅牢に:PDF生成時にファイルIDをシートへ保存し、IDで添付(名前一致に依存しない)
  • 誤送のさらなる防止
    • スクリプトプロパティで「CONFIRM_SEND=OK」が無いと送れないガードを追加
    • 本番前に「下書き作成モード」に切り替える(GmailApp.createDraft
  • 差出人名の統一GmailApp.sendEmail(..., { name: '〇〇学会 運営事務局' })

まとめ

  • PDF名のルールを決めておけば、最終演題番号 × 最新TS正確にひも付け可能
  • SAFE_MODE でドライラン → 本番 の二段構えで誤送を防止
  • 送信履歴をシートに残すことで、後追い・再送条件も明確に

この設計は、演題採択通知以外(査読結果、座長案内、参加証PDF など)にも横展開できます。文面テンプレとフォルダ、ファイル名の規則を差し替えるだけで再利用できるはず。

コメント

タイトルとURLをコピーしました