Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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
}
Loading
Loading