feat(gmail): append send-as signatures
This commit is contained in:
parent
ec3ac8daa5
commit
77a16d10ef
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>...]`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
168
internal/cmd/gmail_send_signature.go
Normal file
168
internal/cmd/gmail_send_signature.go
Normal 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')
|
||||
}
|
||||
}
|
||||
224
internal/cmd/gmail_send_signature_test.go
Normal file
224
internal/cmd/gmail_send_signature_test.go
Normal 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",
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user