# 機能設計書 213-メール通知送信

## 概要

本ドキュメントは、Chatwootにおける「メール通知送信」機能の設計仕様を記載する。本機能は、WebWidgetまたはAPIチャネル経由の会話において、エージェントからのメッセージを連絡先のメールアドレスに送信し、会話の継続性を維持するサービスを提供する。

### 本機能の処理概要

この機能は、WebWidgetやAPIチャネルで会話中に顧客がオフラインになった場合でも、エージェントの返信がメールで通知されることで、会話を継続できるようにする「メール経由の継続性（Email Continuity）」機能を実現する。

**業務上の目的・背景**：Webサイトに設置されたチャットウィジェットでは、顧客がブラウザを閉じたりページを離れたりすると、エージェントの返信を受け取れなくなる。これにより、顧客対応の機会損失や顧客満足度の低下につながる。本機能は、エージェントのメッセージをメールで配信することで、顧客がいつでも会話を継続できる環境を提供する。また、Redisを使用した重複送信防止機構により、複数のメッセージが短時間で送信された場合でも、一定間隔でまとめてメール送信することで、顧客へのメール爆撃を防止している。

**機能の利用シーン**：
- WebWidgetで顧客からの問い合わせがあり、顧客がオフラインになった後にエージェントが返信する場合
- APIチャネル経由の会話でメール継続性が有効な場合
- エージェントがCSAT（顧客満足度調査）を送信する場合

**主要な処理内容**：
1. SendReplyJobからメッセージ送信処理として呼び出される
2. メッセージがメール通知対象かを判定（email_notifiable_message?）
3. 連絡先のメールアドレスが存在するかを確認
4. チャネル設定（continuity_via_email）または機能フラグ（email_continuity_on_api_channel）を確認
5. Redisでの重複送信防止（アトミックなキー設定）
6. ConversationReplyEmailJobをスケジュール（2分後に実行）
7. ジョブ内でメール送信を実行

**関連システム・外部連携**：
- Redis（重複送信防止のためのキー管理）
- ConversationReplyEmailJob（遅延実行ジョブ）
- ConversationReplyMailer（メール送信Mailer）
- SMTPサーバー（メール配信）

**権限による制御**：本機能自体に権限制御はないが、メッセージ送信権限を持つユーザー（エージェント、管理者）の送信メッセージのみが対象となる。

## 関連画面

| 画面No | 画面名 | 関連種別 | 関連する操作・処理 |
|--------|--------|----------|------------------|
| 11 | 会話詳細画面 | 参照画面 | エージェントのメッセージ送信がトリガー |
| 50 | インボックス設定画面 | 参照画面 | continuity_via_email設定の管理 |
| 116 | メッセージ画面（Widget） | 結果表示画面 | 顧客がメール経由で会話を継続 |

## 機能種別

メール送信 / 非同期処理

## 入力仕様

### 入力パラメータ

| パラメータ名 | 型 | 必須 | 説明 | バリデーション |
|-------------|-----|-----|------|---------------|
| message | Message | Yes | 送信対象のメッセージオブジェクト | 存在チェック |

### 入力データソース

- Message: messages テーブル
- Conversation: conversations テーブル（message.conversation経由）
- Contact: contacts テーブル（conversation.contact経由）
- Inbox: inboxes テーブル（message.inbox経由）
- Channel::WebWidget: channel_web_widgets テーブル（inbox.channel経由）

## 出力仕様

### 出力データ

| 項目名 | 型 | 説明 |
|--------|-----|------|
| job_id | String | スケジュールされたConversationReplyEmailJobのID |

### 出力先

- Redisキー: `CONVERSATION::{conversation_id}` （重複防止用）
- Active Job（ConversationReplyEmailJob）
- メール送信（ConversationReplyMailer経由）

## 処理フロー

### 処理シーケンス

```
1. SendReplyJobからの呼び出し
   └─ WebWidgetまたはAPIチャネルの場合にこのサービスが選択される

2. 送信条件判定（should_send_email_notification?）
   ├─ email_notifiable_message? チェック
   │   ├─ プライベートメッセージでないか
   │   ├─ outgoingまたはtemplateタイプか
   │   └─ templateの場合はinput_csatまたはtextか
   ├─ 連絡先のメールアドレスが存在するか
   └─ email_reply_enabled? チェック
       ├─ WebWidget: continuity_via_email設定
       └─ API: email_continuity_on_api_channel機能フラグ

3. Redis重複防止処理
   └─ Redis::Alfred.set(key, message.id, nx: true, ex: 1.hour)
       ├─ 成功: 処理続行
       └─ 失敗（既存キー）: 処理終了

4. ジョブスケジュール
   └─ ConversationReplyEmailJob.set(wait: 2.minutes).perform_later(conversation_id, message_id)

5. ジョブ実行（2分後）
   ├─ 会話取得
   ├─ ConversationReplyMailer でメール送信
   │   ├─ incoming_emailの場合: reply_without_summary
   │   └─ その他: reply_with_summary
   └─ Redisキー削除
```

### フローチャート

```mermaid
flowchart TD
    A[SendReplyJob] --> B{チャネルタイプ?}
    B -->|WebWidget/API| C[SendEmailNotificationService.perform]
    B -->|その他| Z[終了]

    C --> D{email_notifiable_message?}
    D -->|No| Z
    D -->|Yes| E{連絡先メールあり?}
    E -->|No| Z
    E -->|Yes| F{email_reply_enabled?}
    F -->|No| Z
    F -->|Yes| G[Redis.set nx:true ex:1hour]

    G --> H{キー設定成功?}
    H -->|No 既存キー| Z
    H -->|Yes| I[ConversationReplyEmailJob<br/>スケジュール 2分後]

    I --> J[2分後ジョブ実行]
    J --> K{メッセージタイプ?}
    K -->|incoming_email| L[reply_without_summary]
    K -->|その他| M[reply_with_summary]
    L --> N[メール送信]
    M --> N
    N --> O[Redisキー削除]
    O --> Z
```

## ビジネスルール

### 業務ルール

| ルールNo | ルール名 | 内容 | 適用条件 |
|---------|---------|------|---------|
| BR-213-01 | メール通知対象メッセージ | outgoingまたはtemplateタイプのメッセージのみ対象。templateの場合はinput_csatまたはtext。プライベートメッセージは除外 | メッセージ作成時 |
| BR-213-02 | 連絡先メール必須 | 連絡先にメールアドレスが設定されていない場合は送信しない | 送信前チェック |
| BR-213-03 | チャネル設定依存 | WebWidgetはcontinuity_via_email、APIはemail_continuity_on_api_channel機能フラグで制御 | チャネルタイプ判定時 |
| BR-213-04 | 重複送信防止 | 同一会話で1時間以内に複数メッセージがある場合は1回のメールにまとめる | Redis nx:true設定 |
| BR-213-05 | 遅延送信 | メッセージ送信から2分後にメール送信を実行 | ジョブスケジュール時 |
| BR-213-06 | 閲覧済みスキップ | 連絡先が最後のメッセージより後にWidgetを閲覧した場合はメール送信しない | メール送信時 |

### 計算ロジック

Redis重複防止キー:
```ruby
format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
# => "CONVERSATION::{conversation_id}"
```

## データベース操作仕様

### 操作別データベース影響一覧

| 操作 | 対象テーブル | 操作種別 | 概要 |
|-----|-------------|---------|------|
| メッセージ取得 | messages | SELECT | 送信対象メッセージの取得 |
| 連絡先取得 | contacts | SELECT | 連絡先メールアドレスの確認 |
| チャネル設定取得 | channel_web_widgets | SELECT | continuity_via_email設定の取得 |
| 会話取得 | conversations | SELECT | ジョブ実行時の会話取得 |

### テーブル別操作詳細

#### channel_web_widgets

| 操作 | 項目（カラム名） | 更新値・取得条件 | 備考 |
|-----|-----------------|-----------------|------|
| SELECT | continuity_via_email | inbox.channel.continuity_via_email | デフォルトtrue |

#### contacts

| 操作 | 項目（カラム名） | 更新値・取得条件 | 備考 |
|-----|-----------------|-----------------|------|
| SELECT | email | message.conversation.contact.email | 必須チェック |

## エラー処理

### エラーケース一覧

| エラーコード | エラー種別 | 発生条件 | 対処方法 |
|------------|----------|---------|---------|
| - | ActiveRecord::RecordNotFound | ジョブ実行時に会話が削除済み | ジョブ失敗（リトライなし） |
| - | Net::SMTPError | SMTP接続エラー | Active Jobのリトライ機構に依存 |
| - | Redis::CannotConnectError | Redis接続エラー | 処理中断 |

### リトライ仕様

本サービス自体にリトライ処理はない。メール送信はConversationReplyEmailJob内で実行され、ジョブのリトライ機構に依存する。

## トランザクション仕様

本機能でのデータベーストランザクションはない。Redis操作はアトミックなset(nx: true)で重複防止を実現。

## パフォーマンス要件

- Redis操作は単一のSETコマンドで完了
- メール送信は非同期ジョブで実行（2分後）
- 大量メッセージの場合も重複防止により1時間に1回のメール送信

## セキュリティ考慮事項

- 連絡先のメールアドレスは個人情報として適切に管理
- メール内容には会話のメッセージが含まれるため、セキュリティポリシーに準拠
- SMTP認証情報は環境変数または設定ファイルで管理

## 備考

- WebWidgetのcontinuity_via_emailはデフォルトでtrue
- APIチャネルのemail_continuity_on_api_channelは機能フラグで制御
- Redisキーの有効期限は1時間（ジョブ実行遅延2分より長い）

---

## コードリーディングガイド

本機能を理解するために参照すべきファイルと、推奨する読み解き順序を以下に示す。

### 推奨読解順序

#### Step 1: データ構造を理解する

まず、メール送信に関連するモデルと設定を理解する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | message.rb | `app/models/message.rb` | email_notifiable_message?メソッド（196-201行目） |
| 1-2 | web_widget.rb | `app/models/channel/web_widget.rb` | continuity_via_emailカラム（7行目、35行目） |
| 1-3 | redis_keys.rb | `lib/redis/redis_keys.rb` | CONVERSATION_MAILER_KEYの定義（8行目） |

**読解のコツ**:
- `email_notifiable_message?`はprivate?, message_type, content_typeで判定
- `continuity_via_email`はboolean型でデフォルトtrue
- Redisキーは`CONVERSATION::{conversation_id}`形式

#### Step 2: エントリーポイントを理解する

SendReplyJobからの呼び出しとサービスの構造を確認する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | send_reply_job.rb | `app/jobs/send_reply_job.rb` | CHANNEL_SERVICESマッピング（4-16行目） |
| 2-2 | send_email_notification_service.rb | `app/services/messages/send_email_notification_service.rb` | メイン処理サービス |

**主要処理フロー**:

**send_reply_job.rb**:
1. **14行目**: `'Channel::WebWidget' => ::Messages::SendEmailNotificationService`
2. **15行目**: `'Channel::Api' => ::Messages::SendEmailNotificationService`
3. **18-27行目**: ジョブ実行。チャネルに応じたサービスを呼び出し

**send_email_notification_service.rb**:
1. **2行目**: `pattr_initialize [:message!]` - messageを必須パラメータとして初期化
2. **4-16行目**: `perform` - メインエントリーポイント
3. **5行目**: `return unless should_send_email_notification?` - 送信条件判定
4. **7-8行目**: Redisキーの生成
5. **10-13行目**: Redisでアトミックにキー設定（nx: true, ex: 1.hour）
6. **15行目**: ジョブスケジュール（2分後）
7. **20-25行目**: `should_send_email_notification?` - 送信条件判定
8. **27-36行目**: `email_reply_enabled?` - チャネル設定確認

#### Step 3: ジョブとメーラーを理解する

遅延実行されるジョブとメール送信処理を確認する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | conversation_reply_email_job.rb | `app/jobs/conversation_reply_email_job.rb` | 遅延実行ジョブ |
| 3-2 | conversation_reply_mailer.rb | `app/mailers/conversation_reply_mailer.rb` | メール送信Mailer |

**主要処理フロー**:

**conversation_reply_email_job.rb**:
1. **2行目**: `queue_as :mailers` - メーラーキューで実行
2. **4行目**: `def perform(conversation_id, last_queued_id)` - パラメータ受け取り
3. **5行目**: 会話取得
4. **7-11行目**: メッセージタイプに応じてreply_without_summaryまたはreply_with_summary
5. **13行目**: Redisキー削除

**conversation_reply_mailer.rb**:
1. **11-22行目**: `reply_with_summary` - 過去10件+新着メッセージを含むメール
2. **24-35行目**: `reply_without_summary` - 新着メッセージのみのメール
3. **77-83行目**: `conversation_already_viewed?` - 閲覧済みチェック

#### Step 4: Redis操作を理解する

重複送信防止のためのRedis操作を確認する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 4-1 | alfred.rb | `lib/redis/alfred.rb` | Redis操作ラッパー |

**主要処理フロー**:
1. **10-11行目**: `set(key, value, nx: false, ex: false)` - SET操作
2. **24-26行目**: `delete(key)` - DEL操作
3. nx: trueで「キーが存在しない場合のみ設定」を実現

### プログラム呼び出し階層図

```
[メッセージ送信]
Message.create
    │
    ▼
after_commit :dispatch_create_events
    │
    ▼
SendReplyJob.perform_later(message.id)
    │
    ├─ [チャネル判定]
    │   └─ CHANNEL_SERVICES['Channel::WebWidget'] = Messages::SendEmailNotificationService
    │   └─ CHANNEL_SERVICES['Channel::Api'] = Messages::SendEmailNotificationService
    │
    ▼
Messages::SendEmailNotificationService.new(message:).perform
    │
    ├─ should_send_email_notification?
    │      ├─ message.email_notifiable_message?
    │      │      ├─ private? チェック
    │      │      ├─ message_type チェック (outgoing/template)
    │      │      └─ content_type チェック (template時: input_csat/text)
    │      ├─ conversation.contact.email.present? チェック
    │      └─ email_reply_enabled?
    │             ├─ [WebWidget] inbox.channel.continuity_via_email
    │             └─ [API] account.feature_enabled?('email_continuity_on_api_channel')
    │
    ├─ Redis::Alfred.set(CONVERSATION_MAILER_KEY, message.id, nx: true, ex: 1.hour)
    │      └─ [既存キーがある場合は処理終了]
    │
    └─ ConversationReplyEmailJob.set(wait: 2.minutes).perform_later(conversation.id, message.id)
           │
           ▼ [2分後]
           ├─ Conversation.find(conversation_id)
           ├─ ConversationReplyMailer
           │      ├─ [incoming_emailの場合] reply_without_summary
           │      └─ [その他] reply_with_summary
           │             └─ conversation_already_viewed? チェック
           └─ Redis::Alfred.delete(CONVERSATION_MAILER_KEY)
```

### データフロー図

```
[入力]                              [処理]                                [出力]

Message                    Messages::SendEmailNotificationService
  │                                    │
  ├─ message_type ─────────────▶  email_notifiable_message? 判定
  │   (outgoing/template)              │
  │                                    ▼
  └─ conversation ─────────────▶  ┌────────────────────────────────┐
        │                         │       送信条件チェック          │
        ├─ contact.email ────────▶│  - メールアドレス存在確認      │
        │                         │  - チャネル設定確認            │
        └─ inbox.channel ────────▶│    (continuity_via_email)     │
                                  └────────────────────────────────┘
                                           │
                                           ▼
                                  ┌────────────────────────────────┐
                                  │     Redis 重複防止処理          │
Redisキー ◀──────────────────────│  SET CONVERSATION::{id}        │
"CONVERSATION::{id}"             │      nx: true, ex: 1hour       │
                                  └────────────────────────────────┘
                                           │
                                           ▼
                                  ConversationReplyEmailJob
                                      (2分後遅延実行)
                                           │
                                           ▼
                                  ConversationReplyMailer
                                           │
                                           ▼
                                      [メール送信]
                                           │
                                           ▼
Contact.email ◀─────────────────  SMTPサーバー経由で配信
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| send_email_notification_service.rb | `app/services/messages/send_email_notification_service.rb` | サービス | メイン処理。送信条件判定とジョブスケジュール |
| send_reply_job.rb | `app/jobs/send_reply_job.rb` | ジョブ | チャネルに応じた送信サービスの呼び出し |
| conversation_reply_email_job.rb | `app/jobs/conversation_reply_email_job.rb` | ジョブ | 遅延実行。メール送信とRedisキー削除 |
| conversation_reply_mailer.rb | `app/mailers/conversation_reply_mailer.rb` | メーラー | メール本文の生成と送信 |
| message.rb | `app/models/message.rb` | モデル | email_notifiable_message?メソッド |
| web_widget.rb | `app/models/channel/web_widget.rb` | モデル | continuity_via_email設定 |
| alfred.rb | `lib/redis/alfred.rb` | ライブラリ | Redis操作ラッパー |
| redis_keys.rb | `lib/redis/redis_keys.rb` | 定数 | CONVERSATION_MAILER_KEYの定義 |
