diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2163d60..cb4c761 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -128,6 +128,7 @@
- Calendar: add `calendar alias list|set|unset`, and let calendar commands resolve configured aliases before API/name lookup. (#393) — thanks @salmonumbrella.
- Calendar: let `calendar freebusy` / `calendar conflicts` accept `--cal`, names, indices, and `--all` like `calendar events`. (#319) — thanks @salmonumbrella.
- Calendar: add `calendar subscribe` (aliases `sub`, `add-calendar`) to add a shared calendar to the current account’s calendar list. (#327) — thanks @cdthompson.
+- Gmail: add `gmail send --signature`, `--signature-from`, and `--signature-file` to append Gmail send-as or local signatures before sending. (#180, #183) — thanks @kesslerio and @salmonumbrella.
- Gmail: add `watch serve --history-types` filtering (`messageAdded|messageDeleted|labelAdded|labelRemoved`) and include `deletedMessageIds` in webhook payloads. (#168) — thanks @salmonumbrella.
- Gmail: add `gmail labels rename` to rename user labels by ID or exact name, with system-label guards and wrong-case ID safety. (#391) — thanks @adam-zethraeus.
- Gmail: add `gmail messages modify` for single-message label changes, complementing thread- and batch-level modify flows. (#281) — thanks @zerone0x.
diff --git a/README.md b/README.md
index c81c6c8..f647ddf 100644
--- a/README.md
+++ b/README.md
@@ -692,6 +692,10 @@ gog gmail send --to a@b.com --subject "Hi" --body "Plain fallback"
gog gmail send --to a@b.com --subject "Hi" --body-file ./message.txt
gog gmail send --to a@b.com --subject "Hi" --body-file - # Read body from stdin
gog gmail send --to a@b.com --subject "Hi" --body "Plain fallback" --body-html "
Hello
"
+gog gmail send --to a@b.com --subject "Hi" --body "Hello" --signature
+gog gmail send --to a@b.com --subject "Hi" --body "Hello" --from alias@example.com --signature
+gog gmail send --to a@b.com --subject "Hi" --body "Hello" --signature-from alias@example.com
+gog gmail send --to a@b.com --subject "Hi" --body "Hello" --signature-file ./signature.html
gog gmail forward --to a@b.com --note "FYI"
gog gmail forward --to a@b.com --skip-attachments
# Reply + include quoted original message (auto-generates HTML quote unless you pass --body-html)
diff --git a/docs/spec.md b/docs/spec.md
index 1594431..0bcff55 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -291,7 +291,7 @@ Flag aliases:
- `gog gmail labels create `
- `gog gmail labels rename `
- `gog gmail labels modify [--add ...] [--remove ...]`
-- `gog gmail send --to a@b.com --subject S [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id ] [--reply-to addr] [--attach ...]`
+- `gog gmail send --to a@b.com --subject S [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id ] [--reply-to addr] [--from addr] [--signature|--signature-from addr|--signature-file path] [--attach ...]`
- `gog gmail drafts list [--max N] [--page TOKEN]`
- `gog gmail drafts get [--download]`
- `gog gmail drafts create --subject S [--to a@b.com] [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id ] [--reply-to addr] [--attach ...]`
diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go
index c0822b6..e868798 100644
--- a/internal/cmd/gmail_send.go
+++ b/internal/cmd/gmail_send.go
@@ -25,6 +25,9 @@ type GmailSendCmd struct {
ReplyTo string `name:"reply-to" help:"Reply-To header address"`
Attach []string `name:"attach" help:"Attachment file path (repeatable)"`
From string `name:"from" help:"Send from this email address (must be a verified send-as alias)"`
+ Signature bool `name:"signature" help:"Append the Gmail signature from the active send-as address"`
+ SignatureFrom string `name:"signature-from" help:"Append the Gmail signature from this send-as email address"`
+ SignatureFile string `name:"signature-file" help:"Append a local signature file (plain text or HTML)"`
Track bool `name:"track" help:"Enable open tracking (requires tracking setup)"`
TrackSplit bool `name:"track-split" help:"Send tracked messages separately per recipient"`
Quote bool `name:"quote" help:"Include quoted original message in reply (requires --reply-to-message-id or --thread-id)"`
@@ -98,6 +101,9 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error {
if c.Track && strings.TrimSpace(c.BodyHTML) == "" {
return fmt.Errorf("--track requires --body-html (pixel must be in HTML)")
}
+ if sigErr := c.validateSignatureOptions(); sigErr != nil {
+ return sigErr
+ }
attachPaths, err := expandComposeAttachmentPaths(c.Attach)
if err != nil {
@@ -117,6 +123,9 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error {
"body_len": len(strings.TrimSpace(body)),
"body_html_len": len(strings.TrimSpace(c.BodyHTML)),
"attachments": attachPaths,
+ "signature": c.Signature,
+ "signature_from": strings.TrimSpace(c.SignatureFrom),
+ "signature_file": strings.TrimSpace(c.SignatureFile),
"track": c.Track,
"track_split": c.TrackSplit,
}); dryRunErr != nil {
@@ -132,7 +141,19 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error {
if err != nil {
return err
}
- replyInfo, body, htmlBody, err := prepareComposeReply(ctx, svc, replyToMessageID, threadID, c.Quote, body, c.BodyHTML)
+ htmlBodyInput := c.BodyHTML
+ if c.signatureRequested() {
+ signature, source, sigErr := c.resolveComposeSignature(ctx, svc, from.sendingEmail)
+ if sigErr != nil {
+ return sigErr
+ }
+ if signature.empty() {
+ u.Err().Printf("Warning: no signature configured for %s", source)
+ } else {
+ 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
new file mode 100644
index 0000000..677d4b3
--- /dev/null
+++ b/internal/cmd/gmail_send_signature.go
@@ -0,0 +1,168 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ stdhtml "html"
+ "os"
+ "strings"
+
+ nethtml "golang.org/x/net/html"
+ "google.golang.org/api/gmail/v1"
+
+ "github.com/steipete/gogcli/internal/config"
+)
+
+const maxComposeSignatureFileBytes = 1 << 20
+
+type composeSignature struct {
+ Plain string
+ HTML string
+}
+
+func (s composeSignature) empty() bool {
+ return strings.TrimSpace(s.Plain) == "" && strings.TrimSpace(s.HTML) == ""
+}
+
+func (c *GmailSendCmd) signatureRequested() bool {
+ return c.Signature || strings.TrimSpace(c.SignatureFrom) != "" || strings.TrimSpace(c.SignatureFile) != ""
+}
+
+func (c *GmailSendCmd) validateSignatureOptions() error {
+ if strings.TrimSpace(c.SignatureFile) != "" && (c.Signature || strings.TrimSpace(c.SignatureFrom) != "") {
+ return usage("use only one of --signature/--signature-from or --signature-file")
+ }
+ return nil
+}
+
+func (c *GmailSendCmd) resolveComposeSignature(ctx context.Context, svc *gmail.Service, sendingEmail string) (composeSignature, string, error) {
+ if path := strings.TrimSpace(c.SignatureFile); path != "" {
+ signature, err := readComposeSignatureFile(path)
+ return signature, path, err
+ }
+
+ email := strings.TrimSpace(c.SignatureFrom)
+ if email == "" {
+ email = strings.TrimSpace(sendingEmail)
+ }
+ if email == "" {
+ return composeSignature{}, "", usage("missing send-as email for --signature")
+ }
+
+ sendAs, err := svc.Users.Settings.SendAs.Get("me", email).Context(ctx).Do()
+ if err != nil {
+ return composeSignature{}, email, fmt.Errorf("fetch signature for %s: %w", email, err)
+ }
+ htmlSignature := strings.TrimSpace(sendAs.Signature)
+ return composeSignature{
+ Plain: signatureHTMLToText(htmlSignature),
+ HTML: htmlSignature,
+ }, email, nil
+}
+
+func readComposeSignatureFile(path string) (composeSignature, error) {
+ resolved, err := config.ExpandPath(path)
+ if err != nil {
+ return composeSignature{}, err
+ }
+ info, err := os.Stat(resolved)
+ if err != nil {
+ return composeSignature{}, fmt.Errorf("read signature file: %w", err)
+ }
+ if info.Size() > maxComposeSignatureFileBytes {
+ return composeSignature{}, fmt.Errorf("signature file too large: %s", resolved)
+ }
+ // #nosec G304 -- --signature-file is an explicit user-provided path.
+ data, err := os.ReadFile(resolved)
+ if err != nil {
+ return composeSignature{}, fmt.Errorf("read signature file: %w", err)
+ }
+
+ value := strings.TrimSpace(string(data))
+ if value == "" {
+ return composeSignature{}, nil
+ }
+ if looksLikeHTML(value) {
+ return composeSignature{
+ Plain: signatureHTMLToText(value),
+ HTML: value,
+ }, nil
+ }
+ return composeSignature{
+ Plain: value,
+ HTML: escapeTextToHTML(value),
+ }, nil
+}
+
+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 strings.TrimSpace(signature.HTML) != "" && strings.TrimSpace(htmlBody) != "" {
+ htmlBody = appendBodyBlock(htmlBody, ``+strings.TrimSpace(signature.HTML)+`
`)
+ }
+ return plainBody, htmlBody
+}
+
+func appendBodyBlock(body, block string) string {
+ body = strings.TrimRight(body, "\r\n")
+ return body + "\n\n" + block
+}
+
+func signatureHTMLToText(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+ doc, err := nethtml.Parse(strings.NewReader(value))
+ if err != nil {
+ return stripHTMLTags(value)
+ }
+
+ var out strings.Builder
+ var walk func(*nethtml.Node)
+ walk = func(n *nethtml.Node) {
+ if n == nil {
+ return
+ }
+ switch n.Type {
+ case nethtml.TextNode:
+ out.WriteString(n.Data)
+ case nethtml.ElementNode:
+ switch strings.ToLower(n.Data) {
+ case "br":
+ writeSignatureNewline(&out)
+ return
+ case "div", "p", "li":
+ writeSignatureNewline(&out)
+ for child := n.FirstChild; child != nil; child = child.NextSibling {
+ walk(child)
+ }
+ writeSignatureNewline(&out)
+ return
+ }
+ }
+ for child := n.FirstChild; child != nil; child = child.NextSibling {
+ walk(child)
+ }
+ }
+ walk(doc)
+
+ lines := strings.Split(stdhtml.UnescapeString(out.String()), "\n")
+ kept := make([]string, 0, len(lines))
+ for _, line := range lines {
+ if trimmed := strings.TrimSpace(line); trimmed != "" {
+ kept = append(kept, trimmed)
+ }
+ }
+ return strings.Join(kept, "\n")
+}
+
+func writeSignatureNewline(out *strings.Builder) {
+ if out.Len() == 0 {
+ return
+ }
+ if !strings.HasSuffix(out.String(), "\n") {
+ out.WriteByte('\n')
+ }
+}
diff --git a/internal/cmd/gmail_send_signature_test.go b/internal/cmd/gmail_send_signature_test.go
new file mode 100644
index 0000000..c527729
--- /dev/null
+++ b/internal/cmd/gmail_send_signature_test.go
@@ -0,0 +1,224 @@
+package cmd
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/gmail/v1"
+
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+func TestGmailSendCmd_Run_WithSendAsSignature(t *testing.T) {
+ raw := runGmailSendWithSignatureServer(t, func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs":
+ writeSendAsList(t, w, "a@b.com", "Primary User")
+ case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs/a@b.com":
+ writeSendAsGet(t, w, "a@b.com", `Kind regards
Primary User
`)
+ default:
+ http.NotFound(w, r)
+ }
+ }, &GmailSendCmd{
+ To: "recipient@example.com",
+ Subject: "Hello",
+ Body: "Body",
+ BodyHTML: "Body
",
+ Signature: true,
+ })
+
+ if !strings.Contains(raw, "Body\r\n\r\n--\r\nKind regards\r\nPrimary User") {
+ t.Fatalf("plain signature missing from raw message:\n%s", raw)
+ }
+ if !strings.Contains(raw, ``) {
+ t.Fatalf("html signature missing from raw message:\n%s", raw)
+ }
+}
+
+func TestGmailSendCmd_Run_SignatureFromAlias(t *testing.T) {
+ raw := runGmailSendWithSignatureServer(t, func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs":
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "sendAs": []map[string]any{
+ {"sendAsEmail": "a@b.com", "displayName": "Primary User", "verificationStatus": "accepted", "isPrimary": true},
+ {"sendAsEmail": "alias@example.com", "displayName": "Alias", "verificationStatus": "accepted"},
+ },
+ })
+ case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs/alias@example.com":
+ writeSendAsGet(t, w, "alias@example.com", "Alias signature
")
+ default:
+ http.NotFound(w, r)
+ }
+ }, &GmailSendCmd{
+ To: "recipient@example.com",
+ Subject: "Hello",
+ Body: "Body",
+ From: "alias@example.com",
+ SignatureFrom: "alias@example.com",
+ })
+
+ if !strings.Contains(raw, `From: "Alias" `) {
+ t.Fatalf("alias From header missing from raw message:\n%s", raw)
+ }
+ if !strings.Contains(raw, "Body\r\n\r\n--\r\nAlias signature") {
+ t.Fatalf("alias signature missing from raw message:\n%s", raw)
+ }
+}
+
+func TestGmailSendCmd_Run_WithSignatureFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "signature.txt")
+ if err := os.WriteFile(path, []byte("Local Sig\nhttps://example.com"), 0o600); err != nil {
+ t.Fatalf("write signature file: %v", err)
+ }
+
+ raw := runGmailSendWithSignatureServer(t, func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs":
+ writeSendAsList(t, w, "a@b.com", "Primary User")
+ default:
+ http.NotFound(w, r)
+ }
+ }, &GmailSendCmd{
+ To: "recipient@example.com",
+ Subject: "Hello",
+ Body: "Body",
+ BodyHTML: "Body
",
+ SignatureFile: path,
+ })
+
+ if !strings.Contains(raw, "Body\r\n\r\n--\r\nLocal Sig\r\nhttps://example.com") {
+ t.Fatalf("plain file signature missing from raw message:\n%s", raw)
+ }
+ if !strings.Contains(raw, "Local Sig
\r\nhttps://example.com") {
+ t.Fatalf("html file signature missing from raw message:\n%s", raw)
+ }
+}
+
+func TestGmailSendCmd_Run_EmptySignatureWarnsAndSends(t *testing.T) {
+ var raw string
+ svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs":
+ writeSendAsList(t, w, "a@b.com", "Primary User")
+ case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs/a@b.com":
+ writeSendAsGet(t, w, "a@b.com", "")
+ case r.Method == http.MethodPost && r.URL.Path == "/gmail/v1/users/me/messages/send":
+ writeGmailSendResponse(t, w, r, &raw)
+ default:
+ http.NotFound(w, r)
+ }
+ })
+ defer cleanup()
+ stubGmailServiceForTest(t, svc)
+
+ var stderr strings.Builder
+ ctx := newGmailSendSignatureTestContext(t, io.Discard, &stderr)
+ err := (&GmailSendCmd{
+ To: "recipient@example.com",
+ Subject: "Hello",
+ Body: "Body",
+ Signature: true,
+ }).Run(ctx, &RootFlags{Account: "a@b.com"})
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if !strings.Contains(stderr.String(), "Warning: no signature configured for a@b.com") {
+ t.Fatalf("expected warning, got %q", stderr.String())
+ }
+ if raw == "" {
+ t.Fatal("expected message send to continue")
+ }
+}
+
+func TestGmailSendCmd_Run_SignatureOptionConflict(t *testing.T) {
+ err := (&GmailSendCmd{
+ To: "recipient@example.com",
+ Subject: "Hello",
+ Body: "Body",
+ Signature: true,
+ SignatureFile: "sig.txt",
+ }).Run(context.Background(), &RootFlags{Account: "a@b.com"})
+ if err == nil || !strings.Contains(err.Error(), "use only one of") {
+ t.Fatalf("expected signature option conflict, got %v", err)
+ }
+}
+
+func runGmailSendWithSignatureServer(t *testing.T, handler http.HandlerFunc, cmd *GmailSendCmd) string {
+ t.Helper()
+
+ var raw string
+ svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost && r.URL.Path == "/gmail/v1/users/me/messages/send" {
+ writeGmailSendResponse(t, w, r, &raw)
+ return
+ }
+ handler(w, r)
+ })
+ defer cleanup()
+ stubGmailServiceForTest(t, svc)
+
+ if err := cmd.Run(newGmailSendSignatureTestContext(t, io.Discard, io.Discard), &RootFlags{Account: "a@b.com"}); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ return raw
+}
+
+func newGmailSendSignatureTestContext(t *testing.T, stdout, stderr io.Writer) context.Context {
+ t.Helper()
+ u, err := ui.New(ui.Options{Stdout: stdout, Stderr: stderr, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ return ui.WithUI(context.Background(), u)
+}
+
+func writeSendAsList(t *testing.T, w http.ResponseWriter, email, displayName string) {
+ t.Helper()
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "sendAs": []map[string]any{
+ {"sendAsEmail": email, "displayName": displayName, "verificationStatus": "accepted", "isPrimary": true},
+ },
+ })
+}
+
+func writeSendAsGet(t *testing.T, w http.ResponseWriter, email, signature string) {
+ t.Helper()
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "sendAsEmail": email,
+ "verificationStatus": "accepted",
+ "signature": signature,
+ })
+}
+
+func writeGmailSendResponse(t *testing.T, w http.ResponseWriter, r *http.Request, rawOut *string) {
+ t.Helper()
+
+ var msg gmail.Message
+ if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
+ t.Fatalf("decode sent message: %v", err)
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(msg.Raw)
+ if err != nil {
+ t.Fatalf("decode raw message: %v", err)
+ }
+ *rawOut = string(raw)
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "m1",
+ "threadId": "t1",
+ })
+}