Gmail添付ファイルの重複保存を実行前に発見|GASでDrive保存計画をdry-run監査

Gmail添付ファイルの重複保存を実行前に発見|GASでDrive保存計画をdry-run監査

ツールおじ|GoogleアカウントをAIエージェント化

ツールおじ|GoogleアカウントをAIエージェント化

Gmailの添付ファイルをGoogleドライブへ整理するとき、同じ名前のファイルを何度も保存したり、対象メールを広く取りすぎたりすると、あとから整理し直す手間が増えます。この記事では、ファイルを実際には保存せず、対象件数・容量・保存先直下の同名候補だけを先に確認する無料GASを紹介します。

コードは常にdry-runで動きます。メールの件名、送信者、本文、添付ファイル名、メッセージIDはログへ出さず、件数と匿名の位置番号だけで保存計画を確認します。

先に知っておきたいこと

  • できること: Gmail検索に一致したスレッドから添付ファイルを数え、保存先フォルダ直下の同名候補、名前なし、上限超過を確認できます。
  • できないこと: 添付ファイルの保存、上書き、削除、メールの既読化、ラベル変更、転送、送信は行いません。
  • 安全設計: DriveのcreateFile、GmailのsendEmail、moveToTrashなどを使いません。
  • ログ範囲: スレッド・メッセージ・添付の位置番号、分類、合計件数、合計容量だけを記録します。
  • 制約: 保存先フォルダ直下だけを調べます。サブフォルダやファイル内容の一致までは確認しません。

この例は学習・検証用です。実データで試す前に、専用のテストフォルダと少数のサンプルメールを用意し、権限画面と検索条件を確認してください。

準備するもの

  1. 添付ファイル整理用のGoogleドライブフォルダを1つ作ります。
  2. フォルダURLのfolders/より後ろにある文字列をfolderIdとして控えます。
  3. Gmail検索欄でhas:attachment newer_than:7dを試し、対象が広すぎないことを確認します。
  4. 初回はテスト用ラベルを付けたメールに限定し、label:任意のラベル名 has:attachmentのように絞ることを推奨します。

folderIdやGmail検索条件は、記事や公開コードへ貼り付けないでください。自分のApps Script内だけで設定します。

コピーして使える最小コード

function auditGmailAttachmentSavePlanFree() {
  const config = {
    gmailQuery: 'has:attachment newer_than:7d',
    folderId: 'ここを保存先フォルダIDに置き換える',
    maxThreads: 50,
    maxAttachments: 200,
    dryRun: true,
    writeSummary: false,
    summarySheetName: 'AuditSummary'
  };

  validateAttachmentAuditConfig_(config);

  const destination = DriveApp.getFolderById(config.folderId);
  const existingNames = collectDirectChildNames_(destination, config.maxAttachments * 5);
  const threads = GmailApp.search(config.gmailQuery, 0, config.maxThreads + 1);

  if (threads.length > config.maxThreads) {
    throw new Error(
      '検索結果が上限の' + config.maxThreads + 'スレッドを超えています。' +
      'Gmail検索条件を狭めてください。'
    );
  }

  const issues = [];
  let attachmentCount = 0;
  let totalBytes = 0;
  let duplicateNameCount = 0;
  let emptyNameCount = 0;

  threads.forEach(function(thread, threadIndex) {
    const messages = thread.getMessages();

    messages.forEach(function(message, messageIndex) {
      const attachments = message.getAttachments({
        includeInlineImages: false,
        includeAttachments: true
      });

      attachments.forEach(function(attachment, attachmentIndex) {
        attachmentCount += 1;
        if (attachmentCount > config.maxAttachments) {
          throw new Error(
            '添付ファイルが上限の' + config.maxAttachments + '件を超えています。' +
            'Gmail検索条件を狭めてください。'
          );
        }

        const name = String(attachment.getName() || '').trim();
        const bytes = attachment.getBytes().length;
        totalBytes += bytes;

        if (!name) {
          emptyNameCount += 1;
          issues.push({
            thread: threadIndex + 1,
            message: messageIndex + 1,
            attachment: attachmentIndex + 1,
            code: 'ATTACHMENT_NAME_EMPTY'
          });
          return;
        }

        const normalizedName = normalizeAttachmentName_(name);
        if (existingNames[normalizedName]) {
          duplicateNameCount += 1;
          issues.push({
            thread: threadIndex + 1,
            message: messageIndex + 1,
            attachment: attachmentIndex + 1,
            code: 'SAME_NAME_EXISTS_IN_FOLDER'
          });
        }
      });
    });
  });

  const summary = {
    dryRun: config.dryRun,
    checkedThreads: threads.length,
    attachmentCount: attachmentCount,
    totalBytes: totalBytes,
    duplicateNameCount: duplicateNameCount,
    emptyNameCount: emptyNameCount,
    issueCount: issues.length
  };

  Logger.log('dry-run: ' + summary.dryRun);
  Logger.log('確認スレッド数: ' + summary.checkedThreads);
  Logger.log('添付ファイル数: ' + summary.attachmentCount);
  Logger.log('合計容量: ' + formatBytesForAttachmentAudit_(summary.totalBytes));
  Logger.log('保存先直下の同名候補: ' + summary.duplicateNameCount);
  Logger.log('名前なし添付: ' + summary.emptyNameCount);
  issues.forEach(function(issue) {
    Logger.log(
      'スレッド位置' + issue.thread +
      ' / メッセージ位置' + issue.message +
      ' / 添付位置' + issue.attachment +
      ': ' + issue.code
    );
  });
  Logger.log('件名・送信者・本文・添付ファイル名・メッセージIDはログへ出していません。');

  if (config.writeSummary === true) {
    writeAttachmentAuditSummary_(config, summary);
  }

  return summary;
}

function validateAttachmentAuditConfig_(config) {
  if (config.dryRun !== true) {
    throw new Error('安全のためdryRunはtrue固定です。');
  }
  if (!config.gmailQuery || !String(config.gmailQuery).trim()) {
    throw new Error('gmailQueryを入力してください。');
  }
  if (!config.folderId || config.folderId.indexOf('ここを') !== -1) {
    throw new Error('folderIdを保存先フォルダIDへ置き換えてください。');
  }
  if (!Number.isInteger(config.maxThreads) || config.maxThreads < 1 || config.maxThreads > 100) {
    throw new Error('maxThreadsは1〜100の整数にしてください。');
  }
  if (!Number.isInteger(config.maxAttachments) || config.maxAttachments < 1 || config.maxAttachments > 500) {
    throw new Error('maxAttachmentsは1〜500の整数にしてください。');
  }
}

function collectDirectChildNames_(folder, maxFiles) {
  const names = {};
  const files = folder.getFiles();
  let checked = 0;

  while (files.hasNext()) {
    checked += 1;
    if (checked > maxFiles) {
      throw new Error(
        '保存先直下のファイル数が確認上限の' + maxFiles + '件を超えています。' +
        'テスト用フォルダか、より小さい保存先で確認してください。'
      );
    }
    const name = String(files.next().getName() || '').trim();
    if (name) {
      names[normalizeAttachmentName_(name)] = true;
    }
  }
  return names;
}

function normalizeAttachmentName_(name) {
  return String(name)
    .normalize('NFKC')
    .trim()
    .replace(/\s+/g, ' ')
    .toLowerCase();
}

function formatBytesForAttachmentAudit_(bytes) {
  if (bytes < 1024) {
    return bytes + ' B';
  }
  if (bytes < 1024 * 1024) {
    return (bytes / 1024).toFixed(1) + ' KB';
  }
  return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}

function writeAttachmentAuditSummary_(config, summary) {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = spreadsheet.getSheetByName(config.summarySheetName);
  if (!sheet) {
    sheet = spreadsheet.insertSheet(config.summarySheetName);
  }
  sheet.clearContents();
  sheet.getRange(1, 1, 7, 2).setValues([
    ['確認日時', new Date()],
    ['dry-run', summary.dryRun],
    ['確認スレッド数', summary.checkedThreads],
    ['添付ファイル数', summary.attachmentCount],
    ['合計容量bytes', summary.totalBytes],
    ['保存先直下の同名候補', summary.duplicateNameCount],
    ['名前なし添付', summary.emptyNameCount]
  ]);
}

実行手順

  1. 空のスプレッドシートをテスト用に作り、拡張機能からApps Scriptを開きます。
  2. コードを貼り付け、folderIdをテスト用フォルダのIDへ置き換えます。
  3. Gmail検索欄でgmailQueryと同じ条件を試し、対象件数と内容を手で確認します。
  4. maxThreadsを最初は5程度へ下げ、auditGmailAttachmentSavePlanFreeを実行します。
  5. 初回の権限画面で、Gmail、Drive、スプレッドシートへのアクセス範囲を読みます。
  6. 実行ログで、添付件数、合計容量、同名候補、匿名の位置番号を確認します。
  7. 結果が想定どおりでも、この無料コードは保存処理へ切り替えず、保存ルールを先に決めます。

このコードはGmailとDriveを読み取りますが、メールやファイルを変更しません。writeSummaryも初期値はfalseです。件数だけを別シートへ残したい場合に限り、テスト環境でtrueへ変更してください。

エラー分類の読み方

  • ATTACHMENT_NAME_EMPTY: 添付ファイル名を取得できませんでした。自動保存の対象にせず、手動確認します。
  • SAME_NAME_EXISTS_IN_FOLDER: 保存先フォルダ直下に正規化後の同名ファイルがあります。内容が同じとは限りません。

同名候補が出たときは、上書きや削除を自動化せず、受信日を付ける、送信元ごとにフォルダを分ける、同じ内容なら保存しないなどの運用ルールを決めてください。

失敗しやすい点と制限

Gmail検索が広すぎる

has:attachmentだけでは対象が多くなりやすいため、newer_than、label、fromなどを組み合わせ、Gmail検索画面で先に結果を確認してください。コード内の上限を増やして無理に全件処理するより、期間を分けるほうが安全です。

同名は内容一致ではない

この無料例は、保存先直下のファイル名だけを比較します。同じ名前でも内容が異なる場合があり、逆に名前が違っても同じ内容の場合があります。内容ハッシュ、版管理、サブフォルダ横断は対象外です。

サブフォルダは確認しない

collectDirectChildNames_は指定フォルダ直下だけを見ます。月別や送信元別のサブフォルダを使う場合は、保存ルールに合わせて確認範囲を設計してください。

添付ファイルをメモリへ読み込む

容量集計のためgetBytesを使います。大きな添付や大量メールでは実行時間・メモリ上限に達することがあります。maxThreadsとmaxAttachmentsを小さく保ち、少量から試してください。

権限を確認する

Apps Scriptの権限画面では、対象アカウントとアクセス範囲を確認してください。共有アカウントや業務メールへ適用する場合は、組織のルールと管理者方針も確認が必要です。

自動保存はしない

このコードにはcreateFileがありません。同名時の処理、保存期間、共有設定、個人情報を含む添付の扱いを決めずに、自動保存へ進まないでください。

公開前チェックリスト

  • 専用のテストフォルダを使った
  • Gmail検索欄で対象を先に確認した
  • maxThreadsとmaxAttachmentsを小さく設定した
  • dryRunがtrueになっている
  • writeSummaryがfalseになっている
  • 件名、送信者、本文、添付ファイル名、メッセージIDをログへ出していない
  • 保存、上書き、削除、送信、転送、ラベル変更の処理がない
  • 同名候補を内容一致と決めつけていない
  • 権限画面と組織の運用ルールを確認した

まとめ

Gmail添付ファイルの整理では、保存処理を先に作るより、対象メールの範囲、添付件数、容量、同名時の扱いをdry-runで確認することが大切です。この無料コードなら、メールやDriveを変更せずに、保存前の確認材料を集められます。

Drive内のファイルを一覧化し、整理対象や更新状況を確認したい場合は、次の有料Tipsも確認できます。

DriveFileList Pro|Google Driveのファイル一覧化GASテンプレを見る

GasおじのTips商品一覧を見る

※本記事は学習・検証用の例です。実データへ適用する前に、少量のテスト環境で確認してください。Google公式・Google公認の商品ではありません。


あなたも記事の投稿・販売を
始めてみませんか?

Tipsなら簡単に記事を販売できます!
登録無料で始められます!

Tipsなら、無料ですぐに記事の販売をはじめることができます Tipsの詳細はこちら
 

この記事の販売者

ツールおじ|GoogleアカウントをAIエージェント化

おう、Gasおじだ。 AIツールとSaaSに散々突っ込んで遠回りした結果、最後に強かったのは「今あるGoogleアカウント」だった。 今ではAIエージェントであらゆるタスクを自動化して無双してるぞ。 GAS、スプレッドシート、フォーム、Gmail、AIエージェントを組み合わせれば、個人でも小さな会社でも、手作業だらけの業務はかなり自動化できる。 ここでは机上のAI論じゃなく、現場で動くテンプレ、コード、手順だけを置いていくぞ。 高いツールを増やす前に、まずお前さんのGoogleアカウントを仕事するエージェントに変えようじゃないか。

この販売者が書いた他の記事

  • Gmailの予約送信ミスを送信前に発見|GASで宛先・日時をdry-run監査

  • Gmailの定型連絡を「送信せず」まとめて下書き化する|Gmail下書きテンプレGAS Pro

    ¥2,980
    1 %獲得
    (29 円相当)
  • スプレッドシートの入力ミスを公開前に発見|GASでルール表をdry-run監査

関連のおすすめ記事

  • Gmailの定型連絡を「送信せず」まとめて下書き化する|Gmail下書きテンプレGAS Pro

    ¥2,980
    1 %獲得
    (29 円相当)
    ツールおじ|GoogleアカウントをAIエージェント化

    ツールおじ|GoogleアカウントをAIエージェント化

  • USBメモリで持ち運べるLinux Mint 22.1と永続的な書き込み領域の作成法【Rufus だけで作成する方法を初心者向けに徹底解説】

    ¥500
    1 %獲得
    (5 円相当)
    SmartSenior

    SmartSenior

  • 毎朝のToDoを区分・優先度・期限つきでまとめる Morning ToDo Mail Pro

    ¥2,980
    1 %獲得
    (29 円相当)
    ツールおじ|GoogleアカウントをAIエージェント化

    ツールおじ|GoogleアカウントをAIエージェント化