diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 5a854e2..b33ae01 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -198,6 +198,9 @@ nylas email send --list-gpg-keys # List available nylas email send --to EMAIL --template-id TPL --template-data '{}' # Send using a hosted template nylas email send --template-id TPL --template-data-file data.json --render-only nylas email send --to EMAIL --subject SUBJECT --body BODY --signature-id SIG # Send with stored signature +nylas email reply --body BODY # Reply to sender (threads automatically) +nylas email reply --all --body BODY # Reply to everyone on the thread +nylas email reply --interactive # Compose the reply body interactively nylas email search --query "QUERY" # Search emails nylas email delete # Delete email nylas email mark read # Mark as read diff --git a/internal/cli/email/email.go b/internal/cli/email/email.go index 57c4653..858a167 100644 --- a/internal/cli/email/email.go +++ b/internal/cli/email/email.go @@ -16,6 +16,7 @@ func NewEmailCmd() *cobra.Command { cmd.AddCommand(newListCmd()) cmd.AddCommand(newReadCmd()) cmd.AddCommand(newSendCmd()) + cmd.AddCommand(newReplyCmd()) cmd.AddCommand(newSearchCmd()) cmd.AddCommand(newMarkCmd()) cmd.AddCommand(newDeleteCmd()) diff --git a/internal/cli/email/reply.go b/internal/cli/email/reply.go new file mode 100644 index 0000000..922eded --- /dev/null +++ b/internal/cli/email/reply.go @@ -0,0 +1,276 @@ +package email + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newReplyCmd() *cobra.Command { + var body string + var all bool + var interactive bool + var noConfirm bool + + cmd := &cobra.Command{ + Use: "reply [grant-id]", + Short: "Reply to an email", + Long: `Reply to an email message, keeping it in the same thread. + +The original message is fetched to populate the recipient and subject +automatically. By default the reply goes only to the original sender; use +--all to also include the other To/Cc recipients (excluding yourself). + +Threading is preserved via the message's reply_to_message_id, so the reply +groups with the original conversation in mail clients.`, + Example: ` # Reply to the sender + nylas email reply --body "Sounds good, thanks!" + + # Reply to everyone on the thread + nylas email reply --all --body "Looping everyone in." + + # Compose the body interactively + nylas email reply --interactive + + # Reply using a specific grant + nylas email reply --body "On it."`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + messageID := args[0] + remainingArgs := args[1:] + jsonOutput := common.IsJSON(cmd) + + if interactive && body == "" { + body = promptReplyBody() + } + if strings.TrimSpace(body) == "" { + return common.NewUserError("reply body is required", "Use --body to provide the reply text, or --interactive to compose it") + } + + _, err := common.WithClient(remainingArgs, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + grant, err := getGrantForSend(ctx, client, grantID) + if err != nil { + return struct{}{}, err + } + + req, err := buildReplyRequest(ctx, client, grantID, grant, messageID, body, all) + if err != nil { + return struct{}{}, err + } + + printReplyPreview(req) + + if !noConfirm { + fmt.Print("\nSend this reply? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + confirm, _ := reader.ReadString('\n') + confirm = strings.ToLower(strings.TrimSpace(confirm)) + if confirm != "y" && confirm != "yes" { + fmt.Println("Cancelled.") + return struct{}{}, nil + } + } + + spinner := common.NewSpinner("Sending reply...") + spinner.Start() + msg, err := sendMessageForGrant(ctx, client, grantID, grant, req) + spinner.Stop() + if err != nil { + return struct{}{}, common.WrapSendError("reply", err) + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(msg) + } + common.PrintSuccess("Reply sent successfully! Message ID: %s", msg.ID) + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().StringVarP(&body, "body", "b", "", "Reply body (HTML or plain text)") + cmd.Flags().BoolVar(&all, "all", false, "Reply to all recipients (original To and Cc, excluding yourself)") + cmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Compose the reply body interactively") + cmd.Flags().BoolVarP(&noConfirm, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} + +// buildReplyRequest fetches the original message and assembles a send request +// that threads as a reply to it. +func buildReplyRequest( + ctx context.Context, + client ports.NylasClient, + grantID string, + grant *domain.Grant, + messageID, body string, + all bool, +) (*domain.SendMessageRequest, error) { + orig, err := client.GetMessage(ctx, grantID, messageID) + if err != nil { + return nil, common.WrapGetError("message", err) + } + + selfEmail := "" + if grant != nil { + selfEmail = grant.Email + } + + to, cc, err := buildReplyRecipients(orig, selfEmail, all) + if err != nil { + return nil, err + } + + return &domain.SendMessageRequest{ + Subject: replySubject(orig.Subject), + Body: body, + To: to, + Cc: cc, + ReplyToMsgID: messageID, + }, nil +} + +// buildReplyRecipients determines who a reply should go to. The reply targets +// the original Reply-To header when present, otherwise the original sender. The +// replier's own address is always excluded so replying to a message you sent +// goes to the other participants rather than back to yourself. With all set, the +// original To and Cc recipients are added (de-duplicated). When the reply target +// was only yourself (a self-sent message), the other recipients are promoted to +// the To line. +func buildReplyRecipients(orig *domain.Message, selfEmail string, all bool) (to, cc []domain.EmailParticipant, err error) { + noRecipients := common.NewUserError( + "the original message has no one to reply to", + "Check the message ID", + ) + + // Prefer the Reply-To header, but only when it carries a usable address; + // a header present with only blank/empty entries must fall back to From. + target := orig.From + for _, p := range orig.ReplyTo { + if normalizeEmail(p.Email) != "" { + target = orig.ReplyTo + break + } + } + + seen := make(map[string]bool) + if self := normalizeEmail(selfEmail); self != "" { + seen[self] = true + } + + appendUnseen := func(dst *[]domain.EmailParticipant, list []domain.EmailParticipant) { + for _, p := range list { + key := normalizeEmail(p.Email) + if key == "" || seen[key] { + continue + } + seen[key] = true + *dst = append(*dst, p) + } + } + + appendUnseen(&to, target) + if !all { + if len(to) == 0 { + // target existed but resolved to only the replier (a self-sent message). + if len(target) > 0 { + return nil, nil, common.NewUserError( + "replying to a message you sent would only address yourself", + "Use --all to reply to the other recipients on the thread", + ) + } + return nil, nil, noRecipients + } + return to, nil, nil + } + + appendUnseen(&cc, orig.To) + appendUnseen(&cc, orig.Cc) + + // Replying to your own message: promote the other recipients to the To line. + if len(to) == 0 { + to, cc = cc, nil + } + if len(to) == 0 { + return nil, nil, noRecipients + } + + return to, cc, nil +} + +// replySubject prefixes the original subject with "Re: " unless it already +// carries a reply prefix. +func replySubject(original string) string { + trimmed := strings.TrimSpace(original) + if strings.HasPrefix(strings.ToLower(trimmed), "re:") { + return original + } + if trimmed == "" { + return "Re:" + } + return "Re: " + original +} + +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +// promptReplyBody reads a multi-line reply body from stdin, terminated by a +// line containing only ".". +func promptReplyBody() string { + fmt.Println("Body (end with a line containing only '.'):") + return readReplyBody(os.Stdin) +} + +// readReplyBody reads a multi-line body terminated by a line containing only +// "." or by EOF, so a closed/piped stdin cannot loop forever. +func readReplyBody(r io.Reader) string { + reader := bufio.NewReader(r) + var lines []string + for { + line, err := reader.ReadString('\n') + trimmed := strings.TrimRight(line, "\r\n") + if trimmed == "." { + break + } + if err != nil { + if trimmed != "" { + lines = append(lines, trimmed) + } + break + } + lines = append(lines, trimmed) + } + return strings.Join(lines, "\n") +} + +func printReplyPreview(req *domain.SendMessageRequest) { + fmt.Println("\nReply preview:") + if len(req.To) > 0 { + fmt.Printf(" To: %s\n", participantList(req.To)) + } + if len(req.Cc) > 0 { + fmt.Printf(" Cc: %s\n", participantList(req.Cc)) + } + fmt.Printf(" Subject: %s\n", req.Subject) + if req.Body != "" { + fmt.Printf(" Body: %s\n", common.Truncate(req.Body, 50)) + } +} + +func participantList(participants []domain.EmailParticipant) string { + parts := make([]string, len(participants)) + for i, p := range participants { + parts[i] = p.String() + } + return strings.Join(parts, ", ") +} diff --git a/internal/cli/email/reply_test.go b/internal/cli/email/reply_test.go new file mode 100644 index 0000000..4bd49b9 --- /dev/null +++ b/internal/cli/email/reply_test.go @@ -0,0 +1,214 @@ +package email + +import ( + "context" + "strings" + "testing" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReplySubject(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + original string + want string + }{ + {name: "adds prefix", original: "Hello", want: "Re: Hello"}, + {name: "keeps existing Re prefix", original: "Re: Hello", want: "Re: Hello"}, + {name: "keeps existing prefix case-insensitively", original: "re: hello", want: "re: hello"}, + {name: "keeps uppercase prefix", original: "RE: hello", want: "RE: hello"}, + {name: "ignores surrounding whitespace when detecting prefix", original: " Re: hi", want: " Re: hi"}, + {name: "empty subject", original: "", want: "Re:"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, replySubject(tt.original)) + }) + } +} + +func emails(participants []domain.EmailParticipant) []string { + out := make([]string, len(participants)) + for i, p := range participants { + out[i] = p.Email + } + return out +} + +func TestBuildReplyRecipients(t *testing.T) { + t.Parallel() + + t.Run("sender only reply targets the original sender", func(t *testing.T) { + t.Parallel() + orig := &domain.Message{ + From: []domain.EmailParticipant{{Email: "alice@example.com"}}, + To: []domain.EmailParticipant{{Email: "me@example.com"}, {Email: "bob@example.com"}}, + Cc: []domain.EmailParticipant{{Email: "carol@example.com"}}, + } + to, cc, err := buildReplyRecipients(orig, "me@example.com", false) + require.NoError(t, err) + assert.Equal(t, []string{"alice@example.com"}, emails(to)) + assert.Empty(t, cc) + }) + + t.Run("reply-all adds other recipients and excludes self", func(t *testing.T) { + t.Parallel() + orig := &domain.Message{ + From: []domain.EmailParticipant{{Email: "alice@example.com"}}, + To: []domain.EmailParticipant{{Email: "me@example.com"}, {Email: "bob@example.com"}}, + Cc: []domain.EmailParticipant{{Email: "carol@example.com"}}, + } + to, cc, err := buildReplyRecipients(orig, "me@example.com", true) + require.NoError(t, err) + assert.Equal(t, []string{"alice@example.com"}, emails(to)) + assert.Equal(t, []string{"bob@example.com", "carol@example.com"}, emails(cc)) + }) + + t.Run("prefers Reply-To header over From", func(t *testing.T) { + t.Parallel() + orig := &domain.Message{ + From: []domain.EmailParticipant{{Email: "alice@example.com"}}, + ReplyTo: []domain.EmailParticipant{{Email: "list@example.com"}}, + } + to, _, err := buildReplyRecipients(orig, "me@example.com", false) + require.NoError(t, err) + assert.Equal(t, []string{"list@example.com"}, emails(to)) + }) + + t.Run("ignores a blank Reply-To and falls back to From", func(t *testing.T) { + t.Parallel() + orig := &domain.Message{ + From: []domain.EmailParticipant{{Email: "alice@example.com"}}, + ReplyTo: []domain.EmailParticipant{{Email: " "}}, + } + to, _, err := buildReplyRecipients(orig, "me@example.com", false) + require.NoError(t, err) + assert.Equal(t, []string{"alice@example.com"}, emails(to)) + }) + + t.Run("excludes self case-insensitively and dedupes cc", func(t *testing.T) { + t.Parallel() + orig := &domain.Message{ + From: []domain.EmailParticipant{{Email: "alice@example.com"}}, + To: []domain.EmailParticipant{{Email: "ME@example.com"}, {Email: "bob@example.com"}}, + Cc: []domain.EmailParticipant{{Email: "Bob@example.com"}, {Email: "carol@example.com"}}, + } + _, cc, err := buildReplyRecipients(orig, "me@example.com", true) + require.NoError(t, err) + assert.Equal(t, []string{"bob@example.com", "carol@example.com"}, emails(cc)) + }) + + t.Run("reply-all does not duplicate the reply target in cc", func(t *testing.T) { + t.Parallel() + orig := &domain.Message{ + From: []domain.EmailParticipant{{Email: "alice@example.com"}}, + To: []domain.EmailParticipant{{Email: "alice@example.com"}, {Email: "bob@example.com"}}, + } + to, cc, err := buildReplyRecipients(orig, "me@example.com", true) + require.NoError(t, err) + assert.Equal(t, []string{"alice@example.com"}, emails(to)) + assert.Equal(t, []string{"bob@example.com"}, emails(cc)) + }) + + t.Run("errors when nothing to reply to", func(t *testing.T) { + t.Parallel() + orig := &domain.Message{Subject: "orphan"} + _, _, err := buildReplyRecipients(orig, "me@example.com", false) + require.Error(t, err) + }) + + t.Run("reply-all to your own message targets the original recipients", func(t *testing.T) { + t.Parallel() + // You sent this message; replying-all must go to the people you sent it + // to, not back to yourself. + orig := &domain.Message{ + From: []domain.EmailParticipant{{Email: "me@example.com"}}, + To: []domain.EmailParticipant{{Email: "bob@example.com"}}, + Cc: []domain.EmailParticipant{{Email: "carol@example.com"}}, + } + to, cc, err := buildReplyRecipients(orig, "me@example.com", true) + require.NoError(t, err) + assert.Equal(t, []string{"bob@example.com", "carol@example.com"}, emails(to)) + assert.Empty(t, cc, "self should never appear and there is no separate target") + }) + + t.Run("errors when replying to your own message without --all", func(t *testing.T) { + t.Parallel() + orig := &domain.Message{ + From: []domain.EmailParticipant{{Email: "me@example.com"}}, + To: []domain.EmailParticipant{{Email: "bob@example.com"}}, + } + _, _, err := buildReplyRecipients(orig, "me@example.com", false) + require.Error(t, err, "only recipient would be yourself; should guide toward --all") + }) +} + +func TestReadReplyBody(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {name: "terminated by dot", input: "line one\nline two\n.\nignored", want: "line one\nline two"}, + {name: "handles CRLF line endings", input: "line one\r\nline two\r\n.\r\n", want: "line one\nline two"}, + {name: "preserves blank lines before dot", input: "a\n\nb\n.\n", want: "a\n\nb"}, + {name: "eof without dot does not loop", input: "no terminator\nstill no terminator", want: "no terminator\nstill no terminator"}, + {name: "empty input", input: "", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, readReplyBody(strings.NewReader(tt.input))) + }) + } +} + +func TestReplyCmd_ThreadsViaReplyToMsgID(t *testing.T) { + t.Parallel() + + client := nylas.NewMockClient() + client.GetMessageFunc = func(_ context.Context, _, messageID string) (*domain.Message, error) { + return &domain.Message{ + ID: messageID, + Subject: "Project update", + From: []domain.EmailParticipant{{Email: "alice@example.com"}}, + }, nil + } + client.GetGrantFunc = func(_ context.Context, grantID string) (*domain.Grant, error) { + return &domain.Grant{ID: grantID, Provider: domain.ProviderGoogle, Email: "me@example.com"}, nil + } + + var gotReq *domain.SendMessageRequest + client.SendMessageFunc = func(_ context.Context, _ string, req *domain.SendMessageRequest) (*domain.Message, error) { + gotReq = req + return &domain.Message{ID: "sent-id", Subject: req.Subject}, nil + } + + grant := &domain.Grant{ID: "grant-1", Provider: domain.ProviderGoogle, Email: "me@example.com"} + req, err := buildReplyRequest(context.Background(), client, "grant-1", grant, "msg-original", "Sounds good", false) + require.NoError(t, err) + require.NotNil(t, req) + + assert.Equal(t, "msg-original", req.ReplyToMsgID, "reply must thread via reply_to_message_id") + assert.Equal(t, "Re: Project update", req.Subject) + assert.Equal(t, []string{"alice@example.com"}, emails(req.To)) + assert.Equal(t, "Sounds good", req.Body) + + // Sanity: the request actually sends through the per-grant path used by send. + sent, err := sendMessageForGrant(context.Background(), client, "grant-1", grant, req) + require.NoError(t, err) + assert.Equal(t, "sent-id", sent.ID) + require.NotNil(t, gotReq) + assert.Equal(t, "msg-original", gotReq.ReplyToMsgID) +} diff --git a/internal/cli/integration/auth_test_guarded.go b/internal/cli/integration/auth_guarded_test.go similarity index 100% rename from internal/cli/integration/auth_test_guarded.go rename to internal/cli/integration/auth_guarded_test.go diff --git a/internal/cli/integration/auth_test_management.go b/internal/cli/integration/auth_management_test.go similarity index 100% rename from internal/cli/integration/auth_test_management.go rename to internal/cli/integration/auth_management_test.go diff --git a/internal/cli/integration/calendar_test_availability.go b/internal/cli/integration/calendar_availability_test.go similarity index 100% rename from internal/cli/integration/calendar_test_availability.go rename to internal/cli/integration/calendar_availability_test.go diff --git a/internal/cli/integration/calendar_test_commands.go b/internal/cli/integration/calendar_commands_test.go similarity index 100% rename from internal/cli/integration/calendar_test_commands.go rename to internal/cli/integration/calendar_commands_test.go diff --git a/internal/cli/integration/calendar_test_crud.go b/internal/cli/integration/calendar_crud_test.go similarity index 100% rename from internal/cli/integration/calendar_test_crud.go rename to internal/cli/integration/calendar_crud_test.go diff --git a/internal/cli/integration/calendar_test_events.go b/internal/cli/integration/calendar_events_test.go similarity index 100% rename from internal/cli/integration/calendar_test_events.go rename to internal/cli/integration/calendar_events_test.go diff --git a/internal/cli/integration/email_reply_test.go b/internal/cli/integration/email_reply_test.go new file mode 100644 index 0000000..185f7d8 --- /dev/null +++ b/internal/cli/integration/email_reply_test.go @@ -0,0 +1,101 @@ +//go:build integration + +package integration + +import ( + "os" + "strings" + "testing" +) + +func TestCLI_EmailReplyHelp(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + stdout, stderr, err := runCLI("email", "reply", "--help") + if err != nil { + t.Fatalf("email reply --help failed: %v\nstderr: %s", err, stderr) + } + + for _, want := range []string{"--all", "--body", "--interactive", "reply_to_message_id"} { + if !strings.Contains(stdout, want) { + t.Errorf("expected %q in reply help, got: %s", want, stdout) + } + } + + t.Logf("email reply help output:\n%s", stdout) +} + +func TestCLI_EmailReply_RequiresBody(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + // Body validation happens before any client/network call, so this needs no + // credentials and must fail fast regardless of the message ID. + _, stderr, err := runCLI("email", "reply", "some-message-id", "--yes") + if err == nil { + t.Fatal("expected error when replying without a body, but command succeeded") + } + if !strings.Contains(stderr, "reply body is required") { + t.Errorf("expected 'reply body is required' error, got: %s", stderr) + } +} + +func TestCLI_EmailReply_RoundTrip(t *testing.T) { + if os.Getenv("NYLAS_TEST_SEND_EMAIL") != "true" { + t.Skip("Skipping reply round-trip - set NYLAS_TEST_SEND_EMAIL=true to enable") + } + skipIfMissingCreds(t) + + // The original message is sent from the grant, so the grant is its sender. + // Replying excludes your own address by design, so the thread needs a + // non-self recipient and the reply must use --all to reach them. + target := getSendTargetEmail(t) + if strings.EqualFold(strings.TrimSpace(target), strings.TrimSpace(getGrantEmail(t))) { + t.Skip("reply round-trip needs a send target different from the grant's own address (set NYLAS_TEST_EMAIL)") + } + + sendStdout, sendStderr, sendErr := runCLIWithRateLimit(t, + "email", "send", + "--to", target, + "--subject", "CLI Reply Round Trip", + "--body", "Original message for the reply integration test.", + "--yes", + testGrantID, + ) + if sendErr != nil { + t.Fatalf("email send failed: %v\nstderr: %s", sendErr, sendStderr) + } + + messageID := extractMessageID(sendStdout) + if messageID == "" { + t.Fatalf("failed to extract message ID from send output: %s", sendStdout) + } + t.Cleanup(func() { + _, _, _ = runCLI("email", "delete", messageID, "--yes", testGrantID) + }) + + replyStdout, replyStderr, replyErr := runCLIWithRateLimit(t, + "email", "reply", messageID, testGrantID, + "--all", + "--body", "This is the threaded reply.", + "--yes", + ) + if replyErr != nil { + t.Fatalf("email reply failed: %v\nstderr: %s", replyErr, replyStderr) + } + + if !strings.Contains(replyStdout, "Reply sent successfully! Message ID:") { + t.Errorf("expected reply success confirmation in output, got: %s", replyStdout) + } + + if replyID := extractMessageID(replyStdout); replyID != "" { + t.Cleanup(func() { + _, _, _ = runCLI("email", "delete", replyID, "--yes", testGrantID) + }) + } + + t.Logf("email reply output:\n%s", replyStdout) +} diff --git a/internal/cli/integration/slack_test_channels.go b/internal/cli/integration/slack_channels_test.go similarity index 100% rename from internal/cli/integration/slack_test_channels.go rename to internal/cli/integration/slack_channels_test.go diff --git a/internal/cli/integration/slack_test_operations.go b/internal/cli/integration/slack_operations_test.go similarity index 100% rename from internal/cli/integration/slack_test_operations.go rename to internal/cli/integration/slack_operations_test.go