# 通知設計書 9-コメント返信通知

## 概要

本ドキュメントは、Ghost CMSにおけるコメント返信通知（Comment Reply Notification）の設計仕様を記載する。自分のコメントに対して他の会員が返信した際に送信される通知メールの送信ロジック、テンプレート構造、およびデータフローを定義する。

### 本通知の処理概要

本通知は、会員が投稿したコメントに対して他の会員が返信を投稿した際に、元のコメント投稿者（親コメント著者）に対してメール通知を送信する機能である。

**業務上の目的・背景**：コメントでの会話を活性化させ、コミュニティのエンゲージメントを高める。会員が自分のコメントへの返信を見逃さないようにすることで、継続的な議論を促進する。

**通知の送信タイミング**：返信コメントが投稿された直後、CommentReplyCreatedEventがトリガーされた時点で送信される。

**通知の受信者**：親コメントの投稿者（会員）のうち、enable_comment_notifications設定がtrueの会員に送信される。自分自身のコメントへの返信には通知されない。

**通知内容の概要**：メールには返信者の名前、投稿タイトル、返信内容（HTML）、コメントへのリンクが含まれる。i18n対応のテンプレートを使用。

**期待されるアクション**：受信者は「View comments」ボタンをクリックしてコメントセクションを確認し、会話を継続する。

## 通知種別

メール通知（イベント駆動型通知）

## 送信仕様

### 基本情報

| 項目 | 内容 |
|-----|------|
| 送信方式 | 非同期（イベント駆動） |
| 優先度 | 中 |
| リトライ | 無 |

### 送信先決定ロジック

1. 返信コメントの親コメント（parent_idまたはin_reply_to_id）を取得
2. 親コメントの投稿者（会員）を取得
3. 親コメントのステータスが'published'であることを確認
4. 会員のenable_comment_notifications設定を確認
5. 返信者と親コメント投稿者が同一人物でないことを確認
6. 上記条件を満たす場合、親コメント投稿者のメールアドレスに送信

## 通知テンプレート

### メール通知の場合

| 項目 | 内容 |
|-----|------|
| 送信元アドレス | settingsHelpers.getMembersSupportAddress() |
| 送信元名称 | サイトタイトル |
| 件名 | `New reply to your comment on {siteTitle}` |
| 形式 | HTML + テキスト（マルチパート） |

### 本文テンプレート

```
件名: New reply to your comment on {siteTitle}

本文:
Hey there,

Someone just replied to your comment on {postTitle}.

[返信者アバター（イニシャル）]
{memberName}
{memberExpertise} - {replyDate}

{replyHtml}

[View comments ボタン]

---
You can also copy & paste this URL into your browser:
{postUrl}

---
This message was sent from {siteDomain} to {toEmail}
Unsubscribe from comment reply notifications
```

### 添付ファイル

| ファイル名 | 形式 | 条件 | 説明 |
|----------|------|------|------|
| なし | - | - | 本通知には添付ファイルはない |

## テンプレート変数

| 変数名 | 説明 | データ取得元 | 必須 |
|--------|------|-------------|-----|
| siteTitle | サイト名 | settingsCache.get('title') | Yes |
| siteUrl | サイトURL | urlUtils.getSiteUrl() | Yes |
| siteDomain | サイトドメイン | urlUtils.getSiteUrl()から抽出 | Yes |
| postTitle | 投稿タイトル | post.get('title') | Yes |
| postUrl | コメント付き投稿URL | urlService.getUrlByResourceId() + '#ghost-comments-{replyId}' | Yes |
| replyHtml | 返信コメント本文HTML | reply.get('html') | Yes |
| replyDate | 返信投稿日 | moment(reply.get('created_at')).format('D MMM YYYY') | Yes |
| memberName | 返信投稿者名 | member.get('name') または 'Anonymous' | Yes |
| memberExpertise | 返信投稿者の専門分野 | member.get('expertise') | No |
| memberInitials | 返信投稿者のイニシャル | extractInitials(memberName) | Yes |
| accentColor | サイトのアクセントカラー | settingsCache.get('accent_color') | No |
| fromEmail | 送信元アドレス | settingsHelpers.getMembersSupportAddress() | Yes |
| toEmail | 送信先アドレス | parentMember.get('email') | Yes |
| profileUrl | 購読解除URL | emailService.renderer.createUnsubscribeUrl(uuid, {comments: true}) | Yes |

## 送信トリガー・条件

### トリガー一覧

| トリガー種別 | トリガーイベント | 送信条件 | 説明 |
|------------|----------------|---------|------|
| イベント | CommentReplyCreatedEvent | parent.status='published' かつ enable_comment_notifications=true かつ 自己返信でない | 返信コメント投稿 |

### 送信抑止条件

| 条件 | 説明 |
|-----|------|
| 通知設定OFF | 親コメント投稿者のenable_comment_notificationsがfalse |
| 親コメント非公開 | 親コメントのstatusが'published'でない |
| 自己返信 | 返信者と親コメント投稿者が同一人物 |
| 親コメントなし | 返信の親コメントが見つからない |

## 処理フロー

### 送信フロー

```mermaid
flowchart TD
    A[CommentReplyCreatedEvent発火] --> B{typeがin_reply_to?}
    B -->|Yes| C[in_reply_to_idで親コメント取得]
    B -->|No| D[parent_idで親コメント取得]
    C --> E[親コメント投稿者取得]
    D --> E
    E --> F{親コメントが公開済み?}
    F -->|No| G[送信スキップ]
    F -->|Yes| H{通知設定有効?}
    H -->|No| G
    H -->|Yes| I{自己返信?}
    I -->|Yes| G
    I -->|No| J[テンプレートデータ構築]
    J --> K[new-comment-reply.hbsレンダリング]
    K --> L[メール送信]
```

## データベース参照・更新仕様

### 参照テーブル一覧

| テーブル名 | 用途 | 備考 |
|-----------|------|------|
| comments | 親コメント取得 | parent_idまたはin_reply_to_idで検索 |
| comments | 返信コメント情報 | イベントから取得 |
| posts | 投稿情報取得 | reply.post_id |
| members | 親コメント投稿者取得 | コメントのリレーション |
| members | 返信投稿者取得 | reply.member_id |
| settings | サイト設定取得 | settingsCacheから取得 |

### テーブル別参照項目詳細

#### comments（親コメント）

| 参照項目（カラム名） | 用途 | 取得条件 |
|-------------------|------|---------|
| id | 親コメント識別 | reply.parent_id または reply.in_reply_to_id |
| status | 公開状態確認 | 'published'の場合のみ送信 |
| member_id | 親コメント投稿者識別 | リレーション経由 |

#### members（親コメント投稿者）

| 参照項目（カラム名） | 用途 | 取得条件 |
|-------------------|------|---------|
| id | 自己返信チェック | - |
| email | 送信先アドレス | - |
| uuid | 購読解除URL生成 | - |
| enable_comment_notifications | 通知有効/無効 | trueの場合のみ送信 |

#### members（返信投稿者）

| 参照項目（カラム名） | 用途 | 取得条件 |
|-------------------|------|---------|
| name | 返信投稿者名 | - |
| expertise | 専門分野表示 | - |

### 更新テーブル一覧

| テーブル名 | 操作 | 概要 |
|-----------|------|------|
| なし | - | 送信ログ等への書き込みは行われない |

## エラー処理

### エラーケース一覧

| エラー種別 | 発生条件 | 対処方法 |
|----------|---------|---------|
| CommentNotFoundError | 親コメントが見つからない | 早期リターン（ログなし） |
| MemberNotFoundError | 返信投稿者が見つからない | ログ出力、処理スキップ |
| MailError | メール送信失敗 | ログ出力（リトライなし） |

### リトライ仕様

| 項目 | 内容 |
|-----|------|
| リトライ回数 | 0 |
| リトライ間隔 | - |
| リトライ対象エラー | - |

## 配信設定

### レート制限

| 項目 | 内容 |
|-----|------|
| 1分あたり上限 | 設定なし |
| 1日あたり上限 | 設定なし |

### 配信時間帯

特定の配信時間帯制限はない。返信投稿直後に即座に送信。

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

- 返信本文HTML（replyHtml）は三重中括弧 `{{{replyHtml}}}` でエスケープせずに出力
- 親コメント投稿者のメールアドレスは返信者には開示されない
- 購読解除URLにはUUIDが含まれる（会員識別用）
- 自己返信には通知を送信しない（スパム防止）

## 備考

- type='parent'と'in_reply_to'の2種類の返信形式に対応
- i18n対応（Handlebarsの't'ヘルパー経由）
- 購読解除リンクはPortalの会員プロフィールページにリダイレクト
- commentPermalinks labsフラグにより個別コメントへの直接リンク対応
- preheaderは「Someone just replied to your comment」

---

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

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

### 推奨読解順序

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

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | new-comment-reply.hbs | `ghost/core/core/server/services/comments/email-templates/new-comment-reply.hbs` | テンプレート構造、i18n対応 |
| 1-2 | comments-service-emails.js | `ghost/core/core/server/services/comments/comments-service-emails.js` | templateData構築（行111-126） |

**読解のコツ**: new-comment-reply.hbsはnew-comment.hbsと類似しているが、i18n対応（`{{t '...'}}` 形式）やprofileUrl（購読解除）が追加されている点に注意。

#### Step 2: サービス層を理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | comments-service-emails.js | `ghost/core/core/server/services/comments/comments-service-emails.js` | notifyParentCommentAuthor()メソッド |

**主要処理フロー**:
- **80-139行目**: notifyParentCommentAuthor() - メイン送信処理
- **82-86行目**: type判定（'in_reply_to' or 'parent'）
- **89-91行目**: 親コメント公開状態と通知設定チェック
- **96-98行目**: 自己返信チェック
- **125行目**: 購読解除URL生成

#### Step 3: 購読解除URLの生成を理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | email-renderer.js | `ghost/core/core/server/services/email-service/email-renderer.js` | createUnsubscribeUrl() |

**主要処理フロー**:
- emailService.renderer.createUnsubscribeUrl(uuid, {comments: true})
- UUIDベースの購読解除リンク生成

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

```
CommentReplyCreatedEvent (Domain Event)
    |
    └─ Event Handler（イベント購読側）
           |
           └─ CommentsServiceEmails.notifyParentCommentAuthor(reply, {type})
                  |
                  ├─ type判定
                  |      |
                  |      ├─ type='in_reply_to' → models.Comment.findOne({id: in_reply_to_id})
                  |      |
                  |      └─ type='parent' → models.Comment.findOne({id: parent_id})
                  |
                  ├─ parent.related('member') - 親コメント投稿者取得
                  |
                  ├─ 送信条件チェック
                  |      |
                  |      ├─ parent.status === 'published'
                  |      |
                  |      ├─ parentMember.enable_comment_notifications
                  |      |
                  |      └─ parentMember.id !== reply.member_id（自己返信でない）
                  |
                  ├─ models.Post.findOne({id: reply.post_id})
                  |
                  ├─ models.Member.findOne({id: reply.member_id})
                  |
                  ├─ emailService.renderer.createUnsubscribeUrl(uuid, {comments: true})
                  |
                  ├─ CommentsServiceEmailRenderer.renderEmailTemplate('new-comment-reply', data)
                  |      |
                  |      ├─ Handlebars.compile() - HTML生成
                  |      |
                  |      └─ new-comment-reply.txt.js - テキスト生成
                  |
                  └─ sendMail({to, subject, html, text})
                         |
                         └─ mailer.send()
```

### データフロー図

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

CommentReplyCreatedEvent ──▶ CommentsServiceEmails
  - reply                      |
  - type                       ▼
                         親コメント取得
                               |
commentsテーブル ◀──────── 親コメント検索
  - status                     |
  - member_id                  ▼
                         送信条件チェック
                               |
membersテーブル ◀───────── 親コメント投稿者
  - enable_comment_notifications
  - email                      |
  - uuid                       ▼
                         自己返信チェック
                               |
membersテーブル ───────▶ 返信投稿者情報
  - name                       |
  - expertise                  ▼
                         購読解除URL生成
                               |
emailService ─────────▶       |
  - createUnsubscribeUrl()     ▼
                         Handlebarsレンダリング
                               |
settingsテーブル ─────▶       |
  - title                      ▼
  - accent_color         メール送信 ─────────▶ 親コメント投稿者のメールボックス
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| comments-service-emails.js | `ghost/core/core/server/services/comments/comments-service-emails.js` | サービス | メイン処理ロジック（行80-139） |
| comments-service-email-renderer.js | `ghost/core/core/server/services/comments/comments-service-email-renderer.js` | レンダラー | Handlebarsレンダリング |
| new-comment-reply.hbs | `ghost/core/core/server/services/comments/email-templates/new-comment-reply.hbs` | テンプレート | メールHTMLテンプレート |
| new-comment-reply.txt.js | `ghost/core/core/server/services/comments/email-templates/new-comment-reply.txt.js` | テンプレート | メールテキストテンプレート |
| email-renderer.js | `ghost/core/core/server/services/email-service/email-renderer.js` | レンダラー | 購読解除URL生成 |
