# 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 `
`. It does nothing while the Save button is disabled. - **Sidebar links** ask through `confirmDiscard()`. The app uses `BrowserRouter`, so React Router's `useBlocker` isn't available, and the browser's Back button can't be stopped. - **Server:** `lookbackDays` validation messages are now a plain sentence.
- 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 `
` doesn't stop. The wrapper now controls `open`, and refuses to open while the trigger matches `:disabled`, which covers a disabled fieldset as well as its own `disabled` prop. There's a new `select.test.tsx`. - **`useUnsavedChanges`:** registered only when the user can edit, in the newsletter details form and the design editor. The design editor's Back button doesn't ask a viewer. - **`UserSummary`:** a neutral `Badge` with the role, titled "You have viewer access".
### 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 `

`. - `{{introText}}` and `{{footerNote}}` are now bound as a Handlebars `SafeString`, so code-mode designs get the line breaks too. The text is still escaped, so HTML typed into either box shows as text. - The plain-text version keeps the breaks, since `
` converts to a newline.
- On the Recipients page, group changes made in a recipient's edit panel now show straight away in the Groups section below. That covers adding them to a group, removing them, and creating a new group. Before, an open group didn't show the new member, and a group created in the panel wasn't listed until you refreshed the page. ([#304](https://github.com/jshields-ca/LatestArr/pull/304))
Technical details - Fixes #286. - The page now loads groups once and shares them. A `GroupSyncContext` lets the edit panel report a new group (added to the list) and membership changes (which bump a version that open groups reload their members on). - Members reloads keep the current list showing and ignore stale responses.
- In a design's preview with sample content, the source buttons now match your connected sources, for example "Read on BookOrbit" for a BookOrbit source. Before, the sample always said "Read on BookLore". With no sources connected yet, it shows neutral labels such as "Watch now" and "Read now". ([#306](https://github.com/jshields-ca/LatestArr/pull/306))
Technical details - Fixes #291. - `/templates/preview` loads the source connections (kind, name, public URL) and passes them to `renderDesignSample`. - `sampleSourceButtons()` builds the buttons with `buildSourceButtons`, so labels, merging and naming match a real send. A source without a public URL gets a stand-in URL so its button still shows in the sample.
- Updated two third-party libraries with published security fixes, so LatestArr isn't exposed to their denial-of-service and address-matching bugs. ([#279](https://github.com/jshields-ca/LatestArr/pull/279))
Technical details - `ip-address` 10.7.0 → 10.7.3 (via `@fastify/rate-limit`, so it ships in the server): fixes an unbounded parse diagnostic on long input that could stall the process, and `isInSubnet()` comparing IPv4 and IPv6 addresses as one address space. - `brace-expansion` 1.1.18 → 1.1.21 and 5.0.9 → 5.0.12 (via `minimatch`, build and lint tooling): fixes stack exhaustion on nested or comma-heavy brace patterns and quadratic-time expansion. - Lockfile-only change (`pnpm update --depth Infinity`), within each parent's allowed range. Clears Dependabot alerts #61–#68.
- Send statuses read as words, and the design editor fits a phone. ([#336](https://github.com/jshields-ca/LatestArr/pull/336)) - **Readable statuses:** Recent sends and History said `success`, `failed`, or `partial_failure`. They now say **Sent**, **Failed**, or **Partly sent**, which is amber rather than red, since most of it went out. **Waiting** and **Sending** cover sends that haven't finished. - **Design name on phones:** the design editor squeezed the name box to a couple of letters at phone width. It now has its own row, with **Edit as code** and **Save** below it.
Technical details - **`lib/send-run.ts`:** a `STATUS_LABEL` map, and `partial_failure` uses the `warning` badge variant. - **`design-editor-page.tsx`:** the name field gets `min-w-[12rem]`, so the header wraps instead of shrinking it. - Found while taking screenshots for latestarr.app.
- Signing in with SSO from another address, such as the server's LAN IP, failed with "Missing or expired OIDC sign-in attempt". ([#320](https://github.com/jshields-ca/LatestArr/pull/320))
Technical details - Refs #317. - **`GET /api/auth/oidc/login`** redirects to the `login` URL next to `OIDC_REDIRECT_URI` when the request's host differs. The flow cookie can only be set on the host the provider returns to. The redirect target comes from config, never the request. - **Callback errors** redirect to `/login?sso=` (or `/?sso=` for linking) under `WEB_ORIGIN`. The outcomes are `expired`, `failed`, `unverified-email`, `not-linked`, `linked`, `link-taken` and `link-failed`. `OidcAccountNotLinkedError` carries a `reason` (`unverified-email`, `no-account` or `inactive`), which is logged. - **Linking:** - `POST /api/auth/oidc/link` needs a session and passes the same-origin check. It returns the provider URL, and the flow cookie records `link.`. - The callback links only if the same session is still signed in, and refuses an identity that belongs to another account. - `DELETE /api/auth/me/sso` unlinks, and is refused for an account without a password. - `GET /api/auth/me` adds `ssoLinked` and `hasPassword`. - **Web:** `lib/sso.ts` holds the outcome messages. The login page shows the reason, the app shell shows a toast after linking, and the profile has a Single sign-on section.
- Clearing a newsletter's subject now saves. It used to keep the old one. ([#327](https://github.com/jshields-ca/LatestArr/pull/327)) - A design with a **Most watched** section couldn't preview or send for newsletters using a Tautulli source. They failed with "Send failed: data.find is not a function". Most watched now shows Tautulli's most played movies and shows again. ([#301](https://github.com/jshields-ca/LatestArr/pull/301))
Technical details - Fixes #284. - With a `stat_id`, Tautulli's `get_home_stats` returns that one stat block as an object, or `[]` when there are no stats. `getHomeStats` expected an array of blocks, so `.find` threw. - It now accepts the single block, still searches an array by `stat_id`, and returns `[]` for an empty result. - Any other shape (null, a different stat, `rows` that isn't a list) throws a clear "unexpected response" error instead of a `TypeError`. - Client, adapter and send-route tests now use Tautulli's real response shape, with tests for the array and unexpected cases.
## [0.11.2] - 2026-09-29 ### Improved - LatestArr has a new icon: a postage stamp with an L, lifted off a rose tile. It replaces the envelope and sparkle in the app, the browser tab, and the social previews, and comes in light and dark versions. The app now also has icons for adding it to your phone's or computer's home screen. ([#270](https://github.com/jshields-ca/LatestArr/pull/270))
Technical details - Chosen in #269, after four exploration rounds (kept on the `design/icon-exploration` branch). - `docs/assets/brand/generate.mjs` is the single source for the icon: - the brand SVGs (tile, dark, light, maskable, marks, one colour) and PNGs; - `apps/web/public` (`favicon.svg`, `favicon-32.png`, `apple-touch-icon.png`, `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`); - `apps/web/src/components/logo-geometry.ts`, which the in-app `LogoMark` draws from, using per-instance mask IDs. - `index.html` adds the PNG favicon, the Apple touch icon, and a `manifest.webmanifest`. The browser theme colour moves to `#c31d4c`. - The social images are rendered from `docs/assets/brand/social-preview.html`: `docs/assets/social-preview.png` (1280×640, GitHub and README) and `docs/assets/brand/og-image.png` (1200×630, the website). - Usage notes are in `docs/assets/brand/brand.md`.
## [0.11.1] - 2026-09-29 ### Improved - LatestArr now points to its new home at [latestarr.app](https://www.latestarr.app). The web UI footer links the website, the docs, GitHub Discussions, issue reporting and the license, and credits LatestArr's contributors, as the site does. The Add source dialog links each source type's setup guide, and the Default design's email footer links LatestArr.app instead of GitHub. ([#258](https://github.com/jshields-ca/LatestArr/pull/258))
Technical details - **Web UI footer:** the links (LatestArr.app, Docs, Discussions, Report an issue, GPLv3 license) sit in a labelled `nav`, with "© 2026 LatestArr contributors · Led by Jeremy Shields" beneath, linking jeremyshields.ca. The old scootr.ca author link is gone. (#251) - **Add source dialog:** a "Setup guide for …" link to that type's page under `latestarr.app/docs/sources`. Plex, BookLore, Grimmory and Audiobookshelf now show the testers note too, linking #244, to match the README; Jellyfin and Emby still link #196. - **Code mode:** the variables reference links the code mode guide. - **Email footer:** the Default design links `https://www.latestarr.app` (with a globe icon) in place of the repository, and keeps "Report an issue". (#252) - **Footer icons fixed:** the email footer icons were never visible. Their SVG stroke colour was written `%23978490` inside base64, where it isn't URL-decoded, so it was an invalid colour. They now use a literal `#978490`. - The default design snapshots were re-recorded for the footer change.
### Fixed - LatestArr now checks `ENCRYPTION_KEY` when it starts. If the key is missing or invalid, it stops with a message saying how to generate one. Before, it started anyway, and saving a source or SMTP profile failed with a generic error. ([#259](https://github.com/jshields-ca/LatestArr/pull/259))
Technical details - `encryptionKeyProblem()` (`apps/server/src/secrets.ts`) requires `ENCRYPTION_KEY` to be base64 that decodes to exactly 32 bytes. `index.ts` logs the problem at `fatal` and exits 1 before touching the database. - The wrong-length message warns that credentials already saved need the key they were saved with. (#256) - The CI smoke test now starts the container with a generated key, and `CONTRIBUTING.md` describes the new startup error for dev setups where Turborepo's strict env mode drops the key.
## [0.11.0] - 2026-09-29 ### New - Designs can be edited as code. On any design, **Edit as code** switches it to hand-written MJML. You start from exactly the markup its options produce, so you never start from a blank page. The code editor highlights Handlebars tags, flags mistakes on the line where they happen before you save, and sits beside the same live preview. A **Variables and helpers** panel lists everything you can use. The intro, footer note, and buttons stay in **Text and buttons**, so they work the same in code designs. ([#209](https://github.com/jshields-ca/LatestArr/pull/209))
Technical details Closes #200 (part of #199). - Editor: CodeMirror 6 (`components/code-editor.tsx`, loaded on demand in its own chunk), with HTML highlighting coloured from the app's theme tokens, Handlebars tags marked, and server-reported issues as lint diagnostics. `components/design-code-reference.tsx` documents the render context and the `mediaList`, `ifAnyItems`, and `ifKindLinked` helpers. - Server: `render/code-template.ts` checks code before preview and save. A Handlebars syntax error, or markup MJML can't render at all, is an error (422, with `issues`), shown in plain words with its line. MJML's own validation messages are warnings, since sends still render best-effort HTML. MJML is validated with the `{{ }}` tags blanked out, so line numbers still match. - `POST /templates/preview` accepts `mjml` alongside `settings`. `POST /templates/:id/convert-to-code` switches a design to code. `POST`/`PATCH /templates` take an explicit `mode`, and saving a code design with its `settings` no longer turns it back into an options design. - Designs list: code designs sit with the others (marked "Code") and duplicate with their code. - The built-in design's buttons no longer set `width="auto"`, which MJML flags as invalid. The rendered email looks the same, since the button's table already shrinks to fit.
- Several new episodes of the same show now share one row, like "The Daily Show · 4 new episodes", with each episode listed underneath, so a week of a daily show doesn't fill the email. It's on by default, and you can switch it off in a design's Sections settings. Designs you saved earlier keep their "Group by type" setting; turn it on in the design editor to get the new sections. ([#226](https://github.com/jshields-ca/LatestArr/pull/226)) - Design how your newsletters look without drag-and-drop. The new **Designs** page (it replaces Templates) lets you pick colours, font, and layout (cards, compact list, or grid); choose which details each item shows; group items by type in your own order; decide what an empty section shows; add a "Most watched" section; and add custom CSS. A live preview updates as you change things, with sample content or any of your real newsletters. Existing newsletters look exactly as before: they use the Default design, which you can duplicate as a starting point. ([#206](https://github.com/jshields-ca/LatestArr/pull/206))
Technical details Part of #199 (phases 1 and 2; phase 1's renderer shipped in #205) and closes #197. - Web: `pages/designs-page.tsx` (list, with the built-in Default shown first; create from Default, duplicate, delete) and `pages/design-editor-page.tsx` (settings in collapsible sections, with a sticky preview that re-renders 400 ms after the last change through `POST /templates/preview`, ignoring stale responses; unsaved-changes indicator and leave warning; explicit Save). Nav item and route `/designs`; `/templates` redirects there. - Newsletter Content panel: `DesignPicker` replaces the Template picker (Default and your designs, with Edit linking to the design editor), saving with an inline `SaveStatus` instead of a toast. The intro, footer note, buttons, and font moved into the design in #207. - `lib/design.ts` mirrors the server's design settings shape and defaults (`withDesignDefaults` fills options missing from older saved designs). - Server: the ungrouped compact and grid layouts line up with the title, like grouped sections; ungrouped cards stays flush so the Default design's recorded output is unchanged.
- Jellyfin and Emby sources, for movies, TV episodes, books, and audiobooks. Add one with an API key. Newsletters show each item's poster, details, and a link that opens it in your server's web app, and you can import the server's users as recipients. **These need testers:** they're built from the published API docs but haven't been tried on a real server yet. If you run Jellyfin or Emby, please [tell us how it went](https://github.com/jshields-ca/LatestArr/issues/196). ([#211](https://github.com/jshields-ca/LatestArr/pull/211))
Technical details Part of #196 (the issue stays open until both are confirmed on real servers). - New `@latestarr/adapter-jellyfin` package: one client over the API Jellyfin and Emby share (`/System/Info`, `/Library/MediaFolders`, `/Users`, `/Items`, `/Items/{id}/Images/Primary`), with two thin adapters, `jellyfin` and `emby`. They differ only in how the key is sent (Jellyfin: `Authorization: MediaBrowser Token="…"`; Emby: `X-Emby-Token`) and in the web app's item link format. The key is never put in a URL. - Recent items are sorted by date added (neither server filters on it), requested once per chosen library, and cut at the lookback window. Episodes are titled by series with an `SxxExx - episode` subtitle and use the series poster. Books and audiobooks show their author; audiobooks show their length. - Web: both appear in Add source with an API-key hint and a "needs testers" note linking to #196, and support Import users. README marks both "🧪 Needs testers" with a call for testers.
- Preview a newsletter before it goes out. The new **Preview** button shows exactly what the next send would contain, with real items, images, and your intro and buttons, without emailing anyone. From the same window you can send a test copy to just yourself. ([#202](https://github.com/jshields-ca/LatestArr/pull/202))
Technical details Closes #193. `apps/server/src/pipeline/run-newsletter.ts` now separates loading a newsletter (`loadNewsletter`), its SMTP sender (`loadSender`), and rendering from delivery. Real sends, `previewNewsletter`, and `sendTestNewsletter` all use the same render path, so the preview matches the sent email. - `POST /newsletters/:id/preview` returns `{ subject, html, items }`. Embedded images (normally `cid:` attachments) are swapped for inline `data:` URIs so a browser can show them. It needs no SMTP profile, creates no send-run, and doesn't affect missed-send catch-up. A failure returns 502 with the same readable reason as Send now and is logged as a warning. - `POST /newsletters/:id/send-test` (`{ to }`) sends the render to one address with a `[Test]` subject prefix, with no send-run, and logs `Sent a test of "…" to …` with `trigger: "test"` and the admin's email. - Web: `NewsletterPreviewDialog` renders the HTML in a sandboxed `