diff --git a/docs/spec.md b/docs/spec.md index 459c8d28e..e5f2e9afb 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -176,6 +176,7 @@ Environment: - `GOG_ENABLE_COMMANDS_EXACT=calendar.events,gmail.search` (optional exact allowlist; dot paths allowed; parent paths do not allow children) - `GOG_DISABLE_COMMANDS=gmail.send,gmail.drafts.send` (optional denylist; dot paths allowed) - `GOG_GMAIL_NO_SEND=1` (block Gmail send operations) +- `GOG_GMAIL_BASE_URL=https://gmail-proxy.example/` (override the Gmail API endpoint; the endpoint receives OAuth bearer credentials and Gmail data, so use only a trusted HTTPS endpoint; plain HTTP is for loopback-only local development/testing) - `config.json` can also set `keyring_backend` (JSON5; env vars take precedence) - `config.json` can also set `default_timezone` (IANA name or `UTC`) - `config.json` can also set `places_api_key` (or use `GOG_PLACES_API_KEY` / `GOOGLE_PLACES_API_KEY`) for Calendar Places lookups. diff --git a/internal/cmd/gmail_base_url.go b/internal/cmd/gmail_base_url.go new file mode 100644 index 000000000..ed8fd617b --- /dev/null +++ b/internal/cmd/gmail_base_url.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +func validateGmailBaseURL(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", nil + } + + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("invalid GOG_GMAIL_BASE_URL: must be an absolute URL") + } + + switch strings.ToLower(parsed.Scheme) { + case "https": + return value, nil + case "http": + hostname := parsed.Hostname() + ip := net.ParseIP(hostname) + if strings.EqualFold(hostname, "localhost") || ip != nil && ip.IsLoopback() { + return value, nil + } + } + + return "", fmt.Errorf("invalid GOG_GMAIL_BASE_URL: must use HTTPS or loopback HTTP") +} diff --git a/internal/cmd/gmail_base_url_test.go b/internal/cmd/gmail_base_url_test.go new file mode 100644 index 000000000..bcc853899 --- /dev/null +++ b/internal/cmd/gmail_base_url_test.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/openclaw/gogcli/internal/app" +) + +func TestValidateGmailBaseURL(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "unset"}, + {name: "https", value: "https://proxy.example/gmail/"}, + {name: "localhost http", value: "http://localhost:8080/"}, + {name: "ipv4 loopback http", value: "http://127.0.0.1:8080/"}, + {name: "ipv6 loopback http", value: "http://[::1]:8080/"}, + {name: "remote http", value: "http://proxy.example/", wantErr: true}, + {name: "non-loopback ipv4 http", value: "http://192.0.2.1/", wantErr: true}, + {name: "localhost suffix", value: "http://localhost.example/", wantErr: true}, + {name: "unsupported scheme", value: "ftp://localhost/", wantErr: true}, + {name: "missing scheme", value: "proxy.example/", wantErr: true}, + {name: "missing host", value: "https:///gmail/", wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := validateGmailBaseURL(test.value) + if test.wantErr { + if err == nil { + t.Fatalf("validateGmailBaseURL(%q) = %q, want error", test.value, got) + } + return + } + if err != nil { + t.Fatalf("validateGmailBaseURL(%q): %v", test.value, err) + } + if got != test.value { + t.Fatalf("validateGmailBaseURL(%q) = %q", test.value, got) + } + }) + } +} + +func TestGmailBaseURLRoutesAuthenticatedCommands(t *testing.T) { + requests := make(chan *http.Request, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- r.Clone(r.Context()) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`{"id":"message-1"}`)) + return + } + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(server.Close) + t.Setenv("GOG_GMAIL_BASE_URL", server.URL+"/") + + runtime := &app.Runtime{ServicesManaged: true} + result := executeWithTestRuntime(t, []string{ + "--json", "--access-token", "test-token", "gmail", "get", "message-1", + }, runtime) + if result.err != nil { + t.Fatalf("gmail get: %v\nstderr=%s", result.err, result.stderr) + } + + result = executeWithTestRuntime(t, []string{ + "--json", "--force", "--access-token", "test-token", "gmail", "batch", "delete", "message-1", + }, runtime) + if result.err != nil { + t.Fatalf("gmail batch delete: %v\nstderr=%s", result.err, result.stderr) + } + + wantRequests := []struct { + method string + path string + }{ + {method: http.MethodGet, path: "/gmail/v1/users/me/messages/message-1"}, + {method: http.MethodPost, path: "/gmail/v1/users/me/messages/batchDelete"}, + } + for _, want := range wantRequests { + request := <-requests + if request.Method != want.method || request.URL.Path != want.path { + t.Errorf("request = %s %s, want %s %s", request.Method, request.URL.Path, want.method, want.path) + } + if got := request.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("Authorization = %q, want bearer token", got) + } + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 203b3967d..73abe4208 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -294,8 +294,13 @@ func executeWithRuntime(args []string, runtime *app.Runtime) (err error) { Reauth: reauthFn, ReauthCoordinator: googleapi.NewReauthCoordinator(), } + gmailBaseURL, err := validateGmailBaseURL(os.Getenv("GOG_GMAIL_BASE_URL")) + if err != nil { + return err + } ctx = googleapi.WithAuthDependencies(ctx, authDependencies) composeRuntimeGoogleServices(runtime, googleapi.NewFactory(authDependencies, googleapi.FactoryOptions{ + GmailBaseURL: gmailBaseURL, PhotosBaseURL: os.Getenv("GOG_PHOTOS_BASE_URL"), PhotosPickerBaseURL: os.Getenv("GOG_PHOTOS_PICKER_BASE_URL"), })) diff --git a/internal/googleapi/client.go b/internal/googleapi/client.go index ab9477d74..839a606a2 100644 --- a/internal/googleapi/client.go +++ b/internal/googleapi/client.go @@ -47,12 +47,15 @@ func newGoogleServiceForAccount[T any]( service googleauth.Service, label string, factory googleServiceFactory[T], + additionalOptions ...option.ClientOption, ) (*T, error) { opts, err := optionsForAccount(ctx, service, email) if err != nil { return nil, fmt.Errorf("%s options: %w", label, err) } + opts = append(opts, additionalOptions...) + return newGoogleService(ctx, label, opts, factory) } @@ -79,12 +82,15 @@ func newGoogleServiceForRequiredScopes[T any]( errorLabel string, scopes []string, factory googleServiceFactory[T], + additionalOptions ...option.ClientOption, ) (*T, error) { opts, err := optionsForAccountScopesRequiringStoredGrant(ctx, serviceLabel, email, scopes) if err != nil { return nil, fmt.Errorf("%s options: %w", errorLabel, err) } + opts = append(opts, additionalOptions...) + return newGoogleService(ctx, errorLabel, opts, factory) } diff --git a/internal/googleapi/factory.go b/internal/googleapi/factory.go index a169774c1..a716f2149 100644 --- a/internal/googleapi/factory.go +++ b/internal/googleapi/factory.go @@ -20,6 +20,7 @@ import ( "google.golang.org/api/gmail/v1" "google.golang.org/api/keep/v1" "google.golang.org/api/meet/v2" + "google.golang.org/api/option" "google.golang.org/api/people/v1" "google.golang.org/api/script/v1" searchconsole "google.golang.org/api/searchconsole/v1" @@ -32,12 +33,14 @@ import ( ) type FactoryOptions struct { + GmailBaseURL string PhotosBaseURL string PhotosPickerBaseURL string } type Factory struct { auth AuthDependencies + gmailBaseURL string photosBaseURL string photosPickerBaseURL string } @@ -45,6 +48,7 @@ type Factory struct { func NewFactory(auth AuthDependencies, options FactoryOptions) Factory { return Factory{ auth: auth, + gmailBaseURL: options.GmailBaseURL, photosBaseURL: options.PhotosBaseURL, photosPickerBaseURL: options.PhotosPickerBaseURL, } @@ -119,11 +123,19 @@ func (f Factory) Forms(ctx context.Context, account string) (*forms.Service, err } func (f Factory) Gmail(ctx context.Context, account string) (*gmail.Service, error) { - return NewGmail(f.withAuth(ctx), account) + if f.gmailBaseURL == "" { + return NewGmail(f.withAuth(ctx), account) + } + + return NewGmail(f.withAuth(ctx), account, option.WithEndpoint(f.gmailBaseURL)) } func (f Factory) GmailDelete(ctx context.Context, account string) (*gmail.Service, error) { - return NewGmailBatchDelete(f.withAuth(ctx), account) + if f.gmailBaseURL == "" { + return NewGmailBatchDelete(f.withAuth(ctx), account) + } + + return NewGmailBatchDelete(f.withAuth(ctx), account, option.WithEndpoint(f.gmailBaseURL)) } func (f Factory) Keep(ctx context.Context, path, impersonate string) (*keep.Service, error) { diff --git a/internal/googleapi/factory_test.go b/internal/googleapi/factory_test.go index 00fb37609..3299d0cc3 100644 --- a/internal/googleapi/factory_test.go +++ b/internal/googleapi/factory_test.go @@ -19,6 +19,7 @@ func TestFactoryBuildsRepresentativeServices(t *testing.T) { ctx := authclient.WithAccessToken(context.Background(), "test-token") factory := NewFactory(AuthDependencies{}, FactoryOptions{ + GmailBaseURL: "https://gmail.example.test/", PhotosBaseURL: "https://photos.example.test/v1", PhotosPickerBaseURL: "https://picker.example.test/v1", }) @@ -29,6 +30,8 @@ func TestFactoryBuildsRepresentativeServices(t *testing.T) { if svc, err := factory.Gmail(ctx, "user@example.com"); err != nil || svc == nil { t.Fatalf("Gmail() = (%v, %v)", svc, err) + } else if svc.BasePath != "https://gmail.example.test/" { + t.Fatalf("Gmail BasePath = %q", svc.BasePath) } if svc, err := factory.Docs(ctx, "user@example.com"); err != nil || svc == nil { diff --git a/internal/googleapi/gmail.go b/internal/googleapi/gmail.go index 9e919b4b3..5a7f83bfa 100644 --- a/internal/googleapi/gmail.go +++ b/internal/googleapi/gmail.go @@ -4,17 +4,18 @@ import ( "context" "google.golang.org/api/gmail/v1" + "google.golang.org/api/option" "github.com/openclaw/gogcli/internal/googleauth" ) const scopeGmailFullAccess = "https://mail.google.com/" -func NewGmail(ctx context.Context, email string) (*gmail.Service, error) { - return newGoogleServiceForAccount(ctx, email, googleauth.ServiceGmail, "gmail", gmail.NewService) +func NewGmail(ctx context.Context, email string, options ...option.ClientOption) (*gmail.Service, error) { + return newGoogleServiceForAccount(ctx, email, googleauth.ServiceGmail, "gmail", gmail.NewService, options...) } -func NewGmailBatchDelete(ctx context.Context, email string) (*gmail.Service, error) { +func NewGmailBatchDelete(ctx context.Context, email string, options ...option.ClientOption) (*gmail.Service, error) { return newGoogleServiceForRequiredScopes( ctx, email, @@ -22,5 +23,6 @@ func NewGmailBatchDelete(ctx context.Context, email string) (*gmail.Service, err "gmail batch delete", []string{scopeGmailFullAccess}, gmail.NewService, + options..., ) } diff --git a/internal/googleapi/services_more_test.go b/internal/googleapi/services_more_test.go index d241bbe42..d5ea76718 100644 --- a/internal/googleapi/services_more_test.go +++ b/internal/googleapi/services_more_test.go @@ -9,6 +9,7 @@ import ( "testing" "golang.org/x/oauth2" + "google.golang.org/api/option" "github.com/openclaw/gogcli/internal/authclient" "github.com/openclaw/gogcli/internal/googleauth" @@ -90,6 +91,29 @@ func TestNewServicesWithStoredToken(t *testing.T) { } } +func TestNewGmailServicesWithCustomEndpoint(t *testing.T) { + ctx := testClientResolverContext(t) + const endpoint = "https://gmail.example.test/" + + svc, err := NewGmail(ctx, "a@b.com", option.WithEndpoint(endpoint)) + if err != nil { + t.Fatalf("NewGmail: %v", err) + } + + if svc.BasePath != endpoint { + t.Fatalf("NewGmail BasePath = %q, want %q", svc.BasePath, endpoint) + } + + deleteSvc, err := NewGmailBatchDelete(ctx, "a@b.com", option.WithEndpoint(endpoint)) + if err != nil { + t.Fatalf("NewGmailBatchDelete: %v", err) + } + + if deleteSvc.BasePath != endpoint { + t.Fatalf("NewGmailBatchDelete BasePath = %q, want %q", deleteSvc.BasePath, endpoint) + } +} + func TestNewConnectedSheetsRequestsReadOnlySheetsAndBigQueryScopes(t *testing.T) { var gotScopes []string ctx := WithAuthDependencies(context.Background(), AuthDependencies{