0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

メールうざいから一括管理してみた

Posted at

メールの一括管理システム

Gmail, iCloud, outlookを普段から使っていて見ないといけないのですが、iPhoneで見るならこれだけで3アプリ入れないとダメだしPCがないと見れないっていうのもなかなか不便ってことで一括でどのメールが来てもdiscordに通知して見れるようにするシステムを作って見ました

注意

  1. GASの定期実行でメールを確認するのでリアルタイムにメールを通知できるわけではありません
  2. 返信するなどの実装も可能ではありますが、この記事では解説していません(AIによる返信メールの自動生成なども同様です)

システム概要

本システムは以下の流れで動作します。

  1. メール転送とラベル付与
    iCloud/Gmail/outlookメールを共通のGmailに転送し、転送されたメールには自動的に特定のラベルが付与されます。

  2. メール情報の取得と管理
    Google Apps Scriptを利用して、各ラベルに該当するメールをスプレッドシートに記録します。

  3. Discordへの自動通知
    新規のメールが記録された際に、DiscordのWebhookを介してメール情報を通知します。
    個人的にDiscordをよく使っているのでdiscordにしていますが、slackなどでも同様のことができると思います


前準備

共通のGoogleアカウントの作成

各種メールを一括で扱うために、専用のGoogleアカウントを作成してください。
https://www.google.com/intl/ja/account/about/

***.mailcatcher@gmail.comこんな感じで作りました
なんでもいいです

スプレッドシートの準備

先ほど作ったgoogleアカウントでログインして、スプレッドシートを作成しておいてください

  • 「mail list」シート:
    Gmailから取得したメール情報(ラベル、送信元、件名、本文、受信日時など)を記録するシート。
A B C D E F G H I J K
1 - - - - - - - - - -
2 label email webhook thread id message id 件名 送信元 受信日時 本文 message cnt

image.png

こんな感じです
分かりづらw 分かりやすい書き方あれば教えてください

  • 「label info」シート:
    各ラベルに対応する情報を管理するシート。
    必要なカラムは以下の通りです。
    • B列: Gmailラベル名(後でやります)
    • C列: 転送元メールアドレス
    • D列: Discord Webhook URL

image.png

discord webhookの取得方法はサボらしてもらいます
参考: https://zenn.dev/lambta/articles/5edbda4ccb1ec6
image.png
それぞれのチャンネルを作ってwebhookを発行しましょう


メールの転送・ラベリング

次はメールの転送をします
これは、各種(iCloud, Gmail, outlookなど)メールサービスのメールを先ほど作った***.mailcatcher@gmail.comここに転送することでメールを一括管理しようというものです

転送のやり方

ラベリング

ラベルの作成
ラベルの自動付与
自動付与の方はこの記事ではFromに登録することになっていますが、転送しているので、toの方に登録する必要があります
image.png


GAS

前準備のところで作成したスプレッドシートにGASを書いていきます
「拡張機能」→「Apps Script」

const ss = SpreadsheetApp.openById('**スプシのID**');
const mailListSheet = ss.getSheetByName('mail list');
const labelInfoSheet = ss.getSheetByName('label info');

const maxThreadCount = 100; // 1回で取得するスレッド数

function main() {
  const newData = appendMailLog(labelInfos = getLabelInfos());
  const unsentData = getUnsentMessages();

  unsentData.forEach(data => {
    const info = { label: data.label, email: data.email, webhook: data.webhook };
    const message = {
      subject: data.subject,
      from: data.from,
      date: data.date,
      body: data.body,
      sheetRow: data.sheetRow
    };
    if (sendDiscordNotificationWithRetry(info, message)) {
      mailListSheet.getRange(message.sheetRow, 12).setValue("send success");
    }
  });
}

/**
 * label infoシートのB~D列の情報をオブジェクト形式で返す関数
 */
function getLabelInfos() {
  const lastRow = labelInfoSheet.getLastRow();
  if (lastRow < 2) return [];

  const dataRange = labelInfoSheet.getRange(2, 2, lastRow - 1, 3);
  const values = dataRange.getValues();

  return values.map(row => ({
    label: row[0],
    email: row[1],
    webhook: row[2]
  }));
}

/**
 * 「mail list」シートに記録済みのメッセージIDを取得する関数
 * ※ メッセージIDはシートのF列に記録されている前提
 */
function getExistingMessageIds() {
  const lastRow = mailListSheet.getLastRow();
  if (lastRow < 3) return [];

  const idRange = mailListSheet.getRange(3, 6, lastRow - 2, 1);
  const idValues = idRange.getValues();

  return idValues.flat();
}

/**
 * 各ラベルのメールスレッドから重複しないメール情報をオブジェクト形式で収集し、
 * 「mail list」シートに追記する関数
 * なお、各オブジェクトにはシート上の行番号 (sheetRow) も付与します。
 */
function appendMailLog(labelInfos) {
  const result = [];
  const existingMessageIds = getExistingMessageIds();

  labelInfos.forEach(info => {
    const query = 'label:' + info.label;
    const threads = GmailApp.search(query, 0, maxThreadCount);

    threads.forEach(thread => {
      const threadId = thread.getId();
      const messages = thread.getMessages();
      const messageCount = messages.length;

      messages.forEach(message => {
        const messageId = message.getId();
        if (existingMessageIds.indexOf(messageId) !== -1) return;

        const maxBodyLength = 1000;
        let bodyContent = message.getPlainBody();
        if (bodyContent.length > maxBodyLength) {
          bodyContent = bodyContent.substring(0, maxBodyLength) + '...';
        }

        const data = {
          label: info.label,
          email: info.email,
          webhook: info.webhook,
          threadId: threadId,
          messageId: messageId,
          subject: message.getSubject(),
          from: message.getFrom(),
          date: message.getDate(),
          body: bodyContent,
          messageCount: messageCount
        };
        result.push(data);
      });
    });
  });

  if (result.length > 0) {
    const startRow = mailListSheet.getLastRow() + 1;
    const rows = result.map((data, idx) => {
      data.sheetRow = startRow + idx;
      return [
        data.label,
        data.email,
        data.webhook,
        data.threadId,
        data.messageId,
        data.subject,
        data.from,
        data.date,
        data.body,
        data.messageCount,
        "false"
      ];
    });
    mailListSheet.getRange(startRow, 2, rows.length, rows[0].length).setValues(rows);
  }

  return result;
}

/**
 * 「mail list」シートから、L列(12列目)が空欄の未送信メール情報を取得する関数
 * 返すオブジェクトには、シート上の行番号 (sheetRow) も含む
 */
function getUnsentMessages() {
  const lastRow = mailListSheet.getLastRow();
  if (lastRow < 3) return [];

  const dataRange = mailListSheet.getRange(3, 2, lastRow - 2, 10);
  const values = dataRange.getValues();

  const unsent = [];
  values.forEach((row, index) => {
    const sheetRow = index + 3;
    const flag = mailListSheet.getRange(sheetRow, 12).getValue();
    if (flag !== "send success") {
      unsent.push({
        label: row[0],
        email: row[1],
        webhook: row[2],
        threadId: row[3],
        messageId: row[4],
        subject: row[5],
        from: row[6],
        date: row[7],
        body: row[8],
        messageCount: row[9],
        sheetRow: sheetRow
      });
    }
  });
  return unsent;
}

/**
 * DiscordのWebhook URLに対して、メール情報を通知する関数(再試行付き)
 *
 * @param {Object} info - ラベル情報オブジェクト(例: { label, email, webhook })
 * @param {Object} message - メール情報オブジェクト(例: { subject, from, date, body, sheetRow })
 * @return {boolean} 送信成功ならtrue、失敗ならfalse
 */
function sendDiscordNotificationWithRetry(info, message) {
  const formattedDate = Utilities.formatDate(message.date, Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");

  let content = "===" + message.subject + "========================================\n";
  content += "転送元メール: " + info.email + "\n";
  content += "送信元: " + message.from + "\n";
  content += "受信日時: " + formattedDate + "\n";
  content += "```\n";
  content += message.body + "\n";
  content += "```\n";

  const maxDiscordLength = 1000;
  if (content.length > maxDiscordLength) {
    content = content.substring(0, maxDiscordLength - 3) + '...';
  }

  const payload = { content: content };
  const options = {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify(payload)
  };

  const maxAttempts = 3;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      UrlFetchApp.fetch(info.webhook, options);
      return true;
    } catch (e) {
      // 429エラー(レートリミット)なら待機して再試行
      if (e.message.indexOf("429") !== -1) {
        const retryAfter = 1000;
        Utilities.sleep(retryAfter);
      } else {
        console.error("sendDiscordNotification error:", e);
        return false;
      }
    }
  }
  return false;
}

保存してトリガーも設定しましょう
image.png
10分タイマーにしました

終わり

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?