# Changelog
All notable changes to LatestArr are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/).
While the major version is `0`, breaking changes may still land in minor
releases — see semver's own note on initial development for what `0.x`
means in practice.
Releases are cut with [Changesets](https://github.com/changesets/changesets);
see `CONTRIBUTING.md` for how to add a changeset to a PR.
## [0.12.8] - 2026-11-11
### Improved
- Updated the scheduler library that runs newsletter and backup schedules to croner 11. ([#420](https://github.com/jshields-ca/LatestArr/pull/420))
Schedules work as before. One small difference: a schedule set between 2:00 and 2:59 AM now runs at 3:00 AM on the night clocks spring forward, rather than as late as the skipped hour was long (3:30 AM for 2:30 AM).
Technical details
croner 10.0.1 → 11.0.0 (#420). Its breaking changes are in `startAt`, `dayOffset`, `previousRun()` and daylight-saving handling. LatestArr uses none of the first three. `nextRun(since)` is still strictly after `since`, which the missed-send catch-up on startup relies on (checked against 11.0.0).
- Updated nine dependencies, including Nodemailer and the web server's file serving and cookie libraries. ([#419](https://github.com/jshields-ca/LatestArr/pull/419))
Technical details
From #419: nodemailer 10.1.7 → 10.2.1, @fastify/static 10.1.6 → 10.1.8 (serves files with `#` or `?` in their names), @fastify/cookie 11.1.3 → 11.1.5, react-router-dom 7.18.5, lucide-react 1.62.0, vite 8.3.5, read-excel-file 9.3.13, typescript-eslint 8.72.1 and @types/mjml 5.4.2. No code changes. The From-header test (`from-header.test.ts`) still passes on the new Nodemailer.
- Updated glob and minimatch, clearing a moderate security alert for glob so image scanners stop flagging it. ([#421](https://github.com/jshields-ca/LatestArr/pull/421))
LatestArr wasn't exposed: it never cancels a file search, and nothing lets a user supply a search pattern. Nothing to do when upgrading.
Technical details
Clears GHSA-3g3m-2fjr-6pw4 (CVE-2026-118773, glob's abort signal not stopping a walk, fixed in 14.0.2). The minimatch advisory it was paired with (GHSA-g3hv-75wg-m249) was later withdrawn, but minimatch 11 is kept. @fastify/static 10.1.8 → 10.1.9 moves to glob 14; its glob only runs with `wildcard: false`, which LatestArr doesn't set. js-beautify, editorconfig and mjml-cli still ask for the old versions, and only their command-line tools use them, which LatestArr doesn't run. So root `pnpm.overrides` force `glob@13` → `^14.0.2` and `minimatch@10` → `^11.0.1`; they can go once those packages catch up. The linting-only minimatch 3.1.5 (via eslint-plugin-jsx-a11y) stays, as it doesn't contain the affected code.
@fastify/static 10.1.9 also sends a request for a directory with no index file to the not-found handler rather than a 403, so a GET like `/assets` now gets the web app's page.
### Fixed
- SSO sign-in works when `OIDC_SCOPES` is left blank, as `.env.example` shows. ([#417](https://github.com/jshields-ca/LatestArr/pull/417))
With the provided `docker-compose.yml`, a blank `OIDC_SCOPES` reached LatestArr as an empty value rather than a missing one. LatestArr then asked your SSO provider for no scopes, so providers refused the sign-in or sent no ID token. A blank value now means the default, `openid profile email`. A blank `OIDC_CLIENT_SECRET` likewise now means no secret, as it should for public clients. If you set `OIDC_SCOPES=openid profile email` to work around this, you can leave it set.
Technical details
`loadOidcConfigFromEnv` reads them with `||` instead of `??`: `OIDC_SCOPES?.trim() || "openid profile email"` and `OIDC_CLIENT_SECRET || undefined`. Compose passes each `OIDC_*` setting with an empty default, so these were always set. A new `oidc-config.test.ts` covers missing, blank and set values. Found by the latestarr.app session. Fixes #416.
## [0.12.7] - 2026-10-29
### Improved
- Settings in `.env` can have a `LATESTARR_` prefix, so one `.env` can serve a whole stack. ([#410](https://github.com/jshields-ca/LatestArr/pull/410))
With the provided `docker-compose.yml`, any setting can be written as `LATESTARR_ENCRYPTION_KEY`, `LATESTARR_PORT`, `LATESTARR_TZ` and so on. The prefixed name wins over the plain one, so LatestArr can't pick up another app's `PORT` or `TZ` from a shared `.env`. The plain names still work as before, so nothing needs changing. **`BACKUP_PATH`** set in `.env` now reaches the container too. Before, it had to go straight into the compose file.
Technical details
`docker-compose.yml` uses Compose's nested defaults, e.g. `${LATESTARR_ENCRYPTION_KEY:-${ENCRYPTION_KEY:-}}`. Inside the container the names are unchanged, and the server reads them as before: there's no new way for it to find the encryption key. CI runs `docker compose config` to check plain, prefixed and default values. Fixes #399.
### Fixed
- The design editor makes clear which buttons each colour and style changes. ([#406](https://github.com/jshields-ca/LatestArr/pull/406))
- **Where to watch** says its buttons have their own style, outlined at first, and that the Browse link follows it. The **Style** under **Your buttons** only changes buttons you add.
- **Button text** says "Not used: every button is outlined" when it has no effect, because an outlined button uses Button colour for its text and border.
- The colour note ends with the fix most people want: set the Where to watch Style to **Filled**.
Nothing about how emails look has changed.
Technical details
`AccentColours` takes an `unused` map shown in place of a colour's value. Button text counts as used when your buttons are filled, or the Where to watch style is filled (it also styles the Browse link). Fixes #394.
- Two hints in the app now say the right thing. ([#404](https://github.com/jshields-ca/LatestArr/pull/404))
- **Public URL for Plex and Tautulli** suggests `https://app.plex.tv` first. It works for anyone you share your server with, and needs no domain of your own. It used to say to use your Plex server's address.
- **A dark design** now says Light and Dark previews look the same, because email apps in dark mode leave a dark design as it is. It used to say to switch the preview to Dark to check it, as if something should change.
Technical details
`PublicUrlHint` (`sources-page.tsx`) has Plex and Tautulli wording. The design editor's dark-mode note uses a new `isDarkBackground` in `lib/colour.ts`, the same weighting as the server's `isDarkDesign`. Fixes #396 and #397.
- A From name with special characters can no longer change the address an email is sent from. Security updates for nodemailer and other dependencies are included too. ([#414](https://github.com/jshields-ca/LatestArr/pull/414))
- **From name:** LatestArr built the From header as one piece of text, "Name
", which the email library then had to read apart. A name containing angle brackets, like `Flix `, made emails come from that other address. The name and address are now passed separately, so any name is kept as just a name. This covered newsletters, test emails, password reset emails and alerts. Only editors and admins can set a From name.
- **Dependencies:** nodemailer 10.1.7 fixes three ways crafted addresses could be misread. It also updates `undici` and `fast-uri`, which come in through other libraries, to clear their security advisories.
Technical details
`fromHeader(name, address)` in `mailer/send.ts` returns `{ name, address }` (or the address alone with no name), used by `loadSender`, password reset, the SMTP test email and alerts. `from-header.test.ts` builds real messages with Nodemailer's stream transport and checks that names with commas, quotes, brackets and semicolons leave one From address. Dependencies: #389 (nodemailer 10.1.0 to 10.1.7 and nine dev and patch updates), #413 (undici 6.29.1, 7.30.1 and 8.12.3, and fast-uri 3.1.9 and 4.2.3).
- You can type a hex colour like `181818` in the design editor. ([#407](https://github.com/jshields-ca/LatestArr/pull/407))
Each colour now has a box under its name for the hex value, as well as the swatch. Some systems' colour pickers, including Windows', have no hex field, so there was no way to enter an exact colour. Six digits, with or without the `#`, apply as you type. A short form like `#181` applies when you leave the box. Anything that isn't a colour is marked and ignored.
Technical details
`ColourField` gets a text input alongside ``, using `parseHexColour` from `lib/colour.ts`. The server still validates every colour with `hexColor` in the design schema. Fixes #395.
- You can import a source's users from the Recipients page, and they're listed by name. ([#409](https://github.com/jshields-ca/LatestArr/pull/409))
- **From a source** next to **Import** on the Recipients page lists your connected sources that can share their users (Plex, Tautulli, Jellyfin, Emby, Audiobookshelf and RomM). With none connected, it points to the Sources page. Before, this was only on each source's row on the Sources page, which was easy to miss after the setup checklist sent you to Recipients.
- The list of users is sorted by name instead of the order the source gives.
- For Tautulli, a person's Plex username is used when Tautulli's display name is just their email, so the list and the imported recipients show names, not emails.
Technical details
`ImportSourceUsersDialog` moves to `components/import-source-users-dialog.tsx`. It loads the groups itself when it opens, so the Sources page no longer loads them. `ImportFromSourceButton` filters `listSources()` by `KINDS_WITH_USER_IMPORT` and refreshes the page's lists after an import. The Tautulli adapter's `listUsers` picks the first of `friendly_name` and `username` that isn't an email. Fixes #398.
## [0.12.6] - 2026-10-22
### Fixed
- A design switched to code can go back to its options, with **Back to options** in the design editor. ([#382](https://github.com/jshields-ca/LatestArr/pull/382))
Before, **Edit as code** was one-way. The design returns to the options it had when it switched, keeping its intro, footer note and buttons; changes made to the code are dropped, and a confirmation says so first. A design that started as code, from the old template builder, gets the default options.
Technical details
Adds `POST /api/templates/:id/convert-to-options` (editor role, listed in `security.test.ts`), which sets `mode: "design"` and clears `compiledMjml`. A design keeps its `settings` while in code mode, so nothing else needs restoring. Reported in #381.
- A Media List's **Show a few from the library** fallback lists at most 100 items, like the list itself. ([#379](https://github.com/jshields-ca/LatestArr/pull/379))
A design written in code could set any number for it. Designs built from options were never affected.
Technical details
`mediaList`'s `emptyFallback="random"` slice is capped at `MAX_SHOWN_ITEMS`, as #372 did for the main selection. Found by the latestarr.app session.
- The image is built on Node.js 24.22.1, which no longer trusts the eight compromised eMudhra certificates. ([#386](https://github.com/jshields-ca/LatestArr/pull/386))
Those certificates had keys weak enough to work out, so someone with them could make a fake HTTPS certificate for any site. LatestArr uses Node's built-in list of trusted certificates when it connects to your media servers, your email server, single sign-on and poster images. On 0.12.5 and earlier, someone on the network between LatestArr and one of those could have impersonated it. Updating to 0.12.6 closes that.
Also updates markdown-it to 15.0.3, which fixes two ways crafted Markdown could make it slow. LatestArr's intro and footer notes don't switch on either feature involved, so this is a precaution.
Technical details
No code change for the certificates: `docker/Dockerfile` builds `FROM node:24-alpine`, which now resolves to 24.22.1 (Node's 2026-10-21 security release: it rejects chains containing the eMudhra intermediates' keys and updates the bundled roots to NSS 3.131.1). LatestArr doesn't set `NODE_EXTRA_CA_CERTS`, `--use-system-ca` or `rejectUnauthorized`, so Node's bundled store is the one in use. markdown-it 15.0.3 (#385) fixes GHSA advisories for quadratic-time reference definitions and inline HTML; `render/notes.ts` uses the `zero` preset without `reference`, and with `html: false`.
## [0.12.5] - 2026-10-17
### Fixed
- Follow-ups to 0.12.4's full reading of sources. ([#373](https://github.com/jshields-ca/LatestArr/pull/373))
- **No false warning for Audiobookshelf:** the "may have more new items" warning now appears only when a source really stopped at its 1,000-item cap. Several busy Audiobookshelf libraries could trigger it when none reached its own cap. (#370)
- **Most items per section accepts up to 100:** it was limited to 50, while leaving it blank shows up to 100. (#371)
- **Code designs stay a sensible length:** a Media List with no count, or set to show everything, now lists at most 100 items, like designs built from options. (#372)
Technical details
- **#370:** `readNewest` takes an `onCapReached` callback, called only when reading stopped at `MAX_RECENT_ITEMS` without reaching older items, a short page or an error, and never for an explicit `limit`. `FetchRecentItemsParams.onCapReached` passes it through every adapter, and `run-newsletter.ts` warns from it instead of comparing the source's total with 1,000.
- **#371:** the design schema's `sections.limit` and the editor field allow 1–100.
- **#372:** the `mediaList` helper caps its selection at `MAX_SHOWN_ITEMS` (100), including `showAll="true"` and a missing `count`. `{{#each items}}` in hand-written MJML stays as written.
- Found by the latestarr.app session while documenting 0.12.4.
- Jellyfin and Emby sources wait up to 30 seconds for a response, like every other source. ([#376](https://github.com/jshields-ca/LatestArr/pull/376))
They gave up after 15 seconds, so a slow or busy server could fail a check or a send that other sources would have finished.
Technical details
- `jellyfin-client.ts` drops its own 15-second `REQUEST_TIMEOUT_MS` for the shared `SOURCE_REQUEST_TIMEOUT_MS`.
- The 10-second image timeout, defined separately in all six adapter clients, is now `IMAGE_REQUEST_TIMEOUT_MS` in `adapter-core`'s `http-limits.ts`.
- Tautulli's leftover `DEFAULT_FETCH_COUNT` is renamed `POPULAR_FETCH_COUNT`, since it now only sizes "most watched".
- The webhook timeout in `alerts.ts` is named `WEBHOOK_TIMEOUT_MS`.
## [0.12.4] - 2026-10-17
### Fixed
- Newsletters no longer miss new items on a busy week. ([#366](https://github.com/jshields-ca/LatestArr/pull/366))
- **Everything in the lookback window:** LatestArr read only the 100 newest items from each source, shared by all of its libraries. So on a Plex server, a big week of TV could push that week's movies out of the newsletter entirely. It now reads everything added in the newsletter's lookback window, page by page.
- **A safety cap:** at most 1,000 items per source (per library for Audiobookshelf) on each check. Reaching it is logged as a warning on the Logs page.
- **Emails stay a sensible length:** a section with no **Most items per section** limit shows up to 100 items, about the most it could before.
Technical details
- **`adapter-core`:** `readNewest` pages newest-first (`RECENT_PAGE_SIZE` 100) until a page reaches items older than `since`, comes back short, adds nothing new (a source that ignores paging), or the total reaches `MAX_RECENT_ITEMS` (1,000). A first-page error is thrown as before; a later page's error keeps what was read.
- **Adapters:**
- Plex: `X-Plex-Container-Start`;
- Tautulli: `start`;
- Jellyfin and Emby: `StartIndex`;
- Audiobookshelf: `page`, from 0;
- RomM: `offset`;
- BookLore, BookOrbit and Grimmory: OPDS `page`, from 1, since both BookOrbit and BookLore cap `size` at 100.
An explicit `limit` still reads that many.
- **Server:** `run-newsletter.ts` warns when a source returns `MAX_RECENT_ITEMS`. `design.ts` renders sections with `count="${limit ?? MAX_SHOWN_ITEMS}"` (100), and the design editor's placeholder reads "Up to 100". Designs written in code (`{{#each items}}`) aren't capped.
- Found while documenting the limit for #362; the "+ N more" line follows in 0.13.0.
## [0.12.3] - 2026-10-17
### Improved
- Buttons and TV links in newsletters fit what they point to. ([#360](https://github.com/jshields-ca/LatestArr/pull/360))
- **Listen on BookOrbit:** under Audiobooks, a source's button now says **Listen on**, under Books **Read on**, under Movies and TV **Watch on**, and under Games **Play on**. Before, one source had one label everywhere, so BookOrbit's audiobooks had a **Read on BookOrbit** button.
- **TV rows link to the season or show:** a row that groups several new episodes now links to their season, or to the show when they're from different seasons, as Tautulli's newsletter does. Each episode listed under it still links to that episode. This works for Plex and Tautulli sources.
### Fixed
- BookOrbit books and audiobooks now show their synopsis, like movies do. ([#360](https://github.com/jshields-ca/LatestArr/pull/360))
Technical details
- **Synopsis:** BookOrbit puts an entry's description in ``; BookLore and Grimmory use ``. `getEntrySummaryText` now falls back to ``, and ignores an empty one.
- **Verbs:** `buildSourceButtons` adds `labelsByKind`, with the verb chosen by kind (`VERB_BY_KIND`), and the `sourceButtonsFor` helper uses the label for its section's kind. Buttons not under a section keep the app's own verb.
- **TV links:** `NewItem` gains `seasonUrl` and `seriesUrl`. Plex fills them from `parentRatingKey` and `grandparentRatingKey`, and Tautulli from `parent_rating_key` and `grandparent_rating_key`, as Plex Web deep links. `groupEpisodes` links the row to the shared season, else the series, else the first episode.
- Reported in #353 and #359.
- Plex links through `https://app.plex.tv` now open the item, even when Plex first asks people to sign in or choose a profile. ([#364](https://github.com/jshields-ca/LatestArr/pull/364))
They opened the Plex home page instead. Links now use the same form as Tautulli's newsletter links, `https://app.plex.tv/desktop#!/…`, which a user confirmed in #353 keeps the item through Plex's profile picker. Links to a self-hosted Plex address are unchanged.
Technical details
`plexWebAppUrl` returns `https://app.plex.tv/desktop` (no trailing slash) for app.plex.tv. In #353, `/desktop#!/…` reached the item after the profile picker, while `/desktop/#!/…` (0.12.2) and `/web/index.html#!/…` (0.12.1) both ended on the home page. Item, season and series links and the fallback link all use it.
## [0.12.2] - 2026-10-16
### Improved
- When a backup can't be written, the error links to the fix on latestarr.app. ([#352](https://github.com/jshields-ca/LatestArr/pull/352))
The message, on the Backups page, in the log and in the failure alert, now ends with a link to the Backups guide's **Can't write** section, which walks through checking and fixing the folder's permissions.
Technical details
`explainFolderError` in `backups/service.ts` appends `https://www.latestarr.app/docs/backups#cant-write`. The site keeps that anchor stable.
### Fixed
- Book and Plex links in newsletters open the right item, BookOrbit audiobooks show as audiobooks, and the OPDS sign-in hint is corrected. ([#355](https://github.com/jshields-ca/LatestArr/pull/355))
- **BookOrbit, BookLore and Grimmory links:** a book now links to its own page, at your Public URL. Before, every book linked to the library's front page, because these apps' feeds don't include a link to each book.
- **BookOrbit audiobooks:** an item whose files are all audio now shows as an **Audiobook**, under Audiobooks, instead of as a book. A book with both an ebook and its audio stays a book, labelled **Ebook + audio**. MOBI, AZW3 and FB2 books now show as **Ebook**, and CB7 comics as **Comic**, instead of just "Book".
- **Plex links on app.plex.tv:** with `https://app.plex.tv` as the Public URL, links now use Plex's own `/desktop/` address. The old link reached `/desktop/` through a redirect, and the item could be lost on the way, especially when Plex asked people to sign in first, leaving them on the Plex home page.
- **OPDS hint:** BookLore, BookOrbit and Grimmory use separate OPDS accounts, not your normal sign-in. The hint now says so, and where to create one. It used to say to use your normal sign-in.
Technical details
- **`booklore-client.ts`:** `getEntryBookId` reads `urn:booklore:book:` and `urn:bookorbit:book:`, and `isAudiobookEntry` is true when every acquisition link has an `audio/*` type. `getEntryFormatLabel` recognises the MIME types BookOrbit serves for MOBI, AZW and AZW3, FB2 and CB7, and adds " + audio" when an item also has audio files. BookOrbit serves `.m4b` as `audio/mp4` and `.mp3` as `audio/mpeg`.
- **`booklore-adapter.ts`:** links are chosen in this order: `rel="alternate"`, then `/book/` (the route in all three apps), then `rel="self"`, then the library root. `supportsMediaKinds` is now `["book", "audiobook"]`, and `mediaKinds` filters each item. The app always saves an empty filter, so existing newsletters keep their audiobooks.
- **`adapter-core`:** a new `plexWebAppUrl` returns `https://app.plex.tv/desktop/` for app.plex.tv, and `/web/index.html` otherwise. `buildPlexWebDeepLink` and the Plex and Tautulli fallback links use it.
- Reported in #353.
- For a Tautulli source, the **Public URL** hint now says to use your Plex server's address, and that without one, items have no links. ([#350](https://github.com/jshields-ca/LatestArr/pull/350))
It said item links fall back to the Base URL, which is true for every other source but not Tautulli: Tautulli's address isn't a page recipients can use, so LatestArr makes no links from it.
Technical details
`PublicUrlHint` in `pages/sources-page.tsx` takes the source kind, matching `buildExternalUrl` in `tautulli-adapter.ts`, which returns no link without `publicUrl` and builds a Plex Web link from it.
## [0.12.1] - 2026-10-15
### Improved
- The Backups page's help links open the Backups guide on latestarr.app. ([#348](https://github.com/jshields-ca/LatestArr/pull/348))
**Restoring a backup** and the note about where backups are saved now link to the friendly guide instead of the repository's technical reference.
Technical details
`RESTORE_DOCS_URL` and `BACKUP_PATH_DOCS_URL` in `pages/backups-page.tsx` point to `latestarr.app/docs/backups#restoring-a-backup` and `#where-theyre-saved`. The `README.txt` inside each backup still links to `docs/self-hosting.md#restoring-a-backup`, which ships with the code.
- Clearer **Base URL** and **Public URL** fields when adding or editing a source. ([#343](https://github.com/jshields-ca/LatestArr/pull/343))
Each field now has its own short explanation. The **Base URL** is where LatestArr connects, so a local address is fine. The **Public URL** is where recipients go when they click. Before, one hint under Public URL tried to explain both, and called the Base URL "the address above".
Technical details
`BaseUrlHint` and `PublicUrlHint` in `pages/sources-page.tsx`, shared by the Add and Edit source dialogs, each linked to its input with `aria-describedby`.
### Fixed
- A backup that can't be written now says which folder is the problem. ([#341](https://github.com/jshields-ca/LatestArr/pull/341))
Before, a backup folder LatestArr couldn't write to failed with SQLite's "unable to open database file", on the Backups page, in the log, and in the failure alert. It now names the folder and says to check that LatestArr can write to it.
Technical details
`makeBackup` maps `SQLITE_CANTOPEN` and file-system errors (`EACCES`, `EPERM`, `EROFS`, `ENOENT`, `ENOTDIR`, `EEXIST`) to a message naming `backupDir`, keeping the original error as `cause`.
- The Notifications page's cards now span the page like every other page's. ([#342](https://github.com/jshields-ca/LatestArr/pull/342))
They were narrower than the rest on a desktop. The fields inside them keep a comfortable width, as on the Backups page.
Technical details
The `max-w-2xl` cap moves from the form to each card's `CardContent`.
- Each person's result in a send's details reads as a word (**Sent**, **Failed**, **Bounced** or **Unsubscribed**), like the send's own status, instead of `sent` or `failed`. ([#339](https://github.com/jshields-ca/LatestArr/pull/339))
Technical details
- **`components/send-run-history.tsx`:** a `RECIPIENT_STATUS_LABEL` map for `SendRunRecipientResult["status"]`, alongside the run labels in `lib/send-run.ts` from #336.
## [0.12.0] - 2026-10-13
### New
- The container image now runs natively on ARM: Apple Silicon Macs, Raspberry Pi 4 and 5 (on a 64-bit OS), ARM NAS boxes and cloud servers. Docker picks the right image for you, so `docker compose pull` just works, with no `--platform linux/amd64` emulation. ([#323](https://github.com/jshields-ca/LatestArr/pull/323))
Technical details
- Closes #318.
- **`publish-image.yml`** is a new reusable workflow used by `dev-image.yml` and `release.yml`.
- It builds `linux/amd64` on `ubuntu-latest` and `linux/arm64` natively on `ubuntu-24.04-arm`, rather than under QEMU. Each pushes by digest.
- A merge job then publishes one multi-platform manifest per tag, and checks that both architectures are there.
- **CI:** builds and smoke-tests the arm64 image too ("Docker build (arm64)"). The native modules (better-sqlite3, sharp, @node-rs/argon2) load at startup, so a missing arm64 binary fails the health check. The required "Docker build" check keeps its name.
- **Out of scope:** 32-bit ARM (`linux/arm/v7`), because sharp ships no prebuilt binaries for it.
- Automatic backups. LatestArr now backs up its database every day at 03:00 and keeps the last seven, with no downtime. It also backs up before every upgrade. The new **Backups** page (admins only) changes the schedule and what's kept, and can **Back up now**, download, or delete a backup. Point `BACKUP_PATH` at another disk or a NAS so a failed disk can't take both the database and its backups. To restore one, run `docker exec -it latestarr node dist/cli.js restore ` and restart. ([#277](https://github.com/jshields-ca/LatestArr/pull/277))
Technical details
- Closes #263.
- **Snapshots** use SQLite's online backup API (`better-sqlite3`'s `backup()`), never a raw file copy.
- Each snapshot is switched out of WAL mode and must pass `PRAGMA integrity_check` before it's kept.
- It's packed into one ZIP with `manifest.json` (version, migration count, item counts, and an `ENCRYPTION_KEY` fingerprint, never the key) and a README.
- The file is written under a temporary name and renamed, so a crash never leaves a half-written backup.
- The ZIP writer and reader are small and in-house (`backups/zip.ts`), using Node's `zlib` deflate and `crc32`, so there's no new dependency.
- **Names:** `latestarr-backup--v-.zip`. The folder itself is the list of backups, so it stays right after a restore or a manual copy. Download and delete only accept names matching this pattern, so no other path can be reached.
- **Retention:** keep the newest _N_ (default 7), or calendar rules (the newest of each of the last _D_ days, _W_ weeks, and _M_ months).
- The newest backup is always kept, and so are the 3 newest pre-upgrade backups.
- Pruning only runs after a backup succeeds.
- **Schedule:** on by default, daily at 03:00 in the server's time zone (`TZ`), with the same schedule picker as newsletters.
- **Pre-upgrade backup:** taken at startup, before migrations, whenever the version that last ran the database (now saved as `appVersion`) differs from this one. Databases from before 0.12 are labelled "earlier".
- **Failures** are logged, shown on the Backups page, and sent as a failure alert (new **A backup fails** option, on by default, at most one an hour).
- **Restore** uses the recovery CLI:
- `check-backup ` reports the version, contents, integrity, and whether the key matches.
- `restore ` stages a checked database (`restore-pending.db`), and the next start swaps it in, keeping the old one as `latestarr.db.before-restore-
- More colour and button options for designs, under **Colours and font** and **Buttons** in the design editor. ([#312](https://github.com/jshields-ca/LatestArr/pull/312))
- **More colours:** choose your own colours for type labels (text and background) and for links in the intro and footer note, and under **Buttons**, the button colour and button text. Each follows the accent until you set it, and the × puts it back, so existing designs, Default included, look exactly as before. Custom label and link colours get dark-mode versions too.
- **Button style:** rounded, square or pill shape, regular or small, for every button. Your own buttons and "Where to watch" buttons can each be filled or outlined.
- **Contrast check:** the editor warns when a colour pair would be hard to read (below WCAG AA, 4.5:1). It doesn't stop you saving.
Technical details
- Closes #293.
- **Settings:**
- `colors` gains `labelText`, `labelBackground`, `buttonBackground`, `buttonText` and `link`, as nullable hex values defaulting to `null`, which means they follow the accent.
- New `buttons: { shape, style, size }`, defaulting to `rounded`, `filled` and `regular`.
- **`paletteFor`** works out the label, button and link colours, matching today's output when they're unset. The default-design snapshot is unchanged.
- **`darkPaletteFor` and `darkModeHead`** add dark rules only for custom colours that differ from those already covered. Buttons keep their colours in dark mode, as before.
- **`button()`** takes the button settings:
- shape sets `border-radius` to 0, 8 or 999 px;
- size sets the padding and font size, with outlined buttons losing 1 px of padding to their border;
- an outlined primary button uses the background and button colour.
- `mj-button` keeps all of this working in Outlook.
- **Notes' links** use `colors.link`, falling back to the accent.
- **Web:** `lib/colour.ts` has `mix`, `contrastRatio`, `effectiveColour` and `contrastWarnings`. The editor gains `AdvancedColours`, `ButtonStyle` and `ContrastNotes`.
- Add your logo to the top of a design, under **Logo** in the design editor. ([#315](https://github.com/jshields-ca/LatestArr/pull/315))
- **Upload** a PNG, JPEG or GIF (up to 1 MB). It's included in each email, so it shows even where email apps block images from the web. Or use an **image URL**, either copied into each email (the default) or linked.
- **Link it** to your server's site, Overseerr, or your Plex or Jellyfin app.
- Put it **above the newsletter's name or in place of it**, aligned left, centre or right, at the width you choose. It shrinks to fit on phones.
- Add a **dark mode version** for logos with dark lettering, which would otherwise disappear on dark backgrounds. Switch the preview to Dark to check it.
- Code designs can place it themselves with `{{logo}}`.
Technical details
- Closes #302.
- **Storage:**
- New `design_images` table (migration `0011_design_images`) for uploads, referenced by id from a design's `settings.logo`. Backups include it, since they copy the whole database.
- Uploads no design uses are removed after 24 hours (`pruneUnusedDesignImages`, run on upload and when a design is saved or deleted).
- **Uploads:** `POST /api/templates/images` (editors and admins) and `GET /api/templates/images/:id` (signed in, cached as immutable).
- `cleanImage` checks the contents, not the file name, and accepts PNG, JPEG and GIF only. SVG is refused with its own message.
- It re-encodes the image, which drops metadata, turns photos the right way up, and scales anything over 1100 px wide down.
- **Settings:** `logo: { source, imageId, darkImageId, url, darkUrl, urlMode, link, placement, align, maxWidth, alt }`. URLs are http(s) only and `maxWidth` runs from 40 to 550 px. Without a logo, the default design's output is unchanged (snapshot test).
- **Rendering:**
- `resolveLogo` embeds uploads as `cid:` attachments.
- When sending, it fetches image URLs with the same timeout and size cap as posters and an image content type only. If that fails, the email links to the URL instead and the send carries on.
- Previews never fetch URLs (anyone signed in can preview), so the server never requests an address on a viewer's behalf.
- The logo's attachments are kept with the send, so "Send to the rest" includes it.
- **Dark mode:** the dark image is hidden and kept from Outlook with a conditional comment. It's swapped in by `prefers-color-scheme` and `[data-ogsc]` rules, which are added only when a dark image is set. A dark design always shows its dark image.
- **Web:** `DesignLogoFields` in `components/design-logo-fields.tsx`. An incomplete logo holds back Save and is left out of the preview.
- A design's **Intro** and **Footer note** support simple formatting and alignment. ([#311](https://github.com/jshields-ca/LatestArr/pull/311))
- **Formatting:** **bold**, *italic*, ~~strikethrough~~, `[links](https://example.com)` (in the design's accent colour), and bulleted or numbered lists.
- **Alignment:** each note can be left, centre or right aligned.
- **What stays the same:** existing notes look as they did, and HTML typed into a note still shows as text. One thing to check: a `*` or `_` around words, or a line starting with `-` or `1.`, now formats.
Technical details
- Closes #290.
- New `render/notes.ts`, using [markdown-it](https://github.com/markdown-it/markdown-it) 15 (a new server dependency) from its `zero` preset.
- Enabled: lists, newline (`breaks: true`), emphasis, strikethrough, link, escape, entity. `html: false`.
- `validateLink` allows only `http`, `https` and `mailto`.
- Paragraphs, lists and links get inline styles, since email clients ignore most stylesheets. Lists are `inline-block` so they follow the note's alignment.
- A single paragraph renders unwrapped, so the existing snapshot is unchanged. The last block's bottom gap is dropped.
- `{{introText}}` and `{{footerNote}}` now render this Markdown in code-mode designs too, replacing #289's line-break helper. Links use `noteLinkColor`, the design's accent, which `designContentVariables` passes along.
- New settings `content.introAlign` and `content.footerAlign` (`left`, `center` or `right`; default `left`) become `mj-text align`.
- Editor: a `NoteField` with an alignment picker in design mode and a formatting hint.
- A way back in when you forget your password. Choose a **System email** profile on the SMTP Profiles page, and the sign-in page gets a **Forgot password?** link that emails you a one-time reset link. Without email, there's a recovery command for whoever runs the server: `docker exec -it latestarr node dist/cli.js reset-password --email you@example.com` asks you for a new password. Another admin can still reset your password from the Users page. ([#276](https://github.com/jshields-ca/LatestArr/pull/276))
Technical details
- Closes #264.
- **Reset links:** `POST /auth/password-reset/request` and `/confirm`.
- Tokens are 32 random bytes, stored as SHA-256 hashes in the new `password_reset_tokens` table. They work once, for 30 minutes.
- A new link cancels older ones, and an account gets at most one email every 2 minutes. Requests are rate limited (5 per 15 minutes per IP).
- The answer is the same whether or not the account exists, and the email is sent after replying, so timing doesn't reveal it either. SSO-only and deactivated accounts never get a link.
- Using a link sets the password, signs the account out everywhere, and cancels other links. So does any other password change.
- **Links are built from `WEB_ORIGIN`, never the request's Host header.** When the address in use doesn't match `WEB_ORIGIN` (say, a proxy in front of an install still set to localhost), email resets stay off rather than sending a broken link, and the SMTP Profiles page explains why.
- The token travels in the URL fragment (`/reset-password#token=…`), which browsers never send to a server, so it can't reach access logs. The page removes it from the address bar.
- **System email:** `GET`/`PUT /settings/system-mail` (admin only), stored in `settings`. Deleting the chosen profile turns it off. `/auth/providers` now says whether resets are available.
- **Recovery command** (`dist/cli.js`):
- `reset-password --email` asks for the new password twice at a hidden prompt (or reads one line with `--password-stdin`), signs the account out everywhere, and reactivates it if needed. The password is never printed, passed as an argument, or read from the environment.
- `list-admins` lists the admins.
- When run as root (the default for `docker exec`), it switches to the data folder's owner first, so SQLite never leaves root-owned files the server can't write.
- Its actions are written to a new `audit_events` table, which the Logs page merges in.
- CI's Docker smoke test now runs both commands as root, signs in with a password piped to `--password-stdin`, checks it refuses without a terminal, and checks no root-owned files are left in `/app/data`.
- Docs: a "Locked out?" section in `docs/self-hosting.md`, and `WEB_ORIGIN`'s role in reset links.
- A **groups** column in a recipient import adds each person to those groups, and creates any that don't exist yet. ([#322](https://github.com/jshields-ca/LatestArr/pull/322))
- Put several groups in one cell separated by `;`, or by commas in a file separated by semicolons. Groups match by name, ignoring case and spaces.
- The review lists each group: new or existing, and how many people the import adds to it. Nothing changes until you choose **Import**.
- An import only adds people to groups. It never removes anyone.
- The template file now includes a groups column.
Technical details
- Part 2 of #288.
- **Server:**
- Import rows take `groups: string[]` (up to 20 names of up to 100 characters each).
- The plan gains `groups: [{ name, isNew, adding }]`, where existing group names keep their own spelling.
- Applying, in the same transaction, creates the missing groups and inserts memberships with `onConflictDoNothing`. It returns `createdGroups` and `membershipsAdded`.
- An existing recipient who only joins groups no longer counts as unchanged.
- **Web:**
- The parser reads a Group or Groups column, and in heading mode splits cells on the file's own delimiter.
- The review has a Groups section.
- New groups and memberships show on the page straight away (`GroupSyncContext`).
- Import recipients from an Excel file, and undo the last import. ([#325](https://github.com/jshields-ca/LatestArr/pull/325))
- **Excel:** upload an `.xlsx` file. Its first sheet is read like a CSV, with the same review before anything changes. Older `.xls` files get a message asking you to save as `.xlsx` or CSV.
- **Undo last import** appears next to **Import** for 24 hours, or until your next import. It removes the people and groups the import added, takes people back out of the groups it added them to, and puts back the names it changed. Anything changed since is kept, and Undo tells you what. A person is also kept if they've been sent a newsletter since, so their history stays.
Technical details
- Part 3 of #288, which completes it.
- **Excel:**
- Read in the browser with [read-excel-file](https://www.npmjs.com/package/read-excel-file) 9.3.10 (MIT), loaded only when a spreadsheet is chosen. The server never parses a spreadsheet.
- read-excel-file doesn't run formulas. It reads the values the spreadsheet app saved.
- Limits: 5 MB and 1000 rows.
- The first sheet becomes tab-separated text in the import box (`lib/spreadsheet.ts`), so it goes through the same parser and review as a paste or CSV.
- Works under the app's CSP (checked in a browser).
- **Undo:**
- New `recipient_imports` table (migration `0012_recipient_imports`), holding the latest import's changes: recipients created, names changed, memberships added, groups created.
- `GET /api/recipients/imports/latest` and `POST /api/recipients/imports/:id/undo` are editor-only.
- `undoRecipientImport` runs in one transaction and returns what it undid, plus what it kept and why: edited or deactivated people, people sent a newsletter since, names changed again, and groups that were renamed, described, used by a newsletter or given other members.
- Logged on the server.
- **Send to the rest** finishes a newsletter that only reached some of its recipients, without sending anyone a duplicate. Use it when LatestArr stopped partway, the mail server turned some people away, or a rate limit cut a send off. Open the send's **Details** in the newsletter's **History** and choose **Send to the rest**. It sends the same email, with the same items, subject and images, to only the people who didn't get it, including anyone whose delivery failed. A confirmation shows who it will go to, and their results are added to the same send. It works on the newsletter's latest send, within its lookback window, and is for editors and admins. ([#309](https://github.com/jshields-ca/LatestArr/pull/309))
Technical details
- Closes #283.
- **Migration `0010_send_to_the_rest`:**
- `send_runs.subject`, set with `rendered_html`;
- `send_runs.rest_sent_at`;
- a new `send_run_attachments` table holding a send's embedded images (cid, filename, type, bytes). A newsletter's earlier sends' images are deleted when its next send renders, so storage stays at one send per newsletter.
- **New `pipeline/send-to-the-rest.ts`:**
- `planSendToTheRest()` checks that the send is `partial_failure` or `failed` and has its stored subject, HTML and every referenced image.
- It also checks that it's the newsletter's latest send (no later send, by insertion order, reached anyone) and that it started within `lookbackDays`.
- It returns the newsletter's active recipients not recorded as `sent`.
- `sendToTheRest()` claims the run by setting it to `running` in one conditional `UPDATE`, which shares Send now's "already running" lock and stops two requests from both sending. It then sends, replaces each recipient's earlier result, and sets the final status, `finishedAt` and `restSentAt`, in a `finally` block.
- If LatestArr stops partway, the startup clean-up from #280 closes the send as usual.
- **API:**
- `GET /newsletters/:id/send-runs/:runId/rest` (editor) returns the plan, or why the send can't be finished.
- `POST /newsletters/:id/send-runs/:runId/send-to-rest` (editor) answers 409 when the send can't be finished or another send is running.
- The History list adds `canSendToRest` and `restSentAt`, and breaks ties on start time by insertion order.
- **Web:** a `SendToRestDialog` in the send's details, and "Sent to the rest" with its time in History.
- **Docs:** `self-hosting.md` covers finishing a partly sent newsletter. The interrupted-send message and alert now point to **Send to the rest**.
- A quiet **Sponsor** link in the web app's footer, and a line under **Star on GitHub** in the star popover, both linking to GitHub Sponsors. They're plain links: no banners, pop-ups or reminders, and nothing in the newsletters people receive. ([#326](https://github.com/jshields-ca/LatestArr/pull/326))
Technical details
- Closes #266.
- The footer gains `Sponsor` (heart icon) after Discussions, in the same tertiary style as the other links. The star popover gains a muted secondary link under its button.
- Both point to https://github.com/sponsors/jshields-ca and open in a new tab. App-shell tests check both.
- Link SSO to an existing account from your profile. ([#320](https://github.com/jshields-ca/LatestArr/pull/320))
- **Sign-in from another address** now works: **Continue with SSO** first moves you to the address in `OIDC_REDIRECT_URI`, where your identity provider sends you back.
- **Link SSO** in your profile connects your SSO account to the account you're signed in to. It works even when your provider doesn't mark your email address as verified, which stopped automatic matching before. **Unlink SSO** is there too, for accounts that also have a password.
- **Plain messages:** a failed SSO sign-in now goes back to the sign-in page with a reason, not a page of raw JSON, and the logs say exactly why.
- Subject variables, so each issue's subject can change with each send, such as "The latest from {period}" or "{count} new: {counts}". ([#327](https://github.com/jshields-ca/LatestArr/pull/327))
- **Variables:** `{newsletter}`, `{date}`, `{shortDate}`, `{period}`, `{month}`, `{year}`, `{count}` and `{counts}`. They're filled in on each send in the newsletter's timezone, so the subject matches the dates in the email.
- **In the form:** **Insert a variable** adds one at the cursor, and a live line shows the next subject (with example counts). A misspelled variable like `{dat}` is flagged as you type, and saving refuses it, so a typo never reaches an inbox.
- **Preview and history:** **Preview** shows the real next subject, **Send test** uses it (with `[Test]`), and History records the subject as sent.
- **Nothing new:** a subject built around `{count}` or `{counts}` becomes ": nothing new" instead of "0 new".
- **Unchanged:** a blank subject still uses the newsletter's name, and existing subjects work exactly as before.
Technical details
- Closes #267.
- **`render/subject.ts`:**
- `renderSubject` uses single-brace placeholders, not Handlebars, so there's no HTML escaping or template errors in a plain-text header. Only known variables are replaced, and other braces stay as typed.
- The result has control characters and line breaks turned into spaces (no header injection), is trimmed, and is capped at 200 characters.
- `unknownSubjectVariables` backs the save-time check, which uses a plain-language message.
- **`render/dates.ts`:** timezone-aware date formatting, now shared by the subject and the email's send date and date range (`generatedAtFormatted`, `periodFormatted`). The email's dates now use the newsletter's timezone instead of the server's. That only differs around midnight, and an invalid timezone falls back to the server's.
- **Web:** `components/subject-field.tsx` and `lib/subject.ts`, which mirrors the server's variables and formatting for the live preview.
- Users can now be admins, editors, or viewers. Viewers see newsletters, designs, previews, and send history without being able to change anything. Editors can also create and send newsletters, edit designs, and manage recipients. Admins can do everything, including sources, SMTP, notifications, users, and logs. Choose a role when you add someone, or change it from the Users page. Existing users stay admins. ([#274](https://github.com/jshields-ca/LatestArr/pull/274))
Technical details
- Closes #257. Roles are enforced on the server: `requireAuth(db, { read, write })` gives each route group the role it needs for `GET` and for changes, and a route can ask for a different one with `config: { minRole }` (previews are open to viewers; `GET /sources/:id/libraries` and `/users`, recipient lists, and a send's recipient results need an editor).
- A refused request answers `403` with `code: "role_required"` and the role needed, and is logged.
- `security.test.ts` lists every signed-in route with the role it needs, fails when a new route isn't listed, and checks each route against all three roles.
- `POST /users` takes a `role` (default `viewer`) and `PATCH /users/:id` can change it. Admins can't change their own role, and the last active admin can't be demoted, deactivated, or deleted. Role changes apply on the next request.
- The web app hides the pages and controls a role can't use: the sidebar is filtered, admin-only pages show a "No access" card, newsletters and designs are read-only for viewers (a disabled `fieldset`, and a read-only code editor), and pages only fetch what the role can read.
- The SMTP Profiles page is admin-only; editors still get the profile list for choosing one on a newsletter.
### Improved
- A clearer Backups page. ([#336](https://github.com/jshields-ca/LatestArr/pull/336))
- **Status at a glance:** one card shows the last backup, the next one, where they're saved, and how many there are with their total size. A note in that card warns when they're on the same disk as the database, with a link to how to fix it.
- **Always in agreement:** the last backup comes from the backups actually in the folder. After changing `BACKUP_PATH`, it no longer names a backup the list doesn't show.
- **Backups first:** Saved backups comes next, newest first, with Download and Delete in each row.
- **Settings summed up:** Schedule and retention says what's set in its header, like "Daily at 3:00 AM (UTC), keeps the latest 7.". Its form no longer stretches across the page.
- **Restoring in three steps:** check, restore, and restart, each with a copy button, already filled in with your newest backup's name. The `ENCRYPTION_KEY` reminder sits with them.
Technical details
- From release testing in #281.
- **`pages/backups-page.tsx`:** the status card is a definition list, and "Last backup" uses the newest file unless a later run failed. Backups list inside a card. The settings form is `max-w-xl`, with retention in its own group. `RestoreCard` has a `Command` component for copying with `navigator.clipboard`.
- Tests cover the summary, the folder-based status, and copying a command.
- BookOrbit's new logo now shows on the Sources page. ([#308](https://github.com/jshields-ca/LatestArr/pull/308))
Technical details
- Closes #296.
- `assets/logos/bookorbit.svg` is replaced by `bookorbit.webp` (243×243, 16 KB), BookOrbit's new logo from selfh.st/icons. Upstream removed the SVG on 2026-10-05 and now publishes this logo only as PNG and WebP.
- `NOTICE.md` has been updated: the file, its source path, and the project link (`bookorbit.app`).
- The name stays "BookOrbit", one word, as the project writes it. The sample-preview button added in this release says "Read on BookOrbit".
- Saving works the same way everywhere, so it's always clear whether you need to press Save. ([#319](https://github.com/jshields-ca/LatestArr/pull/319))
- **Save buttons** for newsletter details, alerts, backup settings, your profile, and editing a source, SMTP profile, group or recipient:
- the button is enabled only when something has changed, with **Unsaved changes** beside it;
- **Ctrl+S** (or **Cmd+S**) saves;
- you're asked before losing changes: on reload, closing the tab, following a sidebar link, closing the dialog, or collapsing the newsletter.
- **Editing a recipient:** name, email and groups all save together with **Save changes**, and **Cancel** asks before discarding. A new group typed there is created when you save, and a name that matches an existing group just adds them to it.
- **Lookback:** an invalid lookback, such as 0 days, now says "Enter a whole number of days, 1 or more." It used to show a raw validation message.
Technical details
- Closes #295. The audit table is on the issue, and the rule is in CONTRIBUTING.md under "Saving".
- **Shared pieces:**
- `components/ui/save-actions.tsx` (`SaveActions`);
- `lib/unsaved-changes.ts`: `useUnsavedChanges` (an app-wide registry plus `beforeunload`), `useSavedSnapshot`, `guardClose`, `confirmDiscard`, `saveShortcutRef` and `useSaveShortcut`.
- **`saveShortcutRef`** is a React 19 ref with cleanup rather than `onKeyDown`, which jsx-a11y rejects on a `
- The design editor is easier to work through, and button styles now reach every button. ([#331](https://github.com/jshields-ca/LatestArr/pull/331))
- **Where to watch buttons can be filled:** Watch on Plex and Browse movies buttons get their own **Style**. It defaults to Outlined, as they've always been, so existing designs look the same until you change it.
- **One place for buttons:** a new **Buttons** section has your buttons (with where they go and their style), Where to watch buttons (on or off, where they go and their style), and the look of all buttons: shape, size, Button colour, and Button text.
- **In the order of the email:** Colours and font, Logo, Layout, Text, Buttons, Item details, Sections, and Custom CSS. The date range and counts line moved to Layout, and the intro and footer note share a Text section.
- **Easy to review:** each section's header says what's set, like "Cards", "Intro, Footer note", or "6 of 6 shown", so you can check a design without opening everything.
- **Contrast warnings** for buttons now show with the button options, and check whichever styles are in use.
Technical details
- Closes #330, from release testing in #281.
- **Server:** `buttons.sourceStyle` (`"filled" | "outline"`, default `"outline"`) in the design settings schema. `button()` uses `style` for the design's own buttons and `sourceStyle` for source buttons and the library link. Saved designs without it get the default, so they render as before.
- **Web:** `Section` takes a `summary`. New `Group`, `ColourField`, `AccentColours`, and `ChoiceField` components replace `AdvancedColours` and `ButtonStyle`. Fields that share a visible label ("Where they go", "Style") get distinct accessible names. `contrastWarnings` marks button warnings and checks both filled and outlined pairs when both styles are in use.
- Importing recipients now shows what will change before anything is saved, and explains the file format. ([#321](https://github.com/jshields-ca/LatestArr/pull/321))
- **Review first:** after pasting or uploading, **Review** lists the new recipients, name changes for people already on your list, who's already there unchanged, and skipped rows with the reason. Nothing changes until you choose **Import**.
- **Names update:** a name in the file now updates an existing recipient's name. Before, the row was skipped as "Already exists". A row without a name leaves the existing name alone.
- **File format help** with a downloadable template.
- **Spreadsheet-friendly:** a heading row is read by column (Email, E-mail or Email address, plus Name, Full name or Display name), in any order, with other columns ignored. Quoted names like `"Smith, Jane"` stay whole, and cells copied straight from a spreadsheet work too.
Technical details
- Part 1 of #288: format help and a preview before applying.
- **Server:** `POST /api/recipients/import` takes `dryRun: true` and returns `{ plan: { create, update, unchanged, skipped } }` without writing anything (`planRecipientImport` in `http/routes/recipient-import.ts`).
- Applying works the plan out again inside one transaction (`applyRecipientImport`), and returns `{ created, updated, unchanged, skipped }`.
- Emails match existing recipients ignoring case.
- **Web:**
- `components/import-recipients-dialog.tsx` has three steps: edit, review and done. Closing with a pasted list asks first.
- The parser (`lib/recipient-import.ts`) gains `splitCells`, which is quote-aware and splits on commas, semicolons and tabs. It also maps heading rows to columns and drops a leading byte-order mark.
- Adding people to a group on the Recipients page is quicker. The dropdown is now a search box. Type part of a name or email to narrow the list (capitals and accents don't matter), then click a person or press Enter to add them. The box stays open, so you can add several people in a row. The list is sorted by name, with numbers first, instead of the order people were added, and inactive people are marked. The recipient list and the edit panel's group list use the same sorting. ([#310](https://github.com/jshields-ca/LatestArr/pull/310))
Technical details
- Closes #287.
- New `components/ui/combobox.tsx`, an accessible ARIA combobox built on the existing Radix Popover. Focus stays in the text box, the active option is tracked with `aria-activedescendant`, and Arrow keys, Home, End, Enter and Escape work. Up to 50 matches show, with a "type to narrow" note beyond that.
- New `lib/text.ts`:
- `compareText`: `Intl.Collator` with numeric sorting and base sensitivity;
- `foldForSearch` and `matchesSearch`: NFD folding, ignoring case and accents.
- The recipient table's name and email sort and its search use these too.
- Confirmations in LatestArr's own style, and a clearer import summary. ([#333](https://github.com/jshields-ca/LatestArr/pull/333))
- **Confirmations in the app's own style:** discarding unsaved changes, **Undo last import**, **Edit as code**, and **Unlink SSO** now ask in a LatestArr dialog instead of the browser's plain prompt. Each button says what it does, like **Discard** or **Undo import**.
- **Clearer import summary:** after an import you see the counts (Added, Updated, Group memberships, Groups created, Skipped) and a separate note about **Undo last import**.
- **Tidier More colours:** the design editor's extra colours are one per row, so long names like "Label background" no longer wrap.
Technical details
- From release testing in #281.
- **`components/confirm-dialog.tsx`:** `confirmAction(options)` returns a promise, and one `` is mounted in `App`. Both work like toasts, through a module-level store. With no host mounted, a confirm stays unanswered, which counts as Cancel.
- **`lib/unsaved-changes.ts`:** `confirmDiscard()` is now async, `askToDiscard()` is new, and `guardClose` closes once the user discards. Sidebar links prevent navigation, ask, then navigate.
- **`EditRecipientDialog`:** keeps `{ memberIds, newGroups }` against the saved ids, and on Save it updates the details, creates groups, and adds then removes memberships. After a partial failure it reloads their groups.
- **CONTRIBUTING.md "Saving":** everything in a form waits for its Save, and confirmations use `confirmAction`, never `window.confirm`.
- Viewers get a fully read-only view, and everyone can see their role. ([#335](https://github.com/jshields-ca/LatestArr/pull/335))
- **Read-only means read-only:** on newsletters and designs, every control is disabled for viewers, dropdowns included, and they're never asked about unsaved changes.
- **Your role:** editors and viewers see a small **Editor** or **Viewer** badge beside their name in the header.
Technical details
- From release testing in #281.
- **`components/ui/select.tsx`:** Radix's Select opens on pointerdown, which a disabled `
### Fixed
- Updated Handlebars, the template engine behind newsletter and design templates, to pick up upstream security fixes. ([#314](https://github.com/jshields-ca/LatestArr/pull/314))
Technical details
- `handlebars` 4.7.9 → 4.7.10, and the server's range raised to `^4.7.10` so older versions can't be resolved.
- Fixes three JavaScript-injection advisories (Dependabot alerts #71, #72 and #73): an own-property check bypass, AST type confusion in `compile`, and unsafe inline embedding of precompiled templates.
- Updated two bundled libraries to pick up upstream security fixes. ([#313](https://github.com/jshields-ca/LatestArr/pull/313))
Technical details
- Lockfile-only bump of two transitive dependencies:
- `source-map-js` 1.2.1 → 1.2.2 (event-loop denial of service via indexed source-map offsets; reached through mjml and Tailwind);
- `shell-quote` 1.10.0 → 1.12.0 (command injection in `quote()`; reached only through `@changesets/cli`, a dev tool).
- Clears Dependabot alerts #69 and #70.
- In the design editor, changing **Background** seemed to change the text colour, and changing **Text** the background. The preview was following your browser's dark mode, and a design's dark version uses Text as the background and Background as the text. Previews now have a **Light / Dark** switch, start in Light, and remember your choice. A note under the colour pickers explains how dark mode is worked out. The newsletter preview has the same switch. ([#303](https://github.com/jshields-ca/LatestArr/pull/303))
Technical details
- Fixes #285.
- An `srcdoc` iframe's `prefers-color-scheme` follows the viewer's browser, so a viewer in dark mode saw `darkPaletteFor()`'s output.
- New `withPreviewScheme()` (`lib/email-preview.ts`) rewrites the email's `prefers-color-scheme` queries to always-true or never-true, according to the chosen scheme. This also works for code-mode templates with their own dark-mode CSS.
- New `EmailPreviewFrame` and `PreviewSchemeToggle` (`components/email-preview-frame.tsx`), used by the design editor and the newsletter preview dialog.
- The choice is kept in `localStorage` (`latestarr:email-preview-scheme`); it falls back to Light when storage is blocked.
- The sent email is unchanged.
- When a design is set to **Link to the library** for sections with nothing new, the section now shows "Nothing new this time." and a **Browse audiobooks** button styled like the design's other buttons, in its accent colour and in dark mode. Before, it was a bare link that didn't match the design. If the section already ends with its own "Watch on Plex"-style button, the browse button is left out so the same link doesn't appear twice. ([#307](https://github.com/jshields-ca/LatestArr/pull/307))
Technical details
- Fixes #292.
- New Handlebars helper `{{#libraryLinkFor contentType="…" label="…"}}`, which renders its block with `{label, url}` from the linked sources' library address. The else block runs when there's no library address.
- Built-in designs no longer use `mediaList`'s `emptyFallback="link"`. They render `emptyMessage` plus the secondary `button()` inside `ifAnyItems`'s else block, wrapped in `sourceButtonsFor`'s else block when source buttons are placed per section.
- `mediaList`'s `emptyFallback="link"` is unchanged for code-mode designs. It now shares the `libraryLink()` lookup.
- If LatestArr stopped partway through sending a newsletter (a crash, restart, or upgrade), that newsletter could never send again: every later send said one was "already running". Now, when LatestArr starts, it closes the interrupted send, marks it partly sent or failed, shows who got it in History, and alerts you. Nobody who already got it is sent a duplicate. ([#282](https://github.com/jshields-ca/LatestArr/pull/282))
Technical details
- Fixes #280.
- New `closeInterruptedSends()` (`pipeline/interrupted-sends.ts`), run by `startScheduler` before refreshing jobs and before catch-up.
- Each run still `running` becomes `partial_failure`, or `failed` if no recipient was recorded as sent.
- It sets `finishedAt` and an error that says how many of how many recipients were reached, then logs a warning and sends a failure alert (trigger `interrupted`).
- Nothing is resent automatically. The interrupted run keeps its `startedAt`, so missed-send catch-up treats that period as sent.
- A send now records its item and recipient counts before the send loop rather than at the end, so an interrupted send can report "X of Y".
- Known limit: a recipient whose message the SMTP server accepted in the instant before the stop, but which wasn't yet recorded, shows as not sent.
- Line breaks in a design's **Intro** and **Footer note** now show in the preview and in sent emails. Before, every line ran together into one paragraph. A blank line leaves a gap between paragraphs. ([#305](https://github.com/jshields-ca/LatestArr/pull/305))
Technical details
- Fixes #289.
- New `textWithLineBreaks()` in `render/mjml-template.ts` escapes each line and joins them with ` `. One or more blank lines become a single `
` (`RecipientTableRow`), each row: Name, Email, an inline Active/Inactive `Switch`, and Edit/Delete actions.
- Added click-to-sort column headers (`SortableColumnHeader`) for Name/Email/Status, toggling ascending/descending on repeat clicks, with proper `aria-sort` on each `
` and a visible up/down/unsorted icon.
- Added a client-side search box (`matchesRecipientQuery`) filtering by email or display name, case-insensitive substring match — no API changes, `listRecipients()` still fetches the full list once.
- Replaced incremental "Show more" with real Previous/Next pagination (`RECIPIENTS_PAGE_SIZE = 25`), resetting to page 1 whenever the search query changes.
- Header shows a live count ("N recipients" / "N of M match" while searching).
- The newsletter row is easier to act on: "Send now" and the "Enabled" toggle now sit in the row header next to Delete, so neither needs the row expanded, and Send now no longer sits crowded next to "Save changes". ([#171](https://github.com/jshields-ca/LatestArr/pull/171))
Technical details
Closes out the remaining part of #167 (the button-crowding and Send-now-visibility parts were addressed separately). The toggle was a small control that didn't need a whole `SettingRow` to itself; moved it into `NewsletterCard`'s header `actions` (`apps/web/src/pages/newsletters-page.tsx`) as a compact `
- The Dashboard's "Recent sends" card now shows the same expandable send detail as the Newsletters page's History tab — included items, per-recipient delivery status, and a link to view a copy of the sent HTML — instead of just a status line. ([#157](https://github.com/jshields-ca/LatestArr/pull/157))
Technical details
Addresses production feedback that the enhanced send-run history from the Newsletters page (added in #137/#148) wasn't reflected on the Dashboard.
Extracted the newsletters-page's `SendRunHistoryList`/`SendRunDetails` pair into a shared `apps/web/src/components/send-run-history.tsx`, dropping the separate `newsletterId` prop in favor of reading it off `run.newsletterId` (already present on every `SendRun`) so the same list can render runs spanning multiple newsletters. Added an optional `newsletterName` field the Dashboard's cross-newsletter feed populates and the per-newsletter History tab leaves unset. `apps/web/src/pages/newsletters-page.tsx` and `dashboard-page.tsx` both now import from the shared module instead of each keeping their own copy — `dashboard-page.tsx`'s old flat `RecentRunRow` is gone.
- The Edit SMTP profile dialog is now split into Connection / Authentication / Sender identity tabs instead of one long scrolling form. ([#143](https://github.com/jshields-ca/LatestArr/pull/143))
Technical details
Same pattern as the newsletter edit page's Details/History split (`newsletters-page.tsx`) — the Port & encryption group (port field, the always-visible port-convention reference, and the implicit-TLS switch) took up a lot of vertical space on its own, which is what made the dialog feel cramped as a single stacked view. `EditSmtpProfileDialog` in `apps/web/src/pages/smtp-profiles-page.tsx` now wraps Host/Port/encryption, Username/Password, and From name/From email in `Tabs`, with Name staying outside the tabs since it's the profile's own label. The Add SMTP profile dialog is unchanged — it's shorter and wasn't the one reported as cramped.
### Fixed
- A book's release year from Book Orbit/BookLore could show one year earlier than its actual publication year, for anyone self-hosting in a timezone west of UTC. ([#170](https://github.com/jshields-ca/LatestArr/pull/170))
Technical details
`mapEntry()` in `packages/adapters/booklore-family/src/booklore-adapter.ts` built `releaseDate` from OPDS's `dc:issued` field via `new Date(issued)`. `dc:issued` only ever appears as a bare 4-digit year (e.g. `"2020"`), and `new Date("2020")` parses that as UTC midnight — reading it back with local-time getters (`getFullYear()`, `toLocaleDateString()`, ...) in any negative-UTC-offset timezone rolls it back to December 31 of the previous year.
Also the root cause of a flaky test (`booklore-adapter.test.ts`'s "maps entries and filters by the since cutoff", filed as #164) that only failed on a non-UTC-configured machine.
Fixed by constructing the date in local time (`new Date(Number(issued), 0, 1)`) when `dc:issued` is a bare year, so the year survives the round trip regardless of the host's timezone. A full date string, should one ever appear, still goes through the plain `Date` parser unchanged.
- The page footer now stays pinned to the bottom of the browser window on short pages instead of sitting mid-page above empty background. ([#139](https://github.com/jshields-ca/LatestArr/pull/139))
Technical details
The design-polish-round-2 layout change (#125) dropped `flex-1` from the header/sidebar/main content row to fix a different bug — an explicit `h-[calc(100vh-3.5rem)]` height on `aside` was forcing that row to a full viewport height regardless of the footer, causing scroll dead-space on short pages. Removing `flex-1` fixed that but introduced this one: without it, the content row no longer grows to fill leftover space in the `min-h-screen` flex column, so `AppFooter` renders right after short content instead of at the bottom of the viewport.
Restored `flex-1` on the content row (`apps/web/src/components/app-shell.tsx`) without restoring `aside`'s explicit height — `flex-1` alone (paired with `min-h-screen` on the root) fills exactly the space the header and footer leave, with no explicit-height side effect that would force `100vh` regardless of footer height.
Also fixed the local dev proxy (`apps/web/vite.config.ts`): `changeOrigin: true` rewrote the outgoing `Host` header to match the API server but left `Origin` as the Vite dev origin, which tripped the server's same-origin CSRF check (`requireSameOrigin`) on every mutating request when running `apps/web` and `apps/server` as separate dev servers. The proxy now rewrites `Origin` to match too.
- The Recipients table's Name column no longer reads as cramped against the left border. ([#169](https://github.com/jshields-ca/LatestArr/pull/169))
Technical details
- `apps/web/src/pages/recipients-page.tsx`: the Name `
` was missing the left padding its `
` had (`px-4` vs `pr-4`-only), so header text sat indented while row text sat flush against the border.
- Added `GET /recipients/:id/groups` (`apps/server/src/http/routes/recipients.ts`) — the reverse of the existing `GET /recipient-groups/:id`'s `members`, so a recipient-centric UI doesn't need to fetch every group's member list to figure out which ones a given recipient is already in.
- Added `RecipientGroupsField` to `EditRecipientDialog`, mirroring the Newsletters page's `LinkedGroups` pattern (add-via-dropdown, remove-via-chip) plus an inline "create a new group" shortcut that creates and adds in one action. Reuses the existing `POST/DELETE /recipient-groups/:id/members` endpoints — no new mutation endpoints needed.
- SMTP profile cards now correctly label STARTTLS connections (e.g. port 587) instead of showing a misleading "No TLS", and give the "Authenticated" badge success styling instead of neutral. ([#142](https://github.com/jshields-ca/LatestArr/pull/142))
Technical details
The connection-security badge only checked the `secure` boolean (implicit TLS, i.e. port 465), so any STARTTLS profile — the common case for providers like Dreamhost on port 587 — showed "No TLS" even though the connection is encrypted, just via an upgrade after the initial handshake rather than from the start. `connectionSecurityLabel()` in `apps/web/src/pages/smtp-profiles-page.tsx` now also checks `port`, matching the existing `PORT_GUIDE` convention (465 → Implicit TLS, 587 → STARTTLS, anything else → No TLS), and gives both TLS variants `success` badge styling. The "Authenticated" badge also switches from `neutral` to `success` when `hasAuth` is true.
## [0.9.0] - 2026-09-21
### New
- Items in a sent newsletter are clickable. A movie, show, book, audiobook or game links straight to that item on its source: Plex, Tautulli, Audiobookshelf, RomM, or a BookLore-family app. ([#127](https://github.com/jshields-ca/LatestArr/pull/127))
- Sources have an optional **Public URL**, the address recipients' links use, for when a source's own address isn't one they can open. For example, Tautulli's address is its API, not the Plex page people want, and a Tailscale or LAN address doesn't work from outside your network. ([#127](https://github.com/jshields-ca/LatestArr/pull/127))
### Improved
- Editing a newsletter has two tabs. **Details** holds every setting, now including its template, sources and recipient groups, and **History** shows its sends. ([#124](https://github.com/jshields-ca/LatestArr/pull/124))
- The default layout opens with a short line under the title ("Here's what's new in the last 7 days.", using the newsletter's lookback). Its footer links to the project on GitHub and to where to report an issue. ([#123](https://github.com/jshields-ca/LatestArr/pull/123))
- A clearer look. ([#125](https://github.com/jshields-ca/LatestArr/pull/125))
- **Wordmark:** larger and solid instead of a gradient, with "Latest" bold and "Arr" lighter.
- **Motion:** a gentler light sweep on buttons, and a clear pop and glow on the GitHub star.
- **Blue accent:** used in a few more places, such as the header's version and GitHub links.
### Fixed
- Every item in a newsletter has a badge saying what it is: **Movie**, **TV Episode**, **TV Season**, **Game**, **Book** or **Audiobook**. Before, only books, comics, audiobooks and podcasts had one. ([#123](https://github.com/jshields-ca/LatestArr/pull/123))
- A new TV season shows the show's name as its title, with "Season 1" underneath. Before, it showed just "Season 1". ([#123](https://github.com/jshields-ca/LatestArr/pull/123))
- On pages with little content, the footer sits right after it instead of being pushed below the screen. ([#125](https://github.com/jshields-ca/LatestArr/pull/125))
Technical details
- **Links:** a new `publicUrl` column on source connections. Every adapter now sets `externalUrl` where its app has a page for an item:
- Plex and Tautulli use Plex Web deep links, built from the server's machine identifier.
- Tautulli without a Public URL makes no links, since its address is its API.
- BookLore-family entries use their OPDS permalink, rebased onto the Public URL.
- Audiobookshelf uses `/item/` and RomM uses `/rom/`.
- **Safety:** link schemes are checked, so a source can't put a `javascript:` link into an email.
- **Rendering:** the default layout wraps an item's title and poster in its link.
## [0.8.0] - 2026-09-20
### New
- An **All New (This Period)** block for the template builder. Drop it in, and it shows everything added recently, grouped by type, instead of six separate blocks to add and set up. ([#118](https://github.com/jshields-ca/LatestArr/pull/118))
### Improved
- A more polished look. ([#119](https://github.com/jshields-ca/LatestArr/pull/119))
- **Buttons:** a subtle light sweep and a softer lift on hover.
- **Logo:** more prominent, with the version, GitHub and star links beside it on phones too.
- **Navigation:** the current page has a clear accent bar in the sidebar.
- **Footer:** a real page footer with the license, the author and a link to report an issue, replacing the hidden "About" icon.
- **Colour:** a calm blue accent for informational badges, such as the version number.
### Fixed
- Saving a template no longer rebuilds the whole editor in the background, which could cause glitches. ([#122](https://github.com/jshields-ca/LatestArr/pull/122))
## [0.7.1] - 2026-09-20
A maintenance release with no changes to how LatestArr works. It updates core libraries to their next major versions: React, the scheduler, the database driver and the image library, plus the build and test tools.
Behind the scenes
- **Runtime libraries:**
- React and React DOM 18 → 19 ([#115](https://github.com/jshields-ca/LatestArr/pull/115));
- croner, the scheduler, 9 → 10, checked against missed-send catch-up ([#116](https://github.com/jshields-ca/LatestArr/pull/116));
- better-sqlite3, the database driver, 11 → 12.11.1. 13 would have dropped Node 20. ([#117](https://github.com/jshields-ca/LatestArr/pull/117));
- sharp 0.34 → 0.35 ([#111](https://github.com/jshields-ca/LatestArr/pull/111)).
- **Build and test tools:**
- Vite 8, Vitest 5 and the React plugin 6 ([#114](https://github.com/jshields-ca/LatestArr/pull/114));
- ESLint 10 and jsdom ([#112](https://github.com/jshields-ca/LatestArr/pull/112)).
## [0.7.0] - 2026-09-19
### New
- Newsletters show posters and covers from every source. Before, only RomM's games had them, which was the most common feedback on 0.6.0. ([#107](https://github.com/jshields-ca/LatestArr/pull/107))
Images are fetched by LatestArr and attached to the email. Recipients' mail apps never contact your servers, and your sources' addresses and credentials never appear in an email.
- Saving and errors are confirmed the same way on every page, with a short pop-up message. ([#108](https://github.com/jshields-ca/LatestArr/pull/108))
### Improved
- A design refresh, from 0.6.0 feedback. ([#108](https://github.com/jshields-ca/LatestArr/pull/108))
- **Type:** Plus Jakarta Sans for text, and the Newsreader serif for each page's title.
- **Hierarchy:** section labels are small capitals, so they stand apart from bold text.
- **Colour:** a restrained violet accent.
- **Depth:** a frosted-glass look on dialogs, menus and pop-ups.
- The header holds your account and the project links. ([#106](https://github.com/jshields-ca/LatestArr/pull/106))
- **Account:** your name, email, **Edit profile** and **Sign out** moved from the cramped sidebar into the header, so your email no longer gets cut off.
- **Star:** opens a short note on why starring helps, instead of jumping straight to GitHub.
### Fixed
- TV episodes lead with the series name, with "S01E01 - Episode name" underneath, for Plex and Tautulli. Before, the series name was buried below the episode's own title. ([#107](https://github.com/jshields-ca/LatestArr/pull/107))
Technical details
- **Posters:**
- Plex reads `thumb`;
- Tautulli reads `thumb` and `art` through its `pms_image_proxy`;
- Audiobookshelf reads `media.coverPath`;
- the BookLore-family adapter parses OPDS image links.
- **Embedding:** at send time, `pipeline/embed-images.ts` fetches each image with the source's stored credentials (`SourceAdapter.fetchImageBytes`), resizes it with `sharp`, and attaches it as a CID image. Only images that make it into the rendered email are fetched.
- **Toasts:** built on Radix Toast.
## [0.6.0] - 2026-09-16
### New
- More building blocks in the template builder. ([#89](https://github.com/jshields-ca/LatestArr/pull/89))
- **Presets:** ready-made sections for Movies, TV episodes, TV seasons, Books, Audiobooks and Games.
- **Spacing:** a **Divider** and a **Spacer**.
- **Organisation:** blocks are grouped under **Layout** and **Content**.
- Edit a recipient group's name, without deleting it and losing its members. ([#93](https://github.com/jshields-ca/LatestArr/pull/93))
- A **Page not found** screen, with a link back to the dashboard, instead of an empty page. ([#80](https://github.com/jshields-ca/LatestArr/pull/80))
### Improved
- **Newsletters:** ([#83](https://github.com/jshields-ca/LatestArr/pull/83))
- **Readable schedules:** "Weekly on Monday at 8:00 AM" instead of a cron expression.
- **Better defaults:** a new newsletter starts in your browser's timezone, with your SMTP profile picked when you only have one.
- **Grouped fields:** the dialog groups its fields into **Schedule** and **Delivery**.
- **Send history** is easy to scan: one row per send, with a status icon, counts and any error, and failures highlighted in red. It also refreshes straight after **Send now**. ([#83](https://github.com/jshields-ca/LatestArr/pull/83), [#87](https://github.com/jshields-ca/LatestArr/pull/87))
- **Dashboard:** ([#88](https://github.com/jshields-ca/LatestArr/pull/88))
- **Recent sends:** each shows its newsletter, counts and result.
- **Checklist:** collapses to "Setup complete" once you're set up.
- **Stats:** clearer tiles.
- **Contrast:** status badges meet WCAG AA in both themes.
- **Sources:** each type shows its own logo, and BookLore-family sources explain their OPDS sign-in. ([#91](https://github.com/jshields-ca/LatestArr/pull/91))
- **SMTP:** simpler port and encryption help: 587 is STARTTLS, 465 is implicit TLS, and 25 or 2525 is usually unencrypted. ([#86](https://github.com/jshields-ca/LatestArr/pull/86))
- **Look and feel:**
- dropdowns match the app's theme instead of showing the browser's own menu ([#85](https://github.com/jshields-ca/LatestArr/pull/85));
- lists look the same on every page ([#93](https://github.com/jshields-ca/LatestArr/pull/93));
- the sidebar's project links are a compact card ([#90](https://github.com/jshields-ca/LatestArr/pull/90)).
### Fixed
- **Default layout:** newsletters without a template rendered through an old, imageless layout. They now use the same layout as templates, with posters and details. ([#81](https://github.com/jshields-ca/LatestArr/pull/81))
- **Media List block:** it looked right in the builder, but its section came out empty in sent emails. ([#89](https://github.com/jshields-ca/LatestArr/pull/89))
- **Unfinished templates:** a template saved before it had any design failed the whole send. It now renders as well as it can. ([#92](https://github.com/jshields-ca/LatestArr/pull/92))
- **Send now:** a failure shows a specific error instead of "Internal Server Error", and a send that reached nobody is shown differently from a real send. ([#83](https://github.com/jshields-ca/LatestArr/pull/83))
- **Template builder:** five fixes. ([#84](https://github.com/jshields-ca/LatestArr/pull/84))
- panel buttons showed as blank squares;
- the editor used the wrong accent colour;
- clicking a Media List selected the wrong part of it;
- **Move up** and **Move down** didn't say when they couldn't move;
- leaving the page lost unsaved changes without asking.
- **First-run setup** flags a missing name or email, not just a missing password. ([#80](https://github.com/jshields-ca/LatestArr/pull/80))
Technical details
- **Media List:** its exported `
` sat directly under ``, which MJML silently drops. It's now wrapped in ``, with a regression test in `renderMjmlTemplate`.
- **Fragments:** a bare MJML fragment is wrapped in `` before compiling.
- **Lists:** pages share a `ListRow` component and a `ConfirmDeleteButton`.
## [0.5.0] - 2026-09-15
### New
- Pick a schedule without cron: **Daily**, **Weekly** or **Monthly**, with a time and a timezone. A raw cron expression is still there under **Advanced**. ([#75](https://github.com/jshields-ca/LatestArr/pull/75))
- Edit a newsletter after creating it: its schedule, lookback window, subject and SMTP profile. Before, changing any of them meant recreating the newsletter. ([#75](https://github.com/jshields-ca/LatestArr/pull/75))
### Improved
- The template builder's Media List block shows posters and details. ([#76](https://github.com/jshields-ca/LatestArr/pull/76))
- **Details:** runtime, page count, audiobook length, platform and rating.
- **Content types:** all six (movies, TV episodes, TV seasons, books, audiobooks and games).
- **Reordering:** every block gets **Move up** and **Move down** buttons, so you don't have to drag.
- Each source type has its own icon on the Sources page, with its proper name ("BookOrbit", not "bookorbit"). ([#77](https://github.com/jshields-ca/LatestArr/pull/77))
## [0.4.6] - 2026-09-15
### New
- Change your own name and password from **Edit profile**, next to your name in the sidebar. Before, neither could be changed without editing the database. Changing your password asks for the current one, and SSO-only accounts are told they have no password to change. ([#73](https://github.com/jshields-ca/LatestArr/pull/73))
## [0.4.5] - 2026-09-15
### Improved
- Tidier project links in the sidebar: a real GitHub logo, a filled star for starring the repo, and an attribution line underneath. The attribution also corrects an earlier draft that called the license AGPL; LatestArr is GPLv3. ([#71](https://github.com/jshields-ca/LatestArr/pull/71))
## [0.4.4] - 2026-09-15
### New
- Edit recipients, SMTP profiles and sources, instead of deleting and recreating them to fix a typo or change a password. ([#69](https://github.com/jshields-ca/LatestArr/pull/69))
- **Recipients:** their email address can now be changed too, not just their name.
- **Credentials:** saved passwords, tokens and API keys are never shown. Leave them blank to keep them, or enter new ones to replace them.
Technical details
Adds `PATCH /api/sources/:id`. A source's credentials are replaced only as a complete set for its kind.
## [0.4.3] - 2026-09-15
### Fixed
- Sending through port 587 works with providers like DreamHost, instead of failing with an OpenSSL "wrong version number" error. ([#67](https://github.com/jshields-ca/LatestArr/pull/67))
The TLS switch, now labelled **Use implicit TLS (port 465)**, follows the port you enter unless you change it. Port 587 upgrades to TLS after connecting (STARTTLS), and a failed upgrade is now an error instead of quietly sending unencrypted.
Technical details
The switch defaulted on whatever the port, which made the mailer attempt implicit TLS on a STARTTLS port. The mailer now sets `requireTLS` whenever implicit TLS is off.
- The Docker container is always named `latestarr`, whatever folder `docker-compose.yml` is in. Before, a checkout in `test-latestarr/` made a container called `test-latestarr-latestarr-1`. ([#67](https://github.com/jshields-ca/LatestArr/pull/67))
## [0.4.2] - 2026-09-15
### New
- **`TRUST_PROXY=true`** for running behind a reverse proxy that handles HTTPS, so the session cookie is marked secure. ([#65](https://github.com/jshields-ca/LatestArr/pull/65))
### Fixed
- Signing in works when LatestArr is opened over plain HTTP, with no reverse proxy, as in the quick start. Before, sign-in looked like it worked, then immediately showed "Not authenticated". ([#65](https://github.com/jshields-ca/LatestArr/pull/65))
Technical details
The cookie's `Secure` flag followed `NODE_ENV=production`, which the Docker image always sets, so browsers refused the cookie over plain HTTP. It now follows the request's actual protocol, which is only `https` when a trusted proxy says so with `X-Forwarded-Proto`.
## [0.4.1] - 2026-09-15
### Fixed
- The web app loads when opened over plain HTTP, with no reverse proxy. Before, it showed a blank page. ([#63](https://github.com/jshields-ca/LatestArr/pull/63))
Technical details
The default Content Security Policy from `@fastify/helmet` includes `upgrade-insecure-requests`, so browsers rewrote every script and stylesheet request to `https://`, which LatestArr doesn't serve. The directive is now removed.
## [0.4.0] - 2026-09-15
### New
- A published container image, `ghcr.io/jshields-ca/latestarr`, for every release (`:latest` and `:vX.Y.Z`), so you can `docker compose pull` instead of building from source. ([#62](https://github.com/jshields-ca/LatestArr/pull/62))
- Missed sends catch up. If LatestArr was down when a newsletter was due, it sends once when it starts again. Before, that issue was silently skipped. ([#58](https://github.com/jshields-ca/LatestArr/pull/58))
- The running version shows in the sidebar, with links to the project on GitHub. **`PORT`** sets the host port, for when 3000 is taken. ([#61](https://github.com/jshields-ca/LatestArr/pull/61))
### Improved
- Security for internet-facing installs: security headers and a Content Security Policy, a sign-in limit of 10 attempts a minute, and protection against cross-site requests. ([#55](https://github.com/jshields-ca/LatestArr/pull/55))
- Every request is checked before it's used, so malformed input is refused with a clear error instead of reaching the database. ([#57](https://github.com/jshields-ca/LatestArr/pull/57))
- The template builder works by keyboard. Its panels and blocks can be reached with Tab and used with Enter or Space, and blocks can be added without dragging. ([#59](https://github.com/jshields-ca/LatestArr/pull/59))
Technical details
- **Security:** `@fastify/helmet` with a CSP tuned for the builder and Google Fonts; `@fastify/rate-limit` with a generous global default and 10 a minute on sign-in and first-run setup; a same-origin check on every request that changes something.
- **Validation:** zod schemas for every request body, returning 400 for wrong types.
- **Catch-up:** on start, each enabled newsletter's last send (or creation time) is compared with its schedule.
- **Version:** `GET /api/version`, which needs no sign-in.
## [0.3.0] - 2026-09-14
### New
- The dashboard is a setup checklist: connect a source, add recipients and a group, set up SMTP, optionally build a template, and create a newsletter. Once you're set up, it shows live stats and recent sends. ([#53](https://github.com/jshields-ca/LatestArr/pull/53))
- A `docker-compose.yml` and `.env.example` for a quick start, instead of a bare Dockerfile. ([#53](https://github.com/jshields-ca/LatestArr/pull/53))
## [0.2.0] - 2026-09-14
### New
- **A web app for LatestArr.** Before, everything was done through its API. ([#18](https://github.com/jshields-ca/LatestArr/pull/18), [#19](https://github.com/jshields-ca/LatestArr/pull/19), [#24](https://github.com/jshields-ca/LatestArr/pull/24))
- **Sign-in:** a first-run setup screen to create the admin account, and a sign-in page with **Continue with SSO** when OIDC is set up.
- **Layout:** a sidebar on desktop and a menu on phones, in dark or light mode.
- **One container:** the Docker image serves the web app and the API together.
- **Pages for everything,** each wired to the existing API:
- **Sources:** connect, test and delete sources, with the right credential fields for each type. ([#20](https://github.com/jshields-ca/LatestArr/pull/20), [#36](https://github.com/jshields-ca/LatestArr/pull/36))
- **Recipients:** add people, switch them on or off, and manage groups. ([#21](https://github.com/jshields-ca/LatestArr/pull/21))
- **SMTP Profiles:** add a profile, test the connection, and send a test email. ([#22](https://github.com/jshields-ca/LatestArr/pull/22))
- **Newsletters:** create, enable, link sources and groups, **Send now**, and view send history. ([#23](https://github.com/jshields-ca/LatestArr/pull/23))
- **Four more sources:**
- **Plex**, directly, without Tautulli ([#32](https://github.com/jshields-ca/LatestArr/pull/32));
- **BookLore, BookOrbit and Grimmory**, through their OPDS catalogs ([#33](https://github.com/jshields-ca/LatestArr/pull/33));
- **Audiobookshelf** ([#34](https://github.com/jshields-ca/LatestArr/pull/34));
- **RomM** ([#35](https://github.com/jshields-ca/LatestArr/pull/35)).
- **Templates and a drag-and-drop builder** for designing newsletters. ([#25](https://github.com/jshields-ca/LatestArr/pull/25), [#27](https://github.com/jshields-ca/LatestArr/pull/27), [#28](https://github.com/jshields-ca/LatestArr/pull/28), [#29](https://github.com/jshields-ca/LatestArr/pull/29), [#30](https://github.com/jshields-ca/LatestArr/pull/30))
- **The Media List block** fills itself from your sources: choose a content type, a sort and a count.
- **Most watched:** a sort option, from Tautulli's play counts. ([#26](https://github.com/jshields-ca/LatestArr/pull/26))
- **Email-ready:** templates are built into email-safe HTML that works in Outlook and Gmail.
Each newsletter picks a template, or uses the built-in one. (The builder was later replaced by the design editor.)
### Improved
- **A new look, "Bloom":** ([#37](https://github.com/jshields-ca/LatestArr/pull/37))
- **Colour:** a rose accent, chosen to stand apart from the blues and purples common in self-hosted media apps;
- **Light mode:** warmer neutrals;
- **Logo:** an envelope with a spark;
- **Wordmark:** a serif.
- **Accessibility checks** on every page of the web app, which found and fixed a heading-order problem across the app. ([#31](https://github.com/jshields-ca/LatestArr/pull/31))
Technical details
- **Web app:** Tailwind v4 with Radix components. Every API route moved under `/api`, so page and API paths can't collide.
- **Sources:** `GET /sources/kinds` lists the registered source types.
- **Adapters:**
- the BookLore-family adapter is one factory for all three apps, since they share the same OPDS feed;
- Audiobookshelf has no cross-library "recent" endpoint, so it reads each library;
- RomM filters by platform in one request.
- **Templates:** the Template API stores the builder's design JSON and compiled MJML. Newsletters render through MJML and Handlebars, with the hardcoded starter template as the fallback.
- **Accessibility:** `jest-axe` tests cover every page's list, empty and dialog states. `CardTitle` became an `
`.
Behind the scenes
- **Release automation:** a "Version Packages" PR, and a tag and GitHub Release on merge. ([#38](https://github.com/jshields-ca/LatestArr/pull/38))
- **Versioning fix:** Changesets had been skipping every private package. ([#38](https://github.com/jshields-ca/LatestArr/pull/38))
- **First-run fixes:** two crashes on the release workflow's first run. ([#42](https://github.com/jshields-ca/LatestArr/pull/42), [#43](https://github.com/jshields-ca/LatestArr/pull/43))
## [0.1.0] - 2026-09-13
Initial functional milestone: a complete backend engine with no admin UI
yet. An operator can drive the entire loop through the API — connect a
Tautulli server, add recipients, configure SMTP, and build a newsletter —
and it will actually poll for new content and send a digest email on
schedule.
### Added
- Repo scaffolding: pnpm + Turborepo monorepo, CI (lint/typecheck/build/test,
Docker build, Docker runtime smoke test), Dependabot, OSS contribution
docs (`CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`), issue/PR
templates.
- `packages/db`: full Drizzle/SQLite schema and migrations for the entire
data model (users, sessions, source connections, recipients/groups,
templates, newsletters, send runs, settings).
- `packages/crypto`: AES-256-GCM envelope encryption for credentials at
rest.
- Local username/password auth (argon2id, server-side sessions) and
generic OIDC/SSO auth (`openid-client`, PKCE authorization-code flow).
- `packages/adapters/core`: the `SourceAdapter` contract and registry every
future source integration implements.
- `packages/adapters/tautulli`: the reference adapter implementation,
proven end-to-end via a SourceConnection CRUD API (create, test
connection, list libraries, fetch recently-added items).
- Recipients and RecipientGroups CRUD, with group membership management.
- SmtpProfile CRUD and a Nodemailer-based mailer (connection test,
send-test-email).
- Newsletter CRUD, with source and recipient-group associations.
- A hardcoded Handlebars starter template rendering recently-added items
into email HTML (the drag-and-drop builder replacing this is planned for
the next release).
- The send pipeline: fetch recently-added items from every linked source,
render, resolve recipients, send, and record a `SendRun` /
`SendRunRecipientResult` per attempt — exposed via a manual
`POST /newsletters/:id/send-now` trigger and a `GET .../send-runs`
history endpoint.
- A croner-based scheduler running each newsletter on its own cron
expression and timezone, refreshed live whenever a newsletter is
created, updated, or deleted.
[0.1.0]: https://github.com/jshields-ca/latestarr/releases/tag/v0.1.0