Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,16 @@ hey completion
hey compose
hey compose --bcc
hey compose --cc
hey compose --from
hey compose --message
hey compose --subject
hey compose --thread-id
hey compose --to
hey config
hey config get
hey config set
hey config show
hey config unset
hey doctor
hey drafts
hey drafts --all
Expand All @@ -61,6 +64,7 @@ hey recordings --ends-on
hey recordings --limit
hey recordings --starts-on
hey reply
hey reply --from
hey reply --message
hey seen
hey setup
Expand Down
60 changes: 56 additions & 4 deletions internal/cmd/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type composeCommand struct {
subject string
message string
threadID string
from string
}

func newComposeCommand() *composeCommand {
Expand All @@ -42,6 +43,7 @@ func newComposeCommand() *composeCommand {
composeCommand.cmd.Flags().StringVar(&composeCommand.subject, "subject", "", "Message subject (required)")
composeCommand.cmd.Flags().StringVarP(&composeCommand.message, "message", "m", "", "Message body (or opens $EDITOR)")
composeCommand.cmd.Flags().StringVar(&composeCommand.threadID, "thread-id", "", "Thread ID to post message to")
composeCommand.cmd.Flags().StringVar(&composeCommand.from, "from", "", "Sender email address (overrides default)")

return composeCommand
}
Expand Down Expand Up @@ -80,20 +82,70 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error {

ctx := cmd.Context()

// When --from or default_sender is set, we bypass the SDK's service methods
// and call PostMutation directly so we can control the acting_sender_id.
hasSenderOverride := c.from != "" || cfg.DefaultSender != ""

if c.threadID != "" {
topicID, err := strconv.ParseInt(c.threadID, 10, 64)
if err != nil {
return output.ErrUsage(fmt.Sprintf("invalid thread ID: %s", c.threadID))
}
if err := sdk.Messages().CreateTopicMessage(ctx, topicID, message); err != nil {
return convertSDKError(err)
if hasSenderOverride {
senderID, err := effectiveSenderID(ctx, c.from)
if err != nil {
return err
}
body := map[string]any{
"acting_sender_id": senderID,
"message": map[string]any{
"content": message,
},
}
if _, err := sdk.PostMutation(ctx, fmt.Sprintf("/topics/%d/entries.json", topicID), body); err != nil {
return convertSDKError(err)
}
} else {
if err := sdk.Messages().CreateTopicMessage(ctx, topicID, message); err != nil {
return convertSDKError(err)
}
}
} else {
to := parseAddresses(c.to)
cc := parseAddresses(c.cc)
bcc := parseAddresses(c.bcc)
if err := sdk.Messages().Create(ctx, c.subject, message, to, cc, bcc); err != nil {
return convertSDKError(err)
if hasSenderOverride {
senderID, err := effectiveSenderID(ctx, c.from)
if err != nil {
return err
}
addressed := map[string]any{}
if len(to) > 0 {
addressed["directly"] = to
}
if len(cc) > 0 {
addressed["copied"] = cc
}
if len(bcc) > 0 {
addressed["blindcopied"] = bcc
}
body := map[string]any{
"acting_sender_id": senderID,
"message": map[string]any{
"subject": c.subject,
"content": message,
},
"entry": map[string]any{
"addressed": addressed,
},
Comment on lines +138 to +140
Comment on lines +138 to +140
}
if _, err := sdk.PostMutation(ctx, "/messages.json", body); err != nil {
return convertSDKError(err)
}
} else {
if err := sdk.Messages().Create(ctx, c.subject, message, to, cc, bcc); err != nil {
return convertSDKError(err)
}
}
}

Expand Down
90 changes: 86 additions & 4 deletions internal/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"strings"

"github.com/spf13/cobra"

Expand All @@ -12,6 +13,8 @@ type configCommand struct {
cmd *cobra.Command
}

var configKeys = []string{"base_url", "default_sender"}

func newConfigCommand() *configCommand {
configCommand := &configCommand{}
configCommand.cmd = &cobra.Command{
Expand All @@ -21,27 +24,35 @@ func newConfigCommand() *configCommand {

configCommand.cmd.AddCommand(newConfigShowCommand())
configCommand.cmd.AddCommand(newConfigSetCommand())
configCommand.cmd.AddCommand(newConfigGetCommand())
configCommand.cmd.AddCommand(newConfigUnsetCommand())

return configCommand
}

// normalizeConfigKey converts hyphens to underscores for config key lookup.
func normalizeConfigKey(key string) string {
return strings.ReplaceAll(key, "-", "_")
}

func newConfigSetCommand() *cobra.Command {
return &cobra.Command{
Use: "set <key> <value>",
Short: "Set a configuration value in the global config",
Example: ` hey config set base_url http://app.hey.localhost:3003
hey config set base_url https://app.hey.com`,
hey config set base_url https://app.hey.com
hey config set default_sender erik@parrotapp.com`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
key, value := args[0], args[1]
key, value := normalizeConfigKey(args[0]), args[1]

switch key {
case "base_url":
case "base_url", "default_sender":
if err := cfg.SetFromFlag(key, value); err != nil {
return err
}
default:
return output.ErrUsage(fmt.Sprintf("unknown config key: %s (available: base_url)", key))
return output.ErrUsage(fmt.Sprintf("unknown config key: %s (available: %s)", key, strings.Join(configKeys, ", ")))
}

if err := cfg.Save(); err != nil {
Expand All @@ -59,6 +70,72 @@ func newConfigSetCommand() *cobra.Command {
}
}

func newConfigGetCommand() *cobra.Command {
return &cobra.Command{
Use: "get <key>",
Short: "Get a configuration value",
Example: ` hey config get default_sender
hey config get base_url`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
key := normalizeConfigKey(args[0])

var value string
switch key {
case "base_url":
value = cfg.BaseURL
case "default_sender":
value = cfg.DefaultSender
default:
return output.ErrUsage(fmt.Sprintf("unknown config key: %s (available: %s)", key, strings.Join(configKeys, ", ")))
}

if writer.IsStyled() {
if value == "" {
fmt.Fprintf(cmd.OutOrStdout(), "%s is not set\n", key)
} else {
fmt.Fprintln(cmd.OutOrStdout(), value)
}
return nil
}
return writeOK(map[string]string{"key": key, "value": value},
output.WithSummary(value),
)
},
}
}

func newConfigUnsetCommand() *cobra.Command {
return &cobra.Command{
Use: "unset <key>",
Short: "Clear a configuration value",
Example: ` hey config unset default_sender`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
key := normalizeConfigKey(args[0])

switch key {
case "default_sender":
cfg.UnsetField(key)
default:
return output.ErrUsage(fmt.Sprintf("cannot unset key: %s (unsettable keys: default_sender)", key))
}

if err := cfg.Save(); err != nil {
return err
}

if writer.IsStyled() {
fmt.Fprintf(cmd.OutOrStdout(), "Unset %s\n", key)
return nil
}
return writeOK(map[string]string{"key": key},
output.WithSummary(fmt.Sprintf("Unset %s", key)),
)
},
}
}

func newConfigShowCommand() *cobra.Command {
return &cobra.Command{
Use: "show",
Expand All @@ -70,6 +147,11 @@ func newConfigShowCommand() *cobra.Command {
"value": cfg.BaseURL,
"source": string(cfg.SourceOf("base_url")),
},
{
"key": "default_sender",
"value": cfg.DefaultSender,
"source": string(cfg.SourceOf("default_sender")),
},
}

if writer.IsStyled() {
Expand Down
38 changes: 36 additions & 2 deletions internal/cmd/reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
type replyCommand struct {
cmd *cobra.Command
message string
from string
}

func newReplyCommand() *replyCommand {
Expand All @@ -31,6 +32,7 @@ func newReplyCommand() *replyCommand {
}

replyCommand.cmd.Flags().StringVarP(&replyCommand.message, "message", "m", "", "Reply message (or opens $EDITOR)")
replyCommand.cmd.Flags().StringVar(&replyCommand.from, "from", "", "Sender email address (overrides default)")

return replyCommand
}
Expand Down Expand Up @@ -90,8 +92,40 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error {
}
}

if err = sdk.Entries().CreateReply(ctx, latestEntryID, message, addressed.To, addressed.CC, addressed.BCC); err != nil {
return convertSDKError(err)
hasSenderOverride := c.from != "" || cfg.DefaultSender != ""
if hasSenderOverride {
senderID, err := effectiveSenderID(ctx, c.from)
if err != nil {
return err
}
body := map[string]any{
"acting_sender_id": senderID,
"message": map[string]any{
"content": message,
},
}
Comment on lines +95 to +106
addrMap := map[string]any{}
if len(addressed.To) > 0 {
addrMap["directly"] = addressed.To
}
if len(addressed.CC) > 0 {
addrMap["copied"] = addressed.CC
}
if len(addressed.BCC) > 0 {
addrMap["blindcopied"] = addressed.BCC
}
if len(addrMap) > 0 {
body["entry"] = map[string]any{
"addressed": addrMap,
}
}
if _, err := sdk.PostMutation(ctx, fmt.Sprintf("/entries/%d/replies.json", latestEntryID), body); err != nil {
return convertSDKError(err)
}
} else {
if err = sdk.Entries().CreateReply(ctx, latestEntryID, message, addressed.To, addressed.CC, addressed.BCC); err != nil {
return convertSDKError(err)
}
}

if writer.IsStyled() {
Expand Down
51 changes: 51 additions & 0 deletions internal/cmd/sender.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cmd

import (
"context"
"fmt"
"strings"

"github.com/basecamp/hey-cli/internal/output"
)

// resolveSenderID looks up a sender ID by email address from the identity endpoint.
// Returns an error if the email doesn't match any configured sender.
func resolveSenderID(ctx context.Context, email string) (int64, error) {
identity, err := sdk.Identity().GetIdentity(ctx)
if err != nil {
return 0, convertSDKError(err)
}
if identity == nil {
return 0, output.ErrAPI(0, "could not fetch identity")
}

email = strings.ToLower(strings.TrimSpace(email))
var available []string
for _, s := range identity.Senders {
if strings.ToLower(s.EmailAddress) == email {
return s.Id, nil
}
available = append(available, s.EmailAddress)
}

return 0, output.ErrUsage(fmt.Sprintf(
"no sender matching %q (available: %s)",
email, strings.Join(available, ", "),
))
Comment on lines +31 to +34
Comment on lines +31 to +34
}

// effectiveSenderID determines the sender ID to use for a mutation.
// Priority: --from flag > config default_sender > SDK default.
func effectiveSenderID(ctx context.Context, fromFlag string) (int64, error) {
if fromFlag != "" {
return resolveSenderID(ctx, fromFlag)
}
if cfg.DefaultSender != "" {
return resolveSenderID(ctx, cfg.DefaultSender)
}
id, err := sdk.DefaultSenderID(ctx)
if err != nil {
return 0, convertSDKError(err)
}
return id, nil
}
27 changes: 27 additions & 0 deletions internal/cmd/sender_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package cmd

import (
"testing"
)

func TestNormalizeConfigKey(t *testing.T) {
tests := []struct {
input string
want string
}{
{"default-sender", "default_sender"},
{"default_sender", "default_sender"},
{"base-url", "base_url"},
{"base_url", "base_url"},
{"no-hyphens", "no_hyphens"},
}

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeConfigKey(tt.input)
if got != tt.want {
t.Errorf("normalizeConfigKey(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
Loading