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
1 change: 1 addition & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions internal/cmd/gmail_base_url.go
Original file line number Diff line number Diff line change
@@ -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")
}
94 changes: 94 additions & 0 deletions internal/cmd/gmail_base_url_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
5 changes: 5 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}))
Expand Down
6 changes: 6 additions & 0 deletions internal/googleapi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

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

Expand Down
16 changes: 14 additions & 2 deletions internal/googleapi/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -32,19 +33,22 @@ import (
)

type FactoryOptions struct {
GmailBaseURL string
PhotosBaseURL string
PhotosPickerBaseURL string
}

type Factory struct {
auth AuthDependencies
gmailBaseURL string
photosBaseURL string
photosPickerBaseURL string
}

func NewFactory(auth AuthDependencies, options FactoryOptions) Factory {
return Factory{
auth: auth,
gmailBaseURL: options.GmailBaseURL,
photosBaseURL: options.PhotosBaseURL,
photosPickerBaseURL: options.PhotosPickerBaseURL,
}
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions internal/googleapi/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})
Expand All @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions internal/googleapi/gmail.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,25 @@ 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,
string(googleauth.ServiceGmail),
"gmail batch delete",
[]string{scopeGmailFullAccess},
gmail.NewService,
options...,
)
}
24 changes: 24 additions & 0 deletions internal/googleapi/services_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down