From 81ffb6acb984e0c0e49b7d603ad0660f0930ae47 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:08:40 -0800 Subject: [PATCH] fix(gmail): allow workspace native aliases for --from --- internal/cmd/gmail_drafts.go | 2 +- internal/cmd/gmail_drafts_cmd_test.go | 78 ++++++++++++++++++++ internal/cmd/gmail_send.go | 2 +- internal/cmd/gmail_send_test.go | 65 ++++++++++++++++ internal/cmd/gmail_sendas_validation.go | 22 ++++++ internal/cmd/gmail_sendas_validation_test.go | 73 ++++++++++++++++++ 6 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/gmail_sendas_validation.go create mode 100644 internal/cmd/gmail_sendas_validation_test.go diff --git a/internal/cmd/gmail_drafts.go b/internal/cmd/gmail_drafts.go index f601c58a..7bd88432 100644 --- a/internal/cmd/gmail_drafts.go +++ b/internal/cmd/gmail_drafts.go @@ -330,7 +330,7 @@ func buildDraftMessage(ctx context.Context, svc *gmail.Service, account string, if err != nil { return nil, "", fmt.Errorf("invalid --from address %q: %w", input.From, err) } - if sa.VerificationStatus != gmailVerificationAccepted { + if !sendAsAllowedForFrom(sa) { return nil, "", fmt.Errorf("--from address %q is not verified (status: %s)", input.From, sa.VerificationStatus) } fromAddr = input.From diff --git a/internal/cmd/gmail_drafts_cmd_test.go b/internal/cmd/gmail_drafts_cmd_test.go index 7e5b9c2e..cee15fa0 100644 --- a/internal/cmd/gmail_drafts_cmd_test.go +++ b/internal/cmd/gmail_drafts_cmd_test.go @@ -595,6 +595,84 @@ func TestGmailDraftsCreateCmd_WithQuote(t *testing.T) { }) } +func TestGmailDraftsCreateCmd_WithFromWorkspaceAliasNoVerificationStatus(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/settings/sendAs/workspace-alias@example.com") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sendAsEmail": "workspace-alias@example.com", + "displayName": "Workspace Alias", + }) + return + case strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts") && r.Method == http.MethodPost: + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + var draft gmail.Draft + if unmarshalErr := json.Unmarshal(body, &draft); unmarshalErr != nil { + t.Fatalf("unmarshal: %v body=%q", unmarshalErr, string(body)) + } + if draft.Message == nil { + t.Fatalf("expected message in create draft request") + } + raw, err := base64.RawURLEncoding.DecodeString(draft.Message.Raw) + if err != nil { + t.Fatalf("decode raw: %v", err) + } + if !strings.Contains(string(raw), "From: \"Workspace Alias\" \r\n") { + t.Fatalf("missing workspace alias From header in raw:\n%s", string(raw)) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "d-workspace", + "message": map[string]any{ + "id": "m-workspace", + }, + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + _ = captureStdout(t, func() { + if err := runKong(t, &GmailDraftsCreateCmd{}, []string{ + "--to", "a@example.com", + "--subject", "S", + "--body", "Hello", + "--from", "workspace-alias@example.com", + }, ctx, flags); err != nil { + t.Fatalf("execute: %v", err) + } + }) +} + func TestGmailDraftsUpdateCmd_JSON(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index babc2556..803b2451 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -164,7 +164,7 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { } } - if sa.VerificationStatus != gmailVerificationAccepted { + if !sendAsAllowedForFrom(sa) { return fmt.Errorf("--from address %q is not verified (status: %s)", fromEmail, sa.VerificationStatus) } diff --git a/internal/cmd/gmail_send_test.go b/internal/cmd/gmail_send_test.go index cf433de8..3569d438 100644 --- a/internal/cmd/gmail_send_test.go +++ b/internal/cmd/gmail_send_test.go @@ -432,6 +432,71 @@ func TestGmailSendCmd_RunJSON_WithFrom(t *testing.T) { } } +func TestGmailSendCmd_RunJSON_WithFromWorkspaceAliasNoVerificationStatus(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/gmail/v1") + switch { + case r.Method == http.MethodGet && path == "/users/me/settings/sendAs": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sendAs": []map[string]any{ + { + "sendAsEmail": "workspace-alias@example.com", + "displayName": "Workspace Alias", + }, + }, + }) + return + case r.Method == http.MethodPost && path == "/users/me/messages/send": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "m2w", + "threadId": "t2w", + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + cmd := &GmailSendCmd{ + To: "a@example.com", + From: "workspace-alias@example.com", + Subject: "Hello", + Body: "Body", + } + + out := captureStdout(t, func() { + if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + }) + if !strings.Contains(out, "\"from\"") || !strings.Contains(out, "Workspace Alias ") { + t.Fatalf("unexpected output: %q", out) + } +} + func TestGmailSendCmd_RunJSON_WithFromDisplayNameFallbackToList(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) diff --git a/internal/cmd/gmail_sendas_validation.go b/internal/cmd/gmail_sendas_validation.go new file mode 100644 index 00000000..3b371717 --- /dev/null +++ b/internal/cmd/gmail_sendas_validation.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "strings" + + "google.golang.org/api/gmail/v1" +) + +// Gmail-managed Workspace aliases can omit verificationStatus but are still valid +// From addresses when they do not rely on a custom SMTP relay. +func sendAsAllowedForFrom(sa *gmail.SendAs) bool { + if sa == nil { + return false + } + + status := strings.TrimSpace(sa.VerificationStatus) + if strings.EqualFold(status, gmailVerificationAccepted) { + return true + } + + return status == "" && sa.SmtpMsa == nil +} diff --git a/internal/cmd/gmail_sendas_validation_test.go b/internal/cmd/gmail_sendas_validation_test.go new file mode 100644 index 00000000..03542e1d --- /dev/null +++ b/internal/cmd/gmail_sendas_validation_test.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "testing" + + "google.golang.org/api/gmail/v1" +) + +func TestSendAsAllowedForFrom(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + sa *gmail.SendAs + want bool + }{ + { + name: "accepted status", + sa: &gmail.SendAs{ + VerificationStatus: "accepted", + }, + want: true, + }, + { + name: "accepted status case-insensitive", + sa: &gmail.SendAs{ + VerificationStatus: "ACCEPTED", + }, + want: true, + }, + { + name: "workspace alias with empty status and gmail-managed delivery", + sa: &gmail.SendAs{ + VerificationStatus: "", + }, + want: true, + }, + { + name: "empty status with smtp relay is not allowed", + sa: &gmail.SendAs{ + VerificationStatus: "", + SmtpMsa: &gmail.SmtpMsa{ + Host: "smtp.example.com", + }, + }, + want: false, + }, + { + name: "pending status is not allowed", + sa: &gmail.SendAs{ + VerificationStatus: "pending", + }, + want: false, + }, + { + name: "nil send-as", + sa: nil, + want: false, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := sendAsAllowedForFrom(tc.sa) + if got != tc.want { + t.Fatalf("sendAsAllowedForFrom() = %t, want %t", got, tc.want) + } + }) + } +}