Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ hey compose
hey compose --bcc
hey compose --cc
hey compose --message
hey compose --raw-html
hey compose --subject
hey compose --thread-id
hey compose --to
Expand Down Expand Up @@ -62,6 +63,7 @@ hey recordings --limit
hey recordings --starts-on
hey reply
hey reply --message
hey reply --raw-html
hey seen
hey setup
hey skill
Expand Down
11 changes: 9 additions & 2 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
rawHTML bool
}

func newComposeCommand() *composeCommand {
Expand All @@ -27,12 +28,13 @@ func newComposeCommand() *composeCommand {
Use: "compose",
Short: "Compose a new message",
Annotations: map[string]string{
"agent_notes": "Creates a new email. Requires --subject. Use --to (optionally with --cc/--bcc) for new threads or --thread-id for existing ones.",
"agent_notes": "Creates a new email. Requires --subject. Use --to (optionally with --cc/--bcc) for new threads or --thread-id for existing ones. Plain-text paragraphs and line breaks are preserved automatically; use --raw-html only when supplying HEY-compatible HTML.",
},
Example: ` hey compose --to alice@example.com --subject "Hello" -m "Hi there"
hey compose --to alice@example.com --cc bob@example.com --bcc carol@example.org --subject "Hello" -m "Hi"
hey compose --subject "Update" --thread-id 12345 -m "Thread reply"
echo "Long message" | hey compose --to bob@example.com --subject "Report"`,
echo "Long message" | hey compose --to bob@example.com --subject "Report"
hey compose --to bob@example.com --subject "Formatted" --raw-html -m "<p>Hello</p>"`,
RunE: composeCommand.run,
}

Expand All @@ -42,6 +44,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().BoolVar(&composeCommand.rawHTML, "raw-html", false, "Send message body as HEY-compatible HTML without plain-text formatting")

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

ctx := cmd.Context()
message = formatMessageContent(message, c.rawHTML)
if message == "" {
return output.ErrUsage("empty message, aborting")
}

if c.threadID != "" {
topicID, err := strconv.ParseInt(c.threadID, 10, 64)
Expand Down
60 changes: 60 additions & 0 deletions internal/cmd/message_format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package cmd

import (
"html"
"strings"
)

// formatMessageContent converts plain text into the HTML/Action Text content
// expected by HEY. Without block markup, browsers collapse newlines and render
// an entire plain-text message as a single paragraph.
func formatMessageContent(message string, rawHTML bool) string {
if rawHTML {
if strings.TrimSpace(message) == "" {
return ""
}
return message
}

message = strings.ReplaceAll(message, "\r\n", "\n")
message = strings.ReplaceAll(message, "\r", "\n")
message = strings.TrimSpace(message)
if message == "" {
return ""
}

paragraphs := splitParagraphs(message)
formatted := make([]string, 0, len(paragraphs))
for _, paragraph := range paragraphs {
escaped := html.EscapeString(paragraph)
escaped = strings.ReplaceAll(escaped, "\n", "<br>")
formatted = append(formatted, "<p>"+escaped+"</p>")
}

return strings.Join(formatted, "\n")
}

func splitParagraphs(message string) []string {
lines := strings.Split(message, "\n")
paragraphs := make([]string, 0, len(lines))
current := make([]string, 0, 1)

flush := func() {
if len(current) == 0 {
return
}
paragraphs = append(paragraphs, strings.Join(current, "\n"))
current = current[:0]
}

for _, line := range lines {
if strings.TrimSpace(line) == "" {
flush()
continue
}
current = append(current, line)
}
flush()

return paragraphs
}
58 changes: 58 additions & 0 deletions internal/cmd/message_format_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package cmd

import "testing"

func TestFormatMessageContent(t *testing.T) {
tests := []struct {
name string
message string
rawHTML bool
want string
}{
{
name: "separate paragraphs",
message: "Hello,\n\nSecond paragraph.\n\nRegards,\nIvan",
want: "<p>Hello,</p>\n<p>Second paragraph.</p>\n<p>Regards,<br>Ivan</p>",
},
{
name: "normalizes Windows newlines",
message: "First\r\n\r\nSecond\rThird",
want: "<p>First</p>\n<p>Second<br>Third</p>",
},
{
name: "escapes HTML in plain text",
message: `Use <p> & "quotes"`,
want: "<p>Use &lt;p&gt; &amp; &#34;quotes&#34;</p>",
},
{
name: "preserves explicit HTML",
message: "<p>Hello</p><ul><li>One</li></ul>",
rawHTML: true,
want: "<p>Hello</p><ul><li>One</li></ul>",
},
{
name: "rejects whitespace-only HTML",
message: " \n\t ",
rawHTML: true,
want: "",
},
{
name: "trims surrounding whitespace",
message: "\n\n Hello \n\n",
want: "<p>Hello</p>",
},
{
name: "rejects whitespace-only text",
message: " \n\t ",
want: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := formatMessageContent(tt.message, tt.rawHTML); got != tt.want {
t.Fatalf("formatMessageContent() = %q, want %q", got, tt.want)
}
})
}
}
11 changes: 9 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
rawHTML bool
}

func newReplyCommand() *replyCommand {
Expand All @@ -22,15 +23,17 @@ func newReplyCommand() *replyCommand {
Use: "reply <thread-id>",
Short: "Reply to a thread",
Annotations: map[string]string{
"agent_notes": "Replies to the latest entry in a thread. Accepts message via -m, stdin, or $EDITOR.",
"agent_notes": "Replies to the latest entry in a thread. Accepts message via -m, stdin, or $EDITOR. Plain-text paragraphs and line breaks are preserved automatically; use --raw-html only when supplying HEY-compatible HTML.",
},
Example: ` hey reply 12345 -m "Thanks!"
echo "Detailed reply" | hey reply 12345`,
echo "Detailed reply" | hey reply 12345
hey reply 12345 --raw-html -m "<p>Thanks!</p>"`,
RunE: replyCommand.run,
Args: usageExactOneArg(),
}

replyCommand.cmd.Flags().StringVarP(&replyCommand.message, "message", "m", "", "Reply message (or opens $EDITOR)")
replyCommand.cmd.Flags().BoolVar(&replyCommand.rawHTML, "raw-html", false, "Send message body as HEY-compatible HTML without plain-text formatting")

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

message = formatMessageContent(message, c.rawHTML)
if message == "" {
return output.ErrUsage("empty message, aborting")
}
if err = sdk.Entries().CreateReply(ctx, latestEntryID, message, addressed.To, addressed.CC, addressed.BCC); err != nil {
return convertSDKError(err)
}
Expand Down