Skip to content

Commit 0ea120e

Browse files
committed
feat: add --from flag and default-sender config for compose/reply
- Add --from flag to compose and reply commands to override sender - Add default_sender config (hey config set/get/unset default-sender) - Priority: --from flag > config default_sender > HEY default - Resolves sender email to ID via identity endpoint - Bypasses SDK service methods when override is active to control acting_sender_id
1 parent 22aeea7 commit 0ea120e

5 files changed

Lines changed: 259 additions & 13 deletions

File tree

internal/cmd/compose.go

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type composeCommand struct {
1919
subject string
2020
message string
2121
threadID string
22+
from string
2223
}
2324

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

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

8183
ctx := cmd.Context()
8284

85+
// When --from or default_sender is set, we bypass the SDK's service methods
86+
// and call PostMutation directly so we can control the acting_sender_id.
87+
hasSenderOverride := c.from != "" || cfg.DefaultSender != ""
88+
8389
if c.threadID != "" {
8490
topicID, err := strconv.ParseInt(c.threadID, 10, 64)
8591
if err != nil {
8692
return output.ErrUsage(fmt.Sprintf("invalid thread ID: %s", c.threadID))
8793
}
88-
if err := sdk.Messages().CreateTopicMessage(ctx, topicID, message); err != nil {
89-
return convertSDKError(err)
94+
if hasSenderOverride {
95+
senderID, err := effectiveSenderID(ctx, c.from)
96+
if err != nil {
97+
return err
98+
}
99+
body := map[string]any{
100+
"acting_sender_id": senderID,
101+
"message": map[string]any{
102+
"content": message,
103+
},
104+
}
105+
if _, err := sdk.PostMutation(ctx, fmt.Sprintf("/topics/%d/entries.json", topicID), body); err != nil {
106+
return convertSDKError(err)
107+
}
108+
} else {
109+
if err := sdk.Messages().CreateTopicMessage(ctx, topicID, message); err != nil {
110+
return convertSDKError(err)
111+
}
90112
}
91113
} else {
92114
to := parseAddresses(c.to)
93115
cc := parseAddresses(c.cc)
94116
bcc := parseAddresses(c.bcc)
95-
if err := sdk.Messages().Create(ctx, c.subject, message, to, cc, bcc); err != nil {
96-
return convertSDKError(err)
117+
if hasSenderOverride {
118+
senderID, err := effectiveSenderID(ctx, c.from)
119+
if err != nil {
120+
return err
121+
}
122+
addressed := map[string]any{}
123+
if len(to) > 0 {
124+
addressed["directly"] = to
125+
}
126+
if len(cc) > 0 {
127+
addressed["copied"] = cc
128+
}
129+
if len(bcc) > 0 {
130+
addressed["blindcopied"] = bcc
131+
}
132+
body := map[string]any{
133+
"acting_sender_id": senderID,
134+
"message": map[string]any{
135+
"subject": c.subject,
136+
"content": message,
137+
},
138+
"entry": map[string]any{
139+
"addressed": addressed,
140+
},
141+
}
142+
if _, err := sdk.PostMutation(ctx, "/messages.json", body); err != nil {
143+
return convertSDKError(err)
144+
}
145+
} else {
146+
if err := sdk.Messages().Create(ctx, c.subject, message, to, cc, bcc); err != nil {
147+
return convertSDKError(err)
148+
}
97149
}
98150
}
99151

internal/cmd/config.go

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"fmt"
5+
"strings"
56

67
"github.com/spf13/cobra"
78

@@ -12,6 +13,8 @@ type configCommand struct {
1213
cmd *cobra.Command
1314
}
1415

16+
var configKeys = []string{"base_url", "default_sender"}
17+
1518
func newConfigCommand() *configCommand {
1619
configCommand := &configCommand{}
1720
configCommand.cmd = &cobra.Command{
@@ -21,27 +24,35 @@ func newConfigCommand() *configCommand {
2124

2225
configCommand.cmd.AddCommand(newConfigShowCommand())
2326
configCommand.cmd.AddCommand(newConfigSetCommand())
27+
configCommand.cmd.AddCommand(newConfigGetCommand())
28+
configCommand.cmd.AddCommand(newConfigUnsetCommand())
2429

2530
return configCommand
2631
}
2732

33+
// normalizeConfigKey converts hyphens to underscores for config key lookup.
34+
func normalizeConfigKey(key string) string {
35+
return strings.ReplaceAll(key, "-", "_")
36+
}
37+
2838
func newConfigSetCommand() *cobra.Command {
2939
return &cobra.Command{
3040
Use: "set <key> <value>",
3141
Short: "Set a configuration value in the global config",
3242
Example: ` hey config set base_url http://app.hey.localhost:3003
33-
hey config set base_url https://app.hey.com`,
43+
hey config set base_url https://app.hey.com
44+
hey config set default_sender erik@parrotapp.com`,
3445
Args: cobra.ExactArgs(2),
3546
RunE: func(cmd *cobra.Command, args []string) error {
36-
key, value := args[0], args[1]
47+
key, value := normalizeConfigKey(args[0]), args[1]
3748

3849
switch key {
39-
case "base_url":
50+
case "base_url", "default_sender":
4051
if err := cfg.SetFromFlag(key, value); err != nil {
4152
return err
4253
}
4354
default:
44-
return output.ErrUsage(fmt.Sprintf("unknown config key: %s (available: base_url)", key))
55+
return output.ErrUsage(fmt.Sprintf("unknown config key: %s (available: %s)", key, strings.Join(configKeys, ", ")))
4556
}
4657

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

73+
func newConfigGetCommand() *cobra.Command {
74+
return &cobra.Command{
75+
Use: "get <key>",
76+
Short: "Get a configuration value",
77+
Example: ` hey config get default_sender
78+
hey config get base_url`,
79+
Args: cobra.ExactArgs(1),
80+
RunE: func(cmd *cobra.Command, args []string) error {
81+
key := normalizeConfigKey(args[0])
82+
83+
var value string
84+
switch key {
85+
case "base_url":
86+
value = cfg.BaseURL
87+
case "default_sender":
88+
value = cfg.DefaultSender
89+
default:
90+
return output.ErrUsage(fmt.Sprintf("unknown config key: %s (available: %s)", key, strings.Join(configKeys, ", ")))
91+
}
92+
93+
if writer.IsStyled() {
94+
if value == "" {
95+
fmt.Fprintf(cmd.OutOrStdout(), "%s is not set\n", key)
96+
} else {
97+
fmt.Fprintln(cmd.OutOrStdout(), value)
98+
}
99+
return nil
100+
}
101+
return writeOK(map[string]string{"key": key, "value": value},
102+
output.WithSummary(value),
103+
)
104+
},
105+
}
106+
}
107+
108+
func newConfigUnsetCommand() *cobra.Command {
109+
return &cobra.Command{
110+
Use: "unset <key>",
111+
Short: "Clear a configuration value",
112+
Example: ` hey config unset default_sender`,
113+
Args: cobra.ExactArgs(1),
114+
RunE: func(cmd *cobra.Command, args []string) error {
115+
key := normalizeConfigKey(args[0])
116+
117+
switch key {
118+
case "default_sender":
119+
cfg.UnsetField(key)
120+
default:
121+
return output.ErrUsage(fmt.Sprintf("cannot unset key: %s (unsettable keys: default_sender)", key))
122+
}
123+
124+
if err := cfg.Save(); err != nil {
125+
return err
126+
}
127+
128+
if writer.IsStyled() {
129+
fmt.Fprintf(cmd.OutOrStdout(), "Unset %s\n", key)
130+
return nil
131+
}
132+
return writeOK(map[string]string{"key": key},
133+
output.WithSummary(fmt.Sprintf("Unset %s", key)),
134+
)
135+
},
136+
}
137+
}
138+
62139
func newConfigShowCommand() *cobra.Command {
63140
return &cobra.Command{
64141
Use: "show",
@@ -70,6 +147,11 @@ func newConfigShowCommand() *cobra.Command {
70147
"value": cfg.BaseURL,
71148
"source": string(cfg.SourceOf("base_url")),
72149
},
150+
{
151+
"key": "default_sender",
152+
"value": cfg.DefaultSender,
153+
"source": string(cfg.SourceOf("default_sender")),
154+
},
73155
}
74156

75157
if writer.IsStyled() {

internal/cmd/reply.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
type replyCommand struct {
1515
cmd *cobra.Command
1616
message string
17+
from string
1718
}
1819

1920
func newReplyCommand() *replyCommand {
@@ -31,6 +32,7 @@ func newReplyCommand() *replyCommand {
3132
}
3233

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

3537
return replyCommand
3638
}
@@ -90,8 +92,40 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error {
9092
}
9193
}
9294

93-
if err = sdk.Entries().CreateReply(ctx, latestEntryID, message, addressed.To, addressed.CC, addressed.BCC); err != nil {
94-
return convertSDKError(err)
95+
hasSenderOverride := c.from != "" || cfg.DefaultSender != ""
96+
if hasSenderOverride {
97+
senderID, err := effectiveSenderID(ctx, c.from)
98+
if err != nil {
99+
return err
100+
}
101+
body := map[string]any{
102+
"acting_sender_id": senderID,
103+
"message": map[string]any{
104+
"content": message,
105+
},
106+
}
107+
addrMap := map[string]any{}
108+
if len(addressed.To) > 0 {
109+
addrMap["directly"] = addressed.To
110+
}
111+
if len(addressed.CC) > 0 {
112+
addrMap["copied"] = addressed.CC
113+
}
114+
if len(addressed.BCC) > 0 {
115+
addrMap["blindcopied"] = addressed.BCC
116+
}
117+
if len(addrMap) > 0 {
118+
body["entry"] = map[string]any{
119+
"addressed": addrMap,
120+
}
121+
}
122+
if _, err := sdk.PostMutation(ctx, fmt.Sprintf("/entries/%d/replies.json", latestEntryID), body); err != nil {
123+
return convertSDKError(err)
124+
}
125+
} else {
126+
if err = sdk.Entries().CreateReply(ctx, latestEntryID, message, addressed.To, addressed.CC, addressed.BCC); err != nil {
127+
return convertSDKError(err)
128+
}
95129
}
96130

97131
if writer.IsStyled() {

internal/cmd/sender.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/basecamp/hey-cli/internal/output"
9+
)
10+
11+
// resolveSenderID looks up a sender ID by email address from the identity endpoint.
12+
// Returns an error if the email doesn't match any configured sender.
13+
func resolveSenderID(ctx context.Context, email string) (int64, error) {
14+
identity, err := sdk.Identity().GetIdentity(ctx)
15+
if err != nil {
16+
return 0, convertSDKError(err)
17+
}
18+
if identity == nil {
19+
return 0, output.ErrAPI(0, "could not fetch identity")
20+
}
21+
22+
email = strings.ToLower(strings.TrimSpace(email))
23+
var available []string
24+
for _, s := range identity.Senders {
25+
if strings.ToLower(s.EmailAddress) == email {
26+
return s.Id, nil
27+
}
28+
available = append(available, s.EmailAddress)
29+
}
30+
31+
return 0, output.ErrUsage(fmt.Sprintf(
32+
"no sender matching %q (available: %s)",
33+
email, strings.Join(available, ", "),
34+
))
35+
}
36+
37+
// effectiveSenderID determines the sender ID to use for a mutation.
38+
// Priority: --from flag > config default_sender > SDK default.
39+
func effectiveSenderID(ctx context.Context, fromFlag string) (int64, error) {
40+
if fromFlag != "" {
41+
return resolveSenderID(ctx, fromFlag)
42+
}
43+
if cfg.DefaultSender != "" {
44+
return resolveSenderID(ctx, cfg.DefaultSender)
45+
}
46+
id, err := sdk.DefaultSenderID(ctx)
47+
if err != nil {
48+
return 0, convertSDKError(err)
49+
}
50+
return id, nil
51+
}

0 commit comments

Comments
 (0)