# Nightly Ticket Report

> A help desk server that builds a daily SLA report with a workflow, a job per step, started in the background by a tool and read back by another.

Source: https://frontmcp.dev/examples/nightly-ticket-report

Every morning, a support team lead wants to know which of yesterday's tickets broke their SLA: which customers waited too long for a first reply. This example is a help desk server that builds that report. A tool starts a [workflow](https://frontmcp.dev/learn/chaining-jobs-into-workflows) in the background; the workflow collects the day's tickets, finds the SLA breaches, writes the report and emails the lead, with a [job](https://frontmcp.dev/learn/your-first-job) for each step; and a second tool reads the report once it's ready.

**You will learn**
- How to split slow work into jobs, and wire them into a workflow with `dependsOn` and inputs built from earlier steps
- How to start a workflow from your own tool, in the background, and hand its result back through another tool
- How a step retries a flaky dependency, and how a step is skipped when there's nothing to do
- Where jobs get their data from, and why the providers go on the server
- How to test all of it, background run included

## The server

Here is the whole server. Open the **Call** tab: `start_nightly_report` has started a report for 2026-09-26 and returned at once, with the run still `pending`. Now call `get_nightly_report` with the same day to read it. Then open the **Tests** tab.

```ts nightly-report.workflow.ts active
import { Workflow } from "@frontmcp/sdk";

@Workflow({
  name: "nightly-report",
  description: "Collect one day's tickets, find the SLA breaches, write the report and email the team lead",
  steps: [
    // No `input`: this step gets the workflow's input, { day }.
    { id: "collect", jobName: "collect-tickets" },
    {
      id: "breaches",
      jobName: "find-sla-breaches",
      dependsOn: ["collect"],
      input: (steps) => ({ tickets: steps.get("collect").outputs.tickets }),
    },
    {
      id: "report",
      jobName: "write-report",
      dependsOn: ["collect", "breaches"],
      input: (steps) => ({
        day: steps.get("collect").outputs.day,
        total: (steps.get("collect").outputs.tickets as unknown[]).length,
        breaches: steps.get("breaches").outputs.breaches,
      }),
    },
    {
      id: "notify",
      jobName: "notify-lead",
      dependsOn: ["report"],
      // Only email the lead when something broke its SLA.
      condition: (steps) => (steps.get("breaches").outputs.breaches as unknown[]).length > 0,
      input: (steps) => steps.get("report").outputs,
      // The report is saved by now; a failed email shouldn't fail the run.
      continueOnError: true,
    },
  ],
})
export class NightlyReport {}
```

```ts collect-tickets.job.ts
import { Job, JobContext, z } from "@frontmcp/sdk";
import { TicketStore, ticketSchema, type Ticket } from "./stores";

@Job({
  name: "collect-tickets",
  description: "Collect the tickets opened on one day",
  inputSchema: { day: z.string().regex(/^\d{4}-\d\d-\d\d$/) },
  outputSchema: { day: z.string(), tickets: z.array(ticketSchema) },
  // A real ticket database can be briefly unavailable: try again after 500 ms, then 1 s.
  retry: { maxAttempts: 3, backoffMs: 500 },
})
export class CollectTickets extends JobContext {
  async execute({ day }: { day: string }) {
    const tickets: Ticket[] = this.get(TicketStore).openedOn(day);
    this.log(`Collected ${tickets.length} tickets opened on ${day}`);
    return { day, tickets };
  }
}
```

```ts find-sla-breaches.job.ts
import { Job, JobContext, z } from "@frontmcp/sdk";
import { breachSchema, ticketSchema, type Breach, type Ticket } from "./stores";

// Hours we promise to reply within, by priority.
const SLA_HOURS = { high: 4, normal: 24, low: 72 };

@Job({
  name: "find-sla-breaches",
  description: "Find the tickets whose first reply came later than their SLA, or not at all",
  inputSchema: { tickets: z.array(ticketSchema) },
  outputSchema: { breaches: z.array(breachSchema) },
  // Same input, same answer: another attempt wouldn't help.
  retry: { maxAttempts: 1 },
})
export class FindSlaBreaches extends JobContext {
  async execute({ tickets }: { tickets: Ticket[] }) {
    const breaches: Breach[] = tickets
      .map((t) => ({ id: t.id, customer: t.customer, priority: t.priority, repliedAfter: t.firstReplyHours, slaHours: SLA_HOURS[t.priority] }))
      .filter((b) => b.repliedAfter === null || b.repliedAfter > b.slaHours);
    this.log(`${breaches.length} of ${tickets.length} tickets broke their SLA`);
    return { breaches };
  }
}
```

```ts write-report.job.ts
import { Job, JobContext, z } from "@frontmcp/sdk";
import { ReportStore, breachSchema, type Breach } from "./stores";

@Job({
  name: "write-report",
  description: "Write the day's SLA report and save it",
  inputSchema: { day: z.string(), total: z.number(), breaches: z.array(breachSchema) },
  outputSchema: { day: z.string(), text: z.string() },
  retry: { maxAttempts: 1 },
})
export class WriteReport extends JobContext {
  async execute({ day, total, breaches }: { day: string; total: number; breaches: Breach[] }) {
    const lines = breaches.map((b) =>
      b.repliedAfter === null
        ? `${b.id} (${b.customer}, ${b.priority}): no reply yet, SLA ${b.slaHours} h.`
        : `${b.id} (${b.customer}, ${b.priority}): first reply after ${b.repliedAfter} h, SLA ${b.slaHours} h.`,
    );
    const text = [`${day}: ${total} tickets, ${breaches.length} broke their SLA.`, ...lines].join("\n");
    this.get(ReportStore).save({ day, total, breaches, text });
    return { day, text };
  }
}
```

```ts notify-lead.job.ts
import { Job, JobContext, z } from "@frontmcp/sdk";
import { Outbox } from "./stores";

@Job({
  name: "notify-lead",
  description: "Email the team lead the day's report",
  inputSchema: { day: z.string(), text: z.string() },
  outputSchema: { sentTo: z.string() },
  // The mail server is sometimes busy: try again after 200 ms, then 400 ms.
  retry: { maxAttempts: 3, backoffMs: 200 },
})
export class NotifyLead extends JobContext {
  async execute({ day, text }: { day: string; text: string }) {
    const to = "lead@helpdesk.example";
    this.get(Outbox).send(to, `SLA report for ${day}`, text);
    this.log(`Emailed ${to}`);
    return { sentTo: to };
  }
}
```

```ts report.tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { ReportStore } from "./stores";

const day = z.string().regex(/^\d{4}-\d\d-\d\d$/).describe("The day to report on, like 2026-09-26");

@Tool({
  name: "start_nightly_report",
  description:
    "Start building the SLA report for one day. Returns at once, while the report is built in the background. Read it with get_nightly_report a few seconds later.",
  inputSchema: { day },
})
export class StartNightlyReport extends ToolContext {
  async execute({ day }: { day: string }) {
    const run = await this.callTool("execute_workflow", { name: "nightly-report", input: { day }, background: true });
    const { runId, state } = run.structuredContent as { runId: string; state: string };
    return { day, runId, state };
  }
}

@Tool({
  name: "get_nightly_report",
  description: "Read the SLA report for one day, once start_nightly_report has built it.",
  inputSchema: { day },
  annotations: { readOnlyHint: true },
})
export class GetNightlyReport extends ToolContext {
  async execute({ day }: { day: string }) {
    const report = this.get(ReportStore).get(day);
    if (!report) {
      this.fail(
        new PublicMcpError(
          `There's no report for ${day} yet. Start it with start_nightly_report, then try again in a few seconds.`,
          "REPORT_NOT_READY",
        ),
      );
    }
    return report;
  }
}
```

```ts stores.ts
import { Provider, ProviderScope, z } from "@frontmcp/sdk";

export const ticketSchema = z.object({
  id: z.string(),
  customer: z.string(),
  priority: z.enum(["high", "normal", "low"]),
  openedOn: z.string(),
  firstReplyHours: z.number().nullable(), // null: nobody has replied yet
});
export type Ticket = z.infer<typeof ticketSchema>;

export const breachSchema = z.object({
  id: z.string(),
  customer: z.string(),
  priority: z.enum(["high", "normal", "low"]),
  repliedAfter: z.number().nullable(),
  slaHours: z.number(),
});
export type Breach = z.infer<typeof breachSchema>;

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  private tickets: Ticket[] = [
    { id: "T-8", customer: "Ada", priority: "normal", openedOn: "2026-09-24", firstReplyHours: 3 },
    { id: "T-9", customer: "Linus", priority: "high", openedOn: "2026-09-24", firstReplyHours: 6 },
    { id: "T-10", customer: "Grace", priority: "low", openedOn: "2026-09-25", firstReplyHours: 20 },
    { id: "T-11", customer: "Ada", priority: "high", openedOn: "2026-09-25", firstReplyHours: 1 },
    { id: "T-12", customer: "Linus", priority: "high", openedOn: "2026-09-26", firstReplyHours: 8 },
    { id: "T-13", customer: "Grace", priority: "normal", openedOn: "2026-09-26", firstReplyHours: 5 },
    { id: "T-14", customer: "Ada", priority: "normal", openedOn: "2026-09-26", firstReplyHours: null },
    { id: "T-15", customer: "Margaret", priority: "low", openedOn: "2026-09-26", firstReplyHours: 30 },
    { id: "T-16", customer: "Grace", priority: "high", openedOn: "2026-09-26", firstReplyHours: 2 },
  ];

  openedOn(day: string) {
    return this.tickets.filter((t) => t.openedOn === day);
  }
}

export type Report = { day: string; total: number; breaches: Breach[]; text: string };

@Provider({ name: "ReportStore", scope: ProviderScope.GLOBAL })
export class ReportStore {
  private reports = new Map<string, Report>();

  save(report: Report) {
    this.reports.set(report.day, report);
  }

  get(day: string) {
    return this.reports.get(day);
  }
}

@Provider({ name: "Outbox", scope: ProviderScope.GLOBAL })
export class Outbox {
  private tries = 0;
  sent: { to: string; subject: string }[] = [];

  // Stands in for a mail server that's busy on every other try.
  send(to: string, subject: string, body: string) {
    this.tries += 1;
    if (this.tries % 2 === 1) throw new Error("Mail server busy, try again");
    this.sent.push({ to, subject });
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CollectTickets } from "./collect-tickets.job";
import { FindSlaBreaches } from "./find-sla-breaches.job";
import { NightlyReport } from "./nightly-report.workflow";
import { NotifyLead } from "./notify-lead.job";
import { GetNightlyReport, StartNightlyReport } from "./report.tools";
import { WriteReport } from "./write-report.job";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [StartNightlyReport, GetNightlyReport],
  jobs: [CollectTickets, FindSlaBreaches, WriteReport, NotifyLead],
  workflows: [NightlyReport],
})
export class HelpDeskApp {}
```

```ts main.ts
import "reflect-metadata";
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
import { Outbox, ReportStore, TicketStore } from "./stores";

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  // On the server, not the app: jobs only see the server's providers.
  providers: [TicketStore, ReportStore, Outbox],
})
export default class HelpDeskServer {}
```

```ts nightly-report.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
import { Outbox, ReportStore, TicketStore } from "./stores";

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("the model sees the two report tools and the job tools", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t) => t.name).sort();
  expect(names).toEqual([
    "execute_job",
    "execute_workflow",
    "get_job_status",
    "get_nightly_report",
    "get_workflow_status",
    "start_nightly_report",
  ]);
});

test("each step's outputs feed the next", async ({ mcp }) => {
  const { state, result } = (await mcp.tools.call("execute_workflow", { name: "nightly-report", input: { day: "2026-09-26" } })).json();
  expect(state).toBe("completed");
  expect(result.stepResults.collect.outputs.tickets).toHaveLength(5);
  expect(result.stepResults.breaches.outputs.breaches.map((b: { id: string }) => b.id)).toEqual(["T-12", "T-14"]);
  expect(result.stepResults.report.outputs.text).toBe(
    [
      "2026-09-26: 5 tickets, 2 broke their SLA.",
      "T-12 (Linus, high): first reply after 8 h, SLA 4 h.",
      "T-14 (Ada, normal): no reply yet, SLA 24 h.",
    ].join("\n"),
  );
});

test("the lead's email is sent on the second try", async ({ mcp }) => {
  const started = Date.now();
  const { result } = (await mcp.tools.call("execute_workflow", { name: "nightly-report", input: { day: "2026-09-24" } })).json();
  expect(result.stepResults.notify).toEqual({ state: "completed", outputs: { sentTo: "lead@helpdesk.example" } });
  expect(Date.now() - started).toBeGreaterThanOrEqual(200);
});

test("a day without breaches skips the email", async ({ mcp }) => {
  const { state, result } = (await mcp.tools.call("execute_workflow", { name: "nightly-report", input: { day: "2026-09-25" } })).json();
  expect(state).toBe("completed");
  expect(result.stepResults.notify).toEqual({ state: "skipped", outputs: {} });
  expect(result.stepResults.report.outputs.text).toBe("2026-09-25: 2 tickets, 0 broke their SLA.");
});

test("start_nightly_report returns at once, and get_nightly_report reads the report", async ({ mcp }) => {
  const started = await mcp.tools.call("start_nightly_report", { day: "2026-09-23" });
  expect(started.json()).toEqual({ day: "2026-09-23", runId: expect.any(String), state: "pending" });

  let report = await mcp.tools.call("get_nightly_report", { day: "2026-09-23" });
  for (let i = 0; i < 20 && report.isError; i++) {
    await sleep(50);
    report = await mcp.tools.call("get_nightly_report", { day: "2026-09-23" });
  }
  expect(report).toBeSuccessful();
  expect(report.json()).toEqual({ day: "2026-09-23", total: 0, breaches: [], text: "2026-09-23: 0 tickets, 0 broke their SLA." });
});

test("get_nightly_report explains a missing report", async ({ mcp }) => {
  const result = await mcp.tools.call("get_nightly_report", { day: "2026-01-01" });
  expect(result).toBeError("REPORT_NOT_READY");
  expect(result).toHaveTextContent("start_nightly_report");
});

test("a step runs on its own with execute_job, logs included", async ({ mcp }) => {
  const run = (await mcp.tools.call("execute_job", { name: "collect-tickets", input: { day: "2026-09-25" } })).json();
  expect(run.result.tickets.map((t: { id: string }) => t.id)).toEqual(["T-10", "T-11"]);
  expect(run.logs).toEqual([expect.stringMatching(/ Collected 2 tickets opened on 2026-09-25$/)]);
});

test("a signed-in client can poll the run the tool started", async () => {
  const server = await FrontMcpInstance.createDirect({
    info: { name: "Help Desk", version: "1.0.0" },
    apps: [HelpDeskApp],
    providers: [TicketStore, ReportStore, Outbox],
  });
  const asNour = { authContext: { user: { sub: "nour" } } };
  const started = await server.callTool("start_nightly_report", { day: "2026-09-26" }, asNour);
  const { runId } = started.structuredContent as { runId: string };

  let status = (await server.callTool("get_job_status", { runId }, asNour)).structuredContent as any;
  for (let i = 0; i < 20 && status.state !== "completed"; i++) {
    await sleep(50);
    status = (await server.callTool("get_job_status", { runId }, asNour)).structuredContent;
  }
  await server.dispose();
  expect(status).toMatchObject({ jobName: "nightly-report", state: "completed" });
  expect(status.result.stepResults.notify.state).toBe("completed");
});
```

In the Call tab, ask `get_nightly_report` for 2026-09-26: the report lists two tickets, T-12 and T-14. Ask for 2026-09-25 before starting it, and the tool tells the model what to do instead. You can also run a single step on its own: `execute_job` with `{ "name": "collect-tickets", "input": { "day": "2026-09-26" } }` returns the step's result and its log line.

## How it fits together

1. A client calls `start_nightly_report` with a day. The tool starts the `nightly-report` workflow in the background and returns at once, with the run `pending`.
2. The workflow runs each step once the steps it depends on have finished: `collect` finds the day's tickets, `breaches` picks the ones that broke their SLA, and `report` writes the report from both and saves it.
3. If any ticket broke its SLA, `notify` emails the team lead. The mail server is busy on the first try, so the step waits and tries again.
4. The client calls `get_nightly_report` with the same day to read the saved report.

*[Illustration: How the example fits together. The start_nightly_report tool starts the nightly-report workflow in the background. Its steps run in order: collect gets yesterday's tickets, breaches finds the ones that broke their SLA, report writes the report (also taking the tickets from collect), and notify emails the lead, only if something broke its SLA; the first email fails with an SMTP error and the step retries, succeeding on attempt 2. The report step saves to the ReportStore provider, and the get_nightly_report tool reads the report from it.]*
Why two tools, rather than letting the client call `execute_workflow` and poll `get_job_status` itself? `get_job_status` only answers the user who started a run, and an anonymous client, like the Playground's, [can't read its own runs at all](https://frontmcp.dev/reference/sdk/job#who-can-read-a-run). So the workflow saves its result where a tool can read it, and the model gets two tools whose names and descriptions say what they're for.

## The files

### `stores.ts`: the data the jobs share

Three [providers](https://frontmcp.dev/reference/sdk/provider), all `GLOBAL`, so every job and tool sees the same instance:

- `TicketStore` stands in for the help desk's database: nine tickets over four days, each with the hours until its first reply, or `null` if nobody has replied yet.
- `ReportStore` keeps finished reports by day.
- `Outbox` stands in for a mail server that's busy on every other try, so you can watch a step retry.

`ticketSchema` and `breachSchema` are Zod schemas the jobs use in their `inputSchema` and `outputSchema`, so one step's output and the next step's input agree on a shape, and each job checks the input it receives.

### `main.ts`: the providers go on the server

```ts main.ts
@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  providers: [TicketStore, ReportStore, Outbox],
})
```

A job gets its providers from the server, not from the app it's declared in. Move the three providers into the app's `providers` and the tools still work, but the jobs that use them fail with `Provider "TicketStore" is not available`. See [Using a provider](https://frontmcp.dev/reference/sdk/job#using-a-provider).

### `help-desk.app.ts`: tools, jobs and a workflow in one app

The app lists two tools, four jobs and a workflow. Declaring jobs is enough for FrontMCP to add `execute_job`, `execute_workflow`, `get_job_status` and `get_workflow_status`, so a client sees six tools, as the first test checks. [Doing Work in the Background](https://frontmcp.dev/learn/background-work) explains when work belongs in a job rather than a tool.

### `collect-tickets.job.ts`: the first step

```ts collect-tickets.job.ts
async execute({ day }: { day: string }) {
  const tickets: Ticket[] = this.get(TicketStore).openedOn(day);
  this.log(`Collected ${tickets.length} tickets opened on ${day}`);
  return { day, tickets };
}
```

A job looks like a tool: an input schema, an output schema, and an `execute()` that returns the result. It returns `day` as well as the tickets, so later steps can read the day from this step's outputs. `this.log()` lines are part of the result when you run the job alone with `execute_job`; a workflow's result doesn't keep them. `retry` gives a database that's briefly down two more chances. [Your First Job](https://frontmcp.dev/learn/your-first-job) covers each part.

### `find-sla-breaches.job.ts`: a step that doesn't retry

The job compares each ticket's first reply with the hours promised for its priority, and keeps the late ones and the unanswered ones. It sets `retry: { maxAttempts: 1 }` because it's a calculation: the same input fails the same way every time. Without `retry`, a workflow step is tried three times, with waits of 1 and 2 seconds, even though `execute_job` would run the same job once. That's worth deciding on purpose for every job a workflow uses; see the [`@Workflow` caveats](https://frontmcp.dev/reference/sdk/workflow#caveats).

### `write-report.job.ts`: saving the result

The job turns the counts and breaches into a few lines of text, saves them in `ReportStore`, and returns them too, as the `notify` step's input. Saving is what makes the report readable later: a background run's result is only available to the user who started it, through `get_job_status`.

### `notify-lead.job.ts`: retrying a flaky dependency

```ts notify-lead.job.ts
retry: { maxAttempts: 3, backoffMs: 200 },
```

`Outbox.send()` throws on the first try of each run, so the step fails, waits 200 milliseconds, and succeeds on its second attempt. One of the tests measures the wait. Retrying an email is safe: at worst, the lead gets it twice. [Running Jobs in the Background](https://frontmcp.dev/learn/running-jobs-in-the-background) covers retries and what to retry.

### `nightly-report.workflow.ts`: wiring the steps

Each step names its job with `jobName`, the steps it needs with `dependsOn`, and its input:

- `collect` has no `input`, so it gets the workflow's input, `{ day }`, from `start_nightly_report`.
- `breaches` depends on `collect`, and its `input` function reads `steps.get("collect").outputs.tickets`.
- `report` reads both earlier steps, so it lists both in `dependsOn`. A step can only read the steps it depends on: the others may not have finished yet.
- `notify` has a `condition`, so on a day without breaches it's `skipped`, as a test checks. It also sets `continueOnError`: if the mail server stays down through all three attempts, the step fails, but the run still `completed`, because the report was already saved.

[Chaining Jobs into Workflows](https://frontmcp.dev/learn/chaining-jobs-into-workflows) introduces each of these, and the [`@Workflow` reference](https://frontmcp.dev/reference/sdk/workflow#steps) lists every step option.

### `report.tools.ts`: starting the report and reading it back

```ts report.tools.ts
const run = await this.callTool("execute_workflow", { name: "nightly-report", input: { day }, background: true });
```

`start_nightly_report` starts the workflow with [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool), which calls another tool of the same server, as the same caller. It's the call a client would make, so it returns `{ runId, state: "pending" }`, and the tool passes that on. Because the run belongs to the same caller, a signed-in client can poll it with `get_job_status` and that `runId`: the last test does, as `nour`.

`get_nightly_report` reads `ReportStore`. When there's no report, it fails with a [`PublicMcpError`](https://frontmcp.dev/reference/sdk/tool#returning-an-error-the-model-can-read) that tells the model what to do next. It says the same when a step failed and the report was never saved. The run's result wouldn't say why either: in FrontMCP 1.8, the reason a step failed only goes to the server's log.

### `nightly-report.test.ts`

Most tests run the workflow with `execute_workflow`, which waits for every step, so they can check each step's outputs directly: the tickets, the breaches, the report's text, the retried email and the skipped one. One test goes through the tools, the way the Playground's anonymous client would, and polls `get_nightly_report` until the background run has saved the report. The last one signs in as `nour` with `FrontMcpInstance.createDirect()`, and polls the run itself with `get_job_status`.

All the tests but the last share one server, and every run saves a report, so the test that reads one through the tools uses a day that no other test reports on. [Testing Your Server](https://frontmcp.dev/learn/testing-your-server) covers the test API.

## Running it every night

FrontMCP 1.8 doesn't run anything on a schedule: a workflow's `trigger` is only a label. Something outside the server has to call `start_nightly_report` each night, like a scheduled task on your platform that connects as an MCP client.

This example also keeps everything in memory, which is fine in the Playground and not in production:

- Runs are in memory unless you set `@FrontMcp({ jobs: { enabled: true, store: { redis } } })`, so a restart loses them. The Playground can't use Redis.
- `ReportStore` and `TicketStore` are your own providers: keep tickets and reports in your database.
- Several server processes each have their own memory, so `get_nightly_report` could ask a process that didn't build the report. A shared database solves that too.

## Ideas to try

Each of these is a change to the Playground above. Add a test for each.

1. Let `get_nightly_report` tell "still being built" apart from "never started": have `start_nightly_report` note the day in `ReportStore` before it starts the workflow.
2. Count the breaches by priority in a new `count-by-priority` step that depends on `breaches`. It can run at the same time as `report`; make `notify` wait for both.
3. Let whoever starts the report choose who gets the email. The day reaches `collect` as the workflow's input, but an `input` function can't see the workflow's input, so pass the address on through `collect`'s outputs.
4. Test what happens when the mail server is down for good: make `Outbox` always throw, and check that the run is `completed` and the `notify` step `failed`.
