Compare commits
4 Commits
main
...
land/pr-26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25b1fe338e | ||
|
|
3d4a111ddd | ||
|
|
19e0491d55 | ||
|
|
9a0c246d58 |
@ -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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user