Skip to content

Run notifications by email, and a User config for per-machine defaults #109

Closed
5 of 5 issues completed
Closed

Description

@JacobStephens2

Problem Statement

I often have several thirdshift Runs going at once, across several machines: mostly DigitalOcean Linux servers, sometimes my MacBook. The only way to know that a Run has finished, and whether it ended ready for review, merged or as a Failed run, is to keep checking each terminal window. That uses up attention I want for other work.

I also repeat the same choices on every command. If I want every Run on a machine to be a Merge run, I have to remember merge every time. The session log location is fixed with no way to change it.

Solution

A Run can send a Run notification: an email, sent when the Run ends whatever its outcome, that names the issue, the outcome, the pull request and the machine it ran on. I ask for one with --email (or the bare word email), optionally followed by the address to send to. Without an address, it goes to the default address in my User config.

The User config is a TOML file at ~/.thirdshift/config.toml, next to the session logs thirdshift already writes to ~/.thirdshift/logs/. It sets per-machine defaults:

  • the default notification address and sender
  • whether every Run sends a notification without the flag
  • whether every Run is a Merge run without the merge word
  • where session logs go

--no-email and --no-merge override the "always" settings for a single Run. With no User config, or one that says nothing about a setting, a Run does exactly what its command asks, as today.

Emails go through Resend's HTTP API, using the key in the RESEND_API_KEY environment variable, so the same setup works on DigitalOcean (which blocks outbound SMTP ports) and on a MacBook (see ADR 0005). The config never holds the key, so it can be version controlled. thirdshift email-test [<address>] sends a test notification so I can check my setup without starting a Run.

User Stories

  1. As a user running several Runs at once, I want an email when a Run ends, so that I don't have to keep checking terminal windows.
  2. As a user, I want to ask for a Run notification with --email, so that I only get emails for Runs I care about.
  3. As a user, I want the bare word email to work like --email, the way merge and --merge both work, so that I don't have to remember which form to use.
  4. As a user, I want to type an address right after --email (e.g. --email jacob@stephens.page), so that I can send one Run's notification somewhere other than my default.
  5. As a user, I want --email with no address to use my User config's default address, so that I don't retype my address every time.
  6. As a user, I want to type the address without brackets or =, so that the command is natural to type and doesn't trip zsh's glob matching.
  7. As a user, I want the email flag to work before or after the Issue URL, and before or after merge, so that I don't have to remember an order.
  8. As a user, I want a Run notification when the Run ends ready for review, so that I know a pull request is waiting for me.
  9. As a user, I want a Run notification when a Merge run ends merged, so that I know the work has shipped.
  10. As a user, I want a Run notification when a Run ends as a Failed run, so that I can look into it promptly.
  11. As a user, I want a Run notification when a Run is interrupted, including by a closed terminal or SIGTERM, so that a Run that died while I was away doesn't go unnoticed.
  12. As a user, I want the subject to show the repository, issue number, issue title and outcome (e.g. [thirdshift] acme/widgets#123 Add export button: ready for review), so that I can triage from my inbox without opening the email.
  13. As a user, I want the body to include the pull request URL when there is one, so that I can go straight to it.
  14. As a user, I want the body to include the failure cause for a Failed run, so that I know what went wrong before I get back to the machine.
  15. As a user, I want the body to include the session log path, so that I know where to look on the machine.
  16. As a user who runs thirdshift on several machines, I want the body to include the hostname, so that I know which machine the Run was on.
  17. As a user, I want the body to include how long the Run took, so that I get a feel for how long Runs take.
  18. As a user, I want a User config at ~/.thirdshift/config.toml, next to the existing logs folder, so that everything thirdshift owns is in one place.
  19. As a user, I want to set a default notification address (email.to) in the User config, so that --email alone is enough.
  20. As a user, I want to set email.always = true, so that every Run on that machine sends a Run notification without the flag.
  21. As a user, I want to set merge.always = true, so that every Run on that machine is a Merge run without the merge word.
  22. As a user whose config says email.always = true, I want --no-email (or no-email) to skip the notification for one Run, so that I can opt out without editing the config.
  23. As a user whose config says merge.always = true, I want --no-merge (or no-merge) to make one Run stop at ready for review, so that a change I want a human to review can't self-merge.
  24. As a user with no User config, I want Runs to behave exactly as they do today, so that the feature changes nothing until I opt in.
  25. As a user with a User config that doesn't mention email, I want no notification unless I pass the flag, so that defaults stay conservative.
  26. As a user with a User config that doesn't mention merging, I want a Run to be a Merge run only when I pass merge, so that nothing self-merges unexpectedly.
  27. As a user, I want to set the sender with email.from, so that I can send from an address on my verified Resend domain.
  28. As a user without a verified Resend domain, I want email.from to default to onboarding@resend.dev, so that notifications to my own Resend account address work with only email.to and the API key set.
  29. As a user, I want the Resend API key read only from RESEND_API_KEY, never from the config file, so that I can version control my config safely.
  30. As a user, I want to set where session logs are written with logs.dir, defaulting to ~/.thirdshift/logs, so that I can put logs somewhere else on a given machine.
  31. As a user, I want a leading ~ in logs.dir expanded to my home folder, so that I can write paths the way I would in the shell.
  32. As a user, I want a relative logs.dir rejected with a clear error, so that logs don't end up in whatever directory I happened to launch from.
  33. As a user who asked for a Run notification with no address given and no email.to configured, I want the Run to stop at preflight before any work, so that I don't find out hours later that no email was coming.
  34. As a user who asked for a Run notification without RESEND_API_KEY set, I want the Run to stop at preflight with a message naming the variable, so that I can fix it immediately.
  35. As a user, I want a failed send (network error, bad key, Resend error) to be a warning on stderr that never changes the Run's outcome or exit code, so that a notification problem can't make a good pull request look like a failure.
  36. As a user, I want the warning for a failed send to include Resend's error, so that I can fix my setup.
  37. As a user, I want a config file that can't be parsed to stop the Run with an error naming the file, so that a broken config is never silently ignored.
  38. As a user, I want an unknown key or section (e.g. alway = true, [emial]) to stop the Run with an error naming it, so that a typo in merge.always can't silently change whether my Runs self-merge.
  39. As a user, I want update, version and help to never read the User config, so that a broken config can't stop me from updating or getting help.
  40. As a user setting up notifications, I want thirdshift email-test, so that I can check that email works without starting a Run.
  41. As a user, I want thirdshift email-test <address> to send to that address, and thirdshift email-test to send to my configured email.to, so that it behaves like --email.
  42. As a user, I want email-test to go through the same checks and sending code as a Run notification, so that a passing test means real notifications will work.
  43. As a user, I want the test email to be clearly labelled as a test and show the hostname, the time and the sender used, so that I can tell it apart from a real notification.
  44. As a user, I want email-test to print "accepted by Resend; check your inbox" and exit 0 when the send succeeds, so that I know what was and wasn't verified.
  45. As a user, I want email-test to print Resend's error word for word and exit non-zero when the send fails, so that I can fix my setup.
  46. As a user, I want the help text to list the email flags, --no-merge and email-test, and mention the User config, so that I can find these features.
  47. As a user, I want the README to document the User config's keys, RESEND_API_KEY and the onboarding@resend.dev restriction, so that I can set up notifications without reading the source.
  48. As a user, I want contradictory arguments such as --email with --no-email, or merge with --no-merge, rejected as argument errors, so that my intent is never guessed.
  49. As a user, I want a repeated flag rejected as an argument error, so that a mistyped command doesn't quietly do something other than I meant.
  50. As a user on my MacBook, I want notifications to work the same as on Linux, so that it doesn't matter which machine I start a Run on.

Implementation Decisions

  • Domain terms. Run notification and User config are defined in CONTEXT.md. Merge run now includes a Run made a Merge run by the User config. Use these terms in code, help text and messages.

  • Delivery (ADR 0005). Notifications are sent with a single HTTPS POST to Resend's send-email endpoint: a bearer token from RESEND_API_KEY, and a JSON body with from, to, subject and a plain-text body. No mail/sendmail, no SMTP. The HTTP client is reqwest over rustls, already in the dependency tree through the updater. Nothing may pull in OpenSSL (ADR 0003). The send has a bounded timeout (around 30 seconds) so an unreachable API can't hang the end of a Run.

  • Resend endpoint override. The Resend base URL can be overridden with the environment variable THIRDSHIFT_RESEND_URL, for tests only, like the existing THIRDSHIFT_POLL_MS and THIRDSHIFT_CI_GRACE_MS. It is not documented for users.

  • User config module. A new module loads ~/.thirdshift/config.toml (under $HOME) into a typed settings value, with all fields optional:

    • [email]: to (string), from (string, default onboarding@resend.dev), always (bool, default false)
    • [merge]: always (bool, default false)
    • [logs]: dir (string, default ~/.thirdshift/logs)

    A missing file is the same as an empty one. A file that can't be parsed, or that has an unknown key or section, is an error naming the file and the key. A leading ~ in logs.dir is expanded to $HOME, and a relative logs.dir is an error. Parsing uses the toml crate with serde derive, denying unknown fields.

  • When the config is read. It is read at the start of a Run and by email-test, before any work. update, version and help never read it. A config error is reported like a preflight failure, before the worktree exists.

  • Argument parsing. A Run accepts, in any order around the Issue URL:

    • merge / --merge
    • email / --email, optionally followed by an address. The next token is the address only if it contains @ and doesn't start with https://.
    • no-email / --no-email
    • no-merge / --no-merge

    Each can appear at most once. merge with no-merge, or email with no-email, is an argument error (exit 2, with the help text, as today). Any other extra token remains an argument error.

  • Resolving what a Run does. Command flags beat the User config:

    • The Run is a Merge run if merge was given, or merge.always is true and no-merge was not given.
    • It sends a Run notification if email was given, or email.always is true and no-email was not given.
    • The address is the flag's address, else email.to.
  • Preflight for notifications. When a Run will send a notification, preflight checks locally, before any other work, that an address is known and RESEND_API_KEY is set and not empty. There is no network call to validate the key. A failed check stops the Run before any work, with a message naming what's missing, and sends nothing.

  • When the notification is sent. Once arguments, config and the notification preflight are accepted, every way the Run ends sends exactly one Run notification: ready for review, merged, Failed run (including later preflight failures such as an Origin match mismatch) and interrupted. It is sent after the Run's outcome is final and its cleanup is done, just before the process exits. Its result never changes the exit code. A failed send prints a warning with Resend's error on stderr.

  • Notification content.

    • Subject: [thirdshift] <owner>/<repo>#<n> <issue title>: <outcome>, where the outcome is ready for review, merged or failed, or interrupted for an interruption. If the issue title isn't known (e.g. the Run failed before fetching it), it is left out.
    • Plain-text body: pull request URL (if any), failure cause (if failed), session log path (if any), hostname, and how long the Run took.
  • Log location. The session log path is built from the resolved logs.dir instead of the hard-coded ~/.thirdshift/logs. The filename scheme is unchanged.

  • email-test command. thirdshift email-test [<address>] reads the User config, resolves the address the same way (argument, else email.to), runs the same notification preflight, and sends through the same sending code. The subject is marked as a test, and the body shows the hostname, time and sender. On success it prints "accepted by Resend; check your inbox" and exits 0. On failure it prints the error (Resend's text verbatim) and exits non-zero.

  • Hostname. Read from the OS (libc::gethostname, already a dependency), falling back to "unknown host".

  • Help and README. Help lists the new forms. The README documents the User config keys with an example, RESEND_API_KEY, the onboarding@resend.dev limitation, and email-test.

Testing Decisions

  • What makes a good test here. Tests exercise the compiled binary from outside, as a user would: the command line, the User config file, environment variables, the email that reaches the Resend stand-in, stdout/stderr and the exit code. They never call internal functions or assert on internal types.
  • Seam 1 (existing): the end-to-end harness that runs the compiled thirdshift against a temp $HOME, real git, a bare origin, and the fake gh and claude executables. The User config is a config.toml the test writes to the scenario's home/.thirdshift/. It gets a helper for writing the config.
  • Seam 2 (new): a Resend stand-in. The harness starts a small local HTTP server, using only the standard library, and points THIRDSHIFT_RESEND_URL at it. It records each request (method, path, authorization header, JSON body) and replies with a status and body the test chooses. Tests assert on recorded requests, or on there being none.
  • Behaviour to cover:
    • A Run ready for review, a Merge run that merges, a Failed run and an interrupted Run each send exactly one notification, with the right subject, PR URL, failure cause, log path and hostname.
    • A Run without the flag and without email.always sends nothing.
    • email.always sends without the flag, and --no-email suppresses it.
    • merge.always makes a Merge run, and --no-merge suppresses it.
    • An address after the flag wins over email.to, and a bare flag uses email.to. Every bare-word and -- form works, in any position.
    • Contradictory or repeated flags are argument errors with exit 2.
    • A missing address or a missing RESEND_API_KEY stops the Run at preflight: no claude session, no gh calls that change anything, no request to the stand-in.
    • A stand-in reply of 401 or 500 leaves the Run's outcome and exit code unchanged and prints a warning with Resend's message.
    • An unparsable config, an unknown key or section, and a relative logs.dir each stop the Run before any work. update, version and help still succeed with a broken config.
    • logs.dir (with ~) puts session logs there.
    • email-test succeeds (exit 0 and the "accepted by Resend" line), fails (non-zero exit with Resend's error verbatim), uses the argument over email.to, and fails the preflight checks.
  • Prior art:
    • tests/command_surface.rs: help text, argument errors and exit codes
    • tests/fresh_run.rs, tests/merge_run.rs, tests/failed_run.rs: Run outcomes through the fakes
    • tests/preflight.rs: stopping before any work
    • tests/update.rs: a command that runs outside a Run
    • tests/support/mod.rs: the harness the stand-in and config helper extend

Out of Scope

  • Email providers other than Resend, including Amazon SES (SigV4 signing) and SMTP. The config is shaped so a provider can be added later.
  • Push or desktop notifications (ntfy, Pushover, OS notifications) and a user-defined "run this command at the end of a Run" hook.
  • Reading the Resend API key from the config file.
  • A network call at preflight to validate the key.
  • Multiple recipients, HTML email and templating.
  • A $THIRDSHIFT_CONFIG override or an XDG location for the User config.
  • Shell tab completion.

Further Notes

  • Decided in a grilling session; decisions are recorded in CONTEXT.md (Run notification, User config, updated Merge run) and docs/adr/0005-run-notifications-through-resend.md.
  • The evidence behind the delivery choice (no mail server on fresh Ubuntu/Rocky, blocked ports on DigitalOcean and the major clouds, macOS Postfix with no relay) is in docs/research/run-notifications-email.md.
  • Resend accepting a message doesn't prove it was delivered. Neither a Run nor email-test can know more than "accepted".

Activity

  1. JacobStephens2 commented on Oct 2, 2026

    @JacobStephens2
    OwnerAuthor

    This was generated by AI during triage.

    Closing: all five Tickets (#111 to #115) are closed, so the Spec is done and was only left open. While it stayed open and labelled ready-for-agent, every Pickup run took it and did nothing, which is filed as #303.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions