feat(gmail): append send-as signatures

This commit is contained in:
Peter Steinberger 2026-04-28 08:40:43 +01:00
parent ec3ac8daa5
commit 77a16d10ef
No known key found for this signature in database
6 changed files with 420 additions and 2 deletions

View File

@ -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 accounts 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.

View File

@ -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 "<p>Hello</p>"
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 <messageId> --to a@b.com --note "FYI"
gog gmail forward <messageId> --to a@b.com --skip-attachments
# Reply + include quoted original message (auto-generates HTML quote unless you pass --body-html)

View File

@ -291,7 +291,7 @@ Flag aliases:
- `gog gmail labels create <name>`
- `gog gmail labels rename <labelIdOrName> <newName>`
- `gog gmail labels modify <threadIds...> [--add ...] [--remove ...]`
- `gog gmail send --to a@b.com --subject S [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id <messageId>] [--reply-to addr] [--attach <file>...]`
- `gog gmail send --to a@b.com --subject S [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id <messageId>] [--reply-to addr] [--from addr] [--signature|--signature-from addr|--signature-file path] [--attach <file>...]`
- `gog gmail drafts list [--max N] [--page TOKEN]`
- `gog gmail drafts get <draftId> [--download]`
- `gog gmail drafts create --subject S [--to a@b.com] [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id <messageId>] [--reply-to addr] [--attach <file>...]`

View File

@ -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
}

View File

@ -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, `<div class="gmail_signature">`+strings.TrimSpace(signature.HTML)+`</div>`)
}
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')
}
}

View File

@ -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", `<div>Kind regards<br>Primary User</div>`)
default:
http.NotFound(w, r)
}
}, &GmailSendCmd{
To: "recipient@example.com",
Subject: "Hello",
Body: "Body",
BodyHTML: "<p>Body</p>",
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, `<div class="gmail_signature"><div>Kind regards<br>Primary User</div></div>`) {
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", "<p>Alias signature</p>")
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" <alias@example.com>`) {
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: "<p>Body</p>",
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<br>\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",
})
}