fix(gmail): allow Workspace native aliases with empty verification status (#407)

* fix(gmail): allow workspace native aliases for --from

* fix: land Workspace alias send fix and changelog (#407) (thanks @salmonumbrella)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
salmonumbrella 2026-03-07 06:56:25 -08:00 committed by GitHub
parent caf38a3d33
commit 4abcd03da7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 241 additions and 2 deletions

View File

@ -14,6 +14,7 @@
- Contacts: fix grouped parameter types in CRUD helpers to restore builds on newer Go toolchains. (#355) — thanks @laihenyi.
- Timezone: embed the IANA timezone database so Windows builds can resolve calendar timezones correctly. (#388) — thanks @visionik.
- Gmail: add a fetch delay in `watch serve` so History API reads don't race message indexing. (#397) — thanks @salmonumbrella.
- Gmail: allow Workspace-managed send-as aliases with empty verification status in `send` and `drafts create`. (#407) — thanks @salmonumbrella.
- Secrets: respect empty `GOG_KEYRING_PASSWORD` (treat set-to-empty as intentional; avoids headless prompts). (#269) — thanks @zerone0x.
- Calendar: reject ambiguous calendar-name selectors for `calendar events` instead of guessing. (#131) — thanks @salmonumbrella.
- Gmail: `drafts update --quote` now picks a non-draft, non-self message from thread fallback (or errors clearly), avoiding self-quote loops and wrong reply headers. (#394) — thanks @salmonumbrella.

View File

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

View File

@ -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\" <workspace-alias@example.com>\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 })

View File

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

View File

@ -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 <workspace-alias@example.com>") {
t.Fatalf("unexpected output: %q", out)
}
}
func TestGmailSendCmd_RunJSON_WithFromDisplayNameFallbackToList(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })

View File

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

View File

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