= new Set(["high", "medium"]);
+
+/**
+ * 1 行に対する通知を「冪等な再送 / 部分更新では再発火させない」ためのガード。
+ * `prev` が notifiable で `next` も notifiable のとき(例: high→high の冪等
+ * 再送、medium→high のエスカレート)は false を返す。`prev` が notifiable
+ * でなく `next` が notifiable のときだけ true。これにより 1 行あたり最大
+ * 1 通の運用通知に揃える。
+ *
+ * Returns true only when severity transitions from a non-notifiable value
+ * (`low` / `unknown`) into `high` or `medium`. Idempotent retries
+ * (high → high) and lateral moves between notifiable levels (medium → high)
+ * deliberately return false so each row produces at most one operational
+ * email over its lifetime.
+ */
+export function severityBecameNotifiable(prev: ApiErrorSeverity, next: ApiErrorSeverity): boolean {
+ return !NOTIFIABLE_SEVERITIES.has(prev) && NOTIFIABLE_SEVERITIES.has(next);
+}
+
/**
* `Authorization: Bearer ...` ヘッダから token を抜き出す。
* Extract the bearer token from an `Authorization` header value, or null when
@@ -150,6 +176,22 @@ app.put("/:id", async (c) => {
}
const db = c.get("db");
+ // Phase 3 / #809 の通知は「severity が初めて high/medium に到達したとき」
+ // のみ発火させる。`updateAiAnalysis` は冪等なので、リトライや severity を
+ // 含まない部分更新のたびに通知すると重複アラートが発生する。よって
+ // UPDATE 前に行を読み、pre.severity と updated.severity を比較する。
+ // 行が無ければここで 404 を返し、後続の UPDATE を発行しない。
+ //
+ // Phase 3 / #809: only alert when severity *transitions* into high/medium
+ // for the first time. `updateAiAnalysis` is idempotent — retries or partial
+ // callbacks (e.g. an empty body or a callback that only refreshes
+ // `ai_summary`) would otherwise resend the alert. We read the row first so
+ // we can compare pre vs post severity, and so a missing row short-circuits
+ // before the UPDATE.
+ const pre = await getApiErrorById(db, id);
+ if (!pre) {
+ return c.json({ error: "Not found" }, 404);
+ }
try {
const updated = await updateAiAnalysis(db, { id, ...normalized });
if (!updated) {
@@ -161,6 +203,28 @@ app.put("/:id", async (c) => {
// SSE 購読者へ AI 解析結果を配信 (Phase 2 / issue #807)。
// Notify SSE subscribers so the admin UI updates without a page reload.
publishApiErrorUpdate(updated);
+ // 重要エラーのメール通知 (Phase 3 / issue #809)。`pre.severity` が
+ // notifiable でなく、かつ `updated.severity` が notifiable のときだけ
+ // 1 回だけ発火する。冪等な再送やエスカレート済み行への部分更新では
+ // 発火しない。`MONITORING_NOTIFY_EMAIL` 未設定時は notifier 側で no-op。
+ //
+ // Email alert for high-impact errors (Phase 3 / #809). Fires exactly when
+ // severity transitions from a non-notifiable value (`low` / `unknown` /
+ // null) into `high` or `medium`. Idempotent retries and partial updates
+ // on already-escalated rows are deliberate no-ops here. The notifier
+ // itself further no-ops when `MONITORING_NOTIFY_EMAIL` is unset.
+ if (severityBecameNotifiable(pre.severity, updated.severity)) {
+ // fire-and-forget: notifier 側でエラーは swallow 済み。webhook 応答を
+ // Resend 呼び出しで遅延させない。
+ // fire-and-forget so the webhook response doesn't await Resend; the
+ // notifier itself swallows transport errors.
+ void notifyApiErrorAlert({
+ apiErrorId: updated.id,
+ sentryIssueId: updated.sentryIssueId,
+ severity: updated.severity,
+ title: updated.title,
+ });
+ }
// 外部 (GitHub Actions) 向けの webhook なので、`error` キーを成功時に流用する
// 内部 admin API の慣習ではなく、`data` キーで返して "error 有無で失敗判定"
// できる素直な形にする(admin/src/api/admin.ts と異なり消費者がまだ存在しない)。
diff --git a/server/api/src/services/notifier.test.ts b/server/api/src/services/notifier.test.ts
new file mode 100644
index 00000000..d53b01a6
--- /dev/null
+++ b/server/api/src/services/notifier.test.ts
@@ -0,0 +1,251 @@
+/**
+ * `notifier.ts` の単体テスト (Epic #616 Phase 3 / sub-issue #809)。
+ *
+ * 本プロジェクトでは外部通知をメール経由のみに限定する(Slack は使わない)。
+ * テストは `emailService` を mock して、severity と環境変数の組み合わせで
+ * 1 通だけ送られる / 送られないことを担保する。PII (Authorization / Cookie /
+ * raw email) が本文に混入しないことも検証する。
+ *
+ * Unit tests for `notifier.ts` (Epic #616 Phase 3 / sub-issue #809). External
+ * alerting is intentionally email-only (no Slack). Tests mock `emailService`
+ * and assert send count for each (severity, env) combination, plus PII-safe
+ * body content (no Authorization / Cookie / raw email leakage).
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+const mockSendEmail = vi.fn();
+
+vi.mock("./emailService.js", () => ({
+ sendEmail: mockSendEmail,
+}));
+
+// モック設定後にインポート / Import after mock setup
+const { notifyApiErrorAlert } = await import("./notifier.js");
+
+describe("notifyApiErrorAlert", () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ process.env = { ...originalEnv };
+ vi.clearAllMocks();
+ mockSendEmail.mockResolvedValue({ success: true, id: "email_id_1" });
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ vi.restoreAllMocks();
+ });
+
+ it("MONITORING_NOTIFY_EMAIL 未設定なら送信しない (no-op)", async () => {
+ delete process.env.MONITORING_NOTIFY_EMAIL;
+
+ const result = await notifyApiErrorAlert({
+ apiErrorId: "00000000-0000-0000-0000-000000000001",
+ sentryIssueId: "sentry-abc",
+ severity: "high",
+ title: "TypeError: cannot read property",
+ });
+
+ expect(result.email.sent).toBe(false);
+ expect(mockSendEmail).not.toHaveBeenCalled();
+ });
+
+ it("severity が low / unknown のときは送信しない", async () => {
+ process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com";
+
+ const lowResult = await notifyApiErrorAlert({
+ apiErrorId: "00000000-0000-0000-0000-000000000001",
+ sentryIssueId: "sentry-low",
+ severity: "low",
+ title: "minor",
+ });
+ const unknownResult = await notifyApiErrorAlert({
+ apiErrorId: "00000000-0000-0000-0000-000000000002",
+ sentryIssueId: "sentry-unknown",
+ severity: "unknown",
+ title: "unclassified",
+ });
+
+ expect(lowResult.email.sent).toBe(false);
+ expect(unknownResult.email.sent).toBe(false);
+ expect(mockSendEmail).not.toHaveBeenCalled();
+ });
+
+ it("severity=high かつ MONITORING_NOTIFY_EMAIL 設定済みで 1 通だけ送る", async () => {
+ process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com";
+
+ const result = await notifyApiErrorAlert({
+ apiErrorId: "00000000-0000-0000-0000-000000000001",
+ sentryIssueId: "sentry-abc",
+ severity: "high",
+ title: "TypeError: cannot read property",
+ });
+
+ expect(result.email.sent).toBe(true);
+ expect(mockSendEmail).toHaveBeenCalledTimes(1);
+ const args = mockSendEmail.mock.calls[0]?.[0];
+ expect(args.to).toBe("ops@example.com");
+ expect(args.subject).toContain("high");
+ expect(args.subject).toContain("sentry-abc");
+ });
+
+ it("severity=medium でも送る", async () => {
+ process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com";
+
+ const result = await notifyApiErrorAlert({
+ apiErrorId: "00000000-0000-0000-0000-000000000003",
+ sentryIssueId: "sentry-med",
+ severity: "medium",
+ title: "Latency spike",
+ });
+
+ expect(result.email.sent).toBe(true);
+ expect(mockSendEmail).toHaveBeenCalledTimes(1);
+ });
+
+ it("ADMIN_BASE_URL 設定時は管理画面 URL を本文に含める", async () => {
+ process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com";
+ process.env.ADMIN_BASE_URL = "https://admin.zedi-note.app";
+
+ await notifyApiErrorAlert({
+ apiErrorId: "00000000-0000-0000-0000-000000000001",
+ sentryIssueId: "sentry-abc",
+ severity: "high",
+ title: "TypeError",
+ });
+
+ const args = mockSendEmail.mock.calls[0]?.[0];
+ expect(args.html).toContain(
+ "https://admin.zedi-note.app/errors/00000000-0000-0000-0000-000000000001",
+ );
+ });
+
+ it("ADMIN_BASE_URL 未設定時は URL を本文に含めない(no-op フォールバック)", async () => {
+ process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com";
+ delete process.env.ADMIN_BASE_URL;
+
+ await notifyApiErrorAlert({
+ apiErrorId: "00000000-0000-0000-0000-000000000001",
+ sentryIssueId: "sentry-abc",
+ severity: "high",
+ title: "TypeError",
+ });
+
+ const args = mockSendEmail.mock.calls[0]?.[0];
+ // 管理画面 URL は載せないが、最低限 sentry_issue_id と severity は本文にある。
+ // No admin URL is rendered, but sentry_issue_id and severity are still
+ // present so an on-call can pivot to the Sentry issue directly.
+ expect(args.html).not.toContain("http");
+ expect(args.html).toContain("sentry-abc");
+ expect(args.html).toContain("high");
+ });
+
+ it("ADMIN_BASE_URL が http:/https: 以外なら URL を載せない(防御的)", async () => {
+ // operator が javascript:alert(1) や data: URL を誤設定しても、本文の
+ // クリック可能リンクには絶対に流れ込まないことを確認する。
+ //
+ // Defense-in-depth against an operator typo: non-HTTP(S) schemes must
+ // never make it into the alert HTML as a clickable link.
+ process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com";
+
+ for (const bad of [
+ "javascript:alert(1)",
+ "data:text/html,",
+ "file:///etc/passwd",
+ "not-a-url",
+ " ",
+ ]) {
+ vi.clearAllMocks();
+ mockSendEmail.mockResolvedValue({ success: true, id: "x" });
+ process.env.ADMIN_BASE_URL = bad;
+ await notifyApiErrorAlert({
+ apiErrorId: "00000000-0000-0000-0000-000000000001",
+ sentryIssueId: "sentry-abc",
+ severity: "high",
+ title: "TypeError",
+ });
+ const args = mockSendEmail.mock.calls[0]?.[0];
+ expect(args.html).not.toContain("javascript:");
+ expect(args.html).not.toContain("data:");
+ expect(args.html).not.toContain("file:");
+ expect(args.html).not.toContain("href=");
+ }
+ });
+
+ it("件名から CR/LF を取り除く(header-injection 防御)", async () => {
+ // sentryIssueId は実運用では英数記号のみだが、防御的に CR/LF を除去
+ // した結果が件名に反映されることを確認する。
+ //
+ // Defense-in-depth: even if a malformed sentry_issue_id slips through,
+ // CR/LF must not survive into the subject so SMTP transports can never
+ // be tricked into header injection.
+ process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com";
+
+ await notifyApiErrorAlert({
+ apiErrorId: "00000000-0000-0000-0000-000000000001",
+ sentryIssueId: "sentry\r\nBcc:attacker@example.com",
+ severity: "high",
+ title: "TypeError",
+ });
+
+ const args = mockSendEmail.mock.calls[0]?.[0];
+ // 件名に CR/LF が残っていなければヘッダ折り返しによるヘッダ注入は不可能。
+ // 文字列としての "Bcc:" は残るが、改行が挟まらないので別ヘッダにはならない。
+ //
+ // No CR/LF in the subject means an SMTP transport can't fold the line
+ // into a new header. The literal "Bcc:" substring is harmless without a
+ // preceding line break.
+ expect(args.subject).not.toMatch(/[\r\n]/);
+ });
+
+ it("PII (Authorization / Cookie / raw email) を本文に含めない", async () => {
+ process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com";
+ process.env.ADMIN_BASE_URL = "https://admin.zedi-note.app";
+
+ await notifyApiErrorAlert({
+ apiErrorId: "00000000-0000-0000-0000-000000000001",
+ sentryIssueId: "sentry-abc",
+ severity: "high",
+ title: "TypeError",
+ });
+
+ const args = mockSendEmail.mock.calls[0]?.[0];
+ const haystack = `${args.subject}\n${args.html}`.toLowerCase();
+ expect(haystack).not.toContain("authorization");
+ expect(haystack).not.toContain("cookie");
+ expect(haystack).not.toContain("bearer ");
+ });
+
+ it("emailService が失敗しても throw せず success=false を返す", async () => {
+ process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com";
+ mockSendEmail.mockResolvedValueOnce({ success: false, error: "Rate limit" });
+
+ const result = await notifyApiErrorAlert({
+ apiErrorId: "00000000-0000-0000-0000-000000000001",
+ sentryIssueId: "sentry-abc",
+ severity: "high",
+ title: "TypeError",
+ });
+
+ expect(result.email.sent).toBe(false);
+ expect(result.email.error).toBe("Rate limit");
+ });
+
+ it("emailService が例外を投げても throw せず success=false を返す", async () => {
+ process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com";
+ mockSendEmail.mockRejectedValueOnce(new Error("Network down"));
+
+ const result = await notifyApiErrorAlert({
+ apiErrorId: "00000000-0000-0000-0000-000000000001",
+ sentryIssueId: "sentry-abc",
+ severity: "high",
+ title: "TypeError",
+ });
+
+ expect(result.email.sent).toBe(false);
+ expect(result.email.error).toContain("Network down");
+ });
+});
diff --git a/server/api/src/services/notifier.ts b/server/api/src/services/notifier.ts
new file mode 100644
index 00000000..bc043824
--- /dev/null
+++ b/server/api/src/services/notifier.ts
@@ -0,0 +1,217 @@
+/**
+ * 重要 API エラー通知サービス (Epic #616 Phase 3 / sub-issue #809)。
+ *
+ * AI 解析後に severity が `high` / `medium` と判定された `api_errors` 行に対し、
+ * 運用担当の通知メールアドレスへ薄いサマリ(sentry_issue_id, severity, 管理画面
+ * URL)をメールで送る。Slack は本リポジトリでは使わないためサポートしない。
+ *
+ * 設計方針:
+ * - 環境変数(`MONITORING_NOTIFY_EMAIL`)が未設定なら no-op。本番投入前のステー
+ * ジング段階でも例外を出さずに動かせる。
+ * - severity が `high` / `medium` 以外(`low`, `unknown`)は no-op。Phase 3 の
+ * 通知は P1/P2 と整合する重大度のみ対象。
+ * - 本文に Authorization / Cookie / raw email など PII を載せない。本サービスに
+ * 渡すのは集約済みサマリ (`title`, `sentryIssueId`, `severity`) と管理画面 URL
+ * 構築に必要な `apiErrorId` のみ。
+ * - 内部 URL(API ホスト等)は本文に含めない。`ADMIN_BASE_URL` のみ参照する。
+ * - 二重通知防止のため呼び出し側で 1 箇所に集約する想定(githubAiCallback の
+ * `updateAiAnalysis` 成功直後)。
+ *
+ * Email-only alerter for high-impact API errors (Epic #616 Phase 3 / #809).
+ * Slack is intentionally unsupported in this repo. The notifier is no-op when
+ * `MONITORING_NOTIFY_EMAIL` is unset or when severity is not `high`/`medium`,
+ * so it can ship before staging configuration is finalized. The body contains
+ * only summary fields (sentry_issue_id, severity, title) and the public admin
+ * URL — Authorization / Cookie / raw email values are never propagated here.
+ *
+ * @see ./emailService.ts
+ * @see ../routes/webhooks/githubAiCallback.ts
+ * @see https://github.com/otomatty/zedi/issues/616
+ * @see https://github.com/otomatty/zedi/issues/809
+ */
+import { getOptionalEnv } from "../lib/env.js";
+import type { ApiErrorSeverity } from "../schema/apiErrors.js";
+import { sendEmail } from "./emailService.js";
+
+/**
+ * 通知対象とする severity。Phase 3 では `high` と `medium` のみ。
+ * Severities that trigger an alert. Phase 3 covers `high` and `medium` only.
+ */
+const NOTIFIABLE_SEVERITIES: readonly ApiErrorSeverity[] = ["high", "medium"];
+
+/**
+ * `notifyApiErrorAlert` の入力。呼び出し側 (githubAiCallback) は
+ * `updateAiAnalysis` の戻り行から必要なフィールドだけを抜き出して渡す。
+ *
+ * Input payload for `notifyApiErrorAlert`. Callers (githubAiCallback) extract
+ * only the fields they need from the post-update `api_errors` row. Internal
+ * URLs / capability tokens / raw user data must not be passed in.
+ */
+export interface NotifyApiErrorAlertPayload {
+ /** `api_errors.id`(管理画面 URL 構築に使う) / api_errors row id */
+ apiErrorId: string;
+ /** Sentry issue id(本文と件名に出す) / Sentry issue id (rendered in body) */
+ sentryIssueId: string;
+ /** AI 解析後の severity / AI-derived severity */
+ severity: ApiErrorSeverity;
+ /** 短いタイトル(PII を含まない要約) / Short, PII-free error title */
+ title: string;
+}
+
+/**
+ * 通知結果。メール経路の send 結果のみ返す(Slack は未対応)。
+ *
+ * Result of a `notifyApiErrorAlert` call. Only the email channel is reported
+ * because Slack is intentionally unsupported in this repo. `sent: false` with
+ * no `error` indicates an intentional skip (no-op path).
+ */
+export interface NotifyApiErrorAlertResult {
+ email: {
+ /** 送信を実行したか / Whether an email was actually sent */
+ sent: boolean;
+ /** Resend が返したメール ID / Email id returned by Resend (on success) */
+ id?: string;
+ /** 失敗時のエラーメッセージ / Error message when send failed */
+ error?: string;
+ };
+}
+
+/**
+ * HTML エスケープ。件名・本文に挿入する値はすべてここを通す。
+ * Escape user-controlled text before injecting into the HTML body.
+ */
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+/**
+ * 末尾スラッシュを取り除き、`http(s)` スキームのみを許可した正規化済み
+ * base URL を返す。`javascript:` 等の危険なスキームや URL として解析不能な
+ * 値は null にフォールバックして、本文に埋め込まれないようにする。
+ *
+ * Normalize a base URL: strip trailing slashes and only accept the `http:` /
+ * `https:` schemes. Anything else (`javascript:`, `data:`, malformed input,
+ * etc.) returns null so it never lands in alert HTML — operator-controlled
+ * env var, but defense in depth keeps a config typo from producing a clickable
+ * non-HTTP link in an email body.
+ */
+function normalizeBaseUrl(raw: string): string | null {
+ const trimmed = raw.trim().replace(/\/+$/, "");
+ if (trimmed.length === 0) return null;
+ let parsed: URL;
+ try {
+ parsed = new URL(trimmed);
+ } catch {
+ return null;
+ }
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
+ // 正規化済み URL を再合成。pathname が "/" のときは省略する。
+ // Reassemble from parsed components; drop a bare `/` pathname.
+ const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "");
+ return `${parsed.origin}${path}`;
+}
+
+/**
+ * 件名と HTML 本文を組み立てる。本文に含めるのは
+ * - sentry_issue_id
+ * - severity
+ * - title
+ * - 管理画面 URL(ADMIN_BASE_URL 設定時のみ)
+ * のみ。Authorization / Cookie / raw email / 内部 URL は呼び出し側でも
+ * 当サービス内でも参照しない。
+ *
+ * Build subject + HTML body for the alert email. Only sentry_issue_id,
+ * severity, title, and the public admin URL (when `ADMIN_BASE_URL` is set)
+ * are rendered. Authorization, Cookie, raw email, and internal URLs are
+ * never referenced — by contract this function takes only the fields it
+ * renders.
+ */
+function buildEmail(payload: NotifyApiErrorAlertPayload): { subject: string; html: string } {
+ const adminBase = normalizeBaseUrl(getOptionalEnv("ADMIN_BASE_URL"));
+ const adminUrl = adminBase ? `${adminBase}/errors/${payload.apiErrorId}` : null;
+
+ // 件名から CR/LF を取り除いて、メール送信ライブラリが SMTP ヘッダ化する
+ // 際の header injection (RFC 5322 folding 悪用) を防ぐ。Resend は JSON で
+ // 受けるため現状は発火しないが、防御を 1 段噛ませてもコストはほぼゼロ。
+ //
+ // Strip CR/LF from the subject as defense-in-depth against SMTP header
+ // injection. Resend takes the subject as a JSON field today so this isn't
+ // exploitable now, but the cost is one regex and the behavior holds even
+ // if we ever swap mail transports.
+ const subject = `[zedi:${payload.severity}] API error ${payload.sentryIssueId}`.replace(
+ /[\r\n]+/g,
+ " ",
+ );
+
+ const lines: string[] = [
+ `severity: ${escapeHtml(payload.severity)}
`,
+ `sentry_issue_id: ${escapeHtml(payload.sentryIssueId)}
`,
+ `title: ${escapeHtml(payload.title)}
`,
+ ];
+ if (adminUrl) {
+ const safeUrl = escapeHtml(adminUrl);
+ lines.push(`${safeUrl}
`);
+ }
+ return { subject, html: lines.join("\n") };
+}
+
+/**
+ * 重要 API エラーをメール通知する。Slack は使わない。
+ *
+ * - severity が `high` / `medium` 以外なら no-op。
+ * - `MONITORING_NOTIFY_EMAIL` が未設定なら no-op。
+ * - emailService 側のエラーは握りつぶし、`result.email.error` で返す
+ * (通知失敗で呼び出し元(webhook 応答)を 500 にしないため)。
+ *
+ * Send an email alert for a high-impact API error. Slack is not supported.
+ * The notifier short-circuits to a no-op when severity is `low`/`unknown` or
+ * when `MONITORING_NOTIFY_EMAIL` is unset. Email failures are swallowed and
+ * surfaced via `result.email.error` so a notification outage does not turn the
+ * upstream webhook response into a 500.
+ *
+ * @param payload - 通知に必要なサマリ / Alert summary fields
+ * @returns 送信結果 / Per-channel delivery result
+ */
+export async function notifyApiErrorAlert(
+ payload: NotifyApiErrorAlertPayload,
+): Promise {
+ if (!NOTIFIABLE_SEVERITIES.includes(payload.severity)) {
+ return { email: { sent: false } };
+ }
+
+ const to = getOptionalEnv("MONITORING_NOTIFY_EMAIL").trim();
+ if (!to) {
+ console.warn(
+ "[notifier] MONITORING_NOTIFY_EMAIL is not set; skipping alert for sentry_issue_id=",
+ payload.sentryIssueId,
+ );
+ return { email: { sent: false } };
+ }
+
+ const { subject, html } = buildEmail(payload);
+
+ try {
+ const sendResult = await sendEmail({ to, subject, html });
+ if (!sendResult.success) {
+ console.error(
+ `[notifier] email send failed for sentry_issue_id=${payload.sentryIssueId}: ${sendResult.error ?? "unknown error"}`,
+ );
+ return { email: { sent: false, error: sendResult.error } };
+ }
+ console.log(
+ `[notifier] alert email sent for sentry_issue_id=${payload.sentryIssueId} severity=${payload.severity}`,
+ );
+ return { email: { sent: true, id: sendResult.id } };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.error(
+ `[notifier] unexpected error sending alert for sentry_issue_id=${payload.sentryIssueId}: ${message}`,
+ );
+ return { email: { sent: false, error: message } };
+ }
+}