diff --git a/go.mod b/go.mod index 2f2cdf0..1547dba 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.3 require ( github.com/coder/websocket v1.8.15 + github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/go-chi/chi/v5 v5.3.1 github.com/go-webauthn/webauthn v0.17.4 github.com/pquerna/otp v1.5.0 @@ -15,6 +16,8 @@ require ( require ( github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emersion/go-message v0.18.2 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.2.6 // indirect diff --git a/go.sum b/go.sum index 47ca1ee..717d30f 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= +github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= @@ -51,18 +57,49 @@ github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= diff --git a/internal/imap/imap.go b/internal/imap/imap.go new file mode 100644 index 0000000..2aa2515 --- /dev/null +++ b/internal/imap/imap.go @@ -0,0 +1,187 @@ +// Package imap reads a mailbox over IMAP and hands back the messages that should +// become tasks. +// +// This exists next to internal/gmail rather than inside it because the two agree +// on almost nothing. Gmail is an HTTP API with OAuth, labels and stars; IMAP is a +// connection with a password, folders and flags. What they do share is the shape +// of the answer — a handful of messages, each with a sender, a subject and enough +// of a body to read — and that shape is Message here, deliberately identical to +// what the Gmail client returns, so the caller can be told where to look and not +// how to look. +// +// iCloud, Fastmail and any ordinary host speak this. Office 365 no longer accepts +// a password here at all — it wants OAuth of its own — so it is not a host you can +// reach with this package, and pretending otherwise would only fail later and +// less clearly. +package imap + +import ( + "crypto/tls" + "fmt" + "net" + "strings" + "time" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" +) + +// Message is one mail, reduced to what a task needs. +type Message struct { + UID uint32 + From string + Subject string + Snippet string + Date time.Time +} + +// Account is one mailbox to read. +type Account struct { + Host string // "imap.mail.me.com:993" + Username string + Password string // an app-specific password on every host worth naming + Folder string // "INBOX" when empty +} + +// Dial opens a TLS connection and logs in. The caller closes it. +// +// TLS is not optional and there is no flag to make it so: this carries a password +// that unlocks a person's whole mailbox, and a switch for turning that protection +// off is a switch that will one day be found already flipped. +func Dial(a Account) (*Client, error) { + host, _, err := net.SplitHostPort(a.Host) + if err != nil { + return nil, fmt.Errorf("host must include a port, like imap.mail.me.com:993: %w", err) + } + + c, err := imapclient.DialTLS(a.Host, &imapclient.Options{ + TLSConfig: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}, + }) + if err != nil { + return nil, fmt.Errorf("connect to %s: %w", a.Host, err) + } + + if err := c.Login(a.Username, a.Password).Wait(); err != nil { + c.Close() + // Deliberately not repeating the server's own words here. A bad password is + // the overwhelmingly likely cause, and hosts answer it with everything from + // "AUTHENTICATIONFAILED" to a sentence about web login being required. + return nil, fmt.Errorf("%s refused the login for %s: %w", a.Host, a.Username, err) + } + + folder := a.Folder + if folder == "" { + folder = "INBOX" + } + return &Client{c: c, folder: folder}, nil +} + +// Client is a logged-in connection to one mailbox. +type Client struct { + c *imapclient.Client + folder string +} + +// Close ends the session politely, then hangs up regardless. +func (c *Client) Close() error { + if err := c.c.Logout().Wait(); err != nil { + return c.c.Close() + } + return c.c.Close() +} + +// Since returns the flagged messages in the folder newer than uid. +// +// Flagged, not unread: unread is a state the person is still using, and a sync +// that consumed it would be taking something away. Flagging a mail is a deliberate +// act with no other meaning, which is exactly what is wanted — the same reason the +// Gmail side reads stars. +func (c *Client) Since(uid uint32, limit int) ([]Message, error) { + if _, err := c.c.Select(c.folder, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil { + return nil, fmt.Errorf("open %s: %w", c.folder, err) + } + + // From uid+1 to the end. A mailbox with nothing new answers with no uids + // rather than an error, and that is not a failure worth reporting upwards. + set := imap.UIDSetNum() + set.AddRange(imap.UID(uid+1), 0) + + criteria := &imap.SearchCriteria{ + UID: []imap.UIDSet{set}, + Flag: []imap.Flag{imap.FlagFlagged}, + } + found, err := c.c.UIDSearch(criteria, nil).Wait() + if err != nil { + return nil, fmt.Errorf("search %s: %w", c.folder, err) + } + + uids := found.AllUIDs() + if len(uids) == 0 { + return nil, nil + } + if limit > 0 && len(uids) > limit { + // The newest ones. A mailbox flagged over years would otherwise make the + // first sync the largest one, which is the run most likely to be watched. + uids = uids[len(uids)-limit:] + } + + want := imap.UIDSetNum(uids...) + fetch := &imap.FetchOptions{ + Envelope: true, + UID: true, + BodySection: []*imap.FetchItemBodySection{{Specifier: imap.PartSpecifierText, Peek: true}}, + } + msgs, err := c.c.Fetch(want, fetch).Collect() + if err != nil { + return nil, fmt.Errorf("fetch from %s: %w", c.folder, err) + } + + out := make([]Message, 0, len(msgs)) + for _, m := range msgs { + if m.Envelope == nil { + continue + } + out = append(out, Message{ + UID: uint32(m.UID), + From: address(m.Envelope.From), + Subject: strings.TrimSpace(m.Envelope.Subject), + Snippet: snippet(m), + Date: m.Envelope.Date, + }) + } + return out, nil +} + +// address renders the first sender as "Name ", or just the address. +func address(list []imap.Address) string { + if len(list) == 0 { + return "" + } + a := list[0] + addr := a.Addr() + if a.Name == "" { + return addr + } + return a.Name + " <" + addr + ">" +} + +// snippet is the first readable line or so of the body. +// +// No MIME walk and no HTML stripping yet: this is what shows under a task's title, +// and a wrong-but-short line is a smaller problem than a parser that has to be +// right about every mail ever sent. The full mail stays where it is. +const snippetLimit = 400 + +func snippet(m *imapclient.FetchMessageBuffer) string { + for _, body := range m.BodySection { + text := strings.TrimSpace(string(body.Bytes)) + if text == "" { + continue + } + if len(text) > snippetLimit { + text = text[:snippetLimit] + "…" + } + return text + } + return "" +} diff --git a/internal/imap/imap_test.go b/internal/imap/imap_test.go new file mode 100644 index 0000000..b6d21fd --- /dev/null +++ b/internal/imap/imap_test.go @@ -0,0 +1,77 @@ +package imap + +import ( + "strings" + "testing" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" +) + +// A host without a port is the mistake everyone makes once, and the error a TLS +// dial gives for it says nothing useful about which of the two fields is wrong. +func TestAHostWithoutAPortSaysSo(t *testing.T) { + _, err := Dial(Account{Host: "imap.mail.me.com", Username: "kw", Password: "x"}) + if err == nil { + t.Fatal("a host with no port was accepted") + } + if !strings.Contains(err.Error(), "port") { + t.Errorf("the error does not mention the port: %v", err) + } +} + +func TestASenderIsRenderedForReading(t *testing.T) { + for _, c := range []struct { + name string + in []imap.Address + want string + }{ + {"nobody", nil, ""}, + {"bare address", []imap.Address{{Mailbox: "kw", Host: "nolimit.dk"}}, "kw@nolimit.dk"}, + {"with a name", []imap.Address{{Name: "Kristian Wind", Mailbox: "kw", Host: "nolimit.dk"}}, + "Kristian Wind "}, + // The first sender wins. A mail from two people is rare enough that naming + // one is better than a task titled with a list. + {"two senders", []imap.Address{ + {Mailbox: "first", Host: "example.dk"}, + {Mailbox: "second", Host: "example.dk"}, + }, "first@example.dk"}, + } { + t.Run(c.name, func(t *testing.T) { + if got := address(c.in); got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} + +// The snippet is what shows under a task's title, so its only real duty is to end. +func TestASnippetIsTrimmedAndBounded(t *testing.T) { + long := strings.Repeat("a", snippetLimit*2) + m := &imapclient.FetchMessageBuffer{ + BodySection: []imapclient.FetchBodySectionBuffer{ + {Bytes: []byte(" ")}, + {Bytes: []byte(" " + long + " ")}, + }, + } + got := snippet(m) + if strings.HasPrefix(got, " ") || strings.HasSuffix(got, " ") { + t.Errorf("the snippet is not trimmed: %q", got[:20]) + } + // An empty part must not win over the one that has the text. + if !strings.HasPrefix(got, "aaa") { + t.Errorf("an empty body part was preferred: %q", got) + } + if len([]rune(got)) > snippetLimit+1 { + t.Errorf("the snippet runs to %d characters", len([]rune(got))) + } + if !strings.HasSuffix(got, "…") { + t.Error("a cut snippet does not say that it was cut") + } +} + +func TestAnEmptyMailboxIsNotAnError(t *testing.T) { + if got := snippet(&imapclient.FetchMessageBuffer{}); got != "" { + t.Errorf("a mail with no body parts gave %q", got) + } +}