A starred Gmail message became a task and kept its star, so a starred inbox only grew — the IMAP side has always unflagged what it imports, and Gmail could not, because the scope was gmail.readonly. This gives Gmail the same one write: after the task is made, the STARRED label comes off the message, in one batchModify for the whole sweep. Nothing else is touched — not the body, not other labels, and a label trigger's label stays where it is, since that label is the person's own. The scope goes from gmail.readonly to gmail.modify, which is the narrowest scope Google offers that can remove a label. An existing connection keeps working on its old read-only grant — the sync still reads, the unstar is a best-effort 403 that is logged, not fatal — and starts clearing stars once the account is reconnected and the new grant is in place. It self-heals: a mail that could not be unstarred stays starred, stays in the next query, and is tried again. The consent screen now asks for more than it used to; the settings copy, the docs and the scope table all say modify rather than read-only in the same change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
135 lines
4.4 KiB
Go
135 lines
4.4 KiB
Go
package gmail
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The scope is the one thing verdande chooses about the connection, and it is the
|
|
// one the person reads on the consent screen. The flow itself is exercised in
|
|
// internal/google; what belongs here is that Gmail asks for modify — read plus the
|
|
// one write it makes, taking the star off an imported message — and nothing wider.
|
|
func TestTheScopeIsModifyMail(t *testing.T) {
|
|
if !strings.HasSuffix(Scope, "gmail.modify") {
|
|
t.Errorf("scope = %q; want gmail.modify — read, plus removing the star", Scope)
|
|
}
|
|
// Modify is the ceiling. Send, or the full account, would be a different promise
|
|
// to the person on the consent screen than the one this feature needs.
|
|
for _, wider := range []string{"gmail.send", "mail.google.com", "gmail.settings"} {
|
|
if strings.Contains(Scope, wider) {
|
|
t.Errorf("scope = %q; must not include %q", Scope, wider)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unstar removes the star from a batch in one call, and it addresses the messages
|
|
// it was given and only the STARRED label.
|
|
func TestUnstarRemovesOnlyTheStar(t *testing.T) {
|
|
var gotPath string
|
|
var gotBody struct {
|
|
IDs []string `json:"ids"`
|
|
AddLabelIDs []string `json:"addLabelIds"`
|
|
RemoveLabelIDs []string `json:"removeLabelIds"`
|
|
}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
raw, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(raw, &gotBody)
|
|
w.WriteHeader(http.StatusNoContent) // what batchModify answers on success
|
|
}))
|
|
defer srv.Close()
|
|
|
|
err := NewClient("token").At(srv.URL).Unstar(context.Background(), []string{"a", "b"})
|
|
if err != nil {
|
|
t.Fatalf("Unstar: %v", err)
|
|
}
|
|
if !strings.HasSuffix(gotPath, "/users/me/messages/batchModify") {
|
|
t.Errorf("path = %q, want the batchModify endpoint", gotPath)
|
|
}
|
|
if strings.Join(gotBody.IDs, ",") != "a,b" {
|
|
t.Errorf("ids = %v, want [a b]", gotBody.IDs)
|
|
}
|
|
if strings.Join(gotBody.RemoveLabelIDs, ",") != "STARRED" {
|
|
t.Errorf("removeLabelIds = %v, want [STARRED]", gotBody.RemoveLabelIDs)
|
|
}
|
|
if len(gotBody.AddLabelIDs) != 0 {
|
|
t.Errorf("addLabelIds = %v, want none — verdande adds no label", gotBody.AddLabelIDs)
|
|
}
|
|
}
|
|
|
|
// An empty batch is not a request: Gmail rejects a batchModify with no ids, and
|
|
// there is nothing to say to it anyway.
|
|
func TestUnstarOfNothingMakesNoCall(t *testing.T) {
|
|
called := false
|
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
called = true
|
|
}))
|
|
defer srv.Close()
|
|
|
|
if err := NewClient("token").At(srv.URL).Unstar(context.Background(), nil); err != nil {
|
|
t.Fatalf("Unstar(nil): %v", err)
|
|
}
|
|
if called {
|
|
t.Error("Unstar(nil) made a request; it should make none")
|
|
}
|
|
}
|
|
|
|
func TestQuery(t *testing.T) {
|
|
cases := []struct {
|
|
trigger, label string
|
|
wantContains []string
|
|
wantEmpty bool
|
|
}{
|
|
{"starred", "", []string{"is:starred", "newer_than:30d"}, false},
|
|
{"label", "Til handling", []string{`label:"Til handling"`, "newer_than:30d"}, false},
|
|
{"both", "Til handling", []string{"is:starred", `label:"Til handling"`}, false},
|
|
{"both", "", []string{"is:starred"}, false},
|
|
// A label trigger with no label selects nothing, which is right: it would
|
|
// otherwise match the whole mailbox.
|
|
{"label", "", nil, true},
|
|
{"", "", nil, true},
|
|
{"nonsense", "", nil, true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.trigger+"/"+tc.label, func(t *testing.T) {
|
|
got := Query(tc.trigger, tc.label)
|
|
if tc.wantEmpty {
|
|
if got != "" {
|
|
t.Errorf("Query = %q, want empty", got)
|
|
}
|
|
return
|
|
}
|
|
for _, want := range tc.wantContains {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("Query = %q, missing %q", got, want)
|
|
}
|
|
}
|
|
// Without a window, connecting an account with a decade of starred
|
|
// mail would create a decade of tasks in one sweep.
|
|
if !strings.Contains(got, "newer_than:") {
|
|
t.Errorf("Query = %q has no time window", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSenderName(t *testing.T) {
|
|
cases := map[string]string{
|
|
"Anders Jensen <anders@example.dk>": "Anders Jensen",
|
|
`"Jensen, Anders" <anders@example.dk>`: "Jensen, Anders",
|
|
"anders@example.dk": "anders@example.dk",
|
|
"<anders@example.dk>": "anders@example.dk",
|
|
" Anders <a@b.dk> ": "Anders",
|
|
}
|
|
for in, want := range cases {
|
|
if got := SenderName(in); got != want {
|
|
t.Errorf("SenderName(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|