Browse Source

fix(storage): prevent deleted entries from reappearing as unread

Archived entries could be re-ingested as new unread rows when a feed
re-emitted them. Replace the "removed" soft-delete status with an
entry_tombstones table keyed on (feed_id, hash); the INSERT is guarded
by WHERE NOT EXISTS so archival and refresh can no longer race.
Frédéric Guillot 1 month ago
parent
commit
2916831cb1

+ 2 - 2
client/client.go

@@ -1079,14 +1079,14 @@ func (c *Client) FetchCountersContext(ctx context.Context) (*FeedCounters, error
 	return &result, nil
 }
 
-// FlushHistory changes all entries with the status "read" to "removed".
+// FlushHistory deletes all entries with the status "read".
 func (c *Client) FlushHistory() error {
 	ctx, cancel := withDefaultTimeout()
 	defer cancel()
 	return c.FlushHistoryContext(ctx)
 }
 
-// FlushHistoryContext changes all entries with the status "read" to "removed".
+// FlushHistoryContext deletes all entries with the status "read".
 func (c *Client) FlushHistoryContext(ctx context.Context) error {
 	_, err := c.request.Put(ctx, "/v1/flush-history", nil)
 	return err

+ 2 - 3
client/model.go

@@ -10,9 +10,8 @@ import (
 
 // Entry statuses.
 const (
-	EntryStatusUnread  = "unread"
-	EntryStatusRead    = "read"
-	EntryStatusRemoved = "removed"
+	EntryStatusUnread = "unread"
+	EntryStatusRead   = "read"
 )
 
 // User represents a user in the system.

+ 0 - 58
internal/api/api_integration_test.go

@@ -2413,64 +2413,6 @@ func TestGetGlobalEntriesEndpoint(t *testing.T) {
 	}
 }
 
-func TestCannotGetRemovedEntries(t *testing.T) {
-	testConfig := newIntegrationTestConfig()
-	if !testConfig.isConfigured() {
-		t.Skip(skipIntegrationTestsMessage)
-	}
-
-	adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
-
-	regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer adminClient.DeleteUser(regularTestUser.ID)
-
-	regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
-
-	feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
-		FeedURL: testConfig.testFeedURL,
-	})
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	feedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if feedEntries.Total == 0 {
-		t.Fatalf(`Expected at least one entry, got none`)
-	}
-
-	if err := regularUserClient.UpdateEntries([]int64{feedEntries.Entries[0].ID}, miniflux.EntryStatusRemoved); err != nil {
-		t.Fatal(err)
-	}
-
-	if _, err := regularUserClient.Entry(feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
-		t.Fatalf(`Expected entry to be not found, got %v`, err)
-	}
-
-	if _, err := regularUserClient.FeedEntry(feedID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
-		t.Fatalf(`Expected entry to be not found, got %v`, err)
-	}
-
-	if _, err := regularUserClient.CategoryEntry(feedEntries.Entries[0].Feed.Category.ID, feedEntries.Entries[0].ID); err != miniflux.ErrNotFound {
-		t.Fatalf(`Expected entry to be not found, got %v`, err)
-	}
-
-	updatedFeedEntries, err := regularUserClient.Entries(&miniflux.Filter{FeedID: feedID})
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if updatedFeedEntries.Total != feedEntries.Total-1 {
-		t.Fatalf(`Expected %d entries, got %d`, feedEntries.Total-1, updatedFeedEntries.Total)
-	}
-}
-
 func TestUpdateEnclosureEndpoint(t *testing.T) {
 	testConfig := newIntegrationTestConfig()
 	if !testConfig.isConfigured() {

+ 4 - 7
internal/api/entry_handlers.go

@@ -58,7 +58,6 @@ func (h *handler) getFeedEntryHandler(w http.ResponseWriter, r *http.Request) {
 	builder := h.store.NewEntryQueryBuilder(request.UserID(r))
 	builder.WithFeedID(feedID)
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	h.getEntryFromBuilder(w, r, builder)
 }
@@ -79,7 +78,6 @@ func (h *handler) getCategoryEntryHandler(w http.ResponseWriter, r *http.Request
 	builder := h.store.NewEntryQueryBuilder(request.UserID(r))
 	builder.WithCategoryID(categoryID)
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	h.getEntryFromBuilder(w, r, builder)
 }
@@ -93,7 +91,6 @@ func (h *handler) getEntryHandler(w http.ResponseWriter, r *http.Request) {
 
 	builder := h.store.NewEntryQueryBuilder(request.UserID(r))
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	h.getEntryFromBuilder(w, r, builder)
 }
@@ -173,7 +170,6 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
 	builder.WithLimit(limit)
 	builder.WithTags(tags)
 	builder.WithEnclosures()
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	if request.HasQueryParam(r, "globally_visible") {
 		globallyVisible := request.QueryBoolParam(r, "globally_visible", true)
@@ -242,7 +238,6 @@ func (h *handler) saveEntryHandler(w http.ResponseWriter, r *http.Request) {
 
 	builder := h.store.NewEntryQueryBuilder(request.UserID(r))
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	if !h.store.HasSaveEntry(request.UserID(r)) {
 		response.JSONBadRequest(w, r, errors.New("no third-party integration enabled"))
@@ -292,7 +287,6 @@ func (h *handler) updateEntryHandler(w http.ResponseWriter, r *http.Request) {
 	loggedUserID := request.UserID(r)
 	entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
 	entryBuilder.WithEntryID(entryID)
-	entryBuilder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := entryBuilder.GetEntry()
 	if err != nil {
@@ -412,6 +406,10 @@ func (h *handler) importFeedEntryHandler(w http.ResponseWriter, r *http.Request)
 	}
 
 	created, err := h.store.InsertEntryForFeed(userID, feedID, entry)
+	if errors.Is(err, storage.ErrEntryTombstoned) {
+		response.JSONBadRequest(w, r, err)
+		return
+	}
 	if err != nil {
 		response.JSONServerError(w, r, err)
 		return
@@ -449,7 +447,6 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
 
 	entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
 	entryBuilder.WithEntryID(entryID)
-	entryBuilder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := entryBuilder.GetEntry()
 	if err != nil {

+ 0 - 14
internal/cli/cleanup_tasks.go

@@ -47,18 +47,4 @@ func runCleanupTasks(store *storage.Storage) {
 			metric.ArchiveEntriesDuration.WithLabelValues(model.EntryStatusUnread).Observe(time.Since(startTime).Seconds())
 		}
 	}
-
-	if enclosuresAffected, err := store.DeleteEnclosuresOfRemovedEntries(); err != nil {
-		slog.Error("Unable to delete enclosures from removed entries", slog.Any("error", err))
-	} else {
-		slog.Info("Deleting enclosures from removed entries completed",
-			slog.Int64("removed_entries_enclosures_deleted", enclosuresAffected))
-	}
-
-	if contentAffected, err := store.ClearRemovedEntriesContent(config.Opts.CleanupArchiveBatchSize()); err != nil {
-		slog.Error("Unable to clear content from removed entries", slog.Any("error", err))
-	} else {
-		slog.Info("Clearing content from removed entries completed",
-			slog.Int64("removed_entries_content_cleared", contentAffected))
-	}
 }

+ 29 - 0
internal/database/migrations.go

@@ -1457,4 +1457,33 @@ var migrations = [...]func(tx *sql.Tx) error{
 		`)
 		return err
 	},
+	func(tx *sql.Tx) (err error) {
+		_, err = tx.Exec(`
+			CREATE TABLE entry_tombstones (
+				feed_id bigint not null references feeds(id) on delete cascade,
+				hash text not null check (hash <> ''),
+				deleted_at timestamp with time zone not null default now(),
+				primary key (feed_id, hash)
+			);
+
+			CREATE INDEX entry_tombstones_deleted_at_idx
+				ON entry_tombstones (deleted_at);
+
+			INSERT INTO entry_tombstones (feed_id, hash, deleted_at)
+				SELECT feed_id, hash, changed_at
+				FROM entries
+				WHERE status = 'removed' AND hash <> ''
+				ON CONFLICT (feed_id, hash) DO NOTHING;
+
+			DELETE FROM entries WHERE status = 'removed';
+
+			-- The "removed" status is no longer used, so drop the partial
+			-- predicate so the planner can use the index for every search.
+			DROP INDEX document_vectors_idx;
+			CREATE INDEX document_vectors_idx
+				ON entries
+				USING gin(document_vectors);
+		`)
+		return err
+	},
 }

+ 0 - 3
internal/fever/handler.go

@@ -239,7 +239,6 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
 
 	builder := h.store.NewEntryQueryBuilder(userID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 	builder.WithLimit(50)
 
 	switch {
@@ -294,7 +293,6 @@ func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
 	}
 
 	builder = h.store.NewEntryQueryBuilder(userID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 	result.Total, err = builder.CountEntries()
 	if err != nil {
 		response.JSONServerError(w, r, err)
@@ -414,7 +412,6 @@ func (h *feverHandler) handleWriteItems(w http.ResponseWriter, r *http.Request)
 
 	builder := h.store.NewEntryQueryBuilder(userID)
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := builder.GetEntry()
 	if err != nil {

+ 0 - 6
internal/googlereader/handler.go

@@ -238,7 +238,6 @@ func (h *greaderHandler) editTagHandler(w http.ResponseWriter, r *http.Request)
 
 	builder := h.store.NewEntryQueryBuilder(userID)
 	builder.WithEntryIDs(itemIDs)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	entries, err := builder.GetEntries()
 	if err != nil {
@@ -652,7 +651,6 @@ func (h *greaderHandler) streamItemContentsHandler(w http.ResponseWriter, r *htt
 
 	builder := h.store.NewEntryQueryBuilder(userID)
 	builder.WithEnclosures()
-	builder.WithoutStatus(model.EntryStatusRemoved)
 	builder.WithEntryIDs(itemIDs)
 	builder.WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection)
 
@@ -1029,7 +1027,6 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
 		}
 	}
 
-	builder.WithoutStatus(model.EntryStatusRemoved)
 	builder.WithLimit(rm.Count)
 	builder.WithOffset(rm.Offset)
 	builder.WithSorting(model.DefaultSortingOrder, rm.SortDirection)
@@ -1050,7 +1047,6 @@ func (h *greaderHandler) handleReadingListStreamHandler(w http.ResponseWriter, r
 
 func (h *greaderHandler) handleStarredStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
 	builder := h.store.NewEntryQueryBuilder(rm.UserID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 	builder.WithStarred(true)
 	builder.WithLimit(rm.Count)
 	builder.WithOffset(rm.Offset)
@@ -1071,7 +1067,6 @@ func (h *greaderHandler) handleStarredStreamHandler(w http.ResponseWriter, r *ht
 
 func (h *greaderHandler) handleReadStreamHandler(w http.ResponseWriter, r *http.Request, rm requestModifiers) {
 	builder := h.store.NewEntryQueryBuilder(rm.UserID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 	builder.WithStatus(model.EntryStatusRead)
 	builder.WithLimit(rm.Count)
 	builder.WithOffset(rm.Offset)
@@ -1121,7 +1116,6 @@ func (h *greaderHandler) handleFeedStreamHandler(w http.ResponseWriter, r *http.
 	}
 
 	builder := h.store.NewEntryQueryBuilder(rm.UserID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 	builder.WithFeedID(feedID)
 	builder.WithLimit(rm.Count)
 	builder.WithOffset(rm.Offset)

+ 0 - 1
internal/model/entry.go

@@ -11,7 +11,6 @@ import (
 const (
 	EntryStatusUnread       = "unread"
 	EntryStatusRead         = "read"
-	EntryStatusRemoved      = "removed"
 	DefaultSortingOrder     = "published_at"
 	DefaultSortingDirection = "asc"
 )

+ 0 - 21
internal/storage/enclosure.go

@@ -245,24 +245,3 @@ func (s *Storage) UpdateEnclosure(enclosure *model.Enclosure) error {
 
 	return nil
 }
-
-// DeleteEnclosuresOfRemovedEntries deletes enclosures associated with entries marked as "removed".
-func (s *Storage) DeleteEnclosuresOfRemovedEntries() (int64, error) {
-	query := `
-		DELETE FROM
-			enclosures
-		WHERE
-			enclosures.entry_id IN (SELECT id FROM entries WHERE status=$1)
-	`
-	result, err := s.db.Exec(query, model.EntryStatusRemoved)
-	if err != nil {
-		return 0, fmt.Errorf(`store: unable to delete enclosures from removed entries: %v`, err)
-	}
-
-	count, err := result.RowsAffected()
-	if err != nil {
-		return 0, fmt.Errorf(`store: unable to get the number of rows affected while deleting enclosures from removed entries: %v`, err)
-	}
-
-	return count, nil
-}

+ 80 - 135
internal/storage/entry.go

@@ -16,6 +16,10 @@ import (
 	"github.com/lib/pq"
 )
 
+// ErrEntryTombstoned is returned when an entry cannot be created because its
+// (feed_id, hash) pair has a tombstone recording a prior deletion.
+var ErrEntryTombstoned = errors.New("store: entry is tombstoned")
+
 // CountAllEntries returns the number of entries for each status in the database.
 func (s *Storage) CountAllEntries() (map[string]int64, error) {
 	rows, err := s.db.Query(`SELECT status, count(*) FROM entries GROUP BY status`)
@@ -27,7 +31,6 @@ func (s *Storage) CountAllEntries() (map[string]int64, error) {
 	results := make(map[string]int64)
 	results[model.EntryStatusUnread] = 0
 	results[model.EntryStatusRead] = 0
-	results[model.EntryStatusRemoved] = 0
 
 	for rows.Next() {
 		var status string
@@ -40,7 +43,7 @@ func (s *Storage) CountAllEntries() (map[string]int64, error) {
 		results[status] = count
 	}
 
-	results["total"] = results[model.EntryStatusUnread] + results[model.EntryStatusRead] + results[model.EntryStatusRemoved]
+	results["total"] = results[model.EntryStatusUnread] + results[model.EntryStatusRead]
 	return results, nil
 }
 
@@ -100,6 +103,9 @@ func (s *Storage) UpdateEntryTitleAndContent(entry *model.Entry) error {
 // createEntry add a new entry.
 func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
 	truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)
+	// The WHERE NOT EXISTS guard makes the tombstone check atomic with the insert, so a
+	// concurrent archive committing between an earlier existence check and this statement
+	// cannot bring a deleted entry back as unread.
 	query := `
 		INSERT INTO entries
 			(
@@ -117,22 +123,23 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
 				document_vectors,
 				tags
 			)
-		VALUES
-			(
-				$1,
-				$2,
-				$3,
-				$4,
-				$5,
-				$6,
-				$7,
-				$8,
-				$9,
-				$10,
-				now(),
-				setweight(to_tsvector($11), 'A') || setweight(to_tsvector($12), 'B'),
-				$13
-			)
+		SELECT
+			$1,
+			$2,
+			$3,
+			$4,
+			$5,
+			$6,
+			$7,
+			$8,
+			$9,
+			$10,
+			now(),
+			setweight(to_tsvector($11), 'A') || setweight(to_tsvector($12), 'B'),
+			$13
+		WHERE NOT EXISTS (
+			SELECT 1 FROM entry_tombstones WHERE feed_id=$9 AND hash=$2
+		)
 		RETURNING
 			id, status, created_at, changed_at
 	`
@@ -157,6 +164,9 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
 		&entry.CreatedAt,
 		&entry.ChangedAt,
 	)
+	if errors.Is(err, sql.ErrNoRows) {
+		return ErrEntryTombstoned
+	}
 	if err != nil {
 		return fmt.Errorf(`store: unable to create entry %q (feed #%d): %v`, entry.URL, entry.FeedID, err)
 	}
@@ -289,9 +299,20 @@ func (s *Storage) InsertEntryForFeed(userID, feedID int64, entry *model.Entry) (
 }
 
 func (s *Storage) IsNewEntry(feedID int64, entryHash string) bool {
-	var result bool
-	s.db.QueryRow(`SELECT true FROM entries WHERE feed_id=$1 AND hash=$2 LIMIT 1`, feedID, entryHash).Scan(&result)
-	return !result
+	// An entry is new only if it is neither stored nor tombstoned; otherwise
+	// callers (such as the crawler) would do expensive work on every refresh
+	// for items that will be discarded.
+	query := `
+		SELECT
+			EXISTS (
+				SELECT 1 FROM entries WHERE feed_id=$1 AND hash=$2
+			) OR EXISTS (
+				SELECT 1 FROM entry_tombstones WHERE feed_id=$1 AND hash=$2
+			)
+	`
+	var known bool
+	s.db.QueryRow(query, feedID, entryHash).Scan(&known)
+	return !known
 }
 
 func (s *Storage) GetReadTime(feedID int64, entryHash string) int {
@@ -313,73 +334,8 @@ func (s *Storage) GetReadTime(feedID int64, entryHash string) int {
 	return result
 }
 
-// cleanupRemovedEntriesNotInFeed deletes from the database entries marked as "removed" and not visible anymore in the feed.
-func (s *Storage) cleanupRemovedEntriesNotInFeed(feedID int64, entryHashes []string) error {
-	// Acquire locks in id order and skip already-locked rows to avoid deadlocks with
-	// ClearRemovedEntriesContent, which also updates removed entries concurrently.
-	query := `
-		WITH to_delete AS (
-			SELECT id
-			FROM entries
-			WHERE
-				feed_id=$1 AND
-				status=$2 AND
-				NOT (hash=ANY($3))
-			ORDER BY id
-			FOR UPDATE SKIP LOCKED
-		)
-		DELETE FROM entries
-		USING to_delete
-		WHERE entries.id = to_delete.id
-	`
-	if _, err := s.db.Exec(query, feedID, model.EntryStatusRemoved, pq.Array(entryHashes)); err != nil {
-		return fmt.Errorf(`store: unable to remove entries not in feed: %v`, err)
-	}
-
-	return nil
-}
-
-// ClearRemovedEntriesContent clears the content fields of entries marked as "removed", keeping only their metadata.
-func (s *Storage) ClearRemovedEntriesContent(limit int) (int64, error) {
-	// Skip locked rows so this batch scrubber doesn't block or deadlock with the
-	// concurrent cleanup that deletes removed entries in the same table.
-	query := `
-		UPDATE
-			entries
-		SET
-			title='',
-			content=NULL,
-			url='',
-			author=NULL,
-			comments_url=NULL,
-			document_vectors=NULL
-		WHERE id IN (
-			SELECT id
-			FROM entries
-			WHERE status = $1 AND content IS NOT NULL
-			ORDER BY id ASC
-			FOR UPDATE SKIP LOCKED
-			LIMIT $2
-		)
-	`
-
-	result, err := s.db.Exec(query, model.EntryStatusRemoved, limit)
-	if err != nil {
-		return 0, fmt.Errorf(`store: unable to clear content from removed entries: %v`, err)
-	}
-
-	count, err := result.RowsAffected()
-	if err != nil {
-		return 0, fmt.Errorf(`store: unable to get the number of rows affected while clearing content from removed entries: %v`, err)
-	}
-
-	return count, nil
-}
-
 // RefreshFeedEntries updates feed entries while refreshing a feed.
 func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries, updateExistingEntries bool) (newEntries model.Entries, err error) {
-	entryHashes := make([]string, 0, len(entries))
-
 	for _, entry := range entries {
 		entry.UserID = userID
 		entry.FeedID = feedID
@@ -403,7 +359,10 @@ func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries
 			}
 		} else {
 			err = s.createEntry(tx, entry)
-			if err == nil {
+			switch {
+			case errors.Is(err, ErrEntryTombstoned):
+				err = nil
+			case err == nil:
 				newEntries = append(newEntries, entry)
 			}
 		}
@@ -418,55 +377,43 @@ func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries
 		if err := tx.Commit(); err != nil {
 			return nil, fmt.Errorf(`store: unable to commit transaction: %v`, err)
 		}
-
-		entryHashes = append(entryHashes, entry.Hash)
 	}
 
-	go func() {
-		if err := s.cleanupRemovedEntriesNotInFeed(feedID, entryHashes); err != nil {
-			slog.Error("Unable to cleanup removed entries",
-				slog.Int64("user_id", userID),
-				slog.Int64("feed_id", feedID),
-				slog.Any("error", err),
-			)
-		}
-	}()
-
 	return newEntries, nil
 }
 
-// ArchiveEntries changes the status of entries to "removed" after the interval (24h minimum).
+// ArchiveEntries deletes entries older than the given interval and records tombstones so they are not re-ingested.
 func (s *Storage) ArchiveEntries(status string, interval time.Duration, limit int) (int64, error) {
 	if interval < 0 || limit <= 0 {
 		return 0, nil
 	}
 
 	query := `
-		UPDATE
-			entries
-		SET
-			status=$1
-		WHERE
-			id IN (
-				SELECT
-					id
-				FROM
-					entries
-				WHERE
-					status=$2 AND
-					starred is false AND
-					share_code='' AND
-					created_at < now () - $3::interval
-				ORDER BY
-					created_at ASC
-				FOR UPDATE SKIP LOCKED
-				LIMIT $4
-				)
+		WITH to_delete AS (
+			SELECT id, feed_id, hash
+			FROM entries
+			WHERE
+				status=$1 AND
+				starred is false AND
+				share_code='' AND
+				created_at < now() - $2::interval
+			ORDER BY created_at ASC
+			FOR UPDATE SKIP LOCKED
+			LIMIT $3
+		), deleted AS (
+			DELETE FROM entries
+			USING to_delete
+			WHERE entries.id = to_delete.id
+			RETURNING entries.feed_id, entries.hash
+		)
+		INSERT INTO entry_tombstones (feed_id, hash)
+		SELECT feed_id, hash FROM deleted WHERE hash <> ''
+		ON CONFLICT (feed_id, hash) DO NOTHING
 	`
 
 	days := max(int(interval/(24*time.Hour)), 1)
 
-	result, err := s.db.Exec(query, model.EntryStatusRemoved, status, fmt.Sprintf("%d days", days), limit)
+	result, err := s.db.Exec(query, status, fmt.Sprintf("%d days", days), limit)
 	if err != nil {
 		return 0, fmt.Errorf(`store: unable to archive %s entries: %v`, status, err)
 	}
@@ -481,7 +428,6 @@ func (s *Storage) ArchiveEntries(status string, interval time.Duration, limit in
 
 // SetEntriesStatus update the status of the given list of entries.
 func (s *Storage) SetEntriesStatus(userID int64, entryIDs []int64, status string) error {
-	// Entries that have the model.EntryStatusRemoved status are immutable.
 	query := `
 		UPDATE
 			entries
@@ -490,10 +436,9 @@ func (s *Storage) SetEntriesStatus(userID int64, entryIDs []int64, status string
 			changed_at=now()
 		WHERE
 			user_id=$2 AND
-			id=ANY($3) AND
-			status!=$4
+			id=ANY($3)
 		`
-	if _, err := s.db.Exec(query, status, userID, pq.Array(entryIDs), model.EntryStatusRemoved); err != nil {
+	if _, err := s.db.Exec(query, status, userID, pq.Array(entryIDs)); err != nil {
 		return fmt.Errorf(`store: unable to update entries statuses %v: %v`, entryIDs, err)
 	}
 
@@ -564,19 +509,19 @@ func (s *Storage) ToggleStarred(userID int64, entryID int64) error {
 	return nil
 }
 
-// FlushHistory changes all entries with the status "read" to "removed".
+// FlushHistory deletes all read entries (non-starred, non-shared) and records tombstones to prevent re-ingestion.
 func (s *Storage) FlushHistory(userID int64) error {
 	query := `
-		UPDATE
-			entries
-		SET
-			status=$1,
-			changed_at=now()
-		WHERE
-			user_id=$2 AND status=$3 AND starred is false AND share_code=''
+		WITH deleted AS (
+			DELETE FROM entries
+			WHERE user_id=$1 AND status=$2 AND starred is false AND share_code=''
+			RETURNING feed_id, hash
+		)
+		INSERT INTO entry_tombstones (feed_id, hash)
+		SELECT feed_id, hash FROM deleted WHERE hash <> ''
+		ON CONFLICT (feed_id, hash) DO NOTHING
 	`
-	_, err := s.db.Exec(query, model.EntryStatusRemoved, userID, model.EntryStatusRead)
-	if err != nil {
+	if _, err := s.db.Exec(query, userID, model.EntryStatusRead); err != nil {
 		return fmt.Errorf(`store: unable to flush history: %v`, err)
 	}
 

+ 2 - 2
internal/storage/entry_pagination_builder.go

@@ -188,8 +188,8 @@ func (e *entryPaginationBuilder) getEntry(tx *sql.Tx, entryID int64) (*model.Ent
 func NewEntryPaginationBuilder(store *Storage, userID, entryID int64, order, direction string) *entryPaginationBuilder {
 	return &entryPaginationBuilder{
 		store:      store,
-		args:       []any{userID, "removed"},
-		conditions: []string{"e.user_id = $1", "e.status <> $2"},
+		args:       []any{userID},
+		conditions: []string{"e.user_id = $1"},
 		entryID:    entryID,
 		order:      order,
 		direction:  direction,

+ 0 - 2
internal/ui/category_entries_all.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/view"
 )
 
@@ -36,7 +35,6 @@ func (h *handler) showCategoryEntriesAllPage(w http.ResponseWriter, r *http.Requ
 	builder.WithCategoryID(category.ID)
 	builder.WithSorting(user.EntryOrder, user.EntryDirection)
 	builder.WithSorting("id", user.EntryDirection)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 	builder.WithoutContent()
 	builder.WithOffset(offset)
 	builder.WithLimit(user.EntriesPerPage)

+ 0 - 2
internal/ui/category_entries_starred.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/view"
 )
 
@@ -36,7 +35,6 @@ func (h *handler) showCategoryEntriesStarredPage(w http.ResponseWriter, r *http.
 	builder.WithCategoryID(category.ID)
 	builder.WithSorting(user.EntryOrder, user.EntryDirection)
 	builder.WithSorting("id", user.EntryDirection)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 	builder.WithStarred(true)
 	builder.WithoutContent()
 	builder.WithOffset(offset)

+ 0 - 1
internal/ui/entry_category.go

@@ -26,7 +26,6 @@ func (h *handler) showCategoryEntryPage(w http.ResponseWriter, r *http.Request)
 	builder := h.store.NewEntryQueryBuilder(user.ID)
 	builder.WithCategoryID(categoryID)
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := builder.GetEntry()
 	if err != nil {

+ 0 - 1
internal/ui/entry_feed.go

@@ -26,7 +26,6 @@ func (h *handler) showFeedEntryPage(w http.ResponseWriter, r *http.Request) {
 	builder := h.store.NewEntryQueryBuilder(user.ID)
 	builder.WithFeedID(feedID)
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := builder.GetEntry()
 	if err != nil {

+ 0 - 1
internal/ui/entry_read.go

@@ -23,7 +23,6 @@ func (h *handler) showReadEntryPage(w http.ResponseWriter, r *http.Request) {
 	entryID := request.RouteInt64Param(r, "entryID")
 	builder := h.store.NewEntryQueryBuilder(user.ID)
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := builder.GetEntry()
 	if err != nil {

+ 0 - 2
internal/ui/entry_save.go

@@ -9,14 +9,12 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/integration"
-	"miniflux.app/v2/internal/model"
 )
 
 func (h *handler) saveEntry(w http.ResponseWriter, r *http.Request) {
 	entryID := request.RouteInt64Param(r, "entryID")
 	builder := h.store.NewEntryQueryBuilder(request.UserID(r))
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := builder.GetEntry()
 	if err != nil {

+ 0 - 2
internal/ui/entry_scraper.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/mediaproxy"
-	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/reader/processor"
 	"miniflux.app/v2/internal/storage"
 )
@@ -21,7 +20,6 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
 
 	entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID)
 	entryBuilder.WithEntryID(entryID)
-	entryBuilder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := entryBuilder.GetEntry()
 	if err != nil {

+ 0 - 1
internal/ui/entry_search.go

@@ -26,7 +26,6 @@ func (h *handler) showSearchEntryPage(w http.ResponseWriter, r *http.Request) {
 	builder := h.store.NewEntryQueryBuilder(user.ID)
 	builder.WithSearchQuery(searchQuery)
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := builder.GetEntry()
 	if err != nil {

+ 0 - 1
internal/ui/entry_starred.go

@@ -23,7 +23,6 @@ func (h *handler) showStarredEntryPage(w http.ResponseWriter, r *http.Request) {
 	entryID := request.RouteInt64Param(r, "entryID")
 	builder := h.store.NewEntryQueryBuilder(user.ID)
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := builder.GetEntry()
 	if err != nil {

+ 0 - 1
internal/ui/entry_tag.go

@@ -31,7 +31,6 @@ func (h *handler) showTagEntryPage(w http.ResponseWriter, r *http.Request) {
 	builder := h.store.NewEntryQueryBuilder(user.ID)
 	builder.WithTags([]string{tagName})
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := builder.GetEntry()
 	if err != nil {

+ 0 - 1
internal/ui/entry_unread.go

@@ -23,7 +23,6 @@ func (h *handler) showUnreadEntryPage(w http.ResponseWriter, r *http.Request) {
 	entryID := request.RouteInt64Param(r, "entryID")
 	builder := h.store.NewEntryQueryBuilder(user.ID)
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := builder.GetEntry()
 	if err != nil {

+ 0 - 2
internal/ui/feed_entries_all.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/view"
 )
 
@@ -34,7 +33,6 @@ func (h *handler) showFeedEntriesAllPage(w http.ResponseWriter, r *http.Request)
 	offset := request.QueryIntParam(r, "offset", 0)
 	builder := h.store.NewEntryQueryBuilder(user.ID)
 	builder.WithFeedID(feed.ID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 	builder.WithSorting(user.EntryOrder, user.EntryDirection)
 	builder.WithSorting("id", user.EntryDirection)
 	builder.WithoutContent()

+ 0 - 1
internal/ui/search.go

@@ -32,7 +32,6 @@ func (h *handler) showSearchPage(w http.ResponseWriter, r *http.Request) {
 		if unreadOnly {
 			builder.WithStatus(model.EntryStatusUnread)
 		}
-		builder.WithoutStatus(model.EntryStatusRemoved)
 		builder.WithoutContent()
 		builder.WithOffset(offset)
 		builder.WithLimit(user.EntriesPerPage)

+ 0 - 2
internal/ui/starred_entries.go

@@ -8,7 +8,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/view"
 )
 
@@ -21,7 +20,6 @@ func (h *handler) showStarredPage(w http.ResponseWriter, r *http.Request) {
 
 	offset := request.QueryIntParam(r, "offset", 0)
 	builder := h.store.NewEntryQueryBuilder(user.ID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 	builder.WithStarred(true)
 	builder.WithSorting(user.EntryOrder, user.EntryDirection)
 	builder.WithSorting("id", user.EntryDirection)

+ 0 - 1
internal/ui/starred_entry_category.go

@@ -26,7 +26,6 @@ func (h *handler) showStarredCategoryEntryPage(w http.ResponseWriter, r *http.Re
 	builder := h.store.NewEntryQueryBuilder(user.ID)
 	builder.WithCategoryID(categoryID)
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := builder.GetEntry()
 	if err != nil {

+ 0 - 2
internal/ui/tag_entries_all.go

@@ -9,7 +9,6 @@ import (
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/view"
 )
 
@@ -28,7 +27,6 @@ func (h *handler) showTagEntriesAllPage(w http.ResponseWriter, r *http.Request)
 
 	offset := request.QueryIntParam(r, "offset", 0)
 	builder := h.store.NewEntryQueryBuilder(user.ID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 	builder.WithTags([]string{tagName})
 	builder.WithSorting("status", "asc")
 	builder.WithSorting(user.EntryOrder, user.EntryDirection)

+ 0 - 1
internal/ui/unread_entry_category.go

@@ -26,7 +26,6 @@ func (h *handler) showUnreadCategoryEntryPage(w http.ResponseWriter, r *http.Req
 	builder := h.store.NewEntryQueryBuilder(user.ID)
 	builder.WithCategoryID(categoryID)
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := builder.GetEntry()
 	if err != nil {

+ 0 - 1
internal/ui/unread_entry_feed.go

@@ -26,7 +26,6 @@ func (h *handler) showUnreadFeedEntryPage(w http.ResponseWriter, r *http.Request
 	builder := h.store.NewEntryQueryBuilder(user.ID)
 	builder.WithFeedID(feedID)
 	builder.WithEntryID(entryID)
-	builder.WithoutStatus(model.EntryStatusRemoved)
 
 	entry, err := builder.GetEntry()
 	if err != nil {

+ 2 - 2
internal/validator/entry.go

@@ -22,11 +22,11 @@ func ValidateEntriesStatusUpdateRequest(request *model.EntriesStatusUpdateReques
 // ValidateEntryStatus makes sure the entry status is valid.
 func ValidateEntryStatus(status string) error {
 	switch status {
-	case model.EntryStatusRead, model.EntryStatusUnread, model.EntryStatusRemoved:
+	case model.EntryStatusRead, model.EntryStatusUnread:
 		return nil
 	}
 
-	return fmt.Errorf(`invalid entry status, valid status values are: "%s", "%s" and "%s"`, model.EntryStatusRead, model.EntryStatusUnread, model.EntryStatusRemoved)
+	return fmt.Errorf(`invalid entry status, valid status values are: %q and %q`, model.EntryStatusRead, model.EntryStatusUnread)
 }
 
 // ValidateEntryOrder makes sure the sorting order is valid.

+ 5 - 1
internal/validator/entry_test.go

@@ -35,12 +35,16 @@ func TestValidateEntriesStatusUpdateRequest(t *testing.T) {
 }
 
 func TestValidateEntryStatus(t *testing.T) {
-	for _, status := range []string{model.EntryStatusRead, model.EntryStatusUnread, model.EntryStatusRemoved} {
+	for _, status := range []string{model.EntryStatusRead, model.EntryStatusUnread} {
 		if err := ValidateEntryStatus(status); err != nil {
 			t.Error(`A valid status should not generate any error`)
 		}
 	}
 
+	if err := ValidateEntryStatus("removed"); err == nil {
+		t.Error(`The "removed" status is no longer accepted`)
+	}
+
 	if err := ValidateEntryStatus("invalid"); err == nil {
 		t.Error(`An invalid status should generate a error`)
 	}

+ 93 - 46
miniflux.1

@@ -1,5 +1,5 @@
 .\" Manpage for miniflux.
-.TH "MINIFLUX" "1" "March 1, 2026" "\ \&" "\ \&"
+.TH "MINIFLUX" "1" "April 18, 2026" "\ \&" "\ \&"
 
 .SH NAME
 miniflux \- Minimalist and opinionated feed reader
@@ -11,7 +11,6 @@ miniflux \- Minimalist and opinionated feed reader
 \fBminiflux\fR is a minimalist and opinionated feed reader.
 
 .SH OPTIONS
-.PP
 .B \-h, \-help
 .RS 4
 Show usage information and exit\&.
@@ -19,7 +18,9 @@ Show usage information and exit\&.
 .PP
 .B \-config-dump
 .RS 4
-Print parsed configuration values. This will include sensitive information like passwords\&.
+Print parsed configuration values.
+.br
+This will include sensitive information like passwords\&.
 .RE
 .PP
 .B \-c /path/to/miniflux.conf
@@ -130,10 +131,11 @@ Keys are the same as the environment variables described below\&.
 Environment variables override the values defined in the config file\&.
 
 .SH ENVIRONMENT
-.PP
-Boolean options accept the following values (case-insensitive): 1/0, yes/no, true/false, on/off\&.
+Boolean options accept the following values (case-insensitive):
+1/0, yes/no, true/false, on/off\&.
 .br
-For variables ending in \fB_FILE\fR, the value is a path to a file that contains the corresponding secret value\&.
+For variables ending in \fB_FILE\fR, the value is a path to a file
+that contains the corresponding secret value\&.
 .TP
 .B ADMIN_PASSWORD
 Admin user password, used only if \fBCREATE_ADMIN\fR is enabled\&.
@@ -141,7 +143,8 @@ Admin user password, used only if \fBCREATE_ADMIN\fR is enabled\&.
 Default is empty\&.
 .TP
 .B ADMIN_PASSWORD_FILE
-Path to a secret key exposed as a file, it should contain the \fBADMIN_PASSWORD\fR value\&.
+Path to a secret key exposed as a file, it should contain the
+\fBADMIN_PASSWORD\fR value\&.
 .br
 Default is empty\&.
 .TP
@@ -151,21 +154,24 @@ Admin user login, used only if \fBCREATE_ADMIN\fR is enabled\&.
 Default is empty\&.
 .TP
 .B ADMIN_USERNAME_FILE
-Path to a secret key exposed as a file, it should contain the \fBADMIN_USERNAME\fR value\&.
+Path to a secret key exposed as a file, it should contain the
+\fBADMIN_USERNAME\fR value\&.
 .br
 Default is empty\&.
 .TP
 .B AUTH_PROXY_HEADER
 Proxy authentication HTTP header\&.
 .br
-The option \fBTRUSTED_REVERSE_PROXY_NETWORKS\fR must be configured to allow the proxy to authenticate users\&.
+The option \fBTRUSTED_REVERSE_PROXY_NETWORKS\fR must be configured
+to allow the proxy to authenticate users\&.
 .br
 Default is empty.
 .TP
 .B AUTH_PROXY_USER_CREATION
 Set to 1 to create users based on proxy authentication information\&.
 .br
-When disabled, users must already exist in Miniflux to sign in through the proxy\&.
+When disabled, users must already exist in Miniflux to sign in
+through the proxy\&.
 .br
 Disabled by default\&.
 .TP
@@ -195,16 +201,24 @@ Number of entries to archive for each job interval\&.
 Default is 10000 entries\&.
 .TP
 .B CLEANUP_ARCHIVE_READ_DAYS
-Number of days after marking read entries as removed\&.
+Number of days before the cleanup job archives read entries\&.
 .br
-Set to -1 to keep all read entries.
+Archiving deletes non-bookmarked, non-shared read entries from the
+database and records a tombstone to prevent the same entry from being
+imported again during a later feed refresh\&.
+.br
+Set to -1 to disable automatic archiving of read entries\&.
 .br
 Default is 60 days\&.
 .TP
 .B CLEANUP_ARCHIVE_UNREAD_DAYS
-Number of days after marking unread entries as removed\&.
+Number of days before the cleanup job archives unread entries\&.
+.br
+Archiving deletes non-bookmarked, non-shared unread entries from the
+database and records a tombstone to prevent the same entry from being
+imported again during a later feed refresh\&.
 .br
-Set to -1 to keep all unread entries.
+Set to -1 to disable automatic archiving of unread entries\&.
 .br
 Default is 180 days\&.
 .TP
@@ -244,7 +258,8 @@ PostgreSQL connection parameters\&.
 Default is "user=postgres password=postgres dbname=miniflux2 sslmode=disable"\&.
 .TP
 .B DATABASE_URL_FILE
-Path to a secret key exposed as a file, it should contain the \fBDATABASE_URL\fR value\&.
+Path to a secret key exposed as a file, it should contain the
+\fBDATABASE_URL\fR value\&.
 .br
 Default is empty\&.
 .TP
@@ -266,10 +281,12 @@ Default is false (The HTTP service is enabled)\&.
 .B DISABLE_LOCAL_AUTH
 Disable local authentication\&.
 .br
-When set to true, the username/password form is hidden from the login screen, and the
-options to change username/password or unlink OAuth2 account are hidden from the settings page.
+When set to true, the username/password form is hidden from
+the login screen, and the options to change username/password
+or unlink OAuth2 account are hidden from the settings page.
 .br
-This option requires an alternative authentication source such as OAuth2 or auth proxy\&.
+This option requires an alternative authentication source
+such as OAuth2 or auth proxy\&.
 .br
 If remote user creation is disabled, only existing users can sign in\&.
 .br
@@ -320,7 +337,8 @@ Maximum body size for HTTP requests in Mebibyte (MiB)\&.
 Default is 15 MiB\&.
 .TP
 .B HTTP_CLIENT_PROXIES
-Enable proxy rotation for outgoing requests by providing a comma-separated list of proxy URLs\&.
+Enable proxy rotation for outgoing requests by providing a
+comma-separated list of proxy URLs\&.
 .br
 Default is empty\&.
 .TP
@@ -335,9 +353,11 @@ Time limit in seconds before the HTTP client cancels the request\&.
 Default is 20 seconds\&.
 .TP
 .B HTTP_CLIENT_USER_AGENT
-The default User-Agent header to use for the HTTP client. Can be overridden in per-feed settings\&.
+The default User-Agent header to use for the HTTP client.
+Can be overridden in per-feed settings\&.
 .br
-When empty, Miniflux uses a default User-Agent that includes the Miniflux version\&.
+When empty, Miniflux uses a default User-Agent that includes
+the Miniflux version\&.
 .br
 Default is empty.
 .TP
@@ -352,7 +372,8 @@ Forces cookies to use secure flag and send HSTS header\&.
 Default is disabled\&.
 .TP
 .B INTEGRATION_ALLOW_PRIVATE_NETWORKS
-Set to 1 to allow outgoing integration requests to private or loopback networks\&.
+Set to 1 to allow outgoing integration requests to private
+or loopback networks\&.
 .br
 Disabled by default, private networks are refused\&.
 .TP
@@ -367,9 +388,12 @@ Path to SSL private key\&.
 Default is empty\&.
 .TP
 .B LISTEN_ADDR
-Address to listen on. Use absolute path to listen on Unix socket (/var/run/miniflux.sock)\&.
+Address to listen on.
+Use absolute path to listen on Unix socket
+(/var/run/miniflux.sock)\&.
 .br
-Multiple addresses can be specified, separated by commas. For example: 127.0.0.1:8080, 127.0.0.1:8081\&.
+Multiple addresses can be specified, separated by commas.
+For example: 127.0.0.1:8080, 127.0.0.1:8081\&.
 .br
 Default is 127.0.0.1:8080\&.
 .TP
@@ -414,7 +438,8 @@ Time limit in seconds before the media proxy HTTP client cancels the request\&.
 Default is 120 seconds\&.
 .TP
 .B MEDIA_PROXY_RESOURCE_TYPES
-A comma-separated list of media types to proxify. Supported values are: image, audio, video\&.
+A comma-separated list of media types to proxify.
+Supported values are: image, audio, video\&.
 .br
 Default is image\&.
 .TP
@@ -429,7 +454,8 @@ Set a custom private key used to sign proxified media URLs\&.
 By default, a secret key is randomly generated during startup\&.
 .TP
 .B METRICS_ALLOWED_NETWORKS
-List of networks allowed to access the metrics endpoint (comma-separated values)\&.
+List of networks allowed to access the metrics endpoint
+(comma-separated values)\&.
 .br
 Default is 127.0.0.1/8\&.
 .TP
@@ -444,7 +470,8 @@ Metrics endpoint password for basic HTTP authentication\&.
 Default is empty\&.
 .TP
 .B METRICS_PASSWORD_FILE
-Path to a file that contains the password for the metrics endpoint HTTP authentication\&.
+Path to a file that contains the password for the metrics
+endpoint HTTP authentication\&.
 .br
 Default is empty\&.
 .TP
@@ -459,7 +486,8 @@ Metrics endpoint username for basic HTTP authentication\&.
 Default is empty\&.
 .TP
 .B METRICS_USERNAME_FILE
-Path to a file that contains the username for the metrics endpoint HTTP authentication\&.
+Path to a file that contains the username for the metrics
+endpoint HTTP authentication\&.
 .br
 Default is empty\&.
 .TP
@@ -469,7 +497,8 @@ OAuth2 client ID\&.
 Default is empty\&.
 .TP
 .B OAUTH2_CLIENT_ID_FILE
-Path to a secret key exposed as a file, it should contain the \fBOAUTH2_CLIENT_ID\fR value\&.
+Path to a secret key exposed as a file, it should contain the
+\fBOAUTH2_CLIENT_ID\fR value\&.
 .br
 Default is empty\&.
 .TP
@@ -479,7 +508,8 @@ OAuth2 client secret\&.
 Default is empty\&.
 .TP
 .B OAUTH2_CLIENT_SECRET_FILE
-Path to a secret key exposed as a file, it should contain the \fBOAUTH2_CLIENT_SECRET\fR value\&.
+Path to a secret key exposed as a file, it should contain the
+\fBOAUTH2_CLIENT_SECRET\fR value\&.
 .br
 Default is empty\&.
 .TP
@@ -501,35 +531,43 @@ Default is empty\&.
 .B OAUTH2_REDIRECT_URL
 OAuth2 redirect URL\&.
 .br
-This URL must be registered with the provider and is something like https://miniflux.example.org/oauth2/oidc/callback\&.
+This URL must be registered with the provider and is
+something like
+https://miniflux.example.org/oauth2/oidc/callback\&.
 .br
 Default is empty\&.
 .TP
 .B OAUTH2_USER_CREATION
 Set to 1 to authorize OAuth2 user creation\&.
 .br
-When disabled, users must already exist in Miniflux or have a linked OAuth2 account to sign in\&.
+When disabled, users must already exist in Miniflux or have
+a linked OAuth2 account to sign in\&.
 .br
 Disabled by default\&.
 .TP
 .B POLLING_FREQUENCY
 Interval for the background job scheduler.
 .br
-Determines how often a batch of feeds is selected for refresh, based on their last refresh time\&.
+Determines how often a batch of feeds is selected for refresh,
+based on their last refresh time\&.
 .br
 Default is 60 minutes\&.
 .TP
 .B POLLING_LIMIT_PER_HOST
-Limits the number of concurrent requests to the same hostname when polling feeds.
+Limits the number of concurrent requests to the same hostname
+when polling feeds.
 .br
-This helps prevent overwhelming a single server during batch processing by the worker pool.
+This helps prevent overwhelming a single server during batch
+processing by the worker pool.
 .br
 Default is 0 (disabled)\&.
 .TP
 .B POLLING_PARSING_ERROR_LIMIT
-The maximum number of parsing errors that the program will try before stopping polling a feed.
+The maximum number of parsing errors that the program will
+try before stopping polling a feed.
 .br
-Once the limit is reached, the user must refresh the feed manually. Set to 0 for unlimited.
+Once the limit is reached, the user must refresh the feed
+manually. Set to 0 for unlimited.
 .br
 Default is 3\&.
 .TP
@@ -540,11 +578,14 @@ Supported values are "round_robin" and "entry_frequency".
 .br
 - "round_robin": Feeds are polled in a fixed, rotating order.
 .br
-- "entry_frequency": The polling interval for each feed is based on the average update frequency over the past week.
+- "entry_frequency": The polling interval for each feed is
+based on the average update frequency over the past week.
 .br
-The number of feeds polled in a given period is limited by the POLLING_FREQUENCY and BATCH_SIZE settings.
+The number of feeds polled in a given period is limited by
+the POLLING_FREQUENCY and BATCH_SIZE settings.
 .br
-Regardless of the scheduler used, the total number of polled feeds will not exceed the maximum allowed per polling cycle.
+Regardless of the scheduler used, the total number of polled
+feeds will not exceed the maximum allowed per polling cycle.
 .br
 Default is "round_robin"\&.
 .TP
@@ -584,7 +625,9 @@ Minimum interval in minutes for the round robin scheduler\&.
 Default is 60 minutes\&.
 .TP
 .B TRUSTED_REVERSE_PROXY_NETWORKS
-List of networks (CIDR notation) allowed to use the proxy authentication header, \fBX-Forwarded-For\fR, \fBX-Forwarded-Proto\fR, and \fBX-Real-Ip\fR headers\&.
+List of networks (CIDR notation) allowed to use the proxy
+authentication header, \fBX-Forwarded-For\fR,
+\fBX-Forwarded-Proto\fR, and \fBX-Real-Ip\fR headers\&.
 .br
 Default is empty\&.
 .TP
@@ -596,7 +639,9 @@ Enabled by default\&.
 .B WEBAUTHN
 Enable or disable WebAuthn/Passkey authentication\&.
 .br
-You must provide a username on the login page if you are using non-residential keys. However, this is not required for discoverable credentials\&.
+You must provide a username on the login page if you are
+using non-residential keys.
+However, this is not required for discoverable credentials\&.
 .br
 Default is disabled\&.
 .TP
@@ -606,7 +651,10 @@ Number of background workers\&.
 Default is 16 workers\&.
 .TP
 .B YOUTUBE_API_KEY
-YouTube API key for use with FETCH_YOUTUBE_WATCH_TIME. If nonempty, the duration will be fetched from the YouTube API. Otherwise, the duration will be fetched from the YouTube website\&.
+YouTube API key for use with FETCH_YOUTUBE_WATCH_TIME.
+If nonempty, the duration will be fetched from the YouTube API.
+Otherwise, the duration will be fetched from the YouTube
+website\&.
 .br
 Default is empty\&.
 .TP
@@ -615,9 +663,8 @@ YouTube URL which will be used for embeds\&.
 .br
 Default is https://www.youtube-nocookie.com/embed/\&.
 .SH AUTHORS
-.P
-Miniflux is developed and maintained by Fr\['e]d\['e]ric Guillot with contributions from the Miniflux community\&.
+Miniflux is developed and maintained by Fr\['e]d\['e]ric Guillot
+with contributions from the Miniflux community\&.
 
 .SH "COPYRIGHT"
-.P
 Miniflux is released under the Apache 2.0 license\&.