~rockorager/offmap

offmap: Backlog-o-patches v1 PROPOSED

Here's a backlog of patches.

This fails CI because of the golanglintci issue, but it does work.

The folder mapping patch was tested by Bence originally.

Tim Culverhouse (7):
  imap: download emails in batches
  cmds: add 'offmap check' command
  config: implement folder mapping
  chore: remove unused tmp dir for downloads
  chore: reorder goroutine for fetch commands
  sync: add daemon mode
  chore: move quit goroutine outside of loop

 cmd/check.go       |  39 ++++++++++++
 cmd/offmap.go      |   1 +
 cmd/sync.go        | 140 ++++++++++++++++++++++++------------------
 config.go          |  15 ++---
 doc/offmap.1.scd   |  12 ++++
 doc/offmap.5.scd   |  17 ++++++
 imap/capability.go |  30 +++++++++
 imap/client.go     |   1 -
 imap/imap.go       | 147 ++++++++++++++++++++++-----------------------
 imap/imap_test.go  |  35 +++++++++++
 maildir/maildir.go |   2 +
 11 files changed, 300 insertions(+), 139 deletions(-)
 create mode 100644 cmd/check.go
 create mode 100644 imap/capability.go
 create mode 100644 imap/imap_test.go

-- 
2.39.2
#951712 .build.yml failed
offmap/patches/.build.yml: FAILED in 1m41s

[Backlog-o-patches][0] from [Tim Culverhouse][1]

[0]: https://lists.sr.ht/~rockorager/offmap/patches/39503
[1]: mailto:tim@timculverhouse.com

✗ #951712 FAILED offmap/patches/.build.yml https://builds.sr.ht/~rockorager/job/951712
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~rockorager/offmap/patches/39503/mbox | git am -3
Learn more about email & git

[PATCH offmap 1/7] imap: download emails in batches Export this patch

Download email in batches (of size 2000) to prevent issues with Office
365. Requests can be too large for O365 to initiate the download.

Signed-off-by: Tim Culverhouse <tim@timculverhouse.com>
---
 imap/imap.go | 78 +++++++++++++++++++++++++++-------------------------
 1 file changed, 40 insertions(+), 38 deletions(-)

diff --git a/imap/imap.go b/imap/imap.go
index 228669db260e..791248b04615 100644
--- a/imap/imap.go
+++ b/imap/imap.go
@@ -162,15 +162,11 @@ func (s *Store) DownloadEmail(mbox string, emls []*offmap.Email) chan *offmap.Fu
	var dled int32 = 0
	total := len(emls)
	emlMu := sync.Mutex{}
	seq := &imap.SeqSet{}
	for _, eml := range emls {
		seq.AddNum(eml.UID)
	}
	client := s.getConn()
	go func() {
		defer client.done()
		defer close(ch)
		if seq.Empty() {
		if len(emls) == 0 {
			return
		}
		status, err := client.Select(mbox, true)
@@ -180,41 +176,47 @@ func (s *Store) DownloadEmail(mbox string, emls []*offmap.Email) chan *offmap.Fu
		}
		section := &imap.BodySectionName{Peek: true}
		items := []imap.FetchItem{section.FetchItem()}
		msgCh := make(chan *imap.Message)
		msgWg := sync.WaitGroup{}
		msgWg.Add(1)
		go func() {
			for msg := range msgCh {
				atomic.AddInt32(&dled, 1)
				cur := atomic.LoadInt32(&dled)
				emlMu.Lock()
				eml, ok := emlMap[msg.Uid]
				emlMu.Unlock()
				if !ok {
					log.Errorf("imap: %s: eml not found: %d", status.Name, msg.Uid)
					continue
				}
				r := msg.GetBody(section)
				if r == nil {
					log.Errorf("imap: no body")
					continue
				}
				fe := &offmap.FullEmail{
					Email: eml,
					Body:  r,
				}
				ch <- fe
				log.Tracef("imap: (client %d) downloaded %d of %d", client.id, cur, total)
			}
			msgWg.Done()
		}()
		log.Debugf("imap (client %d): downloading uids in mailbox: %s", client.id, mbox)
		err = client.UidFetch(seq, items, msgCh)
		if err != nil {
			log.Errorf("imap (client %d): %v", client.id, err)
			return
		seq := &imap.SeqSet{}
		batch := 0
		for i, eml := range emls {
			seq.AddNum(eml.UID)
			batch = batch + 1
			if batch == 2000 || i == len(emls)-1 {
				msgCh := make(chan *imap.Message)
				go func() {
					err = client.UidFetch(seq, items, msgCh)
					if err != nil {
						log.Errorf("imap (client %d): %v", client.id, err)
						return
					}
				}()
				for msg := range msgCh {
					atomic.AddInt32(&dled, 1)
					cur := atomic.LoadInt32(&dled)
					emlMu.Lock()
					eml, ok := emlMap[msg.Uid]
					emlMu.Unlock()
					if !ok {
						log.Errorf("imap: %s: eml not found: %d", status.Name, msg.Uid)
						continue
					}
					r := msg.GetBody(section)
					if r == nil {
						log.Errorf("imap: no body")
						continue
					}
					fe := &offmap.FullEmail{
						Email: eml,
						Body:  r,
					}
					ch <- fe
					log.Tracef("imap: (client %d) downloaded %d of %d", client.id, cur, total)
				}
				seq.Clear()
				batch = 0
			}
		}
		msgWg.Wait()
	}()
	return ch
}
-- 
2.39.2

[PATCH offmap 2/7] cmds: add 'offmap check' command Export this patch

Add command 'check' which reports capabilities which will be used by
offmap.

Signed-off-by: Tim Culverhouse <tim@timculverhouse.com>
---
 cmd/check.go       | 39 +++++++++++++++++++++++++++++++++++++++
 cmd/offmap.go      |  1 +
 imap/capability.go | 30 ++++++++++++++++++++++++++++++
 imap/client.go     |  1 -
 4 files changed, 70 insertions(+), 1 deletion(-)
 create mode 100644 cmd/check.go
 create mode 100644 imap/capability.go

diff --git a/cmd/check.go b/cmd/check.go
new file mode 100644
index 000000000000..b7cba265136c
--- /dev/null
+++ b/cmd/check.go
@@ -0,0 +1,39 @@
package main

import (
	"fmt"

	"git.sr.ht/~rockorager/offmap"
	"git.sr.ht/~rockorager/offmap/imap"
	"github.com/spf13/cobra"
)

func newCheckCommand() *cobra.Command {
	cmd := &cobra.Command{
		Use:   "check",
		Short: "check reports which capabilities of the imap server will be used",
		RunE:  check,
	}
	cmd.Flags().StringSliceP("account", "a", nil, "only sync the specified account(s)")
	return cmd
}

func check(cmd *cobra.Command, args []string) error {
	accts, err := cmd.Flags().GetStringSlice("account")
	if err != nil {
		return fmt.Errorf("offmap: could not parse account: %v", err)
	}
	cfgs, err := offmap.LoadConfig(accts)
	if err != nil {
		return fmt.Errorf("could not read config: %v", err)
	}
	for _, cfg := range cfgs {
		fmt.Printf("Account %s\n", cfg.Name)
		remote, err := imap.NewStore(cfg)
		if err != nil {
			return fmt.Errorf("could not create store: %v", err)
		}
		remote.Capabilities()
	}
	return nil
}
diff --git a/cmd/offmap.go b/cmd/offmap.go
index 8a2176f28164..3fff3ccd4490 100644
--- a/cmd/offmap.go
+++ b/cmd/offmap.go
@@ -26,6 +26,7 @@ func main() {

	cmd.AddCommand(newSyncCommand())
	cmd.AddCommand(newDiffCommand())
	cmd.AddCommand(newCheckCommand())
	if err := cmd.ExecuteContext(ctx); err != nil {
		log.Fatalf("%v", err)
	}
diff --git a/imap/capability.go b/imap/capability.go
new file mode 100644
index 000000000000..fbd2f41238ed
--- /dev/null
+++ b/imap/capability.go
@@ -0,0 +1,30 @@
package imap

import (
	"fmt"

	"git.sr.ht/~rockorager/offmap/log"
)

func (s *Store) Capabilities() {
	capsUsed := []string{
		"UIDPLUS",
		"CONDSTORE",
		"LIST-STATUS",
	}
	client := s.getConn()
	caps, err := client.Capability()
	if err != nil {
		log.Errorf("could not get capabilities: %v", err)
		return
	}
	for _, cap := range capsUsed {
		_, has := caps[cap]
		switch has {
		case true:
			fmt.Printf("Capability found: %s\n", cap)
		case false:
			fmt.Printf("Your server doesn't support '%s'\n", cap)
		}
	}
}
diff --git a/imap/client.go b/imap/client.go
index c410993950d4..e45f87b68c89 100644
--- a/imap/client.go
+++ b/imap/client.go
@@ -89,7 +89,6 @@ func (s *Store) initClients() {
					conn.lsc = liststatus.NewClient(c)
				}
			}

			s.Lock()
			s.clients[n] = conn
			s.Unlock()
-- 
2.39.2

[PATCH offmap 3/7] config: implement folder mapping Export this patch

Implement folder mapping from remote folder name to local folder name.
Add test for mappings, update docs.

Maps must be of the form:
REMOTE = LOCAL

And it is recommended in the docs that the key should be quoted as well,
specifically when the remote uses '.' delimiters, as this will break
toml parsing. Quoted keys with '.' are parsed as raw strings and will
not break toml.

Example:

"INBOX.test" = "Inbox/test" // Works
INBOX.test = "Inbox/test" // Doesn't work

Implements: https://todo.sr.ht/~rockorager/offmap/19
Signed-off-by: Tim Culverhouse <tim@timculverhouse.com>
---
 config.go          | 15 ++++++++-------
 doc/offmap.5.scd   | 17 +++++++++++++++++
 imap/imap.go       | 26 ++++++++++++++++++++++----
 imap/imap_test.go  | 35 +++++++++++++++++++++++++++++++++++
 maildir/maildir.go |  2 ++
 5 files changed, 84 insertions(+), 11 deletions(-)
 create mode 100644 imap/imap_test.go

diff --git a/config.go b/config.go
index e6fc2016d5d0..2c6bd07d4cc8 100644
--- a/config.go
+++ b/config.go
@@ -11,13 +11,14 @@ import (

type Config struct {
	Name           string
	Maildir        string `toml:"maildir"`
	Host           string `toml:"host"`
	Username       string `toml:"username"`
	Password       string `toml:"password"`
	PasswordCmd    string `toml:"password-cmd"`
	AuthMech       string `toml:"auth-mech"`
	MaxConnections int    `toml:"max-connections"`
	Maildir        string            `toml:"maildir"`
	Host           string            `toml:"host"`
	Username       string            `toml:"username"`
	Password       string            `toml:"password"`
	PasswordCmd    string            `toml:"password-cmd"`
	AuthMech       string            `toml:"auth-mech"`
	MaxConnections int               `toml:"max-connections"`
	Map            map[string]string `toml:"map"`
}

func LoadConfig(accts []string) ([]*Config, error) {
diff --git a/doc/offmap.5.scd b/doc/offmap.5.scd
index da4ee3b7a0b0..e715439ecef0 100644
--- a/doc/offmap.5.scd
+++ b/doc/offmap.5.scd
@@ -68,3 +68,20 @@ auth-mech     = "oauth2"
	effect for IMAP servers who *do not* support LIST-STATUS.

	Default: 1

*map*
	A table of remote mailbox names mapped to local mailbox names. The key
	should always be the remote mailbox name, and is recommended to be in
	quotes. IMAP servers with '.' delimiters require quotes. Maps will apply
	to any children of the mapped mailbox.

	Example:
	```
	[account-name.map]
	"INBOX" = "inbox"
	"Lists/offmap" = "offmap"
	```

	In the above example, a child of "INBOX" would be mapped from
	"INBOX/child" to "inbox/child". However, siblings of "Lists/offmap"
	would not be mapped, and would still exist under "Lists/*".
diff --git a/imap/imap.go b/imap/imap.go
index 791248b04615..db1f7a853097 100644
--- a/imap/imap.go
+++ b/imap/imap.go
@@ -292,10 +292,7 @@ func (s *Store) listMailboxes() ([]*offmap.Mailbox, error) {

	// Process the statuses
	for _, status := range statuses {
		local := status.Name
		if s.Delimiter() != "/" {
			local = strings.ReplaceAll(local, s.Delimiter(), "/")
		}
		local := s.localName(status.Name)
		mailbox := offmap.NewMailbox(status.UidValidity, local, status.Name)
		mailbox.UIDNext = status.UidNext
		if s.SupportCondstore() {
@@ -306,6 +303,27 @@ func (s *Store) listMailboxes() ([]*offmap.Mailbox, error) {
	return s.mailboxes, nil
}

// Finds the local name by replacing delimiters and checking the configured map
func (s *Store) localName(remote string) string {
	local := remote
	nodes := strings.Split(remote, s.Delimiter())
	// Find mapped paths by starting at most specific, and moving up the
	// tree. The first mapped path breaks the loop, as this is the most
	// specific mapping and the only one we will apply
	for i := len(nodes); i > 0; i -= 1 {
		path := strings.Join(nodes[:i], s.Delimiter())
		mapped, ok := s.cfg.Map[path]
		if ok {
			local = strings.Replace(local, path, mapped, 1)
			break
		}
	}
	if s.Delimiter() != "/" {
		local = strings.ReplaceAll(local, s.Delimiter(), "/")
	}
	return local
}

// Returns the delimiter used on the server
func (s *Store) Delimiter() string {
	if s.delim == "" {
diff --git a/imap/imap_test.go b/imap/imap_test.go
new file mode 100644
index 000000000000..1ed10cb0ee4c
--- /dev/null
+++ b/imap/imap_test.go
@@ -0,0 +1,35 @@
package imap

import (
	"testing"

	"git.sr.ht/~rockorager/offmap"
	"github.com/stretchr/testify/assert"
)

func TestLocalName(t *testing.T) {
	cfg := &offmap.Config{}
	cfg.Map = map[string]string{
		"INBOX":            "inbox",
		"INBOX/child":      "child",
		"[GMAIL]/All Mail": "Archive",
	}
	store := Store{cfg: cfg}
	store.delim = "/"
	assert := assert.New(t)
	assert.Equal("inbox", store.localName("INBOX"))
	assert.Equal("child", store.localName("INBOX/child"))
	assert.Equal("child/grandchild", store.localName("INBOX/child/grandchild"))
	assert.Equal("inbox/sibling", store.localName("INBOX/sibling"))
	assert.Equal("Archive", store.localName("[GMAIL]/All Mail"))

	store.delim = "."
	cfg.Map = map[string]string{
		"INBOX":       "inbox",
		"INBOX.child": "child",
	}
	assert.Equal("inbox", store.localName("INBOX"))
	assert.Equal("child", store.localName("INBOX.child"))
	assert.Equal("child/grandchild", store.localName("INBOX.child.grandchild"))
	assert.Equal("inbox/sibling", store.localName("INBOX.sibling"))
}
diff --git a/maildir/maildir.go b/maildir/maildir.go
index a3ed66496a54..7743f40841d8 100644
--- a/maildir/maildir.go
+++ b/maildir/maildir.go
@@ -21,6 +21,7 @@ type Store struct {
	state *offmap.State
	// map of relpath to mailbox
	mailboxes map[string]*offmap.Mailbox
	dirmap    map[string]string
}

func NewStore(cfg *offmap.Config) (*Store, error) {
@@ -35,6 +36,7 @@ func NewStore(cfg *offmap.Config) (*Store, error) {
	if err != nil {
		return nil, fmt.Errorf("maildir: error creating root: %v", err)
	}
	store.dirmap = cfg.Map

	return store, nil
}
-- 
2.39.2

[PATCH offmap 4/7] chore: remove unused tmp dir for downloads Export this patch

A previous commit changed the download strategy. The tmp dir is no
longer needed.

Signed-off-by: Tim Culverhouse <tim@timculverhouse.com>
---
 imap/imap.go | 16 ----------------
 1 file changed, 16 deletions(-)

diff --git a/imap/imap.go b/imap/imap.go
index db1f7a853097..731f726544e9 100644
--- a/imap/imap.go
+++ b/imap/imap.go
@@ -2,9 +2,7 @@ package imap

import (
	"fmt"
	"os"
	"os/exec"
	"path"
	"strconv"
	"strings"
	"sync"
@@ -24,8 +22,6 @@ type Store struct {
	// delim is the imap server's hierarchical delimiter. Offmap uses "/",
	// so a server which uses "." must have the "."s replaced
	delim string
	// tmp is the temp directory where files are located when downloaded
	tmp   string
	cache map[uint32]*offmap.Mailbox

	mailboxes []*offmap.Mailbox
@@ -56,17 +52,6 @@ func NewStore(cfg *offmap.Config) (*Store, error) {
	store.clients = make(map[int]*conn, store.cfg.MaxConnections)
	store.initClients()

	var err error
	cdir, err := os.UserCacheDir()
	if err != nil {
		return nil, fmt.Errorf("imap: could not convert uid: %v", err)
	}
	store.tmp = path.Join(cdir, "offmap")
	err = os.MkdirAll(store.tmp, 0o700)
	if err != nil {
		return nil, fmt.Errorf("imap: could not convert uid: %v", err)
	}

	return store, nil
}

@@ -334,7 +319,6 @@ func (s *Store) Delimiter() string {

func (s *Store) Cleanup() {
	s.logout()
	os.RemoveAll(s.tmp)
}

func parseHighestModSeq(status *imap.MailboxStatus) uint64 {
-- 
2.39.2

[PATCH offmap 5/7] chore: reorder goroutine for fetch commands Export this patch

Reorder for better readability, and remove a waitgroup

Signed-off-by: Tim Culverhouse <tim@timculverhouse.com>
---
 imap/imap.go | 27 +++++++++++----------------
 1 file changed, 11 insertions(+), 16 deletions(-)

diff --git a/imap/imap.go b/imap/imap.go
index 731f726544e9..4db1dff51160 100644
--- a/imap/imap.go
+++ b/imap/imap.go
@@ -106,26 +106,21 @@ func (s *Store) getCurrentState() ([]*offmap.Mailbox, error) {
			seq.AddRange(1, 0)
			items := []imap.FetchItem{imap.FetchFlags}
			msgCh := make(chan *imap.Message)
			msgWg := sync.WaitGroup{}
			msgWg.Add(1)
			go func() {
				for msg := range msgCh {
					flags := flagIMAPToMaildir(msg.Flags)
					eml := offmap.NewEmail(mbox, msg.Uid, "", flags)
					mbox.Emails = append(mbox.Emails, eml)
					atomic.AddInt32(&emails, 1)
				log.Tracef("imap (client %d): fetching uids in mailbox: %s", client.id, mbox.RemoteName)
				err = client.UidFetch(seq, items, msgCh)
				if err != nil {
					log.Errorf("imap (client %d): %v", client.id, err)
					client.done()
					return
				}
				msgWg.Done()
			}()
			log.Tracef("imap (client %d): fetching uids in mailbox: %s", client.id, mbox.RemoteName)
			err = client.UidFetch(seq, items, msgCh)
			if err != nil {
				log.Errorf("imap (client %d): %v", client.id, err)
				client.done()
				wg.Done()
				return
			for msg := range msgCh {
				flags := flagIMAPToMaildir(msg.Flags)
				eml := offmap.NewEmail(mbox, msg.Uid, "", flags)
				mbox.Emails = append(mbox.Emails, eml)
				atomic.AddInt32(&emails, 1)
			}
			msgWg.Wait()
			client.done()
			wg.Done()
		}(client, mbox)
-- 
2.39.2

[PATCH offmap 6/7] sync: add daemon mode Export this patch

Implement a daemon mode, which persists IMAP connections and triggers a
sync every specified duration. The duration is implemented as a wait
period after the last sync, therefore the total round trip is duration +
sync time. This prevents a particularly long sync (initial sync, many
emails to download, etc) from overlapping.

Implements: https://todo.sr.ht/~rockorager/offmap/6
Signed-off-by: Tim Culverhouse <tim@timculverhouse.com>
---
 cmd/sync.go      | 139 ++++++++++++++++++++++++++++-------------------
 doc/offmap.1.scd |  12 ++++
 2 files changed, 95 insertions(+), 56 deletions(-)

diff --git a/cmd/sync.go b/cmd/sync.go
index fbaa48cd34e9..ea51ee15f0a8 100644
--- a/cmd/sync.go
+++ b/cmd/sync.go
@@ -18,6 +18,7 @@ func newSyncCommand() *cobra.Command {
		Short: "sync synchronizes mail stores",
		Run:   syncRun,
	}
	cmd.Flags().DurationP("daemon", "d", 0*time.Second, "run in daemon mode, syncing at the specified interval")
	cmd.Flags().StringSliceP("account", "a", nil, "only sync the specified account(s)")
	return cmd
}
@@ -30,6 +31,11 @@ func syncRun(cmd *cobra.Command, args []string) {
		log.Errorf("offmap: could not parse account: %v", err)
		return
	}
	interval, err := cmd.Flags().GetDuration("daemon")
	if err != nil {
		log.Errorf("offmap: could not parse duration: %v", err)
		return
	}
	cfgs, err := offmap.LoadConfig(accts)
	if err != nil {
		log.Debugf("%v", err)
@@ -60,75 +66,96 @@ func syncRun(cmd *cobra.Command, args []string) {
				log.Fatalf("could not create store: %v", err)
			}

			// Get the diffs
			localDiff, err := local.Diff(state.Mailboxes)
			if err != nil {
				log.Fatalf("%v", err)
			}
			remoteDiff, err := remote.Diff(state.Mailboxes)
			if err != nil {
				log.Fatalf("%v", err)
			}
			var (
				diffWg     sync.WaitGroup
				localDiff  *offmap.Diff
				remoteDiff *offmap.Diff
			)
			for {
				// Get the diffs
				diffWg.Add(2)
				go func() {
					defer diffWg.Done()
					localDiff, err = local.Diff(state.Mailboxes)
					if err != nil {
						log.Fatalf("%v", err)
					}
				}()
				go func() {
					defer diffWg.Done()
					remoteDiff, err = remote.Diff(state.Mailboxes)
					if err != nil {
						log.Fatalf("%v", err)
					}
				}()
				diffWg.Wait()

			var quit atomic.Bool
			go func() {
				// Save our state if we cancel the command
				<-cmd.Context().Done()
				quit.Store(true)
			}()
				var quit atomic.Bool
				go func() {
					// Save our state if we cancel the command
					<-cmd.Context().Done()
					quit.Store(true)
				}()

			// Do mailboxes first
			err = remote.ApplyMailboxDiff(state, localDiff)
			if err != nil {
				// Save any changes we've made. log.Fatalf calls os.Exit(1).
				// Defers are bypassed with this call
				if err := state.Save(); err != nil {
					log.Errorf("offmap: could not save state: %v", err)
				}
				log.Fatalf("imap: could not apply diff: %v", err)
			}

			// Apply remote changes to local
			err = local.ApplyMailboxDiff(state, remoteDiff)
			if err != nil {
				// Save any changes we've made. log.Fatalf calls os.Exit(1).
				// Defers are bypassed with this call
				if err := state.Save(); err != nil {
					log.Errorf("offmap: could not save state: %v", err)
				}
				log.Fatalf("%v", err)
			}

			// Now the state has an up to date picture of mailboxes. Sync
			// each mailbox, if it has changes
			for _, mbox := range state.Mailboxes {
				if quit.Load() {
					break
				}
				ok1, err := remote.ApplyEmailDiff(state, mbox.RemoteName, localDiff)
				// Do mailboxes first
				err = remote.ApplyMailboxDiff(state, localDiff)
				if err != nil {
					log.Errorf("%v", err)
					continue
					// Save any changes we've made. log.Fatalf calls os.Exit(1).
					// Defers are bypassed with this call
					if err := state.Save(); err != nil {
						log.Errorf("offmap: could not save state: %v", err)
					}
					log.Fatalf("imap: could not apply diff: %v", err)
				}
				ok2, err := local.ApplyEmailDiff(state, mbox.LocalName, remoteDiff)

				// Apply remote changes to local
				err = local.ApplyMailboxDiff(state, remoteDiff)
				if err != nil {
					log.Errorf("%v", err)
					continue
					// Save any changes we've made. log.Fatalf calls os.Exit(1).
					// Defers are bypassed with this call
					if err := state.Save(); err != nil {
						log.Errorf("offmap: could not save state: %v", err)
					}
					log.Fatalf("%v", err)
				}
				if ok1 || ok2 {
					err = remote.UpdateMailboxStatus(state, mbox.LocalName)

				// Now the state has an up to date picture of mailboxes. Sync
				// each mailbox, if it has changes
				for _, mbox := range state.Mailboxes {
					if quit.Load() {
						break
					}
					ok1, err := remote.ApplyEmailDiff(state, mbox.RemoteName, localDiff)
					if err != nil {
						log.Errorf("%v", err)
						continue
					}
					ok2, err := local.ApplyEmailDiff(state, mbox.LocalName, remoteDiff)
					if err != nil {
						log.Errorf("%v", err)
						continue
					}
					if ok1 || ok2 {
						err = remote.UpdateMailboxStatus(state, mbox.LocalName)
						if err != nil {
							log.Errorf("%v", err)
						}
					}
				}
			}

			// Save and clean up
			if err := state.Save(); err != nil {
				log.Errorf("offmap: could not save state: %v", err)
				// Save and clean up
				if err := state.Save(); err != nil {
					log.Errorf("offmap: could not save state: %v", err)
				}

				log.Infof("offmap (%s): sync completed in %s", cfg.Name, time.Since(start).Round(1*time.Millisecond))
				if interval == 0 || quit.Load() {
					break
				}
				time.Sleep(interval)
				start = time.Now()
			}
			remote.Cleanup()
			log.Infof("offmap (%s): sync completed in %s", cfg.Name, time.Since(start).Round(1*time.Millisecond))
		}(cfg)
	}

diff --git a/doc/offmap.1.scd b/doc/offmap.1.scd
index bdfcee241a23..565d940a234c 100644
--- a/doc/offmap.1.scd
+++ b/doc/offmap.1.scd
@@ -27,6 +27,18 @@ offmap - an email synchronizer
	offmap sync -a personal -a work
	```

*-d, --daemon* <duration>
	Run offmap in daemon mode, syncing at the specified duration. The
	duration is applied after the last sync is complete. The IMAP connection
	is persisted; it is recommended to keep a low number of max-connections
	when using daemon mode, particularly if your IMAP server supports
	LIST-STATUS.

	Example:
	```
	offmap sync -a personal -d 30s
	```

*-v, --verbose*
	Increase verbosity level. Can be used multiple times

-- 
2.39.2

[PATCH offmap 7/7] chore: move quit goroutine outside of loop Export this patch

Move the quit goroutine outside of the main loop to prevent creating it
several times during daemon mode
---
 cmd/sync.go | 13 ++++++-------
 1 file changed, 6 insertions(+), 7 deletions(-)

diff --git a/cmd/sync.go b/cmd/sync.go
index ea51ee15f0a8..f4533be89cc8 100644
--- a/cmd/sync.go
+++ b/cmd/sync.go
@@ -40,6 +40,12 @@ func syncRun(cmd *cobra.Command, args []string) {
	if err != nil {
		log.Debugf("%v", err)
	}
	var quit atomic.Bool
	go func() {
		// Save our state if we cancel the command
		<-cmd.Context().Done()
		quit.Store(true)
	}()
	var wg sync.WaitGroup
	for _, cfg := range cfgs {
		wg.Add(1)
@@ -90,13 +96,6 @@ func syncRun(cmd *cobra.Command, args []string) {
				}()
				diffWg.Wait()

				var quit atomic.Bool
				go func() {
					// Save our state if we cancel the command
					<-cmd.Context().Done()
					quit.Store(true)
				}()

				// Do mailboxes first
				err = remote.ApplyMailboxDiff(state, localDiff)
				if err != nil {
-- 
2.39.2
offmap/patches/.build.yml: FAILED in 1m41s

[Backlog-o-patches][0] from [Tim Culverhouse][1]

[0]: https://lists.sr.ht/~rockorager/offmap/patches/39503
[1]: mailto:tim@timculverhouse.com

✗ #951712 FAILED offmap/patches/.build.yml https://builds.sr.ht/~rockorager/job/951712