diff --git a/.surface b/.surface index 9dee36fb..38644877 100644 --- a/.surface +++ b/.surface @@ -284,9 +284,14 @@ hey move hey move --to hey reply hey reply --attach +hey reply --bcc +hey reply --cc hey reply --draft +hey reply --dry-run hey reply --message hey reply --message-html +hey reply --replace-recipients +hey reply --to hey screener hey screener approve hey screener approve --box diff --git a/docs/cli.md b/docs/cli.md index 72cbb684..dabbd90d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -224,6 +224,8 @@ hey unshare 123 # turn off the sharing link hey attachment list 123 # list files attached to the thread hey attachment save 456:1 # save a file using its attachment ID hey reply 123 -m "Friday works for me — I'll send an agenda." # or omit -m for $EDITOR +hey reply 123 --to support@example.com -m "The replacement is on the way." +hey reply 123 --to support@example.com --replace-recipients --dry-run --json hey reply 123 -m "Here is the wiring diagram." --attach ./diagram.png hey bulk-reply preview 12345 67890 # inspect threads and exact To/CC/BCC recipients hey bulk-reply send 12345 67890 -m "Thanks for the update." @@ -260,7 +262,9 @@ hey ignore 12345 # ignore future activity on a thread hey stop-ignoring 12345 # resume attention for a thread ``` -`hey thread read` reads a whole thread, oldest entry first, however many pages HEY serves it in — within limits it states: a hundred pages past the first, two thousand entries, as many bodies, 64 MiB of retained thread data and two minutes in all. The byte budget covers entry-index metadata, message bodies and metadata, the recipient identities retained for thread output, and inbound delivery addresses and resolved contact identities retained for JSON. A thread that could only be read in part — a body HEY would not serve, a limit reached — is refused rather than passed off as whole; `--allow-partial` takes what was read, with a `notice` saying what is missing and each entry's `body_state` saying whether its body was `hydrated`, `bodyless` (HEY served none), `over_limit` or `failed`. Each entry whose message was read carries `recipients`, with `to`, `cc` and `bcc` contact lists; the object is absent when the message was not read, while a known-empty line is `[]`. In JSON, an inbound entry also carries `received_via`: every exact account address HEY recorded the message arriving through, including plus tags and catch-all aliases. These are delivery records, not the visible To/CC/BCC recipients. A record's `contact` is optional and is omitted when HEY did not resolve one; the whole field is omitted for sent or generated messages and whenever the message was not read. `--count` and `--ids-only` read the entry index and no messages, so only a truncated index can make them partial. `--markdown` writes the thread as one Markdown document — a heading per entry naming the sender, date and ID, then the body — which is the shape to hand an agent or a notes app. `hey attachment list` reads the bodies in every format, since that is where attachment metadata lives, and answers a partial thread the same way. `hey reply` answers the thread's latest entry and addresses the reply the way HEY does: it asks HEY for the reply's recipients — everyone that entry was addressed to, its sender moved onto the To line, and your own addresses, aliases and catch-alls excluded — falling back to computing them from the entry when that read is unavailable. +`hey thread read` reads a whole thread, oldest entry first, however many pages HEY serves it in — within limits it states: a hundred pages past the first, two thousand entries, as many bodies, 64 MiB of retained thread data and two minutes in all. The byte budget covers entry-index metadata, message bodies and metadata, the recipient identities retained for thread output, and inbound delivery addresses and resolved contact identities retained for JSON. A thread that could only be read in part — a body HEY would not serve, a limit reached — is refused rather than passed off as whole; `--allow-partial` takes what was read, with a `notice` saying what is missing and each entry's `body_state` saying whether its body was `hydrated`, `bodyless` (HEY served none), `over_limit` or `failed`. Each entry whose message was read carries `recipients`, with `to`, `cc` and `bcc` contact lists; the object is absent when the message was not read, while a known-empty line is `[]`. In JSON, an inbound entry also carries `received_via`: every exact account address HEY recorded the message arriving through, including plus tags and catch-all aliases. These are delivery records, not the visible To/CC/BCC recipients. A record's `contact` is optional and is omitted when HEY did not resolve one; the whole field is omitted for sent or generated messages and whenever the message was not read. `--count` and `--ids-only` read the entry index and no messages, so only a truncated index can make them partial. `--markdown` writes the thread as one Markdown document — a heading per entry naming the sender, date and ID, then the body — which is the shape to hand an agent or a notes app. `hey attachment list` reads the bodies in every format, since that is where attachment metadata lives, and answers a partial thread the same way. `hey reply` answers the thread's latest entry and addresses the reply the way HEY does: it asks HEY for the reply's recipients — everyone that entry was addressed to, its sender moved onto the To line, and your own addresses, aliases and catch-alls excluded — falling back to the latest message's metadata for a send when that read is unavailable. + +Repeatable `hey reply --to`, `--cc` and `--bcc` flags add recipients to that envelope; comma-separated addresses also work. An explicitly named address moves to that line instead of appearing twice. `--replace-recipients` discards HEY's prefill and requires at least one explicit address. `--dry-run` needs no body, does not read the original message body, uploads nothing and sends nothing; its JSON data reports the account, thread, entry, subject, resolved sender, and final To, CC and BCC lists. If HEY's envelope prefill is unavailable, a dry run refuses to guess the original recipient lists; use `--replace-recipients` with explicit addresses to preview a complete replacement instead. Email bodies come back as Markdown. `hey thread read` and the TUI render that Markdown for the terminal — headings, emphasis, lists, quotes, tables and code survive, and links keep their URLs and stay clickable where the terminal supports it. `--json` carries the same Markdown in `body`, so an agent reading a thread sees the structure a human sees rather than a flattened wall of text. `--html` keeps HEY's original body HTML and frames each entry with its From, To, CC and BCC headers. diff --git a/internal/cmd/compose.go b/internal/cmd/compose.go index 987f9136..55c58933 100644 --- a/internal/cmd/compose.go +++ b/internal/cmd/compose.go @@ -125,6 +125,9 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error { if resolveErr != nil { return resolveErr } + if !replyHasRecipients(target.Addressed) { + return apierr.ErrUsage("could not determine thread recipients; use hey reply with --to, --cc or --bcc") + } replySDK := target.client messageWithAttachments, attachErr := attachFilesWithClient(ctx, replySDK, message, c.attachments) if attachErr != nil { diff --git a/internal/cmd/compose_test.go b/internal/cmd/compose_test.go index 0ce3cf15..bb2d9359 100644 --- a/internal/cmd/compose_test.go +++ b/internal/cmd/compose_test.go @@ -105,6 +105,19 @@ func TestComposeSubjectRequiredOnlyForANewMessage(t *testing.T) { } } +func TestComposeThreadReplyWithoutRecipientsIsRefusedBeforeWriting(t *testing.T) { + server, sent := threadReplyServer(t, messageWithoutRecipients, 11, 12) + + err := runCLI(t, server, "--account", "8", "compose", "--thread-id", "7", "-m", "must not send") + var cliErr *apierr.Error + if !errors.As(err, &cliErr) || cliErr.Code != "usage" || !strings.Contains(err.Error(), "use hey reply with --to") { + t.Fatalf("expected an actionable usage error, got %v", err) + } + if sent.Path != "" { + t.Errorf("unaddressed compose reply wrote to %q", sent.Path) + } +} + func TestComposeSendsTheMessageAsMarkdown(t *testing.T) { server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12) diff --git a/internal/cmd/reply.go b/internal/cmd/reply.go index a6b3ef84..641de4ca 100644 --- a/internal/cmd/reply.go +++ b/internal/cmd/reply.go @@ -3,21 +3,40 @@ package cmd import ( "fmt" "strconv" + "strings" "github.com/spf13/cobra" "github.com/basecamp/hey-cli/internal/apierr" "github.com/basecamp/hey-cli/internal/editor" "github.com/basecamp/hey-cli/internal/htmlutil" + "github.com/basecamp/hey-cli/internal/mail" "github.com/basecamp/hey-cli/internal/output" + "github.com/basecamp/hey-cli/internal/terminal" ) type replyCommand struct { - cmd *cobra.Command - message string - messageHTML string - attachments []string - draft bool + cmd *cobra.Command + message string + messageHTML string + attachments []string + to []string + cc []string + bcc []string + draft bool + replaceRecipients bool + dryRun bool +} + +type replyPreview struct { + ThreadID int64 `json:"thread_id"` + EntryID int64 `json:"entry_id"` + AccountID int64 `json:"account_id"` + Subject string `json:"subject"` + From mail.ReplySender `json:"from"` + To []string `json:"to"` + CC []string `json:"cc"` + BCC []string `json:"bcc"` } func newReplyCommand() *replyCommand { @@ -28,12 +47,16 @@ func newReplyCommand() *replyCommand { Long: `Reply to a thread's latest entry. The reply is addressed the way HEY's own web app addresses one: everyone that entry was -addressed to, with whoever wrote it on the To line. HEY saves an unaddressed reply as a -draft rather than sending it, so the command fails when it cannot work the recipients out.`, +addressed to, with whoever wrote it on the To line. Repeatable --to, --cc and --bcc +values add or move recipients on those lines; each value can also be comma-separated. +--replace-recipients uses only the explicitly supplied recipients instead. A dry run +resolves and prints the complete envelope without requiring a message or sending one.`, Annotations: map[string]string{ - "agent_notes": "Replies to the latest entry in a thread, addressed the way HEY addresses a reply: everyone that entry was addressed to, plus its sender on the To line, minus the acting user's own addresses. Accepts message via -m, stdin, or $EDITOR, plus repeatable --attach files; an attachment can be sent without body text. The message is Markdown; use --message-html to send raw HTML instead. --draft saves the reply as a draft — carrying those recipients — and answers the draft ID for hey draft show/edit/send/delete.", + "agent_notes": "Replies to the latest entry in a thread, addressed the way HEY addresses a reply: everyone that entry was addressed to, plus its sender on the To line, minus the acting user's own addresses. Repeatable --to/--cc/--bcc flags merge explicit recipients into that prefill; --replace-recipients uses only the explicit lists. --dry-run is read-only, needs no message, and returns the resolved sender, recipients, subject, account, thread and entry. Accepts message via -m, stdin, or $EDITOR, plus repeatable --attach files; an attachment can be sent without body text. The message is Markdown; use --message-html to send raw HTML instead. --draft saves the reply as a draft, carries the resolved recipients, and answers the draft ID for hey draft show/edit/send/delete.", }, Example: ` hey reply 12345 -m "Friday works for me — I'll send an agenda." + hey reply 12345 --to support@example.com -m "The replacement is on the way." + hey reply 12345 --to support@example.com --replace-recipients --dry-run --json hey reply 12345 -m "Attached is the report." --attach ./report.pdf hey reply 12345 -m "Drafting a longer answer — sending tomorrow." --draft echo "Longer reply from a file or a heredoc" | hey reply 12345`, @@ -44,8 +67,14 @@ draft rather than sending it, so the command fails when it cannot work the recip replyCommand.cmd.Flags().StringVarP(&replyCommand.message, "message", "m", "", "Reply message as Markdown (or opens $EDITOR)") replyCommand.cmd.Flags().StringVar(&replyCommand.messageHTML, "message-html", "", "Reply message as raw HTML instead of Markdown") replyCommand.cmd.Flags().StringArrayVar(&replyCommand.attachments, "attach", nil, "File to attach (repeatable)") + replyCommand.cmd.Flags().StringArrayVar(&replyCommand.to, "to", nil, "To recipient email address (repeatable or comma-separated)") + replyCommand.cmd.Flags().StringArrayVar(&replyCommand.cc, "cc", nil, "CC recipient email address (repeatable or comma-separated)") + replyCommand.cmd.Flags().StringArrayVar(&replyCommand.bcc, "bcc", nil, "BCC recipient email address (repeatable or comma-separated)") replyCommand.cmd.Flags().BoolVar(&replyCommand.draft, "draft", false, "Save as a draft instead of sending") + replyCommand.cmd.Flags().BoolVar(&replyCommand.replaceRecipients, "replace-recipients", false, "Replace HEY's reply recipients with the explicit To, CC and BCC lists") + replyCommand.cmd.Flags().BoolVar(&replyCommand.dryRun, "dry-run", false, "Print the resolved reply envelope without sending") replyCommand.cmd.MarkFlagsMutuallyExclusive("message", "message-html") + replyCommand.cmd.MarkFlagsMutuallyExclusive("draft", "dry-run") return replyCommand } @@ -55,6 +84,15 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error { return err } + overrides := replyRecipients{ + To: parseReplyAddresses(c.to), + CC: parseReplyAddresses(c.cc), + BCC: parseReplyAddresses(c.bcc), + } + if _, err := applyReplyRecipientOverrides(replyRecipients{}, overrides, c.replaceRecipients); err != nil { + return err + } + threadID, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return apierr.ErrUsage(fmt.Sprintf("invalid thread ID: %s", args[0])) @@ -62,10 +100,30 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - target, err := resolveThreadReply(ctx, threadID) + resolve := resolveThreadReply + if c.dryRun || c.replaceRecipients { + resolve = resolveThreadReplyWithoutMessage + } + target, err := resolve(ctx, threadID) if err != nil { return err } + if target.Subject == "" { + return apierr.ErrUsage("could not determine the reply subject") + } + if c.dryRun && !c.replaceRecipients && !target.RecipientsResolved { + return apierr.ErrUsageHint("could not resolve HEY's reply recipients without reading the message", "use --replace-recipients with explicit --to, --cc or --bcc addresses") + } + target.Addressed, err = applyReplyRecipientOverrides(target.Addressed, overrides, c.replaceRecipients) + if err != nil { + return err + } + if !replyHasRecipients(target.Addressed) { + return apierr.ErrUsage("could not determine thread recipients; supply --to, --cc or --bcc") + } + if c.dryRun { + return writeReplyPreview(cmd, threadID, target) + } replySDK := target.client message := c.messageHTML @@ -115,3 +173,61 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error { }), ) } + +func parseReplyAddresses(values []string) []string { + addresses := make([]string, 0, len(values)) + for _, value := range values { + addresses = append(addresses, parseAddresses(value)...) + } + return addresses +} + +func writeReplyPreview(cmd *cobra.Command, threadID int64, target *threadReplyTarget) error { + from, err := replySenderForPreview(cmd.Context(), target) + if err != nil { + return err + } + preview := replyPreview{ + ThreadID: threadID, + EntryID: target.EntryID, + AccountID: target.AccountID, + Subject: target.Subject, + From: from, + To: nonNilAddresses(target.Addressed.To), + CC: nonNilAddresses(target.Addressed.CC), + BCC: nonNilAddresses(target.Addressed.BCC), + } + if writer.IsStyled() { + fmt.Fprintf(cmd.OutOrStdout(), "Thread: %d\n", preview.ThreadID) + fmt.Fprintf(cmd.OutOrStdout(), "Entry: %d\n", preview.EntryID) + fmt.Fprintf(cmd.OutOrStdout(), "Account: %d\n", preview.AccountID) + fmt.Fprintf(cmd.OutOrStdout(), "Subject: %s\n", terminal.SanitizeLine(preview.Subject)) + fmt.Fprintf(cmd.OutOrStdout(), "From: %s\n", terminal.SanitizeLine(formatReplySender(preview.From))) + fmt.Fprintf(cmd.OutOrStdout(), "To: %s\n", terminal.SanitizeLine(strings.Join(preview.To, ", "))) + fmt.Fprintf(cmd.OutOrStdout(), "CC: %s\n", terminal.SanitizeLine(strings.Join(preview.CC, ", "))) + fmt.Fprintf(cmd.OutOrStdout(), "BCC: %s\n", terminal.SanitizeLine(strings.Join(preview.BCC, ", "))) + fmt.Fprintln(cmd.OutOrStdout(), "Nothing sent.") + return nil + } + return writeOK(preview, output.WithSummary("Reply preview; nothing sent")) +} + +func nonNilAddresses(addresses []string) []string { + if addresses == nil { + return []string{} + } + return addresses +} + +func formatReplySender(sender mail.ReplySender) string { + name := terminal.SanitizeLine(sender.Name) + email := terminal.SanitizeLine(sender.EmailAddress) + switch { + case name != "" && email != "": + return fmt.Sprintf("%s <%s>", name, email) + case email != "": + return email + default: + return strconv.FormatInt(sender.ID, 10) + } +} diff --git a/internal/cmd/thread_reply.go b/internal/cmd/thread_reply.go index 54d79bfd..3620b423 100644 --- a/internal/cmd/thread_reply.go +++ b/internal/cmd/thread_reply.go @@ -17,25 +17,40 @@ import ( type replyRecipients = mail.ReplyRecipients // threadReplyTarget carries the entry a reply answers, its subject, sender and -// recipients, and an immutable client bound to the thread's mail account. HEY saves an -// unaddressed reply as a draft, so the recipients are not optional. The subject is not -// optional either: HEY never derives one, so a reply sent without it saves drafts that -// read "No subject" in Drafts. ActingSenderID is the identity the reply goes out as — -// the sender HEY resolved for the thread, which on a shared or alternate address is -// not the account default; zero (the prefill named none, or was unreachable) leaves -// the SDK on the account default. +// recipients, and an immutable client bound to the thread's mail account. Recipients +// can be unresolved when the body-free path cannot get HEY's prefill; only a complete +// replacement envelope can safely address that target. The subject is not optional +// either: HEY never derives one, +// so a reply sent without it saves drafts that read "No subject" in Drafts. +// ActingSenderID is the identity the reply goes out as: the sender HEY resolved for +// the thread, which on a shared or alternate address is not the account default; zero +// (the prefill named none, or was unreachable) leaves the SDK on the account default. type threadReplyTarget struct { - EntryID int64 - AccountID int64 - ActingSenderID int64 - Subject string - Addressed replyRecipients - client *hey.Client + EntryID int64 + AccountID int64 + ActingSenderID int64 + Subject string + Sender mail.ReplySender + Addressed replyRecipients + RecipientsResolved bool + client *hey.Client } // resolveThreadReply returns the thread's latest entry, linked account, and the -// recipients a reply to that entry goes to. +// recipients a reply to that entry goes to. If HEY's reply prefill is unavailable, +// it reads the message for the most complete fallback metadata. func resolveThreadReply(ctx context.Context, threadID int64) (*threadReplyTarget, error) { + return resolveThreadReplyTarget(ctx, threadID, true) +} + +// resolveThreadReplyWithoutMessage resolves a reply from the topic entry summary and +// HEY's reply prefill only. Dry runs must not hydrate a body, and a replacement envelope +// does not need the original recipients. +func resolveThreadReplyWithoutMessage(ctx context.Context, threadID int64) (*threadReplyTarget, error) { + return resolveThreadReplyTarget(ctx, threadID, false) +} + +func resolveThreadReplyTarget(ctx context.Context, threadID int64, readMessageFallback bool) (*threadReplyTarget, error) { topic, err := rootSDK.Topics().Get(ctx, threadID) if err != nil { return nil, apierr.FromSDK(err) @@ -48,7 +63,8 @@ func resolveThreadReply(ctx context.Context, threadID int64) (*threadReplyTarget if err != nil { return nil, err } - entryID := topic.Entries[len(topic.Entries)-1].Id + entry := topic.Entries[len(topic.Entries)-1] + entryID := entry.Id target := &threadReplyTarget{ EntryID: entryID, @@ -58,8 +74,20 @@ func resolveThreadReply(ctx context.Context, threadID int64) (*threadReplyTarget prefill, ok := mail.ReplyPrefillFromServer(ctx, threadSDK, entryID) target.ActingSenderID = prefill.ActingSenderID target.Subject = prefill.Subject + target.Sender = prefill.Sender if ok { target.Addressed = prefill.Addressed + target.RecipientsResolved = true + return target, nil + } + + if !readMessageFallback { + if target.Subject == "" { + target.Subject = replySubject(entry.Subject) + if target.Subject == "" { + target.Subject = replySubject(topic.Name) + } + } return target, nil } @@ -71,20 +99,145 @@ func resolveThreadReply(ctx context.Context, threadID int64) (*threadReplyTarget return nil, apierr.ErrNotFound("message", fmt.Sprintf("%d", entryID)) } - addressed := recipientsForReplyTo(*message) - if len(addressed.To) == 0 && len(addressed.CC) == 0 && len(addressed.BCC) == 0 { - return nil, apierr.ErrUsage("could not determine thread recipients") - } - - // The prefill's subject survives an empty recipient list: only the recipients - // needed the local computation. + // The prefill's subject survives the fallback. Otherwise the message is the source + // of truth; entry and topic names are the last body-free fallback. if target.Subject == "" { target.Subject = replySubject(message.Subject) + if target.Subject == "" { + target.Subject = replySubject(entry.Subject) + } + if target.Subject == "" { + target.Subject = replySubject(topic.Name) + } } - target.Addressed = addressed + target.Addressed = recipientsForReplyTo(*message) + target.RecipientsResolved = true return target, nil } +func replyHasRecipients(addressed replyRecipients) bool { + return len(addressed.To)+len(addressed.CC)+len(addressed.BCC) > 0 +} + +// applyReplyRecipientOverrides merges explicit recipient lines into HEY's prefill, or +// replaces the prefill whole. An explicitly named address moves to the requested line +// rather than being sent twice. Conflicting explicit lines are refused because there +// is no honest way to decide which one the caller intended. +func applyReplyRecipientOverrides(addressed, overrides replyRecipients, replace bool) (replyRecipients, error) { + overrides, err := distinctReplyRecipients(overrides) + if err != nil { + return replyRecipients{}, err + } + if !replyHasRecipients(overrides) { + if replace { + return replyRecipients{}, apierr.ErrUsage("--replace-recipients requires at least one --to, --cc or --bcc address") + } + return addressed, nil + } + if replace { + return overrides, nil + } + + explicit := make(map[string]bool) + for _, line := range [][]string{overrides.To, overrides.CC, overrides.BCC} { + for _, address := range line { + explicit[strings.ToLower(address)] = true + } + } + seen := make(map[string]bool) + merged := replyRecipients{ + To: keepReplyRecipients(addressed.To, explicit, seen), + CC: keepReplyRecipients(addressed.CC, explicit, seen), + BCC: keepReplyRecipients(addressed.BCC, explicit, seen), + } + merged.To = append(merged.To, overrides.To...) + merged.CC = append(merged.CC, overrides.CC...) + merged.BCC = append(merged.BCC, overrides.BCC...) + return merged, nil +} + +func distinctReplyRecipients(addressed replyRecipients) (replyRecipients, error) { + seen := make(map[string]string) + distinct := replyRecipients{} + lines := []struct { + name string + addresses []string + target *[]string + }{ + {name: "to", addresses: addressed.To, target: &distinct.To}, + {name: "cc", addresses: addressed.CC, target: &distinct.CC}, + {name: "bcc", addresses: addressed.BCC, target: &distinct.BCC}, + } + for _, line := range lines { + for _, address := range line.addresses { + address = strings.TrimSpace(address) + if address == "" { + continue + } + key := strings.ToLower(address) + if previous, found := seen[key]; found { + if previous != line.name { + return replyRecipients{}, apierr.ErrUsage(fmt.Sprintf("recipient %s was specified for both --%s and --%s", address, previous, line.name)) + } + continue + } + seen[key] = line.name + *line.target = append(*line.target, address) + } + } + return distinct, nil +} + +func keepReplyRecipients(addresses []string, excluded, seen map[string]bool) []string { + var kept []string + for _, address := range addresses { + address = strings.TrimSpace(address) + key := strings.ToLower(address) + if address == "" || excluded[key] || seen[key] { + continue + } + seen[key] = true + kept = append(kept, address) + } + return kept +} + +func replySenderForPreview(ctx context.Context, target *threadReplyTarget) (mail.ReplySender, error) { + if target.Sender.ID > 0 && target.Sender.EmailAddress != "" { + return target.Sender, nil + } + + identity, err := rootSDK.Identity().GetIdentity(ctx) + if err != nil { + return mail.ReplySender{}, apierr.FromSDK(err) + } + if identity == nil { + return mail.ReplySender{}, apierr.ErrAPI(0, "HEY returned no identity data") + } + + var fallback *generated.Sender + for i := range identity.Senders { + sender := &identity.Senders[i] + if sender.AccountId != target.AccountID { + continue + } + if target.ActingSenderID > 0 && sender.Id == target.ActingSenderID { + return previewReplySender(*sender), nil + } + if fallback == nil || sender.Default { + fallback = sender + } + } + if target.ActingSenderID == 0 && fallback != nil { + return previewReplySender(*fallback), nil + } + return mail.ReplySender{}, apierr.ErrNotFound("sender for account", fmt.Sprintf("%d", target.AccountID)) +} + +func previewReplySender(sender generated.Sender) mail.ReplySender { + return mail.ReplySender{ID: sender.Id, Name: sender.Name, EmailAddress: sender.EmailAddress} +} + // replySubject answers the subject a reply to the given subject carries, the way HEY // derives it in Entry::Replyable#reply_subject: a "Re: " prefix, without doubling one // already there in any casing. An empty subject stays empty rather than becoming a @@ -111,11 +264,14 @@ func recipientsForReplyTo(message generated.Message) replyRecipients { if sender == "" { sender = message.Creator.EmailAddress } + return recipientsForReplyAddressed(message.Addressed, sender) +} +func recipientsForReplyAddressed(addressed generated.Addressed, sender string) replyRecipients { recipients := replyRecipients{ - To: addressesOf(message.Addressed.Directly, sender), - CC: addressesOf(message.Addressed.Copied, sender), - BCC: addressesOf(message.Addressed.Blindcopied, sender), + To: addressesOf(addressed.Directly, sender), + CC: addressesOf(addressed.Copied, sender), + BCC: addressesOf(addressed.Blindcopied, sender), } if sender != "" { recipients.To = append(recipients.To, sender) diff --git a/internal/cmd/thread_reply_test.go b/internal/cmd/thread_reply_test.go index 0deccfe4..39e8b109 100644 --- a/internal/cmd/thread_reply_test.go +++ b/internal/cmd/thread_reply_test.go @@ -15,6 +15,7 @@ import ( "github.com/basecamp/hey-sdk/go/pkg/generated" "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/mail" ) // messageAddressedToJane is entry 12 as HEY serves it: Rick wrote it, Jane was on the @@ -45,6 +46,9 @@ type sentReply struct { To []string CC []string BCC []string + MessageReads int + MessageStatus int + TopicEntryJSON string // ReplyNewJSON, when set before the command runs, is what GET // /entries/{id}/replies/new answers — HEY's own computed reply recipients. @@ -108,7 +112,7 @@ func threadReplyServer(t *testing.T, messageJSON string, entryIDs ...int64) (*ht t.Errorf("identity account = %q, want unscoped", got) } w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"id":1,"accounts":[{"id":8,"status":"active"},{"id":9,"status":"active"}],"senders":[{"id":42,"account_id":9,"default":true}]}`) + fmt.Fprint(w, `{"id":1,"accounts":[{"id":8,"status":"active"},{"id":9,"status":"active"}],"senders":[{"id":42,"account_id":9,"name":"Jane Doe","email_address":"me@example.com","default":true}]}`) case r.URL.Path == "/topics/7.json": sent.TopicAccountFilter = r.URL.Query().Get("filtered_account_id") w.Header().Set("Content-Type", "application/json") @@ -116,9 +120,17 @@ func threadReplyServer(t *testing.T, messageJSON string, entryIDs ...int64) (*ht for _, id := range entryIDs { entries = append(entries, fmt.Sprintf(`{"id":%d}`, id)) } - fmt.Fprintf(w, `{"id":7,"account_id":9,"entries":[%s]}`, strings.Join(entries, ",")) + if sent.TopicEntryJSON != "" { + entries = []string{sent.TopicEntryJSON} + } + fmt.Fprintf(w, `{"id":7,"account_id":9,"name":"Weekly sync","entries":[%s]}`, strings.Join(entries, ",")) case strings.HasPrefix(r.URL.Path, "/messages/"): + sent.MessageReads++ sent.MessageAccountFilter = r.URL.Query().Get("filtered_account_id") + if sent.MessageStatus != 0 { + http.Error(w, `{"message":"message unavailable"}`, sent.MessageStatus) + return + } if r.URL.Path != "/messages/12.json" { t.Errorf("read %s, want the thread's latest entry", r.URL.Path) } @@ -216,17 +228,43 @@ func TestResolveThreadReplyFollowsTheLatestEntrysRecipients(t *testing.T) { } } -// An unaddressed reply is saved as a draft rather than sent, so a thread we cannot read -// recipients from is refused before anything is written. +// Resolution keeps an unaddressed target so an explicit recipient override can make +// the reply addressable. The command still refuses to send it without that override. func TestResolveThreadReplyWithoutRecipients(t *testing.T) { server, _ := threadReplyServer(t, messageWithoutRecipients, 11, 12) withSDKPointedAt(t, server) - _, err := resolveThreadReply(context.Background(), 7) + target, err := resolveThreadReply(context.Background(), 7) + if err != nil { + t.Fatalf("resolve unaddressed reply: %v", err) + } + if replyHasRecipients(target.Addressed) { + t.Errorf("recipients = %+v, want none", target.Addressed) + } +} + +func TestReplyOverrideCanAddressAnOtherwiseUnaddressedThread(t *testing.T) { + server, sent := threadReplyServer(t, messageWithoutRecipients, 11, 12) + + err := runCLI(t, server, "--account", "8", "reply", "7", "-m", "please route this", "--to", "support@example.com") + if err != nil { + t.Fatalf("reply with explicit recipient: %v", err) + } + if want := []string{"support@example.com"}; !reflect.DeepEqual(sent.To, want) { + t.Errorf("to = %v, want %v", sent.To, want) + } +} + +func TestReplyWithoutResolvedOrExplicitRecipientsIsRefused(t *testing.T) { + server, sent := threadReplyServer(t, messageWithoutRecipients, 11, 12) + err := runCLI(t, server, "--account", "8", "reply", "7", "-m", "must not send") var cliErr *apierr.Error - if !errors.As(err, &cliErr) || cliErr.Code != "usage" { - t.Fatalf("expected a usage error, got %v", err) + if !errors.As(err, &cliErr) || cliErr.Code != "usage" || !strings.Contains(err.Error(), "supply --to") { + t.Fatalf("expected an actionable usage error, got %v", err) + } + if sent.Path != "" { + t.Errorf("unaddressed reply wrote to %q", sent.Path) } } @@ -347,6 +385,12 @@ func TestReplySendsRawHTMLVerbatim(t *testing.T) { // runCLI drives a command the way the binary does — through the root command, so the // output writer and auth are set up — against a test server. func runCLI(t *testing.T, server *httptest.Server, args ...string) error { + t.Helper() + _, err := runCLIOutput(t, server, args...) + return err +} + +func runCLIOutput(t *testing.T, server *httptest.Server, args ...string) (string, error) { t.Helper() t.Setenv("HEY_TOKEN", "test-token") t.Setenv("HEY_NO_KEYRING", "1") @@ -362,7 +406,254 @@ func runCLI(t *testing.T, server *httptest.Server, args ...string) error { root.SetErr(&buf) root.SetArgs(append([]string{"--json", "--base-url", server.URL}, args...)) - return root.Execute() + err := root.Execute() + return buf.String(), err +} + +func TestReplyMergesRecipientOverridesIntoHEYsPrefill(t *testing.T) { + server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12) + sent.ReplyNewJSON = `{"subject":"Re: Weekly sync","content":"