commit 86f331ce8400642a39426246f31c219b43716250 equwal <truex@equwal.com> 2026-09-25 02:53:09 -0700 Default feeds for new accounts, and the daily email on by default SBM_DEFAULT_FEEDS names the feeds that an account follows until it unfollows them: an account without a feeds.txt has these, and its own file counts as soon as it changes the list, also when the file is empty. On sbmsync.com the default is the feed of recentlywritten.com. The daily email is now on for every account that the server can send email to. The account gave its address at sign-up. The setting is NoMail, so the accounts from before this change get the email too. A save of the settings without the email box, on a server that sends no email, keeps the choice. The first run for an account still sends nothing: it only notes the items of that day.
README.md | 12 ++++++--- feed.go | 51 ++++++++++++++++++++++++++++++--------- feed_test.go | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++----- feedjob.go | 10 ++++---- feedjob_test.go | 21 +++++++++------- main.go | 3 +++ pages.html | 4 +-- store.go | 16 +++++++----- 8 files changed, 149 insertions(+), 43 deletions(-)
diff --git a/README.md b/README.md index 4e6aae1..a22e842 100644 --- a/README.md +++ b/README.md @@ -76,15 +76,19 @@ It uses [sfeed](https://codemadness.org/sfeed.html) on the server: - Follow a feed: type its address and a name. The list is one plain file, `feeds.txt`, with one feed on each line: the URL, a tab and the name. + A new account starts with the default feeds of the server + (`SBM_DEFAULT_FEEDS`; on sbmsync.com, the feed of recentlywritten.com). + You can unfollow them like any other feed. - The server fetches the feeds every 30 minutes with `sfeed_update` and shows the items newest first, with the feed, the time and the author. A setting opens the links in a new tab. - Explore: turn it on, and the server looks through your bookmarked pages for feeds with `sfeed_web`, 20 pages with each update, and offers what it finds. -- Digest: turn it on, and the server sends you the new items by email once - a day, in the form of `sfeed_plain`. The first day after you turn it on - sends nothing, so that you do not get the whole history. +- Digest: the server sends you the new items by email once a day, in the + form of `sfeed_plain`, unless you turn that off in the settings of the + page. The first day sends nothing, so that you do not get the whole + history. A server without an SMTP server sends no digest. Read the feed in a terminal with the token of a device (`bm-sync login` keeps one in `~/.config/sbm/sync`): @@ -212,6 +216,8 @@ All settings come from the environment. With Docker, put them in `.env`. SBM_SMTP_PASSWORD password on the SMTP server SBM_MAIL_FROM sender, as "sbm Sync <sync@example.org>" (default SBM_SMTP_USER) + SBM_DEFAULT_FEEDS feed URLs, with spaces between them, that a new + account follows until it unfollows them (optional) Without `SBM_SMTP_HOST`, the server sends no email, and accounts need no email check. With it, each new account gets an email with a link. The diff --git a/feed.go b/feed.go index 3d8d537..d2da8f1 100644 --- a/feed.go +++ b/feed.go @@ -23,6 +23,7 @@ package main import ( "errors" + "fmt" "io" "io/fs" "net/http" @@ -52,10 +53,11 @@ var ( notFile = regexp.MustCompile(`[^A-Za-z0-9._-]+`) ) -// feedSettings are the feed choices of an account. +// feedSettings are the feed choices of an account. The daily email is on +// by default: the account gave its address at sign-up. type feedSettings struct { NewTab bool `json:"newtab,omitempty"` // the feed page opens links in a new tab - Mail bool `json:"mail,omitempty"` // an email with the new items, once a day + NoMail bool `json:"nomail,omitempty"` // no email with the new items once a day Explore bool `json:"explore,omitempty"` // the job looks through the bookmarks for feeds } @@ -219,18 +221,39 @@ func (s *Store) feedDir(id string) string { return filepath.Join(s.dir, "feeds", id) } -// readFeeds gives feeds.txt of the account. A missing file is empty. -func (s *Store) readFeeds(a *Account) (string, error) { - m := s.lock(a.ID) - m.Lock() - defer m.Unlock() +// defaultFeeds gives the feeds.txt of a new account from the setting +// SBM_DEFAULT_FEEDS: feed URLs with spaces between them. The host of each +// URL is its name. +func defaultFeeds(setting string) (string, error) { + var ls []string + for _, u := range strings.Fields(setting) { + u, err := feedURL(u) + if err != nil { + return "", fmt.Errorf("SBM_DEFAULT_FEEDS: %q: %v", u, err) + } + ls = append(ls, u+"\t"+feedName("", u)) + } + return join(ls), nil +} + +// feedsText gives feeds.txt of the account. The caller holds the lock of +// the account. An account without the file has the default feeds. +func (s *Store) feedsText(a *Account) (string, error) { b, err := os.ReadFile(filepath.Join(s.feedDir(a.ID), "feeds.txt")) if errors.Is(err, fs.ErrNotExist) { - return "", nil + return s.defaultFeeds, nil } return string(b), err } +// readFeeds gives feeds.txt of the account. +func (s *Store) readFeeds(a *Account) (string, error) { + m := s.lock(a.ID) + m.Lock() + defer m.Unlock() + return s.feedsText(a) +} + // changeFeeds gives feeds.txt to f and writes what f gives back. No other // change to the file comes between the two. func (s *Store) changeFeeds(a *Account, f func(text string) (string, error)) error { @@ -238,11 +261,11 @@ func (s *Store) changeFeeds(a *Account, f func(text string) (string, error)) err m.Lock() defer m.Unlock() dir := s.feedDir(a.ID) - b, err := os.ReadFile(filepath.Join(dir, "feeds.txt")) - if err != nil && !errors.Is(err, fs.ErrNotExist) { + old, err := s.feedsText(a) + if err != nil { return err } - text, err := f(string(b)) + text, err := f(old) if err != nil { return err } @@ -498,9 +521,13 @@ func (s *server) saveFeedSettings(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxForm) f := feedSettings{ NewTab: r.PostFormValue("newtab") != "", - Mail: s.mail != nil && r.PostFormValue("mail") != "", + NoMail: s.store.view(u).Feed.NoMail, Explore: r.PostFormValue("explore") != "", } + // The form has the email box only when the server sends email. + if s.mail != nil { + f.NoMail = r.PostFormValue("mail") == "" + } if err := s.store.setFeed(u, f); err != nil { s.fail(w, err) return diff --git a/feed_test.go b/feed_test.go index f28caf5..174af58 100644 --- a/feed_test.go +++ b/feed_test.go @@ -10,6 +10,7 @@ import ( "slices" "strings" "testing" + "time" "pgregory.net/rapid" ) @@ -127,6 +128,59 @@ func TestFollowAndUnfollow(t *testing.T) { } } +// A new account follows the default feeds of the server. It can unfollow +// them, and they do not come back. +func TestDefaultFeeds(t *testing.T) { + e := newEnv(t, false) + e.s.store.defaultFeeds = "https://recentlywritten.com/rss.xml\trecentlywritten.com\n" + c, tok, _ := e.account("") + a := e.s.store.byTok(tok) + if got := e.feedsOf(tok); got != e.s.store.defaultFeeds { + t.Fatalf("feeds of a new account: %q", got) + } + if !strings.Contains(e.body(c, "/feed"), `<a href="/feed?feed=recentlywritten.com">recentlywritten.com</a>`) { + t.Error("the page does not show the default feed") + } + // The job fetches the default feed of an account that never opened the page. + f := &fakeTools{items: map[string]string{"https://recentlywritten.com/rss.xml": oneItem}, now: time.Now()} + e.s.feeds = f.tools() + e.s.runFeeds() + if !strings.Contains(e.feedFile(a, "sfeedrc"), "'https://recentlywritten.com/rss.xml'") { + t.Error("the job does not fetch the default feed") + } + e.send(c, "/feed/add", url.Values{"url": {"https://a.org/rss.xml"}, "name": {"A"}}) + if got := e.feedsOf(tok); got != e.s.store.defaultFeeds+"https://a.org/rss.xml\tA\n" { + t.Fatalf("after a follow: %q", got) + } + e.send(c, "/feed/delete", url.Values{"url": {"https://recentlywritten.com/rss.xml"}}) + if got := e.feedsOf(tok); got != "https://a.org/rss.xml\tA\n" { + t.Fatalf("after the unfollow of the default feed: %q", got) + } + e.send(c, "/feed/delete", url.Values{"url": {"https://a.org/rss.xml"}}) + if got := e.feedsOf(tok); got != "" { + t.Fatalf("after the unfollow of all feeds, the default is back: %q", got) + } + e.s.runFeeds() + if got := e.feedsOf(tok); got != "" { + t.Fatalf("the job brought the default feed back: %q", got) + } +} + +func TestDefaultFeedsSetting(t *testing.T) { + got, err := defaultFeeds(" https://recentlywritten.com/rss.xml\nhttp://a.org/feed ") + if err != nil || got != "https://recentlywritten.com/rss.xml\trecentlywritten.com\nhttp://a.org/feed\ta.org\n" { + t.Errorf("defaultFeeds = %q, %v", got, err) + } + if got, err := defaultFeeds(""); err != nil || got != "" { + t.Errorf("empty setting: %q, %v", got, err) + } + for _, bad := range []string{"ftp://a.org/feed", "https://a.org/it's", "a.org/feed"} { + if _, err := defaultFeeds(bad); err == nil { + t.Errorf("defaultFeeds(%q) gave no error", bad) + } + } +} + func TestFeedLimit(t *testing.T) { e := newEnv(t, false) c, tok, _ := e.account("") @@ -211,6 +265,8 @@ func TestFeedPages(t *testing.T) { } } +// The daily email is on by default. The page offers the box only when the +// server sends email, and a save without the box keeps the choice. func TestFeedMailSettingNeedsAnSMTPServer(t *testing.T) { e := newEnv(t, false) c, tok, _ := e.account("") @@ -218,17 +274,24 @@ func TestFeedMailSettingNeedsAnSMTPServer(t *testing.T) { if strings.Contains(e.body(c, "/feed"), `name="mail"`) { t.Error("the page offers the digest without an SMTP server") } - e.send(c, "/feed/settings", url.Values{"mail": {"on"}, "explore": {"on"}}) - if f := e.s.store.view(a).Feed; f.Mail || !f.Explore { + e.send(c, "/feed/settings", url.Values{"explore": {"on"}}) + if f := e.s.store.view(a).Feed; f.NoMail || !f.Explore { t.Errorf("settings without an SMTP server: %+v", f) } e.withMail() - if !strings.Contains(e.body(c, "/feed"), `name="mail"`) { - t.Error("the page does not offer the digest") + if !strings.Contains(e.body(c, "/feed"), `<input type="checkbox" class="check" name="mail" checked>`) { + t.Error("the page does not offer the digest, on") + } + e.send(c, "/feed/settings", url.Values{}) + if f := e.s.store.view(a).Feed; !f.NoMail || f.Explore { + t.Errorf("settings with the box cleared: %+v", f) + } + if !strings.Contains(e.body(c, "/feed"), `<input type="checkbox" class="check" name="mail">`) { + t.Error("the page shows the digest on") } e.send(c, "/feed/settings", url.Values{"mail": {"on"}}) - if f := e.s.store.view(a).Feed; !f.Mail || f.Explore { - t.Errorf("settings with an SMTP server: %+v", f) + if f := e.s.store.view(a).Feed; f.NoMail { + t.Errorf("settings with the box ticked: %+v", f) } } diff --git a/feedjob.go b/feedjob.go index 2f991fe..61b4fdf 100644 --- a/feedjob.go +++ b/feedjob.go @@ -7,8 +7,8 @@ package main // each feed and merges the new items into items/<file>; // 2. when the account asked for it, looks through the bookmarks of the // account for feeds with sfeed_web, and keeps what it found in explore; -// 3. when the account asked for it, sends the items that are new since the -// last digest by email, once a day, in the form of sfeed_plain. +// 3. unless the account turned it off, sends the items that are new since +// the last digest by email, once a day, in the form of sfeed_plain. // // The job writes only its own files under feeds/<account>/. The web server // writes only feeds.txt and accounts.json. So the two never write the same @@ -183,7 +183,7 @@ func (s *server) feedAccount(p *Account) error { } feeds = append(feeds, f) } - if len(feeds) == 0 && !a.Feed.Explore && !a.Feed.Mail { + if len(feeds) == 0 && !a.Feed.Explore { if _, err := os.Stat(dir); errors.Is(err, fs.ErrNotExist) { return nil // the account never used feeds } @@ -224,7 +224,7 @@ func (s *server) feedAccount(p *Account) error { log.Printf("account %s: explore: %v", a.ID, err) } } - if !a.Feed.Mail { + if a.Feed.NoMail { os.Remove(filepath.Join(dir, "mailed")) os.Remove(filepath.Join(dir, "digest")) } else if s.mail != nil && s.verified(a) && s.active(a) { @@ -294,7 +294,7 @@ func (s *server) explore(p *Account, dir string, cache map[string]bool) error { } // digest sends the items that came in since the last digest, once a day. -// The first run after the opt-in sends nothing: it only notes the items of +// The first run for an account sends nothing: it only notes the items of // that day, so that nobody gets the whole history. func (s *server) digest(a Account, dir string, feeds []Feed) error { mailedPath, digestPath := filepath.Join(dir, "mailed"), filepath.Join(dir, "digest") diff --git a/feedjob_test.go b/feedjob_test.go index c109207..ee645df 100644 --- a/feedjob_test.go +++ b/feedjob_test.go @@ -151,12 +151,8 @@ func TestJobSendsADailyDigest(t *testing.T) { f := &fakeTools{items: map[string]string{"https://a.org/rss.xml": oneItem}, now: start} e.s.feeds = f.tools() - e.s.runFeeds() - if e.sent() != 0 || e.feedFile(a, "mailed") != "" { - t.Fatal("the job mails without the opt-in") - } - e.send(c, "/feed/settings", url.Values{"mail": {"on"}}) - // The first run after the opt-in notes the items and sends nothing. + // The email is on by default. The first run notes the items and sends + // nothing. e.s.runFeeds() if e.sent() != 0 || e.feedFile(a, "mailed") != "Alpha\tid1\n" || strings.TrimSpace(e.feedFile(a, "digest")) != "1800000000" { t.Fatalf("first run: %d emails, mailed %q, digest %q", e.sent(), e.feedFile(a, "mailed"), e.feedFile(a, "digest")) @@ -201,21 +197,29 @@ func TestJobSendsADailyDigest(t *testing.T) { if e.feedFile(a, "mailed") != "" || e.feedFile(a, "digest") != "" { t.Error("the opt-out kept the digest files") } + f.items["https://a.org/rss.xml"] = "1700010800\tFour\thttps://a.org/4\t\t\tid4\t\t\t\n" + f.items["https://a.org/rss.xml"] + f.now = start.Add(80 * time.Hour) + e.s.runFeeds() if e.sent() != 2 { t.Error("a digest went out after the opt-out") } + // The opt-in starts fresh: the first run notes the items only. + e.send(c, "/feed/settings", url.Values{"mail": {"on"}}) + e.s.runFeeds() + if e.sent() != 2 || e.feedFile(a, "mailed") == "" { + t.Errorf("after the opt-in: %d emails, mailed %q", e.sent(), e.feedFile(a, "mailed")) + } } // A digest that does not go out is sent again with the next run. func TestJobKeepsTheItemsOfAFailedDigest(t *testing.T) { e := newEnv(t, false) - c, tok, _ := e.account("") + _, tok, _ := e.account("") e.withMail() a := e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\n") start := time.Unix(1_800_000_000, 0) f := &fakeTools{items: map[string]string{"https://a.org/rss.xml": ""}, now: start} e.s.feeds = f.tools() - e.send(c, "/feed/settings", url.Values{"mail": {"on"}}) e.s.runFeeds() f.items["https://a.org/rss.xml"] = oneItem f.now = start.Add(25 * time.Hour) @@ -237,7 +241,6 @@ func TestJobMailsOnlyConfirmedAccounts(t *testing.T) { e.signup("me@example.org") tok := e.token("me@example.org") a := e.setFeeds(tok, "https://a.org/rss.xml\tAlpha\n") - e.s.store.setFeed(a, feedSettings{Mail: true}) f := &fakeTools{items: map[string]string{"https://a.org/rss.xml": oneItem}, now: time.Now()} e.s.feeds = f.tools() e.s.runFeeds() diff --git a/main.go b/main.go index a0bc4ab..1783724 100644 --- a/main.go +++ b/main.go @@ -75,6 +75,9 @@ func main() { if err != nil { log.Fatal(err) } + if store.defaultFeeds, err = defaultFeeds(os.Getenv("SBM_DEFAULT_FEEDS")); err != nil { + log.Fatal(err) + } s := &server{ store: store, site: site, diff --git a/pages.html b/pages.html index 3288acd..0f9b86e 100644 --- a/pages.html +++ b/pages.html @@ -167,7 +167,7 @@ bookmarks for feeds. The server fetches new items every 30 minutes.</p>{{end}} <details><summary>Settings</summary> <form method="post" action="/feed/settings"> <label><input type="checkbox" class="check" name="newtab"{{if .Settings.NewTab}} checked{{end}}>Open links in a new tab</label> -{{if .HasMail}}<label><input type="checkbox" class="check" name="mail"{{if .Settings.Mail}} checked{{end}}>Send me the new items by email, once a day</label>{{end}} +{{if .HasMail}}<label><input type="checkbox" class="check" name="mail"{{if not .Settings.NoMail}} checked{{end}}>Send me the new items by email, once a day</label>{{end}} <label><input type="checkbox" class="check" name="explore"{{if .Settings.Explore}} checked{{end}}>Explore: look through my bookmarks for feeds</label> <button>Save</button> </form></details> @@ -338,7 +338,7 @@ When this server checks email addresses, it also sends you an email with a link <li>When you subscribe: your Stripe customer ID and the state of your subscription: to bill you.</li> <li>When you follow feeds: the addresses of the feeds, the items that the server fetched from them, and the addresses of the feeds that the server found on your bookmarked pages when you asked it to look: -to show you the items, and to send them to you by email once a day when you asked for that.</li> +to show you the items, and to send you the new ones by email once a day, unless you turn that off on the feed page.</li> </ul> <p>The server does not sell or share this data, and does not use it for ads. There are no analytics and no trackers. The only cookie keeps you signed in.</p> diff --git a/store.go b/store.go index 478b3ac..67f6fb3 100644 --- a/store.go +++ b/store.go @@ -52,12 +52,16 @@ type Account struct { // of each bookmark file in files/<account ID>/<SHA-256 of the text>. The file // HEAD beside the versions names the current one. type Store struct { - dir string - mu sync.Mutex // for the maps, the accounts and accounts.json - accounts map[string]*Account - byEmail map[string]*Account - byToken map[string]*Account - locks map[string]*sync.Mutex // one for the files of each account + dir string + // defaultFeeds is the feeds.txt of an account that has none yet, see + // feed.go. Once the account changes its feeds, its own file counts, + // also when it is empty. + defaultFeeds string + mu sync.Mutex // for the maps, the accounts and accounts.json + accounts map[string]*Account + byEmail map[string]*Account + byToken map[string]*Account + locks map[string]*sync.Mutex // one for the files of each account } func openStore(dir string) (*Store, error) {