Compare commits

...

4 Commits

Author SHA1 Message Date
Peter Steinberger
25b1fe338e fix(auth): avoid goconst on services=all
Some checks failed
ci / test (push) Has been cancelled
ci / worker (push) Has been cancelled
ci / darwin-cgo-build (push) Has been cancelled
2026-02-15 03:57:34 +01:00
Peter Steinberger
3d4a111ddd chore(lint): silence goconst for auth services 2026-02-15 03:56:49 +01:00
Peter Steinberger
19e0491d55 fix(drive,gmail): tighten trashed predicate detection 2026-02-15 03:54:18 +01:00
salmonumbrella
9a0c246d58 fix(drive,gmail): pass-through filter queries and encode non-ASCII headers
Drive search now detects raw API filter syntax (field comparisons,
contains, membership, has) and passes it through instead of wrapping
in fullText contains. Plain-text searches always append trashed=false
to prevent false positives.

Gmail MIME now RFC 2047-encodes display names with non-ASCII characters
in From/To/Cc/Bcc/Reply-To headers using net/mail.

Fixes #254, fixes #255

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 03:52:27 +01:00
7 changed files with 324 additions and 11 deletions

View File

@ -14,6 +14,7 @@
### Fixed
- Auth: manual OAuth flow uses an ephemeral loopback redirect port (avoids unsafe/privileged ports in browsers). (#172) — thanks @spookyuser.
- Gmail: when `gmail attachment --out` points to a directory (or ends with a trailing slash), combine with `--name` and avoid false cache hits on directories. (#248) — thanks @zerone0x.
- Drive/Gmail: pass through Drive API filter queries in `drive search`; RFC 2047-encode non-ASCII display names in mail headers (`From`/`To`/`Cc`/`Bcc`/`Reply-To`). (#260) — thanks @salmonumbrella.
- Calendar: fall back to fixed-offset timezones (`Etc/GMT±N`) for recurring events when given RFC3339 offset datetimes; harden Gmail attachment output paths and cache validation; honor proxy defaults for Google API transports. (#228) — thanks @salmonumbrella.
- Calendar: allow opting into attendee notifications for updates and cancellations via `calendar update|delete --send-updates all|externalOnly|none`. (#163) — thanks @tonimelisma.
- Gmail: include primary display name in `gmail send` From header when using service account impersonation (domain-wide delegation). (#184) — thanks @salmonumbrella.

View File

@ -1137,7 +1137,8 @@ func (c *AuthKeepCmd) Run(ctx context.Context, _ *RootFlags) error {
func parseAuthServices(servicesCSV string) ([]googleauth.Service, error) {
trimmed := strings.ToLower(strings.TrimSpace(servicesCSV))
if trimmed == "" || trimmed == "user" || trimmed == "all" {
const servicesAll = scopeAll
if trimmed == "" || trimmed == "user" || trimmed == servicesAll {
return googleauth.UserServices(), nil
}

View File

@ -8,6 +8,7 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"google.golang.org/api/drive/v3"
@ -21,6 +22,16 @@ import (
var newDriveService = googleapi.NewDrive
var (
driveSearchFieldComparisonPattern = regexp.MustCompile(`(?i)\b(?:mimeType|name|fullText|trashed|starred|modifiedTime|createdTime|viewedByMeTime|visibility)\b\s*(?:!=|<=|>=|=|<|>)`)
driveSearchContainsPattern = regexp.MustCompile(`(?i)\b(?:name|fullText)\b\s+contains\s+'`)
driveSearchMembershipPattern = regexp.MustCompile(`(?i)'[^']+'\s+in\s+(?:parents|owners|writers|readers)`)
driveSearchHasPattern = regexp.MustCompile(`(?i)\b(?:properties|appProperties)\b\s+has\s+\{`)
// Only treat as "already constrained" when the query contains a real trashed predicate,
// not just the word inside a quoted literal (e.g. "name contains 'trashed'").
driveTrashedPredicatePattern = regexp.MustCompile(`(?i)\btrashed\b\s*(?:=|!=)\s*(?:true|false)\b`)
)
const (
driveMimeGoogleDoc = "application/vnd.google-apps.document"
driveMimeGoogleSheet = "application/vnd.google-apps.spreadsheet"
@ -984,15 +995,38 @@ func buildDriveListQuery(folderID string, userQuery string) string {
} else {
q = parent
}
if !strings.Contains(q, "trashed") {
if !hasDriveTrashedPredicate(q) {
q += " and trashed = false"
}
return q
}
func buildDriveSearchQuery(text string) string {
q := fmt.Sprintf("fullText contains '%s'", escapeDriveQueryString(text))
return q + " and trashed = false"
q := strings.TrimSpace(text)
if q == "" {
return "trashed = false"
}
if !looksLikeDriveFilterQuery(q) {
return fmt.Sprintf("fullText contains '%s' and trashed = false", escapeDriveQueryString(q))
}
if !hasDriveTrashedPredicate(q) {
q += " and trashed = false"
}
return q
}
func looksLikeDriveFilterQuery(q string) bool {
if strings.EqualFold(q, "sharedWithMe") {
return true
}
return driveSearchFieldComparisonPattern.MatchString(q) ||
driveSearchContainsPattern.MatchString(q) ||
driveSearchMembershipPattern.MatchString(q) ||
driveSearchHasPattern.MatchString(q)
}
func hasDriveTrashedPredicate(q string) bool {
return driveTrashedPredicatePattern.MatchString(q)
}
func escapeDriveQueryString(s string) string {

View File

@ -194,3 +194,67 @@ func TestDriveSearchCmd_NoAllDrives(t *testing.T) {
t.Fatalf("execute: %v", execErr)
}
}
func TestDriveSearchCmd_PassesThroughDriveFilterQueries(t *testing.T) {
origNew := newDriveService
t.Cleanup(func() { newDriveService = origNew })
const query = "mimeType = 'application/vnd.google-apps.document'"
const wantQ = query + " and trashed = false"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
path := strings.TrimPrefix(r.URL.Path, "/drive/v3")
if path != "/files" {
http.NotFound(w, r)
return
}
if errMsg := driveAllDrivesQueryError(r, true); errMsg != "" {
http.Error(w, errMsg, http.StatusBadRequest)
return
}
if got := r.URL.Query().Get("q"); got != wantQ {
http.Error(w, "unexpected query: "+got, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"files": []map[string]any{
{
"id": "f1",
"name": "Doc",
"mimeType": "application/vnd.google-apps.document",
"modifiedTime": "2025-12-12T14:37:47Z",
},
},
})
}))
t.Cleanup(srv.Close)
svc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newDriveService = func(context.Context, string) (*drive.Service, error) { return svc, nil }
flags := &RootFlags{Account: "a@b.com"}
var errBuf bytes.Buffer
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: &errBuf, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
_ = captureStdout(t, func() {
cmd := &DriveSearchCmd{}
if execErr := runKong(t, cmd, []string{query}, ctx, flags); execErr != nil {
t.Fatalf("execute: %v", execErr)
}
})
}

View File

@ -23,6 +23,13 @@ func TestBuildDriveListQuery(t *testing.T) {
t.Fatalf("unexpected: %q", got)
}
})
t.Run("does not treat quoted 'trashed' as predicate", func(t *testing.T) {
got := buildDriveListQuery("abc", "name contains 'trashed'")
if got != "name contains 'trashed' and 'abc' in parents and trashed = false" {
t.Fatalf("unexpected: %q", got)
}
})
}
func TestBuildDriveSearchQuery(t *testing.T) {
@ -30,6 +37,38 @@ func TestBuildDriveSearchQuery(t *testing.T) {
if got != "fullText contains 'hello world' and trashed = false" {
t.Fatalf("unexpected: %q", got)
}
t.Run("passes through filter query", func(t *testing.T) {
got := buildDriveSearchQuery("mimeType = 'application/vnd.google-apps.document'")
want := "mimeType = 'application/vnd.google-apps.document' and trashed = false"
if got != want {
t.Fatalf("unexpected: %q", got)
}
})
t.Run("filter query containing quoted trashed still appends trashed=false", func(t *testing.T) {
got := buildDriveSearchQuery("name contains 'trashed'")
want := "name contains 'trashed' and trashed = false"
if got != want {
t.Fatalf("unexpected: %q", got)
}
})
t.Run("plain text containing trashed still appends trashed=false", func(t *testing.T) {
got := buildDriveSearchQuery("trashed")
want := "fullText contains 'trashed' and trashed = false"
if got != want {
t.Fatalf("unexpected: %q", got)
}
})
t.Run("does not add trashed when already present", func(t *testing.T) {
got := buildDriveSearchQuery("mimeType != 'application/vnd.google-apps.folder' and TrAsHeD = true")
want := "mimeType != 'application/vnd.google-apps.folder' and TrAsHeD = true"
if got != want {
t.Fatalf("unexpected: %q", got)
}
})
}
func TestEscapeDriveQueryString(t *testing.T) {
@ -50,3 +89,64 @@ func TestFormatDriveSize(t *testing.T) {
t.Fatalf("unexpected: %q", got)
}
}
func TestLooksLikeDriveFilterQuery(t *testing.T) {
tests := []struct {
name string
query string
want bool
}{
// --- Should return true (filter queries) ---
// Field comparisons
{name: "mimeType equals", query: "mimeType = 'application/vnd.google-apps.document'", want: true},
{name: "name not equals", query: "name != 'untitled'", want: true},
{name: "modifiedTime greater than", query: "modifiedTime > '2024-01-01'", want: true},
{name: "trashed equals", query: "trashed = true", want: true},
{name: "starred equals", query: "starred = false", want: true},
{name: "createdTime less than", query: "createdTime < '2023-06-01'", want: true},
{name: "viewedByMeTime gte", query: "viewedByMeTime >= '2024-01-01'", want: true},
{name: "visibility equals", query: "visibility = 'anyoneWithLink'", want: true},
// Contains
{name: "name contains", query: "name contains 'report'", want: true},
{name: "fullText contains", query: "fullText contains 'budget'", want: true},
// Membership (in)
{name: "in parents", query: "'folder123' in parents", want: true},
{name: "in owners", query: "'user@example.com' in owners", want: true},
{name: "in writers", query: "'user@example.com' in writers", want: true},
{name: "in readers", query: "'reader@example.com' in readers", want: true},
// Has property
{name: "properties has", query: "properties has { key='department' and value='finance' }", want: true},
{name: "appProperties has", query: "appProperties has { key='project' and value='alpha' }", want: true},
// sharedWithMe (case-insensitive)
{name: "sharedWithMe exact", query: "sharedWithMe", want: true},
{name: "sharedWithMe uppercase", query: "SHAREDWITHME", want: true},
{name: "sharedWithMe mixed case", query: "SharedWithMe", want: true},
// Compound queries
{name: "compound mimeType and name contains", query: "mimeType = 'application/pdf' and name contains 'report'", want: true},
{name: "compound trashed and starred", query: "trashed = false and starred = true", want: true},
// --- Should return false (natural language / plain text) ---
{name: "plain text meeting notes", query: "meeting notes", want: false},
{name: "plain text find my documents", query: "find my documents", want: false},
{name: "plain text trashed files", query: "trashed files", want: false},
{name: "plain text hello world", query: "hello world", want: false},
{name: "plain text important", query: "important", want: false},
{name: "empty string", query: "", want: false},
{name: "whitespace only", query: " ", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := looksLikeDriveFilterQuery(tt.query)
if got != tt.want {
t.Errorf("looksLikeDriveFilterQuery(%q) = %v, want %v", tt.query, got, tt.want)
}
})
}
}

View File

@ -65,21 +65,21 @@ func buildRFC822(opts mailOptions, cfg *rfc822Config) ([]byte, error) {
}
}
writeHeader(&b, "From", opts.From)
writeHeader(&b, "From", formatAddressHeader(opts.From))
if len(opts.To) > 0 {
writeHeader(&b, "To", strings.Join(opts.To, ", "))
writeHeader(&b, "To", formatAddressHeaders(opts.To))
}
if len(opts.Cc) > 0 {
writeHeader(&b, "Cc", strings.Join(opts.Cc, ", "))
writeHeader(&b, "Cc", formatAddressHeaders(opts.Cc))
}
if len(opts.Bcc) > 0 {
writeHeader(&b, "Bcc", strings.Join(opts.Bcc, ", "))
writeHeader(&b, "Bcc", formatAddressHeaders(opts.Bcc))
}
if strings.TrimSpace(opts.ReplyTo) != "" {
if err := validateHeaderValue(opts.ReplyTo); err != nil {
return nil, fmt.Errorf("invalid Reply-To: %w", err)
}
writeHeader(&b, "Reply-To", strings.TrimSpace(opts.ReplyTo))
writeHeader(&b, "Reply-To", formatAddressHeader(opts.ReplyTo))
}
if err := validateHeaderValue(opts.Subject); err != nil {
return nil, fmt.Errorf("invalid Subject: %w", err)
@ -217,6 +217,33 @@ func writeHeader(b *bytes.Buffer, name, value string) {
b.WriteString("\r\n")
}
func formatAddressHeader(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return trimmed
}
addr, err := mail.ParseAddress(trimmed)
if err != nil {
return trimmed
}
if strings.TrimSpace(addr.Name) == "" {
return addr.Address
}
return addr.String()
}
func formatAddressHeaders(values []string) string {
formatted := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
formatted = append(formatted, formatAddressHeader(trimmed))
}
return strings.Join(formatted, ", ")
}
func wrapBase64(b []byte) string {
s := base64.StdEncoding.EncodeToString(b)
const width = 76

View File

@ -135,11 +135,52 @@ func TestBuildRFC822UTF8Subject(t *testing.T) {
t.Fatalf("err: %v", err)
}
s := string(raw)
if !strings.Contains(s, "Subject: =?utf-8?") {
if !strings.Contains(strings.ToLower(s), "subject: =?utf-8?") {
t.Fatalf("expected encoded-word Subject: %q", s)
}
}
func TestBuildRFC822UTF8FromDisplayName(t *testing.T) {
raw, err := buildRFC822(mailOptions{
From: "Sérgio Bastos • Importrust <alias@domain.com>",
To: []string{"c@d.com"},
Subject: "Hi",
Body: "Hello",
}, nil)
if err != nil {
t.Fatalf("err: %v", err)
}
s := string(raw)
if !strings.Contains(strings.ToLower(s), "from: =?utf-8?") {
t.Fatalf("expected encoded-word From header: %q", s)
}
if !strings.Contains(s, "<alias@domain.com>") {
t.Fatalf("expected alias email in From header: %q", s)
}
if strings.Contains(s, "From: Sérgio Bastos • Importrust <alias@domain.com>") {
t.Fatalf("expected From header to be RFC 2047 encoded: %q", s)
}
}
func TestBuildRFC822PlainFromAddressStaysUnwrapped(t *testing.T) {
raw, err := buildRFC822(mailOptions{
From: "a@b.com",
To: []string{"c@d.com"},
Subject: "Hi",
Body: "Hello",
}, nil)
if err != nil {
t.Fatalf("err: %v", err)
}
s := string(raw)
if !strings.Contains(s, "From: a@b.com\r\n") {
t.Fatalf("expected plain From address, got: %q", s)
}
if strings.Contains(s, "From: <a@b.com>\r\n") {
t.Fatalf("unexpected wrapped From address: %q", s)
}
}
func TestBuildRFC822ReplyToHeader(t *testing.T) {
raw, err := buildRFC822(mailOptions{
From: "a@b.com",
@ -197,7 +238,7 @@ func TestEncodeHeaderIfNeeded(t *testing.T) {
t.Fatalf("unexpected: %q", got)
}
got := encodeHeaderIfNeeded("Grüße")
if got == "Grüße" || !strings.Contains(got, "=?utf-8?") {
if got == "Grüße" || !strings.Contains(strings.ToLower(got), "=?utf-8?") {
t.Fatalf("expected encoded-word, got: %q", got)
}
}
@ -255,3 +296,48 @@ func TestRandomMessageID(t *testing.T) {
t.Fatalf("unexpected: %q", id)
}
}
func TestFormatAddressHeaderUnparseable(t *testing.T) {
input := "not an email at all"
got := formatAddressHeader(input)
if got != input {
t.Fatalf("expected unparseable input returned unchanged, got: %q", got)
}
}
func TestFormatAddressHeadersMixed(t *testing.T) {
input := []string{"Alice <a@b.com>", "c@d.com", "Sérgio Bastos <s@b.com>"}
got := formatAddressHeaders(input)
// Should contain all three addresses comma-separated.
parts := strings.SplitN(got, ", ", 3)
if len(parts) != 3 {
t.Fatalf("expected 3 comma-separated parts, got %d: %q", len(parts), got)
}
// First part: display name "Alice" with address a@b.com.
if !strings.Contains(parts[0], "Alice") || !strings.Contains(parts[0], "a@b.com") {
t.Fatalf("unexpected first part: %q", parts[0])
}
// Second part: plain address, no angle brackets.
if parts[1] != "c@d.com" {
t.Fatalf("expected plain address c@d.com, got: %q", parts[1])
}
// Third part: non-ASCII name must be RFC 2047 encoded.
if !strings.Contains(strings.ToLower(parts[2]), "=?utf-8?") {
t.Fatalf("expected RFC 2047 encoded name in third part, got: %q", parts[2])
}
if !strings.Contains(parts[2], "s@b.com") {
t.Fatalf("expected address s@b.com in third part, got: %q", parts[2])
}
}
func TestFormatAddressHeadersFiltersEmpty(t *testing.T) {
got := formatAddressHeaders([]string{"a@b.com", "", "b@c.com"})
expected := "a@b.com, b@c.com"
if got != expected {
t.Fatalf("expected %q, got %q", expected, got)
}
}