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:
parent
caf38a3d33
commit
4abcd03da7
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
|
||||
22
internal/cmd/gmail_sendas_validation.go
Normal file
22
internal/cmd/gmail_sendas_validation.go
Normal 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
|
||||
}
|
||||
73
internal/cmd/gmail_sendas_validation_test.go
Normal file
73
internal/cmd/gmail_sendas_validation_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user