`},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ svc, got, cleanup := newSignatureComposeService(t, map[string]string{"me@example.com": tc.signature}, nil)
+ defer cleanup()
+ args := append([]string{"--account", "me@example.com", "gmail"}, tc.cmd...)
+ args = append(args, "plain-msg-1", "--to", "a@example.com", "--signature")
+ if tc.note != "" {
+ args = append(args, "--note", tc.note)
+ }
+ if result := executeWithGmailTestService(t, args, svc); result.err != nil {
+ t.Fatalf("forward: %v", result.err)
+ }
+ if !strings.Contains(got.raw, "Content-Type: text/html") {
+ t.Fatalf("missing text/html part:\n%s", got.raw)
+ }
+ if !strings.Contains(got.raw, tc.wantSig) {
+ t.Fatalf("signature not above forwarded block:\n%s", got.raw)
+ }
+ if !strings.Contains(got.raw, "1 <b> & 2
\r\nsecond line") {
+ t.Fatalf("plain original not escaped into HTML:\n%s", got.raw)
+ }
+ })
+ }
+}
+
+// mockBodylessForwardSource is a forward source with an attachment and no body text.
+func mockBodylessForwardSource() map[string]any {
+ return map[string]any{
+ "id": "bodyless-msg-1",
+ "threadId": "thread-1",
+ "payload": map[string]any{
+ "mimeType": "multipart/mixed",
+ "headers": []map[string]any{
+ {"name": "From", "value": "Alice
"},
+ {"name": "Date", "value": "Mon, 10 Mar 2026 09:00:00 -0400"},
+ {"name": "Subject", "value": "Attachment only"},
+ },
+ "parts": []map[string]any{
+ {
+ "mimeType": "application/pdf",
+ "filename": "scan.pdf",
+ "body": map[string]any{"attachmentId": "att-1", "size": 10},
+ },
+ },
+ },
+ }
+}
+
+func TestGmailForward_BodylessOriginalKeepsHTMLSignature(t *testing.T) {
+ t.Setenv("GOG_TIMEZONE", "UTC")
+ const imageSig = `
`
+ for _, cmd := range [][]string{{"drafts", "forward"}, {"forward"}} {
+ t.Run(strings.Join(cmd, " "), func(t *testing.T) {
+ svc, got, cleanup := newSignatureComposeService(t, map[string]string{"me@example.com": imageSig}, nil)
+ defer cleanup()
+ args := append([]string{"--account", "me@example.com", "gmail"}, cmd...)
+ args = append(args, "bodyless-msg-1", "--to", "a@example.com", "--skip-attachments", "--signature")
+ if result := executeWithGmailTestService(t, args, svc); result.err != nil {
+ t.Fatalf("forward: %v", result.err)
+ }
+ if !strings.Contains(got.raw, ``+imageSig+`
`) {
+ t.Fatalf("image signature lost for a bodyless original:\n%s", got.raw)
+ }
+ })
+ }
+}
+
+func TestGmailDraftsForward_PlainOnlyOriginalWithoutSignatureHasNoHTMLPart(t *testing.T) {
+ t.Setenv("GOG_TIMEZONE", "UTC")
+ svc, got, cleanup := newSignatureComposeService(t, primarySignature(), nil)
+ defer cleanup()
+
+ result := executeWithGmailTestService(t, []string{
+ "--account", "me@example.com", "gmail", "drafts", "forward", "plain-msg-1",
+ "--to", "a@example.com", "--note", "FYI",
+ }, svc)
+ if result.err != nil {
+ t.Fatalf("drafts forward: %v", result.err)
+ }
+ if strings.Contains(got.raw, "text/html") {
+ t.Fatalf("plain-only forward without a signature must stay plain:\n%s", got.raw)
+ }
+}
diff --git a/internal/cmd/gmail_forward.go b/internal/cmd/gmail_forward.go
index e14f565f2..bb592ddbe 100644
--- a/internal/cmd/gmail_forward.go
+++ b/internal/cmd/gmail_forward.go
@@ -20,13 +20,14 @@ type GmailForwardCmd struct {
}
type GmailForwardOptions struct {
- To string `name:"to" help:"Recipients (comma-separated; required when sending, optional when saving a draft)"`
- Cc string `name:"cc" help:"CC recipients (comma-separated)"`
- Bcc string `name:"bcc" help:"BCC recipients (comma-separated)"`
- Note string `name:"note" aliases:"intro" help:"Introductory text above the forwarded message"`
- NoteFile string `name:"note-file" help:"Note file path (plain text; '-' for stdin)"`
- From string `name:"from" help:"Send from this email address (must be a verified send-as alias)"`
- SkipAttachments bool `name:"skip-attachments" help:"Do not include original attachments"`
+ To string `name:"to" help:"Recipients (comma-separated; required when sending, optional when saving a draft)"`
+ Cc string `name:"cc" help:"CC recipients (comma-separated)"`
+ Bcc string `name:"bcc" help:"BCC recipients (comma-separated)"`
+ Note string `name:"note" aliases:"intro" help:"Introductory text above the forwarded message"`
+ NoteFile string `name:"note-file" help:"Note file path (plain text; '-' for stdin)"`
+ From string `name:"from" help:"Send from this email address (must be a verified send-as alias)"`
+ SkipAttachments bool `name:"skip-attachments" help:"Do not include original attachments"`
+ composeSignatureOptions `embed:""`
}
// recipientRequirement records whether a compose path must have recipients. The
@@ -106,6 +107,9 @@ func (c *GmailForwardOptions) dryRunFields(inputs forwardComposeInputs) map[stri
"from": strings.TrimSpace(c.From),
"note_len": len(inputs.note),
"skip_attachments": c.SkipAttachments,
+ "signature": c.Signature,
+ "signature_from": strings.TrimSpace(c.SignatureFrom),
+ "signature_file": strings.TrimSpace(c.SignatureFile),
}
}
@@ -120,6 +124,10 @@ func (c *GmailForwardOptions) resolveForwardInputs(ctx context.Context, messageI
return forwardComposeInputs{}, usage("required: messageId")
}
+ if signatureErr := c.validateSignatureOptions(); signatureErr != nil {
+ return forwardComposeInputs{}, signatureErr
+ }
+
// Parsed before the dry-run so it reports the lists the build will use.
toRecipients, ccRecipients, bccRecipients, err := parseComposeRecipients(c.To, c.Cc, c.Bcc)
if err != nil {
@@ -156,6 +164,11 @@ func (c *GmailForwardOptions) buildForwardComposeMessage(ctx context.Context, sv
return forwardComposeMessage{}, err
}
+ signature, err := c.requestedSignature(ctx, svc, from.sendingEmail)
+ if err != nil {
+ return forwardComposeMessage{}, err
+ }
+
// Fetch the original message in full format (headers + body + attachment metadata).
origMsg, err := svc.Users.Messages.Get("me", inputs.messageID).Format(gmailFormatFull).Context(ctx).Do()
if err != nil {
@@ -184,12 +197,18 @@ func (c *GmailForwardOptions) buildForwardComposeMessage(ctx context.Context, sv
}
// Build forwarded body (plain text).
- fwdPlain := formatForwardedMessage(inputs.note, origFrom, origDate, origSubject, origTo, origCc, origPlain, loc)
+ fwdPlain := formatForwardedMessage(inputs.note, signature, origFrom, origDate, origSubject, origTo, origCc, origPlain, loc)
- // Build forwarded body (HTML) if original had HTML.
+ // Build forwarded body (HTML) if the original had HTML. An original with
+ // only plain text, or no body text at all, still gets one when the
+ // signature has HTML, so the signature is not lost.
var fwdHTML string
- if origHTML != "" {
- fwdHTML = formatForwardedMessageHTML(inputs.note, origFrom, origDate, origSubject, origTo, origCc, origHTML, loc)
+ if origHTML != "" || signature.htmlBlock() != "" {
+ htmlContent := origHTML
+ if htmlContent == "" {
+ htmlContent = escapeTextToHTML(origPlain)
+ }
+ fwdHTML = formatForwardedMessageHTML(inputs.note, signature, origFrom, origDate, origSubject, origTo, origCc, htmlContent, loc)
}
// Preserve CID-backed inline resources required by the forwarded HTML and,
@@ -268,13 +287,17 @@ func stripForwardPrefix(subject string) string {
}
// formatForwardedMessage builds the plain-text forwarded body.
-func formatForwardedMessage(note, from, date, subject, to, cc, body string, loc *time.Location) string {
+func formatForwardedMessage(note string, signature composeSignature, from, date, subject, to, cc, body string, loc *time.Location) string {
var sb strings.Builder
if strings.TrimSpace(note) != "" {
sb.WriteString(strings.TrimSpace(note))
sb.WriteString("\n\n")
}
+ if block := signature.plainBlock(); block != "" {
+ sb.WriteString(block)
+ sb.WriteString("\n\n")
+ }
sb.WriteString("---------- Forwarded message ---------\n")
for _, h := range forwardedMessageHeaders(from, date, subject, to, cc, loc) {
@@ -295,7 +318,7 @@ func formatForwardedMessage(note, from, date, subject, to, cc, body string, loc
}
// formatForwardedMessageHTML builds the HTML forwarded body.
-func formatForwardedMessageHTML(note, from, date, subject, to, cc, htmlContent string, loc *time.Location) string {
+func formatForwardedMessageHTML(note string, signature composeSignature, from, date, subject, to, cc, htmlContent string, loc *time.Location) string {
var sb strings.Builder
if strings.TrimSpace(note) != "" {
@@ -303,6 +326,10 @@ func formatForwardedMessageHTML(note, from, date, subject, to, cc, htmlContent s
sb.WriteString(html.EscapeString(strings.TrimSpace(note)))
sb.WriteString("
")
}
+ if block := signature.htmlBlock(); block != "" {
+ sb.WriteString(block)
+ sb.WriteString("
")
+ }
sb.WriteString(``)
sb.WriteString(`
---------- Forwarded message ---------
`)
diff --git a/internal/cmd/gmail_reply_commands.go b/internal/cmd/gmail_reply_commands.go
index 2ec3bcccf..ec223bb31 100644
--- a/internal/cmd/gmail_reply_commands.go
+++ b/internal/cmd/gmail_reply_commands.go
@@ -184,7 +184,6 @@ func (c *GmailReplyOptions) resolveReplyInputs(ctx context.Context, messageID st
// builds the reply recipients and body, and returns the message without sending
// so the caller controls how it is dispatched.
func (c *GmailReplyOptions) buildReplyComposeMessage(ctx context.Context, svc *gmail.Service, account string, inputs replyComposeInputs, replyAll bool) (replyComposeMessage, error) {
- u := ui.FromContext(ctx)
body, htmlBody := inputs.body, inputs.htmlBody
sendAs, sendAsErr := listSendAs(ctx, svc)
@@ -205,17 +204,11 @@ func (c *GmailReplyOptions) buildReplyComposeMessage(ctx context.Context, svc *g
}
}
}
- if c.signatureRequested() {
- signature, source, sigErr := c.resolveComposeSignature(ctx, svc, from.sendingEmail)
- if sigErr != nil {
- return replyComposeMessage{}, sigErr
- }
- if signature.empty() {
- u.Err().Linef("Warning: no signature configured for %s", source)
- } else {
- body, htmlBody = appendComposeSignature(body, htmlBody, signature)
- }
+ signature, err := c.requestedSignature(ctx, svc, from.sendingEmail)
+ if err != nil {
+ return replyComposeMessage{}, err
}
+ body, htmlBody = appendComposeSignature(body, htmlBody, signature)
body, htmlBody, err = applyReplyQuote(ctx, !c.NoQuote, info, body, htmlBody)
if err != nil {
return replyComposeMessage{}, err
diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go
index ba5cb462f..61f5d75ac 100644
--- a/internal/cmd/gmail_send.go
+++ b/internal/cmd/gmail_send.go
@@ -167,17 +167,11 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error {
if err != nil {
return err
}
- if c.signatureRequested() {
- signature, source, sigErr := c.resolveComposeSignature(ctx, svc, from.sendingEmail)
- if sigErr != nil {
- return sigErr
- }
- if signature.empty() {
- u.Err().Linef("Warning: no signature configured for %s", source)
- } else {
- body, htmlBodyInput = appendComposeSignature(body, htmlBodyInput, signature)
- }
+ signature, err := c.requestedSignature(ctx, svc, from.sendingEmail)
+ if err != nil {
+ return err
}
+ body, htmlBodyInput = appendComposeSignature(body, htmlBodyInput, signature)
replyInfo, body, htmlBody, err := prepareComposeReply(ctx, svc, replyToMessageID, threadID, c.Quote, body, htmlBodyInput)
if err != nil {
return err
diff --git a/internal/cmd/gmail_send_signature.go b/internal/cmd/gmail_send_signature.go
index bef4e053f..cba7eb531 100644
--- a/internal/cmd/gmail_send_signature.go
+++ b/internal/cmd/gmail_send_signature.go
@@ -12,6 +12,7 @@ import (
"github.com/openclaw/gogcli/internal/config"
"github.com/openclaw/gogcli/internal/gmailcontent"
+ "github.com/openclaw/gogcli/internal/ui"
)
const maxComposeSignatureFileBytes = 1 << 20
@@ -44,6 +45,41 @@ func (c *composeSignatureOptions) validateSignatureOptions() error {
return nil
}
+// plainBlock is the signature as appended to a plain-text body, or "" when the
+// signature has no plain text (for example an image-only signature).
+func (s composeSignature) plainBlock() string {
+ if strings.TrimSpace(s.Plain) == "" {
+ return ""
+ }
+ return "--\n" + strings.TrimSpace(s.Plain)
+}
+
+// htmlBlock is the signature as appended to an HTML body, or "" when empty.
+func (s composeSignature) htmlBlock() string {
+ if strings.TrimSpace(s.HTML) == "" {
+ return ""
+ }
+ return `
` + strings.TrimSpace(s.HTML) + `
`
+}
+
+// requestedSignature resolves the signature the signature flags ask for. It
+// returns the zero value when none was requested or the resolved one is empty;
+// an empty one also warns on stderr, so callers can append the result as-is.
+func (c *composeSignatureOptions) requestedSignature(ctx context.Context, svc *gmail.Service, sendingEmail string) (composeSignature, error) {
+ if !c.signatureRequested() {
+ return composeSignature{}, nil
+ }
+ signature, source, err := c.resolveComposeSignature(ctx, svc, sendingEmail)
+ if err != nil {
+ return composeSignature{}, err
+ }
+ if signature.empty() {
+ ui.FromContext(ctx).Err().Linef("Warning: no signature configured for %s", source)
+ return composeSignature{}, nil
+ }
+ return signature, nil
+}
+
func (c *composeSignatureOptions) resolveComposeSignature(ctx context.Context, svc *gmail.Service, sendingEmail string) (composeSignature, string, error) {
if path := strings.TrimSpace(c.SignatureFile); path != "" {
signature, err := readComposeSignatureFile(path)
@@ -104,11 +140,11 @@ func readComposeSignatureFile(path string) (composeSignature, error) {
}
func appendComposeSignature(plainBody, htmlBody string, signature composeSignature) (string, string) {
- if strings.TrimSpace(signature.Plain) != "" && strings.TrimSpace(plainBody) != "" {
- plainBody = appendBodyBlock(plainBody, "--\n"+strings.TrimSpace(signature.Plain))
+ if block := signature.plainBlock(); block != "" && strings.TrimSpace(plainBody) != "" {
+ plainBody = appendBodyBlock(plainBody, block)
}
- if strings.TrimSpace(signature.HTML) != "" && strings.TrimSpace(htmlBody) != "" {
- htmlBody = appendBodyBlock(htmlBody, `
`+strings.TrimSpace(signature.HTML)+`
`)
+ if block := signature.htmlBlock(); block != "" && strings.TrimSpace(htmlBody) != "" {
+ htmlBody = appendBodyBlock(htmlBody, block)
}
return plainBody, htmlBody
}