👁️

メール開封通知をゼロから作る

2025/01/26に公開

はじめに

開封通知がどういう仕組みで実装されているか気になったのでゼロから作ってみました。
今回はウェブからGmailを送信するときに追跡用画像を埋め込み、開封通知を受け取るようにしています。

仕組み

メール開封通知は、1x1のピクセルの画像をメールに埋め込み、メール開封時にその画像も読み込まれ通知されるという非常にシンプルな仕組みです。
(クライアントが画像を読み込む以上、Discord等の既読通知にも応用できると思います。)

受信者側の構成図

送信者側の構成図

環境

Chrome拡張機能

  • JavaScript
  • Firebase

バックエンド

  • Express.js (TypeScript)
  • Firebase Cloud Messaging
  • MongoDB

バックエンドの実装

Firebase

  • サービスアカウントを作成してコンフィグをダウンロードしてください

MongoDB

今回は無料プランのMongoDBを使っています。
https://www.mongodb.com/ja-jp

画像が読み込まれた時の実装

/api/tracker/:param

router.get("/:param", logger);

track_idはデータベースでそれぞれのユーザーに紐づくようにしておく

export const logger = async (req: express.Request, res: express.Response) => {
    res.setHeader("Access-Control-Allow-Origin", "*");
    
    const track_id = req.params.param;

    // track_idから詳細取得
    const tracking = await Tracker.findOne({ tracker_id: track_id });
    // 細かいエラーハンドリング
    ...

    // track_idに紐付けられているユーザーを取得して、FCMを使って通知
    const user = await User.findOne({ user_id: tracking.user_id });
    if (user) {
      await firebase.send(
        user.token,
        tracking.seen.toString(),
        tracking.email_title
      );
    }

    // Create 1x1 image
    const img = Buffer.from(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/wcAAgMBgU3UcwAAAABJRU5ErkJggg==",
        "base64"
    );
    
    // Set header to image
    res.writeHead(200, {
        "Content-Type": "image/png",
        "Content-Length": img.length,
    });
    
    res.end(img);
};

Firebaseの設定
※seenは何回開封されたかのカウント

class Firebase {
  private admin: admin.app.App;

  constructor(firebase_config: any) {
    this.admin = admin.initializeApp({
      credential: admin.credential.cert(
        firebase_config as admin.ServiceAccount
      ),
    });
  }

  public async send(token: string, seen: string, title: string) {
    const message = {
      data: {
        seen: seen,
        email_title: title,
      },
      token: token,
    };

    try {
      const res = await this.admin.messaging().send(message);
    } catch (error) {
      console.log(error);
    }
  }
}

export const firebase = new Firebase(account_config);

[任意]MongoDBのスキーマ

mongooseを使っています。

ユーザーデータ

import { Schema, model, connect } from "mongoose";

const userSchema = new Schema({
  user_id: {
    type: String,
    required: true,
  },
  token: {
    type: String,
    required: true,
  },
});

export const User = model("User", userSchema);

トラッキングデータ

const trackerSchema = new Schema({
  user_id: {
    type: String,
    required: true,
  },
  seen: {
    type: Number,
    default: 0,
    required: true,
  },
  tracker_id: {
    type: String,
    required: true,
  },
  email_title: {
    type: String,
    required: true,
  }
});

trackerSchema.index({ expireAfter: 1 }, { expireAfterSeconds: 604800 });

export const Tracker = model("Tracker", trackerSchema);

Chrome拡張機能の実装

Firebaseで設定すること

  • FirebaseからFirebase Cloud Messaging API (V1)が有効になっているか確認
  • 同タブのWeb Push certificatesからKey Pairを取得して、今回はvapidKeyとして保存

firebaseConfig.js

const firebaseConfig = {
  apiKey: "",
  authDomain: "",
  projectId: "",
  storageBucket: "",
  messagingSenderId: "",
  appId: "",
  measurementId: "",
};

const vapidKey =
  "";

export { firebaseConfig, vapidKey };

実装編

個々のユーザーに通知を送信するためにtokenを取得しなければいけません。
現時点ではtokenを取得する方法が、ポップアップを開いた時に取得する方法しかわからなかったので、今回はその方法を書いています。
拡張機能を更新するたびにtokenは変わるので、更新の度にポップアップを一度開いてもらう必要があります。

Firebase JavaScript SDKをダウンロード

今回はCDNを使わずダウンロードして使っています。

https://www.gstatic.com/firebasejs/11.2.0/firebase-app.js
https://www.gstatic.com/firebasejs/11.2.0/firebase-messaging-sw.js
https://www.gstatic.com/firebasejs/11.2.0/firebase-messaging.js

manifest.json

declarative_net_requestを使う理由は、自分のメールを開いた時に開封通知が発生しないようにするためです。
※この例では相手が同じ拡張機能を使っていた場合を想定していません。

{
  "manifest_version": 3,
  "name": "開封通知",
  "description": "ただの開封通知",
  "version": "1.0",
  "background": {
    "service_worker": "service-worker.js",
    "type": "module"
  },
  "permissions": ["notifications", "storage", "declarativeNetRequest"],
  "action": {
    "default_popup": "popup.html"
  },
  "content_scripts": [
    {
      "matches": ["https://mail.google.com/*"],
      "js": ["content-script.js"]
    }
  ],
  "icons": {
    "16": "/icons/16.png",
    "48": "/icons/48.png",
    "128": "/icons/128.png"
  },
  "declarative_net_request": {
    "rule_resources": [
      {
        "id": "example_rule",
        "enabled": true,
        "path": "example.com.rule.json"
      }
    ]
  }
}

example.com.rule.json

[
  {
    "id": 1,
    "priority": 1,
    "action": {
      "type": "block"
    },
    "condition": {
      "urlFilter": "tracker.example.com",
      "resourceTypes": ["image"]
    }
  }
]

popup.js

取得したtokenは先ほど実装したAPIを通してデータベースに保存しています。

import { initializeApp } from "./firebase/firebase-app.js";
import { getMessaging, getToken } from "./firebase/firebase-messaging.js";
import { firebaseConfig, vapidKey } from "./config/firebaseConfig.js";

// Initialize Firebase
const firebaseApp = initializeApp(firebaseConfig);
const messaging = getMessaging(firebaseApp);

...

// Get token
const get_token = async () => {
  try {
    const nav = await navigator.serviceWorker.ready;
    const token = await getToken(messaging, {
      vapidKey,
      serviceWorkerRegistration: nav,
    });
    return token;
  } catch (error) {
    throw error;
  }
};

service-worker.js

ここで通知を受け取れるように設定します。

import { initializeApp } from "./firebase/firebase-app.js";
import {
  getMessaging,
  onBackgroundMessage,
} from "./firebase/firebase-messaging-sw.js";

import { firebaseConfig } from "./config/firebaseConfig.js";

const firebaseApp = initializeApp(firebaseConfig);
const messaging = getMessaging(firebaseApp);

onBackgroundMessage(messaging, (payload) => {
  console.log("[background.js] Received background message ", payload);

  // Show custom notification
  const notificationOptions = {
    body: `${payload.data.seen}回見られました`,
    icon: "./icons/128.png",
  };

  // Show the notification with the payload data
  return self.registration.showNotification(
    payload.data.email_title,
    notificationOptions
  );
});

content-script.jsからCREATE_TRACKING_URLコマンドが送られてきた時に、トラッキングURLを発行する部分

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  (async () => {
    if (message.command === "CREATE_TRACKING_URL") {
      const title = message.email_title;

      const payload = {
        email_title: title,
      };

      try {
        const res = await fetch(...);

        if (!res.ok) throw new Error("Response was not ok");

        const data = await res.json();
   
        const tracking_id = data["tracking_id"];
        sendResponse({
          url: `${config.endpoint}/api/tracker/${tracking_id}`,
        });
      } catch (error) {
        console.error(error);
        sendResponse({ url: undefined });
      }
    }
  })();

  return true;
});

content-script.js

開封通知で送信がクリックされたときにCREATE_TRACKING_URLを送信して、取得したトラッキングURLをメールに埋め込む

const on_submit_click = async () => {
  const email_title = document.querySelector('[aria-label="Subject"]');

  let tracking_url = "";
  try {
    const res = await chrome.runtime.sendMessage({
      command: "CREATE_TRACKING_URL",
      email_title: email_title.value,
    });
    tracking_url = res.url;
  } catch (error) {
    console.error(error);
    return;
  }

  const messageBodyDiv = document.querySelector(
    'div[aria-label="Message Body"]'
  );
  if (messageBodyDiv) {
    const imgElement = document.createElement("img");
    imgElement.src = tracking_url;
    imgElement.width = 1;
    imgElement.height = 1;
    imgElement.style.display = "none";

    messageBodyDiv.appendChild(imgElement);
  }

  setTimeout(() => {
    const targetElm = document.querySelector('[aria-label="Send ‪(⌘Enter)‬"]');
    if (targetElm) targetElm.click();
  }, 500);
};

ボタンを追加

const add_button = async () => {
  const targetElm = document.querySelector('[aria-label="Send ‪(⌘Enter)‬"]');

  if (targetElm && !document.getElementById("custom-button")) {
    const newElm = document.createElement("div");
    newElm.textContent = "開封通知で送信";
    newElm.id = "custom-button";

    newElm.style.backgroundColor = "crimson";
    newElm.style.color = "white";

    newElm.style.borderRadius = "38px";
    newElm.style.cursor = "pointer";

    newElm.style.paddingRight = "18px";
    newElm.style.paddingLeft = "18px";
    newElm.style.paddingTop = "8px";
    newElm.style.paddingBottom = "8px";

    newElm.style.marginRight = "5px";

    newElm.style["-webkit-font-smoothing"] = "antialiased";
    newElm.style["fontFamily"] =
      '"Google Sans",Roboto,RobotoDraft,Helvetica,Arial,sans-serif';
    newElm.style["fontWeight"] = "500";
    newElm.style["fontSize"] = ".875rem";

    newElm.addEventListener("click", on_submit_click);

    targetElm.parentNode.parentNode.insertBefore(newElm, targetElm.parentNode);
  }

  setTimeout(add_button, 1000);
};

add_button();

実際の通知

右の正方形は設定したアイコン、左の長方形はサービスのタイトルが設定されます。

作って思ったこと

自分で作ってホストするのはコストが高いので、既存のサービスを使うのが得策だと思いました。

[おまけ] iOS、Android、PCで開封通知ができるメーラーを探してる人へ

[おまけ] 開封通知をされたくない人へ

画像読み込みをブロックする or もしくはセキュアなメールサービスを使う

  • Proton Mail
    プライバシーに重きを置いたメールプロバイダーと言ったら定番はこれな気がしています。
    こんな感じでメールについているトラッカーを自動的にブロックしてくれます。

Discussion