Skip to content

Commit bd07a7a

Browse files
malobclaude
andcommitted
fix(gmail): render quoted and forwarded dates in Gmail's human style
Reply and forward attributions emitted the original message's raw RFC 2822 Date header verbatim, e.g. "On Wed, 17 Jun 2026 00:29:51 -0500, ... wrote:" and "Date: Wed, 17 Jun 2026 00:29:51 -0500". Reformat it to the human-readable style Gmail uses ("Wed, Jun 17, 2026 at 12:29 AM") across all four render paths: reply plain, reply HTML, forward plain, forward HTML. Like Gmail, the timestamp is converted into the user's timezone rather than left in the sender's offset. The location is resolved via the existing mailDateLocation helper, so it honors GOG_TIMEZONE / default_timezone and falls back to the local timezone -- the same resolution already used for the outgoing Date header. An unparseable Date is left as-is (trimmed) so it never breaks the quote or forward block. The new formatQuoteDate and the existing formatGmailDateInLocation now share a formatMailDateInLocation core (they differed only in the output layout). Includes unit coverage for formatQuoteDate and the HTML reply/forward attribution paths; existing reply/forward/draft tests updated to the new format. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent fc13b41 commit bd07a7a

7 files changed

Lines changed: 131 additions & 31 deletions

File tree

internal/cmd/execute_gmail_forward_test.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
"strings"
99
"testing"
10+
"time"
1011

1112
"google.golang.org/api/gmail/v1"
1213

@@ -60,6 +61,7 @@ func mockOriginalMessage(withAttachment bool) map[string]any {
6061
}
6162

6263
func TestExecute_GmailForward_Basic(t *testing.T) {
64+
t.Setenv("GOG_TIMEZONE", "UTC")
6365
var sentRaw string
6466
var sentThreadID string
6567

@@ -115,8 +117,9 @@ func TestExecute_GmailForward_Basic(t *testing.T) {
115117
if !strings.Contains(sentRaw, "From: Alice <[email protected]>") {
116118
t.Errorf("expected original From in forwarded body")
117119
}
118-
if !strings.Contains(sentRaw, "Date: Mon, 10 Mar 2026 09:00:00 -0400") {
119-
t.Errorf("expected original Date in forwarded body")
120+
// Original Date (09:00 -0400) reformatted to Gmail style in UTC.
121+
if !strings.Contains(sentRaw, "Date: Tue, Mar 10, 2026 at 1:00 PM") {
122+
t.Errorf("expected reformatted original Date in forwarded body")
120123
}
121124

122125
// Verify note text.
@@ -319,13 +322,15 @@ func TestFormatForwardedMessage(t *testing.T) {
319322
320323
321324
"Body text here.",
325+
time.UTC,
322326
)
323327

324328
checks := []string{
325329
"See below",
326330
"---------- Forwarded message ---------",
327331
"From: Alice <[email protected]>",
328-
"Date: Mon, 10 Mar 2026 09:00:00 -0400",
332+
// 09:00 -0400 rendered in UTC; weekday is recomputed (Mar 10 2026 is a Tue).
333+
"Date: Tue, Mar 10, 2026 at 1:00 PM",
329334
"Subject: Test Subject",
330335
331336
@@ -339,7 +344,7 @@ func TestFormatForwardedMessage(t *testing.T) {
339344
}
340345

341346
func TestFormatForwardedMessage_NoNote(t *testing.T) {
342-
result := formatForwardedMessage("", "[email protected]", "", "Subj", "[email protected]", "", "Body.")
347+
result := formatForwardedMessage("", "[email protected]", "", "Subj", "[email protected]", "", "Body.", time.UTC)
343348
if strings.HasPrefix(result, "\n\n------") {
344349
// Should not have leading blank lines when note is empty.
345350
t.Errorf("expected no leading blank lines when note is empty")
@@ -353,16 +358,21 @@ func TestFormatForwardedMessageHTML(t *testing.T) {
353358
result := formatForwardedMessageHTML(
354359
"Check this out",
355360
"Alice <[email protected]>",
356-
"Mon, 10 Mar 2026",
361+
"Mon, 10 Mar 2026 09:00:00 -0400",
357362
"Test",
358363
359364
"",
360365
"<p>Original content</p>",
366+
time.UTC,
361367
)
362368

363369
if !strings.Contains(result, "Check this out") {
364370
t.Error("missing note in HTML")
365371
}
372+
// Date (09:00 -0400) reformatted to Gmail style in UTC.
373+
if !strings.Contains(result, "Tue, Mar 10, 2026 at 1:00 PM") {
374+
t.Errorf("missing reformatted date in HTML, got:\n%s", result)
375+
}
366376
if !strings.Contains(result, "Forwarded message") {
367377
t.Error("missing forward separator in HTML")
368378
}

internal/cmd/gmail_compose.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,13 @@ func prepareComposeReply(ctx context.Context, svc *gmail.Service, replyToMessage
167167
if err != nil {
168168
return nil, "", "", err
169169
}
170-
plainBody, htmlBody = applyQuoteToBodies(plainBody, htmlBody, quote, info)
170+
if quote {
171+
loc, locErr := mailDateLocation(ctx, stderrWriter(ctx))
172+
if locErr != nil {
173+
return nil, "", "", locErr
174+
}
175+
plainBody, htmlBody = applyQuoteToBodies(plainBody, htmlBody, quote, info, loc)
176+
}
171177
return info, plainBody, htmlBody, nil
172178
}
173179

internal/cmd/gmail_date.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,21 @@ import (
66
"time"
77
)
88

9-
func formatGmailDateInLocation(raw string, loc *time.Location) string {
9+
const (
10+
// listDateLayout renders message-list and thread dates, e.g. "2026-06-17 00:29".
11+
listDateLayout = "2006-01-02 15:04"
12+
// quoteDateLayout renders dates in the Gmail-style human form used for reply
13+
// attributions and forwarded-message headers, e.g. "Wed, Jun 17, 2026 at 12:29 AM".
14+
quoteDateLayout = "Mon, Jan 2, 2006 at 3:04 PM"
15+
)
16+
17+
// formatMailDateInLocation parses an RFC 2822 Date header and renders it with
18+
// layout, converted into loc (a nil loc falls back to time.Local). Empty input
19+
// and dates that cannot be parsed are returned trimmed and otherwise unchanged,
20+
// so an unexpected Date value never breaks the output. A parseable date's
21+
// weekday is recomputed from the instant in loc, which also corrects a wrong
22+
// weekday in the source header.
23+
func formatMailDateInLocation(raw string, loc *time.Location, layout string) string {
1024
raw = strings.TrimSpace(raw)
1125
if raw == "" {
1226
return ""
@@ -15,11 +29,23 @@ func formatGmailDateInLocation(raw string, loc *time.Location) string {
1529
loc = time.Local
1630
}
1731
if t, err := mailParseDate(raw); err == nil {
18-
return t.In(loc).Format("2006-01-02 15:04")
32+
return t.In(loc).Format(layout)
1933
}
2034
return raw
2135
}
2236

37+
// formatGmailDateInLocation renders a Date header for message and thread listings.
38+
func formatGmailDateInLocation(raw string, loc *time.Location) string {
39+
return formatMailDateInLocation(raw, loc, listDateLayout)
40+
}
41+
42+
// formatQuoteDate renders an original message's Date header in the Gmail-style
43+
// human form used for quoted replies and forwarded-message headers, converted
44+
// into loc (matching how Gmail shows the attribution in the reader's timezone).
45+
func formatQuoteDate(raw string, loc *time.Location) string {
46+
return formatMailDateInLocation(raw, loc, quoteDateLayout)
47+
}
48+
2349
func mailParseDate(s string) (time.Time, error) {
2450
// net/mail has the most compatible Date parser, but we keep this isolated for easier tests/mocks later.
2551
return mail.ParseDate(s)

internal/cmd/gmail_drafts_cmd_test.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ func TestGmailDraftsCreateCmd_WithFromAndReply(t *testing.T) {
510510
}
511511

512512
func TestGmailDraftsCreateCmd_WithQuote(t *testing.T) {
513+
t.Setenv("GOG_TIMEZONE", "UTC")
513514
originalPlain := "Original plain line"
514515
originalHTML := "<p>Original <b>HTML</b></p>"
515516

@@ -527,7 +528,7 @@ func TestGmailDraftsCreateCmd_WithQuote(t *testing.T) {
527528
if !strings.Contains(s, "Hello reply") {
528529
t.Fatalf("missing body in raw:\n%s", s)
529530
}
530-
if !strings.Contains(s, "On Mon, 1 Jan 2024 00:00:00 +0000, Alice <[email protected]> wrote:") {
531+
if !strings.Contains(s, "On Mon, Jan 1, 2024 at 12:00 AM, Alice <[email protected]> wrote:") {
531532
t.Fatalf("missing quoted attribution in raw:\n%s", s)
532533
}
533534
if !strings.Contains(s, "> Original plain line") {
@@ -920,6 +921,7 @@ func TestGmailDraftsUpdateCmd_PreservesToWhenNotProvided(t *testing.T) {
920921
}
921922

922923
func TestGmailDraftsUpdateCmd_WithQuoteFromExistingThread(t *testing.T) {
924+
t.Setenv("GOG_TIMEZONE", "UTC")
923925
originalPlain := "Original thread message"
924926
originalHTML := "<div>Original <i>thread</i> HTML</div>"
925927

@@ -1044,7 +1046,7 @@ func TestGmailDraftsUpdateCmd_WithQuoteFromExistingThread(t *testing.T) {
10441046
if !strings.Contains(s, "Updated body") {
10451047
t.Fatalf("missing body in raw:\n%s", s)
10461048
}
1047-
if !strings.Contains(s, "On Tue, 2 Jan 2024 03:04:05 +0000, Bob <[email protected]> wrote:") {
1049+
if !strings.Contains(s, "On Tue, Jan 2, 2024 at 3:04 AM, Bob <[email protected]> wrote:") {
10481050
t.Fatalf("missing quoted attribution in raw:\n%s", s)
10491051
}
10501052
if !strings.Contains(s, "> Original thread message") {
@@ -1154,6 +1156,7 @@ func TestGmailDraftsUpdateCmd_QuoteRequiresNonDraftNonSelfThreadMessage(t *testi
11541156
}
11551157

11561158
func TestGmailDraftsUpdateCmd_WithQuoteAndReplyToMessageID(t *testing.T) {
1159+
t.Setenv("GOG_TIMEZONE", "UTC")
11571160
originalPlain := "Quoted from explicit message id"
11581161

11591162
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -1185,7 +1188,7 @@ func TestGmailDraftsUpdateCmd_WithQuoteAndReplyToMessageID(t *testing.T) {
11851188
if !strings.Contains(s, "Updated body") {
11861189
t.Fatalf("missing body in raw:\n%s", s)
11871190
}
1188-
if !strings.Contains(s, "On Wed, 3 Jan 2024 06:07:08 +0000, Carol <[email protected]> wrote:") {
1191+
if !strings.Contains(s, "On Wed, Jan 3, 2024 at 6:07 AM, Carol <[email protected]> wrote:") {
11891192
t.Fatalf("missing quoted attribution in raw:\n%s", s)
11901193
}
11911194
if !strings.Contains(s, "> Quoted from explicit message id") {

internal/cmd/gmail_forward.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"html"
77
"net/mail"
88
"strings"
9+
"time"
910

1011
"github.com/steipete/gogcli/internal/gmailcontent"
1112
"github.com/steipete/gogcli/internal/ui"
@@ -79,13 +80,20 @@ func (c *GmailForwardCmd) Run(ctx context.Context, flags *RootFlags) error {
7980
// Build forward subject (avoid stacking prefixes).
8081
fwdSubject := buildForwardSubject(origSubject)
8182

83+
// Resolve the timezone for the forwarded Date header from the same
84+
// configured/local source as the reply quote and the outgoing Date header.
85+
loc, err := mailDateLocation(ctx, stderrWriter(ctx))
86+
if err != nil {
87+
return err
88+
}
89+
8290
// Build forwarded body (plain text).
83-
fwdPlain := formatForwardedMessage(note, origFrom, origDate, origSubject, origTo, origCc, origPlain)
91+
fwdPlain := formatForwardedMessage(note, origFrom, origDate, origSubject, origTo, origCc, origPlain, loc)
8492

8593
// Build forwarded body (HTML) if original had HTML.
8694
var fwdHTML string
8795
if origHTML != "" {
88-
fwdHTML = formatForwardedMessageHTML(note, origFrom, origDate, origSubject, origTo, origCc, origHTML)
96+
fwdHTML = formatForwardedMessageHTML(note, origFrom, origDate, origSubject, origTo, origCc, origHTML, loc)
8997
}
9098

9199
// Preserve CID-backed inline resources required by the forwarded HTML and,
@@ -130,10 +138,10 @@ type forwardedHeader struct {
130138
value string
131139
}
132140

133-
func forwardedMessageHeaders(from, date, subject, to, cc string) []forwardedHeader {
141+
func forwardedMessageHeaders(from, date, subject, to, cc string, loc *time.Location) []forwardedHeader {
134142
return []forwardedHeader{
135143
{"From", from},
136-
{"Date", date},
144+
{"Date", formatQuoteDate(date, loc)},
137145
{"Subject", subject},
138146
{"To", to},
139147
{"Cc", cc},
@@ -170,7 +178,7 @@ func stripForwardPrefix(subject string) string {
170178
}
171179

172180
// formatForwardedMessage builds the plain-text forwarded body.
173-
func formatForwardedMessage(note, from, date, subject, to, cc, body string) string {
181+
func formatForwardedMessage(note, from, date, subject, to, cc, body string, loc *time.Location) string {
174182
var sb strings.Builder
175183

176184
if strings.TrimSpace(note) != "" {
@@ -179,7 +187,7 @@ func formatForwardedMessage(note, from, date, subject, to, cc, body string) stri
179187
}
180188

181189
sb.WriteString("---------- Forwarded message ---------\n")
182-
for _, h := range forwardedMessageHeaders(from, date, subject, to, cc) {
190+
for _, h := range forwardedMessageHeaders(from, date, subject, to, cc, loc) {
183191
if h.value != "" {
184192
fmt.Fprintf(&sb, "%s: %s\n", h.label, h.value)
185193
}
@@ -197,7 +205,7 @@ func formatForwardedMessage(note, from, date, subject, to, cc, body string) stri
197205
}
198206

199207
// formatForwardedMessageHTML builds the HTML forwarded body.
200-
func formatForwardedMessageHTML(note, from, date, subject, to, cc, htmlContent string) string {
208+
func formatForwardedMessageHTML(note, from, date, subject, to, cc, htmlContent string, loc *time.Location) string {
201209
var sb strings.Builder
202210

203211
if strings.TrimSpace(note) != "" {
@@ -210,7 +218,7 @@ func formatForwardedMessageHTML(note, from, date, subject, to, cc, htmlContent s
210218
sb.WriteString(`<div style="margin:0 0 10px 0;color:#777">---------- Forwarded message ---------</div>`)
211219
sb.WriteString(`<div style="margin:0 0 10px 0;color:#777">`)
212220

213-
for _, h := range forwardedMessageHeaders(from, date, subject, to, cc) {
221+
for _, h := range forwardedMessageHeaders(from, date, subject, to, cc, loc) {
214222
if h.value != "" {
215223
displayName := html.EscapeString(h.value)
216224
// Format the From address more nicely if it has a name part.

internal/cmd/gmail_reply.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"html"
77
"net/mail"
88
"strings"
9+
"time"
910

1011
"google.golang.org/api/gmail/v1"
1112

@@ -257,7 +258,7 @@ func escapeTextToHTML(value string) string {
257258
return strings.ReplaceAll(value, "\n", "<br>\n")
258259
}
259260

260-
func applyQuoteToBodies(plainBody string, htmlBody string, quote bool, info *replyInfo) (string, string) {
261+
func applyQuoteToBodies(plainBody string, htmlBody string, quote bool, info *replyInfo, loc *time.Location) (string, string) {
261262
if !quote || info == nil {
262263
return plainBody, htmlBody
263264
}
@@ -278,7 +279,7 @@ func applyQuoteToBodies(plainBody string, htmlBody string, quote bool, info *rep
278279

279280
outPlain := userPlain
280281
if quotedPlain != "" && (!hasHTMLReply || strings.TrimSpace(userPlain) != "") {
281-
outPlain += formatQuotedMessage(info.FromAddr, info.Date, quotedPlain)
282+
outPlain += formatQuotedMessage(info.FromAddr, info.Date, quotedPlain, loc)
282283
}
283284

284285
quoteContent := info.BodyHTML
@@ -289,7 +290,7 @@ func applyQuoteToBodies(plainBody string, htmlBody string, quote bool, info *rep
289290
return outPlain, htmlBody
290291
}
291292

292-
quoteHTML := formatQuotedMessageHTMLWithContent(info.FromAddr, info.Date, quoteContent)
293+
quoteHTML := formatQuotedMessageHTMLWithContent(info.FromAddr, info.Date, quoteContent, loc)
293294

294295
outHTML := htmlBody
295296
if strings.TrimSpace(outHTML) == "" {
@@ -302,17 +303,18 @@ func applyQuoteToBodies(plainBody string, htmlBody string, quote bool, info *rep
302303
}
303304

304305
// formatQuotedMessage formats the original message as a quoted reply.
305-
func formatQuotedMessage(from, date, body string) string {
306+
func formatQuotedMessage(from, date, body string, loc *time.Location) string {
306307
if body == "" {
307308
return ""
308309
}
309310

310311
var sb strings.Builder
311312
sb.WriteString("\n\n")
312313

314+
displayDate := formatQuoteDate(date, loc)
313315
switch {
314-
case date != "" && from != "":
315-
fmt.Fprintf(&sb, "On %s, %s wrote:\n", date, from)
316+
case displayDate != "" && from != "":
317+
fmt.Fprintf(&sb, "On %s, %s wrote:\n", displayDate, from)
316318
case from != "":
317319
fmt.Fprintf(&sb, "%s wrote:\n", from)
318320
default:
@@ -329,14 +331,14 @@ func formatQuotedMessage(from, date, body string) string {
329331
return sb.String()
330332
}
331333

332-
func formatQuotedMessageHTMLWithContent(from, date, htmlContent string) string {
334+
func formatQuotedMessageHTMLWithContent(from, date, htmlContent string, loc *time.Location) string {
333335
senderName := from
334336
if addr, err := mail.ParseAddress(from); err == nil && addr.Name != "" {
335337
senderName = addr.Name
336338
}
337339

338-
dateStr := date
339-
if date == "" {
340+
dateStr := formatQuoteDate(date, loc)
341+
if dateStr == "" {
340342
dateStr = "an earlier date"
341343
}
342344

0 commit comments

Comments
 (0)