Docker を用いた node-cron & nodemailer を使ったメール送信バッチシステム

node-cronとnodemailerを利用して

* DBに登録されたメール送信リクエストを定期的にチェック
* 未送信のものを5件ずつメールサーバーを介して送信
* 送信済みフラグを立てる

というシステムを作ってゆく
また、Dockerを用いた開発環境も紹介する


# 全体像
1. DB
    * テーブル(mail_queue)に以下の列がある想定
        * to_address(送信先アドレス)
        * subject (件名)
        * body (本文)
        * from_address(送信元アドレス)
        * smtp_host(SMTPサーバー、固定)
        * smtp_id(SMTP認証ユーザー名、固定）
        * smtp_pw(SMTP認証パスワード、固定)
        * smtp_port(SMTPサーバーポート、固定)
        * sent_flag(送信済みフラグ:0=未送信、1=送信済み)

2. Node.jsアプリケーション
    * node-cronを使って、一定時間ごとにmail_queueテーブルをチェック
    * nodemailerを使って5件ずつメールを送信
    * 送信成功したらsent_flagを1に更新
3. Docker構成
    * Node.jsアプリ(コンテナ)
    * DB(コンテナ)
    * 必要に応じてメールサーバー（SMTPサーバー）を立てる

# ディレクトリ構成
```
my-mail-system/
├─ docker-compose.yml
├─ backend/
│  ├─ Dockerfile
│  ├─ package.json
│  ├─ package-lock.json
│  └─ src/
│     ├─ index.js
│     └─ db.js (DB接続設定など)
└─ .env 
```
# Node.jsアプリの実装例
### 1.パッケージインストール
```bash:bash
# backend ディレクトリで
npm init -y
npm install node-cron nodemailer pg  # PostgreSQLの場合
```


### 2.DB接続設定(db.jsと.envファイル)

環境変数を読み込むためにdotenvパッケージをインストールします
``` bash:bash
npm install dotenv
```

```makefile:.envファイル
DB_HOST=db
DB_USER=user
DB_PASSWORD=pass
DB_NAME=maildb
DB_PORT=5432
```
::: note
プロジェクトのルートディレクトリに.envファイルを作成して、接続情報を記述します。
:::

```javascript:db.js
const { Pool } = require('pg');
require('dotenv').config(); // .envファイルの読み込み

const pool = new Pool({
  host: process.env.DB_HOST,       // ホスト名
  user: process.env.DB_USER,       // ユーザー名
  password: process.env.DB_PASSWORD, // パスワード
  database: process.env.DB_NAME,   // データベース名
  port: process.env.DB_PORT,       // ポート番号
});

module.exports = pool;
```


#### 注意点
1. .envをGitで管理しない
    .envファイルには機密情報が含まれるため、必ず.gitignoreに追加して下さい
2. 環境変数をDocker Composeで定義する場合
    Docker環境を使用している場合、docker-compose.ymlで環境変数を定義できます
    ```yaml:docker-compose.yml
    services:
      db:
        image: postgres:15
        environment:
          POSTGRES_DB: maildb
          POSTGRES_USER: user
          POSTGRES_PASSWORD: pass
        ports:
          - "5432:5432"
    ```
3. 本番環境では安全な方法で管理
    本番環境では、.envファイルではなく、AWS Secrets ManagerやGoogle Cloud Secret Managerなどを使って管理するのがおすすめです。

### 3.メインロジック(index.js)
```javascript:index.js
const cron = require('node-cron');
const nodemailer = require('nodemailer');
const pool = require('./db'); // PostgreSQL接続設定

const main = async () => {
  console.log('Worker started');

  // 毎分実行 (例: "* * * * *" -> 1分ごと)
  cron.schedule('* * * * *', async () => {
    console.log('=== Cron job triggered ===');
    try {
      // 1) 未送信のレコードを最大5件取得する
      const res = await pool.query(
        'SELECT id, to_address, subject, body, from_address, smtp_host, smtp_id, smtp_pw, smtp_port FROM mail_queue WHERE sent_flag = $1 LIMIT 5',
        [0]
      );
      const rows = res.rows; // PostgreSQLでは`rows`にデータが格納される

      if (!rows.length) {
        console.log('No unsent mails.');
        return;
      }

      // 2) 取得したレコード分だけメール送信
      for (const mail of rows) {
        const transporter = nodemailer.createTransport({
          host: mail.smtp_host,
          port: mail.smtp_port,
          secure: false, // 587などTLSを使うなら false
          auth: {
            user: mail.smtp_id,
            pass: mail.smtp_pw,
          },
        });

        const mailOptions = {
          from: mail.from_address,
          to: mail.to_address,
          subject: mail.subject,
          text: mail.body,
        };

        try {
          await transporter.sendMail(mailOptions);
          console.log(`Mail sent to: ${mail.to_address}`);

          // 3) 送信成功したらフラグを立てる
          await pool.query(
            'UPDATE mail_queue SET sent_flag = $1 WHERE id = $2',
            [1, mail.id]
          );
        } catch (err) {
          console.error('Error sending mail:', err);
        }
      }
    } catch (err) {
      console.error('Error in cron job:', err);
    }
  });
};

main();
```
::: note warn
上記は簡易的な例です。実際にはエラーハンドリングやリトライ制御、HTMLメールや添付ファイル対応などが必要に応じて追加されます。
:::

# Docker構成
### 1.docker-compose.yml
```yaml:docker-compose.yml
version: '3.8'
services:
  db:
    image: postgres:15
    container_name: my-mail-db
    environment:
      - POSTGRES_DB=maildb
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - DB_HOST=db
    ports:
      - "5432:5432"
    volumes:
      - db_data:/var/lib/postgresql/data

  backend:
    build: ./backend
    container_name: my-mail-backend
    depends_on:
      - db
    volumes:
      - ./backend/src:/usr/src/app/src
    ports:
      - "3000:3000"
    env_file:
      - .env

volumes:
  db_data:
```
::: note warn
docker-compose.yml で .env を明示的に読み込むようにしないとDocker環境では.envが読み込まれないようです
:::
* dbサービス
    * postgreSQL 15.0　イメージを使用
    * POSTGRES_DB=maildbなど環境変数で初期DBを作成
* backendサービス
    * ./backendディレクトリにあるDockerfileからビルド
    * depends_onでdbが起動してから起動するようにしている
    * volumesでローカルのソースコードをコンテナ内にマウント(ホットリロードできるように設定)
    * commandでNode.jsアプリ(index.js)を起動
### 2.Dockerfileの例(backend用)
```dockerfile:dockerfile
# backend/Dockerfile
FROM node:18

WORKDIR /usr/src/app

# package.json と package-lock.json をコピー
COPY package*.json ./

# npm install 実行
RUN npm install

# ソースコードをコピー
COPY . .

# ホットリロード(開発用)でなければエントリポイントはCMDでもOK
# CMD ["node", "src/index.js"]
```
::: note warn
CMD がコメントアウトされているため、コンテナ起動後、起動時に常駐するプロセスが実行されないため何も実行されずに exit 0（正常終了）でコンテナが停止します。開発時はコメントアウトをはずし忘れないこと
:::
### 3.コンテナ起動
```bash:bash
docker-compose up -d --build
```
* --buildオプションでDockerfuleに従って再ビルド
* 立ち上がったら、backendコンテナ内でnode src/index.jsが実行され、node-cronが動き始める
### 4.DBテーブル作成(PostgrSQL)
```sql:sql
CREATE TABLE mail_queue (
  id SERIAL PRIMARY KEY,
  to_address VARCHAR(255) NOT NULL,
  subject VARCHAR(255) NOT NULL,
  body TEXT NOT NULL,
  from_address VARCHAR(255) NOT NULL,
  smtp_host VARCHAR(255) NOT NULL,
  smtp_id VARCHAR(255) NOT NULL,
  smtp_pw VARCHAR(255) NOT NULL,
  smtp_port INTEGER NOT NULL,
  sent_flag INTEGER NOT NULL DEFAULT 0
);
```
docker-compose で起動後、PostgreSQLコンテナに入って上記クエリを実行したり、環境変数等で初回起動時にマイグレーションを走らせる方法もあります

# 実行イメージ
1. docker-compose up -d --buildでコンテナ起動
2. dbコンテナ(postageSQL)の初期化後、SQLでテーブル作成
3. mail_queueテーブルに(to_address, subject, body, from_address, smtp_host, smtp_id, smtp_pw, smtp_port, sent_flag=0) をINSERT
4. backendコンテナ内のnode-cronが毎分未送信レコードをチェック→nodemailerで送信→成功したらsent_flag=1に更新
5. ログはdocker-compose logs -f backendなどで確認可能

# 補足(SMTPサーバー)
* 実際に運用する場合は、信頼できるSMTPサーバーを利用します
* テスト環境用に一時的に[mailhog](https://github.com/mailhog/MailHog)のようなテスト用SMTPサーバーをdocker-composeで起動して受信確認するケースもあります
例:docker-compose.ymlにMailhogを追加する場合
```yaml:yaml
  mailhog:
    image: mailhog/mailhog
    container_name: my-mailhog
    ports:
      - "8025:8025"  # Web UI
```
* Mailhogを立ち上げると、SMTP:1025,Web UI: 8025が提供され、ブラウザから http://localhost:8025 で受信メールを簡単に確認できます
* Node.js側のSMTP設定をhost=mailhog,port=1025にすると、実際のメールの代わりにMailhogのUIで受信を確認できます


# まとめ
* node-cron で一定間隔ごとにDBをチェックして、未送信メールがあればnodemailerで送信→送信済みフラグを更新
* Docker + docker-composeでNode.jsとDBをコンテナ化し、必要に応じてテスト用SMTPサーバーも起動する