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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,27 @@ hspt tasks create --subject "Follow up" --body "Call about renewal" --priority H
hspt calls create --body "Discussed pricing" --direction OUTBOUND --duration 300
```

The `tasks` and `emails` commands also support a `search` subcommand backed by the
HubSpot CRM Search API, with repeatable `--filter` and `--sort` flags:

```bash
# Open tasks for a specific owner, oldest first
hspt tasks search --filter "hs_task_status=NOT_STARTED" --filter "hubspot_owner_id=77999105" --sort "hs_timestamp:asc"

# Overdue tasks (not started, due on or before a date — ISO dates are converted automatically)
hspt tasks search --filter "hs_task_status=NOT_STARTED" --filter "hs_timestamp<=2026-03-17" --sort "hs_timestamp:asc"

# Logged emails by direction, newest first
hspt emails search --filter "hs_email_direction=EMAIL" --sort "hs_timestamp:desc" --limit 20

# Emails whose subject contains a phrase
hspt emails search --filter "hs_email_subject:CONTAINS_TOKEN:Dev Academy" --limit 10
```
Comment thread
piekstra marked this conversation as resolved.

Filters accept shorthand (`prop=value`, `prop!=value`, `prop>=value`, `prop<=value`,
`prop>value`, `prop<value`) and explicit operators (`prop:OPERATOR:value`,
`prop:BETWEEN:low:high`, `prop:IN:a,b,c`). Sorts accept `prop:asc` or `prop:desc`.

### Associations

```bash
Expand Down
8 changes: 5 additions & 3 deletions api/crm.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,11 @@ type ListOptions struct {

// SearchFilter represents a single filter condition
type SearchFilter struct {
PropertyName string `json:"propertyName"`
Operator string `json:"operator"`
Value string `json:"value,omitempty"`
PropertyName string `json:"propertyName"`
Operator string `json:"operator"`
Value string `json:"value,omitempty"`
HighValue string `json:"highValue,omitempty"` // used by the BETWEEN operator
Values []string `json:"values,omitempty"` // used by the IN / NOT_IN operators
}

// SearchFilterGroup represents a group of filters (ANDed together)
Expand Down
71 changes: 71 additions & 0 deletions api/crm_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -428,6 +429,76 @@ func TestClient_SearchObjects(t *testing.T) {

require.NoError(t, err)
})

t.Run("search tasks sends filter, sort, and pagination", func(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/crm/v3/objects/tasks/search", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
assert.NoError(t, json.NewDecoder(r.Body).Decode(&body))
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"results": [{"id": "1", "properties": {"hs_task_status": "NOT_STARTED"}}]}`))
}))
defer server.Close()

client := &Client{BaseURL: server.URL, AccessToken: "test-token", HTTPClient: server.Client()}

result, err := client.SearchObjects(ObjectTypeTasks, SearchRequest{
FilterGroups: []SearchFilterGroup{
{Filters: []SearchFilter{{PropertyName: "hs_task_status", Operator: "EQ", Value: "NOT_STARTED"}}},
},
Sorts: []SearchSort{{PropertyName: "hs_timestamp", Direction: "ASCENDING"}},
Properties: []string{"hs_task_subject"},
Limit: 50,
After: "cursor123",
})

require.NoError(t, err)
assert.Len(t, result.Results, 1)
assert.Equal(t, float64(50), body["limit"])
assert.Equal(t, "cursor123", body["after"])

groups := body["filterGroups"].([]interface{})
f := groups[0].(map[string]interface{})["filters"].([]interface{})[0].(map[string]interface{})
assert.Equal(t, "hs_task_status", f["propertyName"])
Comment thread
piekstra marked this conversation as resolved.
assert.Equal(t, "EQ", f["operator"])
assert.Equal(t, "NOT_STARTED", f["value"])

sorts := body["sorts"].([]interface{})
s := sorts[0].(map[string]interface{})
assert.Equal(t, "hs_timestamp", s["propertyName"])
assert.Equal(t, "ASCENDING", s["direction"])
})

t.Run("search emails serializes BETWEEN highValue and IN values", func(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/crm/v3/objects/emails/search", r.URL.Path)
assert.NoError(t, json.NewDecoder(r.Body).Decode(&body))
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"results": []}`))
}))
defer server.Close()

client := &Client{BaseURL: server.URL, AccessToken: "test-token", HTTPClient: server.Client()}

_, err := client.SearchObjects(ObjectTypeEmails, SearchRequest{
FilterGroups: []SearchFilterGroup{
{Filters: []SearchFilter{
{PropertyName: "hs_timestamp", Operator: "BETWEEN", Value: "1000", HighValue: "2000"},
{PropertyName: "hs_email_direction", Operator: "IN", Values: []string{"EMAIL", "INCOMING_EMAIL"}},
}},
},
})

require.NoError(t, err)
groups := body["filterGroups"].([]interface{})
filters := groups[0].(map[string]interface{})["filters"].([]interface{})
between := filters[0].(map[string]interface{})
assert.Equal(t, "2000", between["highValue"])
in := filters[1].(map[string]interface{})
assert.Equal(t, []interface{}{"EMAIL", "INCOMING_EMAIL"}, in["values"])
})
}

func TestClient_AllObjectTypes(t *testing.T) {
Expand Down
30 changes: 30 additions & 0 deletions internal/cmd/emails/emails.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/open-cli-collective/hubspot-cli/api"
"github.com/open-cli-collective/hubspot-cli/internal/cmd/root"
"github.com/open-cli-collective/hubspot-cli/internal/cmd/shared"
)

// DefaultProperties are the default properties to fetch for emails
Expand All @@ -26,6 +27,7 @@ func Register(parent *cobra.Command, opts *root.Options) {
cmd.AddCommand(newCreateCmd(opts))
cmd.AddCommand(newUpdateCmd(opts))
cmd.AddCommand(newDeleteCmd(opts))
cmd.AddCommand(newSearchCmd(opts))

parent.AddCommand(cmd)
}
Expand Down Expand Up @@ -357,6 +359,34 @@ func newDeleteCmd(opts *root.Options) *cobra.Command {
return cmd
}

func newSearchCmd(opts *root.Options) *cobra.Command {
return shared.NewSearchCmd(opts, shared.SearchCmdConfig{
ObjectType: api.ObjectTypeEmails,
Noun: "email",
Short: "Search email engagements",
Long: "Search email engagements using the HubSpot CRM Search API with filtering and sorting.",
Example: ` # Outbound emails, newest first
hspt emails search --filter "hs_email_direction=EMAIL" --sort "hs_timestamp:desc" --limit 20

# Emails whose subject contains a phrase
hspt emails search --filter "hs_email_subject:CONTAINS_TOKEN:Dev Academy" --limit 10

# Emails for a specific owner within a date range
hspt emails search --filter "hubspot_owner_id=77999105" --filter "hs_timestamp:BETWEEN:2026-01-01:2026-03-01"`,
DefaultProperties: DefaultProperties,
Headers: []string{"ID", "SUBJECT", "DIRECTION", "STATUS", "TIMESTAMP"},
Row: func(obj api.CRMObject) []string {
return []string{
obj.ID,
truncate(obj.GetProperty("hs_email_subject"), 40),
obj.GetProperty("hs_email_direction"),
obj.GetProperty("hs_email_status"),
obj.GetProperty("hs_timestamp"),
}
},
})
}

func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
Expand Down
Loading
Loading