Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions internal/cmd/drive.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"

"google.golang.org/api/drive/v3"
Expand All @@ -21,6 +22,14 @@ 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+\{`)
driveTrashedPattern = regexp.MustCompile(`(?i)\btrashed\b`)
)

const (
driveMimeGoogleDoc = "application/vnd.google-apps.document"
driveMimeGoogleSheet = "application/vnd.google-apps.spreadsheet"
Expand Down Expand Up @@ -956,15 +965,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 driveTrashedPattern.MatchString(q)
}

func escapeDriveQueryString(s string) string {
Expand Down
59 changes: 59 additions & 0 deletions internal/cmd/drive_search_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,62 @@ func TestDriveSearchCmd_NoResultsAndEmptyQuery(t *testing.T) {
t.Fatalf("expected empty query error")
}
}

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
}
if errMsg := driveAllDrivesQueryError(r); 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)
}
})
}
85 changes: 85 additions & 0 deletions internal/cmd/drive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,30 @@ 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("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) {
Expand All @@ -50,3 +74,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)
}
})
}
}
37 changes: 32 additions & 5 deletions internal/cmd/gmail_mime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions internal/cmd/gmail_mime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,47 @@ func TestBuildRFC822UTF8Subject(t *testing.T) {
}
}

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(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",
Expand Down Expand Up @@ -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(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)
}
}
1 change: 1 addition & 0 deletions internal/cmd/gmail_send_batches_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func TestSendGmailBatches_WithTracking(t *testing.T) {
Enabled: true,
WorkerURL: "https://example.com",
TrackingKey: mustTrackingKey(t),
AdminKey: "test-admin-key",
}

batches := buildSendBatches(
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/gmail_track.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ type GmailTrackCmd struct {
Setup GmailTrackSetupCmd `cmd:"" help:"Set up email tracking (deploy Cloudflare Worker)"`
Opens GmailTrackOpensCmd `cmd:"" help:"Query email opens"`
Status GmailTrackStatusCmd `cmd:"" help:"Show tracking configuration status"`
Key GmailTrackKeyCmd `cmd:"" help:"Manage tracking key rotation"`
}
Loading