Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/cmd/gmail_drafts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions internal/cmd/gmail_drafts_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/gmail_send.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
65 changes: 65 additions & 0 deletions internal/cmd/gmail_send_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
22 changes: 22 additions & 0 deletions internal/cmd/gmail_sendas_validation.go
Original file line number Diff line number Diff line change
@@ -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 changes: 73 additions & 0 deletions internal/cmd/gmail_sendas_validation_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}