Skip to content

Commit 7ea05c4

Browse files
committed
fix: preserve safe reply draft formatting
1 parent 18929a6 commit 7ea05c4

3 files changed

Lines changed: 99 additions & 28 deletions

File tree

internal/cmd/draft.go

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -253,12 +253,6 @@ func createMessageDraft(ctx context.Context, w io.Writer, draft draftFormRequest
253253
}
254254

255255
func createReplyDraft(ctx context.Context, w io.Writer, threadID int64, draft draftFormRequest) error {
256-
topicResp, err := sdk.GetHTML(ctx, fmt.Sprintf("/topics/%d", threadID))
257-
if err != nil {
258-
return convertSDKError(err)
259-
}
260-
addressed := htmlutil.ParseTopicAddressed(string(topicResp.Data))
261-
262256
entriesResp, err := sdk.GetHTML(ctx, fmt.Sprintf("/topics/%d/entries", threadID))
263257
if err != nil {
264258
return convertSDKError(err)
@@ -268,14 +262,7 @@ func createReplyDraft(ctx context.Context, w io.Writer, threadID int64, draft dr
268262
return output.ErrNotFound("entries for thread", fmt.Sprintf("%d", threadID))
269263
}
270264

271-
latestEntryID := entries[len(entries)-1].ID
272-
if len(draft.To) == 0 && len(draft.CC) == 0 && len(draft.BCC) == 0 {
273-
draft.To = addressed.To
274-
draft.CC = addressed.CC
275-
draft.BCC = addressed.BCC
276-
}
277-
278-
return createReplyDraftForEntry(ctx, w, latestEntryID, draft)
265+
return createReplyDraftForEntry(ctx, w, entries[len(entries)-1].ID, draft)
279266
}
280267

281268
func createReplyDraftForEntry(ctx context.Context, w io.Writer, latestEntryID int64, draft draftFormRequest) error {
@@ -291,6 +278,10 @@ func createReplyDraftForEntry(ctx context.Context, w io.Writer, latestEntryID in
291278
if draft.Subject == "" {
292279
draft.Subject = replyForm.Request.Subject
293280
}
281+
draft = withReplyFormRecipients(draft, replyForm.Request)
282+
if len(draft.To) == 0 && len(draft.CC) == 0 && len(draft.BCC) == 0 {
283+
return output.ErrUsage("could not determine thread recipients")
284+
}
294285

295286
values := draftValues(senderID, draft)
296287
resp, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/entries/%d/replies", latestEntryID), values, replyForm.CSRFToken)
@@ -391,7 +382,7 @@ func draftValues(senderID int64, draft draftFormRequest) url.Values {
391382
values.Set("acting_sender_id", fmt.Sprintf("%d", senderID))
392383
values.Set("entry[status]", "drafted")
393384
values.Set("message[subject]", draft.Subject)
394-
values.Set("message[content]", draft.Content)
385+
values.Set("message[content]", draftContentHTML(draft.Content))
395386
for _, to := range draft.To {
396387
values.Add("entry[addressed][directly][]", to)
397388
}
@@ -404,6 +395,40 @@ func draftValues(senderID int64, draft draftFormRequest) url.Values {
404395
return values
405396
}
406397

398+
func withReplyFormRecipients(draft, replyForm draftFormRequest) draftFormRequest {
399+
if len(draft.To) > 0 || len(draft.CC) > 0 || len(draft.BCC) > 0 {
400+
return draft
401+
}
402+
draft.To = replyForm.To
403+
draft.CC = replyForm.CC
404+
draft.BCC = replyForm.BCC
405+
return draft
406+
}
407+
408+
func draftContentHTML(content string) string {
409+
if looksLikeDraftHTML(content) {
410+
return content
411+
}
412+
413+
content = strings.ReplaceAll(content, "\r\n", "\n")
414+
content = strings.ReplaceAll(content, "\r", "\n")
415+
lines := strings.Split(content, "\n")
416+
for i, line := range lines {
417+
lines[i] = html.EscapeString(line)
418+
}
419+
return "<div>" + strings.Join(lines, "<br>") + "</div>"
420+
}
421+
422+
func looksLikeDraftHTML(content string) bool {
423+
trimmed := strings.TrimSpace(strings.ToLower(content))
424+
return strings.HasPrefix(trimmed, "<div") ||
425+
strings.HasPrefix(trimmed, "<p") ||
426+
strings.HasPrefix(trimmed, "<ul") ||
427+
strings.HasPrefix(trimmed, "<ol") ||
428+
strings.HasPrefix(trimmed, "<blockquote") ||
429+
strings.Contains(trimmed, "<br")
430+
}
431+
407432
func submitDraftForm(ctx context.Context, method, path string, values url.Values, csrfToken string) (draftResponse, error) {
408433
if csrfToken != "" {
409434
values.Set("authenticity_token", csrfToken)

internal/cmd/draft_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,56 @@ func TestDraftValues(t *testing.T) {
3737
}
3838
}
3939

40+
func TestDraftValuesFormatsPlainTextContent(t *testing.T) {
41+
values := draftValues(123, draftFormRequest{
42+
Subject: "Hello",
43+
Content: "Hi Chrissie,\n\nThanks & all the best.\n\nMike",
44+
To: []string{"chrissie@example.com"},
45+
})
46+
47+
want := "<div>Hi Chrissie,<br><br>Thanks &amp; all the best.<br><br>Mike</div>"
48+
if got := values.Get("message[content]"); got != want {
49+
t.Fatalf("content = %q, want %q", got, want)
50+
}
51+
}
52+
53+
func TestWithReplyFormRecipientsUsesReplyFormDefaults(t *testing.T) {
54+
got := withReplyFormRecipients(draftFormRequest{
55+
Content: "Thanks",
56+
}, draftFormRequest{
57+
To: []string{"chrissie@example.com"},
58+
CC: []string{"friend@example.com"},
59+
BCC: nil,
60+
})
61+
62+
want := draftFormRequest{
63+
Content: "Thanks",
64+
To: []string{"chrissie@example.com"},
65+
CC: []string{"friend@example.com"},
66+
BCC: nil,
67+
}
68+
if !reflect.DeepEqual(got, want) {
69+
t.Fatalf("draft = %#v, want %#v", got, want)
70+
}
71+
}
72+
73+
func TestWithReplyFormRecipientsKeepsExplicitRecipients(t *testing.T) {
74+
got := withReplyFormRecipients(draftFormRequest{
75+
Content: "Thanks",
76+
To: []string{"selected@example.com"},
77+
}, draftFormRequest{
78+
To: []string{"form@example.com"},
79+
BCC: []string{"hidden@example.com"},
80+
})
81+
82+
if !reflect.DeepEqual(got.To, []string{"selected@example.com"}) {
83+
t.Fatalf("to = %#v", got.To)
84+
}
85+
if len(got.BCC) != 0 {
86+
t.Fatalf("bcc = %#v, want empty explicit recipient state to be preserved", got.BCC)
87+
}
88+
}
89+
4090
func TestDraftResponseFromLocation(t *testing.T) {
4191
resp := draftResponseFromLocation("https://app.hey.com/messages/2159062391")
4292

internal/cmd/reply.go

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,6 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error {
5050

5151
ctx := cmd.Context()
5252

53-
// Fetch topic page to extract recipients (To/CC/BCC).
54-
topicResp, err := sdk.GetHTML(ctx, fmt.Sprintf("/topics/%d", threadID))
55-
if err != nil {
56-
return convertSDKError(err)
57-
}
58-
addressed := htmlutil.ParseTopicAddressed(string(topicResp.Data))
59-
if len(addressed.To) == 0 && len(addressed.CC) == 0 && len(addressed.BCC) == 0 {
60-
return output.ErrUsage("could not determine thread recipients")
61-
}
62-
6353
// Fetch entries to find the latest entry ID for the reply.
6454
entriesResp, err := sdk.GetHTML(ctx, fmt.Sprintf("/topics/%d/entries", threadID))
6555
if err != nil {
@@ -96,12 +86,18 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error {
9686
if c.draft {
9787
return createReplyDraftForEntry(ctx, cmd.OutOrStdout(), latestEntryID, draftFormRequest{
9888
Content: message,
99-
To: addressed.To,
100-
CC: addressed.CC,
101-
BCC: addressed.BCC,
10289
})
10390
}
10491

92+
replyForm, err := loadReplyDraftForm(ctx, latestEntryID)
93+
if err != nil {
94+
return err
95+
}
96+
addressed := replyForm.Request
97+
if len(addressed.To) == 0 && len(addressed.CC) == 0 && len(addressed.BCC) == 0 {
98+
return output.ErrUsage("could not determine thread recipients")
99+
}
100+
105101
if err = sdk.Entries().CreateReply(ctx, latestEntryID, message, addressed.To, addressed.CC, addressed.BCC); err != nil {
106102
return convertSDKError(err)
107103
}

0 commit comments

Comments
 (0)