Building Relay

Part 3 · Chapter 3.21

The email nobody was sending

You will produce: The outbox pattern a third time, over a column the "When to stop trying" chapter already wrote — and Mailpit, because only a received message can prove an email carries no secret · about 60 minutes including the exercise

Source: SRS — Software Requirements Specification · Journey map

Chương retry-và-disable đã dựng cơ chế tự động tắt webhook. Một endpoint hỏng suốt một giờ với hai mươi lần thử sẽ bị tắt, và một dòng được ghi lại để nói rằng tổ chức đó xứng đáng nhận một lời giải thích:

services/api/src/db/repository.ts (excerpt)
await tx.insert(webhookDisableNotifications).values({
  id: randomUUID(),
  …
  // NOT SET, and that is the point. FR-WHK-07 asks for the organisation to be
  // notified by email and this platform has no email; `delivered_at` exists in
  // order to be null until a transport does.
});

Hai chương sau, những dòng ấy vẫn nằm đó và delivered_at vẫn null trên từng dòng một. FR-WHK-07 mới hoàn thành một nửa: nền tảng biết mình nợ một email và không có cách nào gửi đi.

Chương này gửi nó. Phần thú vị không nằm ở SMTP.

Cái bảng vốn đã đúng hình dạng

flowchart TB
    subgraph c33["chương 3.3 · event"]
      o1["outbox<br/>published_at"] --> o1x["NATS"]
    end
    subgraph c35["chương 3.5 · delivery"]
      o2["webhook_deliveries<br/>state · next_attempt_at"] --> o2x["endpoint của khách hàng"]
    end
    subgraph c38["chương 3.8 · notification"]
      o3["webhook_disable_notifications<br/>delivered_at"] --> o3x["SMTP"]
    end
    note["cái thứ ba KHÔNG cần migration:<br/>chương 3.6 đã viết delivered_at<br/>và để nó null suốt"]
    o3 -.-> note
    style o3 fill:#064e3b,color:#fff,stroke:#059669
Ba outbox, ba chương — và lần thứ ba không tốn một thay đổi schema nào.

Chương outbox rút bảng outbox sang NATS, khoá trên published_at IS NULL. Chương webhook dispatcher rút webhook_deliveries sang endpoint của khách, khoá trên state và thời điểm đến hạn. Chương này rút webhook_disable_notifications sang SMTP, khoá trên delivered_at IS NULL.

Cột đó tồn tại từ chương retry-và-disable và null trên mọi dòng từng được ghi. Nghĩa là claim predicate mà transport cần đã được viết ra hai chương trước khi transport tồn tại — và cái backlog hai chương đó tích lại không phải vấn đề migration. Nó là công việc chưa gửi, theo chính định nghĩa của predicate:

services/api/src/db/repository.ts (excerpt)
WHERE n.delivered_at IS NULL
ORDER BY n.disabled_at, n.id
LIMIT $1
  FOR UPDATE OF n SKIP LOCKED

FOR UPDATE OF n SKIP LOCKED là cơ chế của chương outbox giữ nguyên: hai api instance có thể rút cùng lúc và bỏ qua dòng bên kia đã giành thay vì chặn nhau. OF n là phần thêm — claim join environments, applications và webhook_endpoints để dựng message, và khoá luôn những dòng đó sẽ khiến việc gửi một email chặn mất khách đang sửa endpoint của họ.

Mailpit, và hiến pháp VII

Hiến pháp VII đòi container thứ năm phải tự biện minh. Đây là lời biện minh, và nó là FR-WHK-07:

Thông báo KHÔNG ĐƯỢC chứa signing secret của endpoint, một API key, hay bất kỳ credential nào khác.

flowchart LR
    facts["DisableFacts<br/>url · environment · attempts<br/>KHÔNG có trường nào cho bí mật"]
    mail["disableNotification()"]
    smtp["Mailpit · SMTP"]
    api["Mailpit HTTP API"]
    test["lời khẳng định"]
    facts --> mail --> smtp --> api --> test
    stub["một STUB sẽ để test đọc lại<br/>chính đối tượng bên gửi truyền vào —<br/>nên một bí mật nằm trong header mà<br/>stub không mô hình hóa sẽ lọt qua"]
    test -.-> stub
    style smtp fill:#064e3b,color:#fff,stroke:#059669
    style stub fill:#7f1d1d,color:#fff,stroke:#dc2626
Vì sao assertion cần một server thật chứ không phải một stub.

Đó là một khẳng định về nội dung của một email. Một stub mailer ghi lại object mà bên gửi truyền vào, nên một test assert trên stub là đang đọc lại chính input của mình — và một mailer nhét bí mật vào một header mà stub không mô hình hoá vẫn sẽ pass. Vật chứng duy nhất phân xử được là một message mà server đã thực sự nhận, đủ cả header, encoding và mọi thứ.

Vậy nên Mailpit: một SMTP server nhận tất, không giao đi đâu, và mở một HTTP API để đọc thứ nó bắt được. Cổng 11025 và 18025, lệch khỏi mặc định, khớp quy ước 15432/16379/14222 mà mọi store khác trong compose file này đều theo — để lane không đụng với container riêng của lập trình viên. Có healthcheck, vì thiếu nó thì docker compose up -d --wait chờ đang chạy chứ không phải đã sẵn sàng, và suite có thể đọc API trước khi nó phục vụ. Không volume: một hộp thư test sống sót qua restart sẽ là một hộp thư test rò rỉ trạng thái giữa các lần chạy.

Message, và cái đường nối làm FR-WHK-07 thành sự thật

Mailer gồm hai mảnh, và chỗ cắt chính là biện pháp bảo mật.

services/api/src/notifications/mailer.ts (excerpt)
export interface DisableFacts {
  endpointUrl: string;
  environmentName: string;
  disabledAt: Date;
  runStartedAt: Date;
  attempts: number;
  /** Null when the endpoint never answered — a refused connection has no status. */
  lastStatus: number | null;
  lastError: string | null;
}

disableNotification(facts) biến chỗ đó thành subject và body, không chạm tới SMTP, không chạm đồng hồ, không chạm database — nên thứ email nói được quyết định bởi một unit test. createMailer() là phần nói chuyện với server, và mỏng đến mức chẳng còn gì trong đó để làm sai.

DisableFacts không có field nào cho bí mật. Mailer không thể làm rò thứ nó chưa từng được đưa, điều đó biến FR-WHK-07 thành thuộc tính của kiểu dữ liệu thay vì của một filter đặt trên output — và filter là thứ bạn viết khi hình dạng đã thua từ trước.

Bản quét vẫn chạy, cả trong unit test lẫn trên thứ Mailpit nhận được, bởi "không thể xảy ra" là một khẳng định còn một bản quét là bằng chứng.

Người nhận, phân giải ngay lúc gửi

services/api/src/db/repository.ts (excerpt)
SELECT DISTINCT h.email
  FROM memberships m
  JOIN humans h ON h.id = m.human_id
 WHERE m.organisation_id = $1
   AND h.email IS NOT NULL

Tổ chức đến từ chính dòng dữ liệu, không phải từ chủ sở hữu hiện tại của endpoint. Chương retry-và-disable đã phi chuẩn hoá organisation_id lên notification đúng vì điều này, với lý do viết thẳng vào schema lúc đó:

dòng này ghi lại một nghĩa vụ NHƯ NÓ VỐN CÓ lúc endpoint bị tắt, và một application chuyển giữa các tổ chức về sau không được lặng lẽ đổi đích của một notification vốn đã nợ một bên khác

Chương này là đoạn code đầu tiên phụ thuộc vào điều đó, hai chương sau khi cột được thêm. Một phi chuẩn hoá có lý do viết ra mà không ai đọc là một ván cược; đây là lúc ván cược đó được trả.

Mọi thành viên có địa chỉ, không riêng owner. memberships.role là một trong owner, admin hoặc member, và chọn một tập con ở đây là chương này tự bịa ra một mô hình notification-preferences — mà đó là chuyện của product.

Một message cho mỗi người nhận, không bao giờ một message với vài địa chỉ trên đó. Địa chỉ email của đồng nghiệp một khách hàng là dữ liệu của khách hàng đó, và một header To mà mọi người nhận đều đọc được là một sự tiết lộ không ai yêu cầu.

Không còn ai để viết thư

humans.email cho phép null. Một con người đăng nhập qua provider không trả về địa chỉ nào thì không có địa chỉ, nên một tổ chức mà mọi thành viên đều không thể liên lạc là một trạng thái schema cho phép — một nhánh, không phải một if phòng thủ.

services/api/src/notifications/notification-relay.ts (excerpt)
if (recipients.length === 0) {
  logger.log("error", "notifications.unaddressable", {
    organisation_id: row.organisationId,
    notification_id: row.id,
    detail:
      "webhook disablement could not be notified: no member has an email address",
  });
  return;
}

Trả về bình thường đánh dấu dòng đã gửi, và điều đó là cố ý. Không có địa chỉ nào để thử lại, và để nó còn giành được nghĩa là giành lại đúng cái dòng không gửi được ấy mỗi năm giây, mãi mãi. Thứ thay thế email là dòng log: nghĩa vụ được giải trừ tới mức có thể, và việc nó không hoàn thành được thì được ghi lại thay vì nuốt đi.

Mutation không thể fail

Bộ sabotage của chương có một phát nhắm vào file này: đánh dấu delivered_at trước khi lệnh gửi trả về. Nó phải làm đỏ cái test nói rằng dấu được đặt sau.

Nó pass.

Lần theo điều đó tìm ra một lỗi tệ hơn thứ mutation đang tìm.

flowchart TB
    subgraph before["một transaction cho cả lô"]
      b1["nhận việc cũ nhất trước<br/>dòng A · địa chỉ hỏng<br/>dòng B · dòng C"]
      b2["gửi A → NÉM LỖI"]
      b3["transaction cuộn ngược"]
      b4["A, B và C đều chưa được đánh dấu"]
      b5["lượt sau lại nhận A đầu tiên"]
      b1 --> b2 --> b3 --> b4 --> b5
      b5 -.->|"mãi mãi"| b1
    end
    subgraph after["cô lập theo từng dòng"]
      a1["nhận việc cũ nhất trước"]
      a2["A ném lỗi → onError, không đánh dấu"]
      a3["B và C gửi đi → đánh dấu"]
      a4["A thử lại lượt sau<br/>B và C đã đi rồi"]
      a1 --> a2 --> a3 --> a4
    end
    style b5 fill:#7f1d1d,color:#fff,stroke:#dc2626
    style a3 fill:#064e3b,color:#fff,stroke:#059669
Một địa chỉ mail server từ chối, và cả hàng đợi dừng lại vĩnh viễn.

Các dòng được giành cũ nhất trước. Một dòng luôn ném lỗi — một địa chỉ mà mail server từ chối thẳng, chuyện hết sức bình thường với một địa chỉ — vì thế luôn bị giành đầu tiên, luôn làm abort cả batch, và mọi notification phía sau nó không bao giờ được gửi. Head-of-line blocking vĩnh viễn cả hàng đợi, chỉ từ một người nhận hỏng.

Bản vá là cô lập theo từng dòng:

services/api/src/db/repository.ts (excerpt)
for (const raw of claimed.rows) {
  try {
    await deliver(row);
    delivered.push(row.id);
  } catch (error) {
    // Not marked. The row stays claimable and the next pass tries it again,
    // which is the whole reason this is a table rather than a call.
    onError(row, error);
  }
}

Giờ không gì roll back nữa, nên thứ tự là thật và mutation fail đúng như nó phải fail. Và có một test được đặt tên theo cái lỗi không ai đi tìm: một địa chỉ Mailpit từ chối, một địa chỉ tốt đứng sau, và cái tốt đi được.

Mail server biến mất thì tốn gì

Không gì cả, và có một test nói vậy.

Tắt một endpoint là ghi một dòng rồi trả về; chuyện có ai email được hay không là vấn đề của riêng vòng lặp này. Với relay trỏ vào một cổng không trả lời gì: không ném lỗi, không giao thư, dòng vẫn còn giành được, và một endpoint thứ hai vẫn tắt bình thường trong lúc đó. Việc giao message, API và webhook dispatch không hề chạm tới SMTP.

Đó là lý do transport là một cái bảng và một vòng lặp chứ không phải một lời gọi nằm trong đường đi disable. Một SMTP timeout bên trong sweepDisabledEndpoints sẽ biến một sự cố mail thành một sự cố webhook — đánh đổi dependency ít quan trọng nhất trong hệ thống lấy một trong những thứ quan trọng nhất.

Relay poll mỗi năm giây thay vì 200ms như event relay. Không ai chờ nó: FR-WHK-07 yêu cầu tổ chức được báo, không yêu cầu được báo trong vòng một giây, và một mail server bị poll năm lần một giây bởi một service không có gì để gửi là một service đang bất lịch sự với dependency của mình.

Toàn văn chương này

Mọi thứ ở trên, đúng như repository đang giữ.

Transport

Ba file mới. mailer.ts tách phần message khỏi phần gửi, bởi thứ một email NÓI được quyết định bởi unit test còn thứ SMTP làm thì không.

services/api/src/notifications/mailer.ts
import { createTransport, type Transporter } from "nodemailer";
 
// The disablement notification (FR-021, FR-WHK-07).
//
// TWO PIECES, SEPARATED ON PURPOSE. `disableNotification` turns facts into a
// message and touches nothing — no SMTP, no clock, no database — so what the
// email SAYS is decided by a unit test. `createMailer` is the part that talks to
// a server, and it is thin enough that there is nothing in it to get wrong.
//
// THE SEAM IS THE SECURITY CONTROL. `DisableFacts` has no field for a secret, so
// the mailer cannot leak one it was never given — FR-021 is enforced by the
// shape of the input rather than by a filter over the output, and a filter is
// what you write when the shape already lost. The test scans the message anyway,
// because "cannot happen" is a claim and a scan is evidence.
 
/** Everything the message is allowed to know. */
export interface DisableFacts {
  endpointUrl: string;
  environmentName: string;
  disabledAt: Date;
  runStartedAt: Date;
  attempts: number;
  /** Null when the endpoint never answered — a refused connection has no status. */
  lastStatus: number | null;
  lastError: string | null;
}
 
export interface Mail {
  subject: string;
  text: string;
}
 
/** How long the failing run went on, in whole minutes. Rounded up, because "0
 * minutes" reads as "no time passed" for a run that lasted forty seconds. */
function durationMinutes(from: Date, to: Date): number {
  return Math.max(1, Math.ceil((to.getTime() - from.getTime()) / 60_000));
}
 
export function disableNotification(facts: DisableFacts): Mail {
  const host = new URL(facts.endpointUrl).host;
  // What it was failing WITH. A status when there was one, the transport error
  // when the request never got far enough to have one. Printing `null` would be
  // accurate and unusable.
  const cause =
    facts.lastStatus !== null
      ? `HTTP ${facts.lastStatus}`
      : (facts.lastError ?? "no response");
  const minutes = durationMinutes(facts.runStartedAt, facts.disabledAt);
 
  return {
    subject: `Relay disabled your webhook endpoint at ${host}`,
    text: [
      `Relay has stopped delivering webhooks to:`,
      ``,
      `    ${facts.endpointUrl}`,
      ``,
      `Environment: ${facts.environmentName}`,
      `Failing for: ${minutes} minute${minutes === 1 ? "" : "s"}`,
      `Attempts:    ${facts.attempts}`,
      `Last result: ${cause}`,
      ``,
      // The instruction, not just the state. A notification that reports a
      // problem without naming the action is a notification that becomes a
      // support ticket.
      `Deliveries will not resume on their own. Fix the endpoint, then`,
      `re-enable it from the webhook settings for this environment.`,
      ``,
      // Said explicitly, because the absence is the thing a reader will wonder
      // about. Nothing here identifies the endpoint beyond its own URL.
      `This message contains no signing secret and no credential. If you`,
      `need the secret to verify deliveries, read it from the dashboard.`,
    ].join("\n"),
  };
}
 
export const DEFAULT_SMTP_URL = "smtp://localhost:1025";
 
export interface Mailer {
  send: (to: string, mail: Mail) => Promise<void>;
  close: () => void;
}
 
/** A full URL with a default here, never a host and a port the caller composes.
 * `harness.ts` records what the other shape costs: a caller that builds its own
 * URL is a second source of truth for an address, which is how the e2e suite
 * first failed. */
export function createMailer(
  url: string = process.env["RELAY_SMTP_URL"] ?? DEFAULT_SMTP_URL,
  from = "Relay <relay@relay.example>",
): Mailer {
  const transport: Transporter = createTransport(url);
  return {
    send: async (to, mail) => {
      await transport.sendMail({ from, to, ...mail });
    },
    close: () => {
      transport.close();
    },
  };
}
services/api/src/notifications/notification-relay.ts
import type { Logger } from "@relay/service-kit";
 
import type { Db } from "../db/client";
import {
  drainDisableNotifications,
  organisationRecipients,
  type DisableNotificationRow,
} from "../db/repository";
import { disableNotification, type Mailer } from "./mailer";
 
// The notification relay (FR-018 to FR-023).
//
// THE OUTBOX A THIRD TIME, and deliberately the same shape as the outbox chapter's:
// claim undelivered rows oldest-first with `FOR UPDATE SKIP LOCKED`, do the
// side effect, mark what succeeded, and put the mark in a `finally`. A reader
// who understood the event relay understands this one, which is the argument
// for reaching for a pattern the codebase already has rather than a queue
// library it does not (constitution VII).
//
// WHAT IS DIFFERENT is that the side effect is an email, which cannot be undone
// and cannot be deduplicated by the recipient. That pushes every ambiguous case
// the same way: send once too few rather than once too many is WRONG here —
// FR-WHK-07 exists because an endpoint went quiet and nobody was told — so a
// crash between the send and the mark resends, and the chapter says so.
//
// It also makes a failing row a ROW's problem rather than the batch's. The event
// relay lets one bad publish abort its batch, because a broker is up or down;
// one address a mail server refuses is one address, and aborting on it would
// abort every batch for ever — it is claimed first, being oldest. The repository
// catches per row and this callback is where the failure surfaces.
//
// NOT ON THE REQUEST PATH, for the outbox chapter's reason. Disabling an endpoint
// writes a row and returns; whether a mail server is reachable is this loop's
// problem. An SMTP timeout inside the dispatcher's disablement check would make
// a mail outage into a webhook outage.
 
/** Rows per pass. Smaller than the event relay's hundred because each row is a
 * network round trip to a mail server rather than a publish to a local broker,
 * and a batch is a transaction. */
const BATCH_SIZE = 20;
 
/** Slower than the event relay's 200ms, and it should be. Nothing is waiting on
 * this: FR-WHK-07 asks that the organisation be told, not that it be told within
 * the second, and a mail server polled five times a second by a service with
 * nothing to send is a service being rude to its dependencies. */
const IDLE_INTERVAL_MS = 5_000;
 
export interface NotificationRelay {
  start(): void;
  stop(): Promise<void>;
  /** One pass, for tests and for the walk script — the same code path `start`
   * runs, so nothing is proven about a loop only tests exercise. */
  drainOnce(): Promise<number>;
}
 
export function createNotificationRelay({
  db,
  mailer,
  logger,
  batchSize = BATCH_SIZE,
  intervalMs = IDLE_INTERVAL_MS,
}: {
  db: Db;
  mailer: Mailer;
  logger: Logger;
  batchSize?: number;
  intervalMs?: number;
}): NotificationRelay {
  let running = false;
  let loop: Promise<void> = Promise.resolve();
 
  async function deliver(row: DisableNotificationRow): Promise<void> {
    // Resolved from the ROW's organisation, not from the endpoint's current
    // owner. the retry-and-disable chapter denormalised that column so this lookup could not
    // follow an application that moved after the disablement (FR-022).
    const recipients = await organisationRecipients(db, row.organisationId);
 
    if (recipients.length === 0) {
      // A REAL BRANCH, not a defensive `if`. `humans.email` is nullable — a
      // human who signed in through a provider that returned no address has
      // none — so an organisation whose every member is unaddressable is a
      // state the schema permits and this code will meet (FR-023).
      //
      // The row is still marked delivered. There is no address to retry to, and
      // leaving it claimable would mean this relay reclaimed the same
      // undeliverable row every five seconds for ever. What replaces the email
      // is this log line: the obligation is discharged as far as it can be, and
      // the fact that it could not be met is recorded rather than swallowed.
      logger.log("error", "notifications.unaddressable", {
        organisation_id: row.organisationId,
        notification_id: row.id,
        detail:
          "webhook disablement could not be notified: no member has an email address",
      });
      return;
    }
 
    const mail = disableNotification({
      endpointUrl: row.endpointUrl,
      environmentName: row.environmentName,
      disabledAt: row.disabledAt,
      runStartedAt: row.runStartedAt,
      attempts: row.runAttempts,
      lastStatus: row.lastStatus,
      lastError: row.lastError,
    });
 
    // Sequential, and one message per recipient rather than one message with
    // several addresses on it: a customer's colleagues' email addresses are
    // that customer's data, and putting them in a header every recipient can
    // read is a disclosure nobody asked for.
    for (const to of recipients) {
      await mailer.send(to, mail);
    }
    logger.log("info", "notifications.sent", {
      notification_id: row.id,
      recipients: recipients.length,
    });
  }
 
  async function drainOnce(): Promise<number> {
    return drainDisableNotifications(db, batchSize, deliver, (row, error) => {
      // One row's failure, one line, and the batch keeps going. A mail server
      // that is down produces one of these per claimed row and then a drain of
      // zero, which the loop treats as idle — correct, because there is nothing
      // this process can do but wait.
      logger.log("error", "notifications.send_failed", {
        notification_id: row.id,
        error: String(error),
      });
    });
  }
 
  async function run(): Promise<void> {
    while (running) {
      try {
        const sent = await drainOnce();
        if (sent > 0) {
          // A count and an id. Never an address: a recipient list in a log line
          // is a customer's people in an operator's terminal (NFR-SEC-06).
          logger.log("info", "notifications.drained", { count: sent });
          continue;
        }
      } catch (error) {
        // A mail server that is down lands here. Rows stay claimable and the
        // next pass tries again, which is the whole reason this is a table
        // rather than a call.
        logger.log("error", "notifications.drain_failed", {
          error: String(error),
        });
      }
      await new Promise((resolve) => setTimeout(resolve, intervalMs));
    }
  }
 
  return {
    start() {
      if (running) return;
      running = true;
      loop = run();
    },
    async stop() {
      running = false;
      await loop;
      mailer.close();
    },
    drainOnce,
  };
}
services/api/src/notifications/notifications.module.ts
import { Inject, Injectable, Module, type OnModuleDestroy } from "@nestjs/common";
 
import { createLogger } from "@relay/service-kit";
 
import { createDb, createPool, type Db } from "../db/client";
import { createMailer } from "./mailer";
import {
  createNotificationRelay,
  type NotificationRelay,
} from "./notification-relay";
 
// The notification relay's home. Same shape as the outbox
// module's, deliberately: a loop that reads a table, does a side effect, and
// shares no state with the request path — so promoting it out of this service
// would mean moving this file and nothing else.
 
export const NOTIFICATION_RELAY = "NOTIFICATION_RELAY";
 
/** Off for the suites that want a quiet database, on everywhere else. Same
 * switch and same reasoning as `RELAY_OUTBOX_RELAY`: most integration tests
 * assert on rows, and a background loop marking them delivered mid-assertion is
 * a race between test files rather than a property of the system.
 *
 * FLAPPING IS NOT SOLVED HERE, and is worth naming rather than discovering. An
 * endpoint that is disabled, re-enabled and disabled again produces two rows and
 * two emails, and nothing collapses them — the spec's own edge case says neither
 * must suppress the other, because a second outage really is a second thing to
 * be told about. An endpoint flapping hourly therefore sends hourly. Solving it
 * means a notification-preferences model, which is product. */
export function notificationRelayEnabled(): boolean {
  return (
    (process.env.RELAY_NOTIFICATION_RELAY ?? "on").toLowerCase() !== "off"
  );
}
 
@Injectable()
export class NotificationRelayService implements OnModuleDestroy {
  constructor(
    @Inject(NOTIFICATION_RELAY) private readonly relay: NotificationRelay,
  ) {}
 
  start(): void {
    if (notificationRelayEnabled()) this.relay.start();
  }
 
  async onModuleDestroy(): Promise<void> {
    await this.relay.stop();
  }
}
 
@Module({
  providers: [
    {
      provide: NOTIFICATION_RELAY,
      useFactory: (): NotificationRelay =>
        createNotificationRelay({
          db: createDb(createPool()) as Db,
          mailer: createMailer(),
          logger: createLogger("notifications"),
        }),
    },
    NotificationRelayService,
  ],
  exports: [NOTIFICATION_RELAY, NotificationRelayService],
})
export class NotificationsModule {}

Claim, và nơi nó được khởi động

Repository nhận thêm phần drain và phần tra cứu người nhận; main.ts khởi động vòng lặp bên cạnh hai vòng kia.

services/api/src/db/repository.ts
@@ -312,12 +312,187 @@ export async function drainOutbox(
       }
     }
     return published.length;
   });
 }
 
+// ---------------------------------------------------------------------------
+// The disablement notifications (FR-018 to FR-020). THE OUTBOX A
+// THIRD TIME — after the outbox chapter's events and the webhook dispatcher chapter's deliveries — and
+// this one needed no migration at all: the retry-and-disable chapter gave the table a
+// `delivered_at` column and left it null throughout, which is a claim predicate
+// already written down.
+//
+// The backlog the disablement chapter accumulated therefore drains on the first run
+// with NO SPECIAL
+// HANDLING. By the predicate's own definition those rows are undelivered work,
+// and code that treated them as a migration would be code asserting they are
+// different when they are not (FR-020).
+//
+// Admin surface, like `drainOutbox`: one relay serves every environment, because
+// a notification is an obligation the platform owes rather than tenant traffic.
+// ---------------------------------------------------------------------------
+
+export interface DisableNotificationRow {
+  id: string;
+  organisationId: string;
+  /** What to call the environment in an email. `environments` has a `kind` —
+   * development, staging, production — and no name of its own, so on its own it
+   * is ambiguous for an organisation with four applications. The application's
+   * name and the kind together are the shortest thing a reader can act on. */
+  environmentName: string;
+  endpointUrl: string;
+  disabledAt: Date;
+  runStartedAt: Date;
+  runAttempts: number;
+  lastStatus: number | null;
+  lastError: string | null;
+}
+
+/** Claim up to `limit` undelivered notifications, hand each to `deliver`, and
+ * mark the ones that went out — all inside ONE transaction.
+ *
+ * AN EXPLICIT LIMIT, no default. The deduplication chapter's baseline found four suites broken
+ * by tests that asserted local facts about a global, oldest-first operation, and
+ * this is another global operation: a caller that wants only its own rows drained
+ * has to say how many, and a test that forgets ends up asserting about somebody
+ * else's fixture.
+ *
+ * SEND THEN MARK: a crash between the two resends one email, which is the
+ * accepted cost, and marking first would lose it silently — a notification
+ * nobody received is the failure FR-WHK-07 exists to prevent.
+ *
+ * PER-ROW ISOLATION, and this is where it departs from `drainOutbox` one screen
+ * up. That one lets a failing publish abort the batch, on the reasoning that the
+ * broker is either up or down and a partial batch means down. An email is not
+ * like that: one address a server refuses is one address, and letting it abort
+ * the batch would make it abort EVERY batch — claimed first because it is
+ * oldest, throwing, rolling back, and no notification behind it ever going out.
+ * Head-of-line blocking, permanent, from one bad recipient.
+ *
+ * So a row that throws is reported through `onError` and simply not marked. It
+ * stays claimable and the rows behind it still go.
+ *
+ * A NOTE ON WHY THE ORDERING IS OBSERVABLE AT ALL. It was not, at first: with
+ * the mark in a `finally` and the throw escaping the transaction callback, the
+ * transaction rolled back and undid the mark, so marking BEFORE the send and
+ * marking after it produced identical behaviour. The chapter's own sabotage
+ * mutation could not fail (research R44). Catching per row is what makes the two
+ * different, because now nothing rolls back.
+ */
+export async function drainDisableNotifications(
+  db: Db,
+  limit: number,
+  deliver: (row: DisableNotificationRow) => Promise<void>,
+  /** REQUIRED, AND IT CARRIED A DEFAULT UNTIL THE RATCHET ASKED. `= () => {}` was
+   * never constructed: the one caller is `notification-relay.ts`'s `drainOnce`, which
+   * has always passed a logging callback, so v8 counted the default as a function that
+   * exists and never runs — `repository.ts` functions 99.12% against a pin of 100.
+   *
+   * DELETED RATHER THAN COVERED, which is what this file's ratchet is for and the sixth
+   * time it has produced a deletion. A default that swallows a delivery failure silently
+   * is the wrong default anyway: this parameter is how a caller learns that a row was
+   * claimed and not sent, and making it required means the compiler asks the question
+   * rather than a reviewer. */
+  onError: (row: DisableNotificationRow, error: unknown) => void,
+): Promise<number> {
+  return db.transaction(async (tx) => {
+    const claimed = (await tx.execute(
+      sql`SELECT n.id                AS "id",
+                 n.organisation_id   AS "organisationId",
+                 a.name || ' / ' || e.kind AS "environmentName",
+                 w.url               AS "endpointUrl",
+                 n.disabled_at       AS "disabledAt",
+                 n.run_started_at    AS "runStartedAt",
+                 n.run_attempts      AS "runAttempts",
+                 n.last_status       AS "lastStatus",
+                 n.last_error        AS "lastError"
+            FROM webhook_disable_notifications n
+            JOIN environments e ON e.id = n.environment_id
+            JOIN applications a ON a.id = e.application_id
+            JOIN webhook_endpoints w ON w.id = n.endpoint_id
+           WHERE n.delivered_at IS NULL
+           ORDER BY n.disabled_at, n.id
+           LIMIT ${limit}
+             FOR UPDATE OF n SKIP LOCKED`,
+    )) as unknown as {
+      rows: (Omit<DisableNotificationRow, "disabledAt" | "runStartedAt"> & {
+        disabledAt: string | Date;
+        runStartedAt: string | Date;
+      })[];
+    };
+
+    const delivered: string[] = [];
+    for (const raw of claimed.rows) {
+      // Timestamps back as `Date`, not as whatever the driver felt like. Raw
+      // SQL through `execute` skips drizzle's column mapping, and a timestamptz
+      // arrives as a string — which reaches the mailer as an object with no
+      // `getTime`, one call later and one file away. Coerced here, at the
+      // boundary that produced it, rather than defended against downstream.
+      const row: DisableNotificationRow = {
+        ...raw,
+        disabledAt: new Date(raw.disabledAt),
+        runStartedAt: new Date(raw.runStartedAt),
+      };
+      try {
+        await deliver(row);
+        delivered.push(row.id);
+      } catch (error) {
+        // Not marked. The row stays claimable and the next pass tries it again,
+        // which is the whole reason this is a table rather than a call.
+        onError(row, error);
+      }
+    }
+
+    if (delivered.length > 0) {
+      // The builder rather than raw SQL, unlike the outbox drain one screen up.
+      // That one interpolates `ARRAY[…]::bigint[]` through `sql.raw` because its
+      // ids are integers; these are uuids, and `sql` renders a JS array as a
+      // comma-separated parameter list — which Postgres reads as a row
+      // expression and rejects with "record type has too many columns".
+      await tx
+        .update(webhookDisableNotifications)
+        .set({ deliveredAt: new Date() })
+        .where(inArray(webhookDisableNotifications.id, delivered));
+    }
+    return delivered.length;
+  });
+}
+
+/** The addresses to notify for an organisation, at SEND TIME (FR-022).
+ *
+ * Resolved from the row's `organisation_id`, which the retry-and-disable chapter denormalised onto
+ * the notification precisely so this lookup could not follow the endpoint's
+ * CURRENT owner. An application that moved between organisations after the
+ * disablement must not silently retarget an obligation already owed to somebody
+ * else — the disablement chapter wrote the reason down and this is the first code to
+ * depend on it.
+ *
+ * `humans.email` is nullable, so this can legitimately return nothing. That is a
+ * branch the caller has to handle, not a case that cannot arise.
+ *
+ * EVERY member, not only the owners. `memberships.role` is one of owner, admin
+ * or member, and picking a subset here would be this chapter inventing a
+ * notification-preferences model — which is product, and belongs to whichever
+ * chapter builds preferences. Everyone who can see the endpoint hears that it
+ * stopped. */
+export async function organisationRecipients(
+  db: Db,
+  organisationId: string,
+): Promise<string[]> {
+  const result = (await db.execute(
+    sql`SELECT DISTINCT h.email AS "email"
+          FROM memberships m
+          JOIN humans h ON h.id = m.human_id
+         WHERE m.organisation_id = ${organisationId}
+           AND h.email IS NOT NULL
+         ORDER BY h.email`,
+  )) as unknown as { rows: { email: string }[] };
+  return result.rows.map((row) => row.email);
+}
+
 /** How far behind the relay is. The single number worth alarming on later, and
  * the one the chapter shows going up while the broker is down. */
 /** The name this consumer claims events under. One name, because the ledger is
  * keyed per consumer and the dispatcher is one consumer however many processes
  * run it (the broker chapter's data model). */
 export const DISPATCHER_CONSUMER = "dispatcher";
services/api/src/main.ts
@@ -2,12 +2,13 @@ import "reflect-metadata";
 
 import { NestFactory } from "@nestjs/core";
 import { createLogger } from "@relay/service-kit";
 
 import { AppModule } from "./app.module";
 import { EventConsumerService } from "./consumer/consumer.module";
+import { NotificationRelayService } from "./notifications/notifications.module";
 import { OutboxRelayService } from "./outbox/outbox.module";
 import { DeliveryRelayService } from "./webhooks/webhooks.module";
 
 // Nest's own banner logger stays off: this workspace already decided what a
 // log line looks like (one JSON object, NFR-OBS-01), and the framework does
 // not get a second opinion.
@@ -29,12 +30,17 @@ async function bootstrap(): Promise<void> {
   const port =
     typeof address === "object" && address !== null ? (address.port ?? requested) : requested;
   // The relay starts AFTER the server is listening, and starting it cannot fail: the
   // publisher connects lazily, so an unreachable broker leaves events accumulating in
   // Postgres instead of preventing the api from serving writes (research R9).
   app.get(OutboxRelayService).start();
+  // The disablement notifications the retry-and-disable chapter wrote and nothing
+  // delivered. Its backlog drains on this first start as ordinary undelivered
+  // work — no migration and no special case, because `delivered_at IS NULL` was
+  // already true of every one of those rows.
+  app.get(NotificationRelayService).start();
   // And the second relay: the same loop over a different table,
   // publishing deliveries that have become due. Started here for the outbox chapter's reason —
   // a retry schedule that only runs when someone remembers is not a schedule.
   app.get(DeliveryRelayService).start();
   // And the first thing that reads what the relay publishes.
   // Same placement, same reason, same lazy connection: an unreachable broker

Container thứ năm

Mailpit, healthcheck của nó, và mục đăng ký giờ đã có một cổng gác chạy cả hai chiều.

compose.yaml
@@ -70,12 +70,39 @@ services:
       test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8123/ping"]
       interval: 5s
       timeout: 3s
       retries: 5
       start_period: 15s
 
+  mailpit:
+    image: axllent/mailpit:v1.28
+    # An SMTP server that accepts everything and delivers nothing,
+    # with an HTTP API for reading what it caught.
+    #
+    # WHY A CONTAINER RATHER THAN A FAKE. FR-021 says an email must not contain a
+    # signing secret, and the only artefact that can settle that is the message a
+    # server RECEIVED — a stub records what the sender passed, which is the same
+    # object the assertion would be reading, so a mailer that dropped the secret
+    # into a header the stub does not model would pass. Constitution VII asks a
+    # fifth container to justify itself; this is the justification (research R9).
+    #
+    # No volume. Mailpit holds messages in memory, and a test inbox that survived
+    # a restart would be a test inbox that leaks state between runs — the same
+    # reasoning as Redis's, which the entry above records.
+    ports:
+      - "${RELAY_MAILPIT_HTTP_PORT:-8025}:8025"
+      - "${RELAY_MAILPIT_SMTP_PORT:-1025}:1025"
+    healthcheck:
+      # Without one, `docker compose up -d --wait` waits for RUNNING rather than
+      # for READY, and V9 can read the API before it is serving. `infra.test.ts`
+      # says the same thing about the other four.
+      test: ["CMD", "/mailpit", "readyz"]
+      interval: 5s
+      timeout: 3s
+      retries: 5
+
 
   # --- the services (the webhook dispatcher chapter) -----------------------
   # Behind `--profile services`, for the reason above.
 
   api:
     profiles: ["services"]
packages/config/src/infra.ts
@@ -8,12 +8,16 @@ export const COMPOSE_FILE = "compose.yaml";
 
 export const INFRA_SERVICES = [
   "postgres",
   "redis",
   "nats",
   "clickhouse",
+  // The fifth, and the only one that is not a store: Mailpit
+  // catches the SMTP the notification relay sends so a test can read what was
+  // RECEIVED rather than what was passed (FR-021).
+  "mailpit",
 ] as const;
 
 export const DURABLE_VOLUMES = [
   "postgres-data",
   "nats-data",
   "clickhouse-data",
packages/config/src/infra.test.ts
@@ -29,12 +29,33 @@ describe("the compose declaration agrees with @relay/config", () => {
 
   it("gives every service a healthcheck — up --wait must mean ready", () => {
     const healthchecks = compose.match(/^ {4}healthcheck:$/gm) ?? [];
     expect(healthchecks.length).toBeGreaterThanOrEqual(INFRA_SERVICES.length);
   });
 
+  it("REGISTERS every compose service, which is the direction this file lacked", () => {
+    // The assertion above runs one way: every registered service must appear in
+    // compose. Nothing ran the other way, so a container added to compose and
+    // never registered here was invisible — `INFRA_SERVICES` would quietly stop
+    // naming the local infrastructure while every test still passed. This chapter
+    // added a fifth container and the gap is how it nearly went unnoticed.
+    //
+    // The services behind `--profile services` are Relay's own and are not
+    // infrastructure, so they are excluded by name rather than by pattern: a
+    // list is auditable and a pattern would silently absorb the next container.
+    const ours = new Set(["api", "gateway", "dispatcher"]);
+    // Only the `services:` block. Volume names sit at the same indentation one
+    // block down, and a match that swept the whole file would report
+    // `postgres-data` as an unregistered service.
+    const services = compose.slice(0, compose.indexOf("\nvolumes:"));
+    const declared = [...services.matchAll(/^ {2}([a-z][a-z0-9-]*):$/gm)]
+      .map((match) => match[1] as string)
+      .filter((service) => !ours.has(service));
+    expect([...declared].sort()).toEqual([...INFRA_SERVICES].sort());
+  });
+
   it("persists exactly the durable stores — and never Redis", () => {
     for (const volume of DURABLE_VOLUMES) {
       expect(compose).toContain(`${volume}:`);
     }
     expect(compose).not.toContain("redis-data");
   });

Những gì chương này không giao

Bất cứ điều gì về flapping. Một endpoint bị tắt, bật lại rồi tắt nữa sinh ra hai dòng và hai email, và không gì gộp chúng lại. Đó là cố ý — một sự cố thứ hai thật sự là một chuyện thứ hai cần được báo, và edge case của spec nói rằng không notification nào được đè notification kia — nhưng một endpoint flap hàng giờ sẽ gửi hàng giờ. Giải nó nghĩa là một mô hình notification-preferences, mà đó là product chứ không phải đường ống.

Giới hạn retry. Một dòng fail mãi mãi sẽ được thử lại mãi mãi, mỗi năm giây. Nó không còn chặn ai nữa, đó là nửa cấp bách; một trạng thái dead-letter cho notification là nửa còn lại và không có ở đây.

Khả năng gửi đến đích. Không SPF, không DKIM, không xử lý bounce, không provider. Mailpit nhận tất, điều đó đúng với một hộp thư test và chẳng nói gì cho bạn về việc một mail server thật sẽ làm gì.

Mọi notification khác. Đây là một email cho một sự kiện. Các ngưỡng quota của FR-RTL cần thêm ba cái nữa, và chúng đến cùng với quota.