leash-sdk 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auth"
4
+ require_relative "env"
5
+ require_relative "errors"
6
+ require_relative "integrations"
7
+ require_relative "transport"
8
+ require_relative "types"
9
+
10
+ module Leash
11
+ DEFAULT_PLATFORM_URL = "https://leash.build"
12
+
13
+ # Unified Leash client — namespaces for `auth`, `env`, `integrations`.
14
+ # Ruby mirror of `leash-sdk-ts/src/leash.ts` and `leash-sdk-python/leash/client.py`.
15
+ #
16
+ # Construction is server-only in 0.4 and requires a request object:
17
+ #
18
+ # leash = Leash.new(request: request) # Rails / Sinatra / Hanami / Rack hash
19
+ # user = leash.auth.user # Leash::User or nil
20
+ # key = leash.env.get("OPENAI_API_KEY") # String or nil
21
+ # msgs = leash.integrations.gmail.list_messages(max_results: 5)
22
+ #
23
+ # Authentication precedence (mirror TS / Python / Go exactly):
24
+ #
25
+ # 1. `LEASH_API_KEY` env var (or explicit `api_key:` constructor arg)
26
+ # 2. `Authorization: Bearer <jwt>` header on the request — used for
27
+ # `auth.user` AND as an env-read fallback (when no API key), but
28
+ # NEVER forwarded on integration POSTs
29
+ # 3. `leash-auth` cookie from the request
30
+ #
31
+ # The Bearer fallback for env reads is documented here per the Go-reviewer
32
+ # callout: when the constructor sees no `LEASH_API_KEY` but does see an
33
+ # inbound `Authorization: Bearer …`, the bearer JWT is used as the API key
34
+ # for the platform's `/api/apps/me/secrets/[key]` endpoint. The bearer is
35
+ # still suppressed on integration POSTs (Critical #1 in the 0.4 plan).
36
+ class Client
37
+ # @return [Leash::Integrations::Namespace]
38
+ attr_reader :integrations
39
+
40
+ # @return [Leash::Env]
41
+ attr_reader :env
42
+
43
+ # @return [Auth]
44
+ attr_reader :auth
45
+
46
+ # @return [String]
47
+ attr_reader :platform_url
48
+
49
+ # @param request [Object, Hash] any Rack-conforming request (Rails
50
+ # `ActionDispatch::Request`, Sinatra `Sinatra::Request`, Hanami,
51
+ # a plain Rack `env` hash, or anything quacking with `.cookies` / `.env`).
52
+ # @param platform_url [String, nil] override the platform base URL.
53
+ # Defaults to `LEASH_PLATFORM_URL` env var or `https://leash.build`.
54
+ # @param api_key [String, nil] explicit `LEASH_API_KEY` override.
55
+ # @param transport [Leash::Transport, nil] inject a transport (handy for
56
+ # tests). When omitted, a default `Net::HTTP`-backed transport is built.
57
+ def initialize(request:, platform_url: nil, api_key: nil, transport: nil)
58
+ if request.nil?
59
+ raise Error.new(
60
+ "Leash requires a request object in server environments.",
61
+ code: "NO_REQUEST_SERVER_CONSTRUCT",
62
+ action: "Pass request: req to Leash.new in your route handler.",
63
+ see_also: "https://leash.build/docs/sdk"
64
+ )
65
+ end
66
+
67
+ @request = request
68
+ @platform_url = (platform_url || ENV["LEASH_PLATFORM_URL"] || DEFAULT_PLATFORM_URL)
69
+ .to_s.sub(%r{/+\z}, "")
70
+
71
+ # Auth precedence: explicit api_key > LEASH_API_KEY env > bearer header > cookie.
72
+ env_api_key = ENV["LEASH_API_KEY"]
73
+ env_api_key = nil if env_api_key && env_api_key.empty?
74
+ @explicit_api_key = api_key || env_api_key
75
+
76
+ @bearer_token = Auth.extract_bearer_token(request)
77
+ @cookie_value = Auth.extract_cookie(request)
78
+
79
+ # Bearer→env fallback: when no app key is present, the inbound user
80
+ # JWT is used to authenticate env reads to the platform. NEVER used
81
+ # as `X-API-Key` on integration POSTs (see Transport docstring).
82
+ @env_api_key = @explicit_api_key || @bearer_token
83
+
84
+ @transport = transport || Transport.new(
85
+ platform_url: @platform_url,
86
+ api_key: @explicit_api_key,
87
+ cookie_value: @cookie_value
88
+ )
89
+
90
+ @auth = AuthFacade.new(request)
91
+ @env = Env.new(
92
+ platform_url: @platform_url,
93
+ api_key: @env_api_key,
94
+ transport: @transport
95
+ )
96
+ @integrations = Integrations::Namespace.new(@transport)
97
+ end
98
+
99
+ # @api private — exposed so tests can introspect resolved credentials.
100
+ attr_reader :bearer_token, :cookie_value, :explicit_api_key
101
+
102
+ # `leash.auth` — sync, non-throwing wrapper around {Leash::Auth.get_user}.
103
+ class AuthFacade
104
+ def initialize(request)
105
+ @request = request
106
+ end
107
+
108
+ # @return [Leash::User, nil] the authenticated user, or `nil` when not
109
+ # authenticated. Never raises — swallows decode errors so handlers can
110
+ # branch with a clean `if user.nil?`.
111
+ #
112
+ # Note: this method reads ONLY the `leash-auth` cookie from the request.
113
+ # `Authorization: Bearer <jwt>` headers are deliberately NOT used for
114
+ # identity resolution here — they're reserved for env-fetch fallback +
115
+ # CLI/agent flows. To get the user from a Bearer token, you'd hit
116
+ # `/api/auth/me` directly with that token.
117
+ def user
118
+ Leash::Auth.get_user(@request)
119
+ rescue Leash::AuthError
120
+ nil
121
+ rescue StandardError
122
+ nil
123
+ end
124
+
125
+ # @return [Boolean]
126
+ def authenticated?
127
+ !user.nil?
128
+ end
129
+ end
130
+ end
131
+
132
+ # `Leash.new(...)` is the idiomatic Ruby entry point. We delegate to
133
+ # `Client.new` so the module-level constant `Leash` stays a module
134
+ # (necessary because `Leash::Auth`, `Leash::Error`, etc. all live under it).
135
+ def self.new(**kwargs)
136
+ Client.new(**kwargs)
137
+ end
138
+ end
data/lib/leash/env.rb ADDED
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ require_relative "errors"
6
+
7
+ module Leash
8
+ # `leash.env` namespace — runtime env-var fetcher with a per-instance
9
+ # 60 s TTL cache. Mirrors the TS `leash.env.get` / `leash.env.getMany`
10
+ # surface and the Python `EnvNamespace` behaviour (`Optional[str]` — Ruby
11
+ # returns `nil` for HTTP 404 instead of raising, so callers can branch
12
+ # naturally).
13
+ class Env
14
+ CACHE_TTL_S = 60
15
+
16
+ attr_reader :platform_url
17
+
18
+ def initialize(platform_url:, api_key:, transport:)
19
+ @platform_url = platform_url.to_s.sub(%r{/+\z}, "")
20
+ @api_key = api_key
21
+ @transport = transport
22
+ @cache = {}
23
+ end
24
+
25
+ # Resolve a single env-var value.
26
+ #
27
+ # @param key [String] the env-var name (uppercase by convention)
28
+ # @param fresh [Boolean] skip the TTL cache for this call; the freshly
29
+ # fetched value is still written back to the cache
30
+ # @return [String, nil] the value, or `nil` when the platform reports the
31
+ # key as not declared / not found
32
+ # @raise [Leash::Error] for auth / invalid-key / plan / platform errors
33
+ def get(key, fresh: false)
34
+ now = monotonic_now
35
+ unless fresh
36
+ entry = @cache[key]
37
+ return entry[:value] if entry && entry[:expires_at] > now
38
+ end
39
+
40
+ value = fetch_one(key)
41
+ @cache[key] = { value: value, expires_at: now + CACHE_TTL_S }
42
+ value
43
+ end
44
+
45
+ # Bulk variant — resolve multiple keys sequentially against the shared
46
+ # TTL cache.
47
+ #
48
+ # @return [Hash{String => String, nil}]
49
+ def get_many(keys)
50
+ result = {}
51
+ keys.each { |k| result[k] = get(k) }
52
+ result
53
+ end
54
+
55
+ # @api private — for tests / introspection.
56
+ def clear_cache!
57
+ @cache.clear
58
+ end
59
+
60
+ private
61
+
62
+ def monotonic_now
63
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+ end
65
+
66
+ def fetch_one(key)
67
+ raise NoApiKeyError unless @api_key
68
+
69
+ # Use path-style percent-encoding (TS `encodeURIComponent` semantics):
70
+ # spaces become %20, not + (CGI.escape's form-encoding default).
71
+ encoded = CGI.escape(key).gsub("+", "%20")
72
+ url = "#{@platform_url}/api/apps/me/secrets/#{encoded}"
73
+
74
+ # Env reads use `Authorization: Bearer <api_key>` — matches the platform
75
+ # contract for /api/apps/me/secrets/[key] (see leash.ts line 299 + 303).
76
+ response = @transport.get_json(url, headers: {
77
+ "Authorization" => "Bearer #{@api_key}"
78
+ })
79
+
80
+ status = response.respond_to?(:code) ? response.code.to_i : 0
81
+ body = parse_json(response.respond_to?(:body) ? response.body : nil)
82
+
83
+ case status
84
+ when 400
85
+ raise Error.new(
86
+ "Invalid env-var key: '#{key}'.",
87
+ code: "INVALID_KEY",
88
+ action: "Env-var names must match /^[A-Za-z_][A-Za-z0-9_]*$/ and be no longer than 100 characters.",
89
+ see_also: "https://leash.build/docs/sdk",
90
+ status: 400
91
+ )
92
+ when 401
93
+ raise UnauthorizedError.new(
94
+ "Missing or invalid LEASH_API_KEY.",
95
+ action: "Mint a fresh API key at /dashboard/organization.",
96
+ see_also: "https://leash.build/dashboard/organization",
97
+ status: 401
98
+ )
99
+ when 402
100
+ required_plan = body.is_a?(Hash) ? body["requiredPlan"] : nil
101
+ suffix = required_plan ? " (requiredPlan: #{required_plan})" : ""
102
+ raise UpgradeRequiredError.new(
103
+ "leash.env.get requires the Growth plan or above#{suffix}.",
104
+ action: "Upgrade at https://leash.build/dashboard/billing.",
105
+ see_also: "https://leash.build/dashboard/billing",
106
+ status: 402
107
+ )
108
+ when 404
109
+ # Ruby idiom: return nil for missing keys instead of raising so
110
+ # callers can branch with `if value.nil?`. Mirrors Python.
111
+ return nil
112
+ when 502
113
+ platform_error = body.is_a?(Hash) ? body["error"] : nil
114
+ raise Error.new(
115
+ platform_error || "Secret source resync failed on the platform side.",
116
+ code: "SOURCE_RESYNC_FAILED",
117
+ action: "Check your secret source configuration in the Leash dashboard.",
118
+ see_also: "https://leash.build/dashboard",
119
+ status: 502
120
+ )
121
+ end
122
+
123
+ if status >= 400
124
+ raise Error.new(
125
+ "Unexpected response from platform: HTTP #{status}",
126
+ code: "ENV_FETCH_ERROR",
127
+ action: "Check the Leash platform status and your configuration.",
128
+ see_also: "https://leash.build/docs/sdk",
129
+ status: status
130
+ )
131
+ end
132
+
133
+ value = body.is_a?(Hash) ? body["value"] : nil
134
+ unless value.is_a?(String)
135
+ raise Error.new(
136
+ "Platform returned an unexpected response shape for key '#{key}'.",
137
+ code: "ENV_FETCH_ERROR",
138
+ action: "Check the Leash platform status and your configuration.",
139
+ see_also: "https://leash.build/docs/sdk",
140
+ status: status
141
+ )
142
+ end
143
+ value
144
+ end
145
+
146
+ def parse_json(body)
147
+ return nil if body.nil? || body.empty?
148
+
149
+ JSON.parse(body)
150
+ rescue JSON::ParserError
151
+ nil
152
+ end
153
+
154
+ class NoApiKeyError < Error
155
+ def initialize
156
+ super(
157
+ "LEASH_API_KEY is required to call leash.env.get().",
158
+ code: "NO_API_KEY",
159
+ action: "Set LEASH_API_KEY in your environment or pass api_key: ... to Leash.new.",
160
+ see_also: "https://leash.build/dashboard/organization"
161
+ )
162
+ end
163
+ end
164
+ end
165
+ end
data/lib/leash/errors.rb CHANGED
@@ -1,34 +1,126 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leash
4
- # Base error class for all Leash SDK errors.
4
+ # Structured error type raised by every Leash SDK call site.
5
5
  #
6
- # @attr_reader [String, nil] code the error code from the platform
7
- # @attr_reader [String, nil] connect_url the OAuth connect URL (present when provider is not connected)
6
+ # Mirrors `leash-sdk-ts/src/errors.ts` and Python's `leash.errors.LeashError`.
7
+ # The `code` field is the stable machine-readable identifier consumers should
8
+ # switch on; `message` is the human-readable line; `action` and `see_also`
9
+ # are optional remediation hints.
10
+ #
11
+ # Known codes (kept in sync with leash-sdk-ts):
12
+ # - NO_API_KEY
13
+ # - NO_REQUEST_SERVER_CONSTRUCT
14
+ # - BROWSER_MODE_UNSUPPORTED
15
+ # - UNAUTHORIZED
16
+ # - NO_AUTH_CONTEXT
17
+ # - INTEGRATION_NOT_ENABLED
18
+ # - INTEGRATION_ERROR
19
+ # - UPGRADE_REQUIRED
20
+ # - PLAN_BLOCK
21
+ # - CONNECTION_REQUIRED
22
+ # - NETWORK_ERROR
23
+ # - KEY_NOT_DECLARED
24
+ # - INVALID_KEY
25
+ # - SOURCE_RESYNC_FAILED
26
+ # - ENV_FETCH_ERROR
8
27
  class Error < StandardError
9
- attr_reader :code, :connect_url
28
+ attr_reader :code, :action, :see_also, :status, :cause
10
29
 
11
- # @param message [String] human-readable error message
12
- # @param code [String, nil] machine-readable error code
13
- # @param connect_url [String, nil] URL to initiate OAuth connection
14
- def initialize(message, code: nil, connect_url: nil)
30
+ def initialize(message, code: nil, action: nil, see_also: nil, status: nil,
31
+ cause: nil, connect_url: nil)
15
32
  super(message)
33
+ @message = message
16
34
  @code = code
35
+ @action = action
36
+ @see_also = see_also
37
+ @status = status
38
+ @cause = cause
17
39
  @connect_url = connect_url
18
40
  end
41
+
42
+ # Compatibility shim — the 0.3 surface exposed `connect_url`.
43
+ def connect_url
44
+ @connect_url
45
+ end
46
+
47
+ # Override `message` to return our stored value — avoids Exception#message
48
+ # falling back to `to_s` and recursing infinitely.
49
+ def message
50
+ @message
51
+ end
52
+
53
+ def to_s
54
+ out = +"x #{@message}"
55
+ out << "\n Fix: #{@action}" if @action
56
+ out << "\n See: #{@see_also}" if @see_also
57
+ out
58
+ end
59
+ end
60
+
61
+ # 402 from the platform — feature requires a higher plan.
62
+ class UpgradeRequiredError < Error
63
+ def initialize(message = "This feature requires a higher plan.", **opts)
64
+ opts[:code] ||= "UPGRADE_REQUIRED"
65
+ super(message, **opts)
66
+ end
67
+ end
68
+
69
+ # Alias kept for the surface contract described in the 0.4 plan
70
+ # (`Leash::PlanBlockError`). Same class as `UpgradeRequiredError`.
71
+ PlanBlockError = UpgradeRequiredError
72
+
73
+ # 403 from the platform — provider not connected for the current user.
74
+ class ConnectionRequiredError < Error
75
+ def initialize(message = "Integration not connected.", **opts)
76
+ opts[:code] ||= "INTEGRATION_NOT_ENABLED"
77
+ super(message, **opts)
78
+ end
79
+ end
80
+
81
+ # 404 on env.get — env-var key isn't declared / not found.
82
+ class KeyNotDeclaredError < Error
83
+ def initialize(message = "Key is not declared.", **opts)
84
+ opts[:code] ||= "KEY_NOT_DECLARED"
85
+ super(message, **opts)
86
+ end
87
+ end
88
+
89
+ # 401 from the platform — missing / invalid credentials.
90
+ class UnauthorizedError < Error
91
+ def initialize(message = "Unauthorized.", **opts)
92
+ opts[:code] ||= "UNAUTHORIZED"
93
+ super(message, **opts)
94
+ end
19
95
  end
20
96
 
21
- # Raised when the provider is not connected for the current user.
22
- class NotConnectedError < Error
23
- def initialize(message = "Integration not connected", connect_url: nil)
24
- super(message, code: "not_connected", connect_url: connect_url)
97
+ # Transport-level failure (DNS, refused connection, timeout, …).
98
+ class NetworkError < Error
99
+ def initialize(message = "Failed to reach the Leash platform.", **opts)
100
+ opts[:code] ||= "NETWORK_ERROR"
101
+ super(message, **opts)
25
102
  end
26
103
  end
27
104
 
28
- # Raised when the OAuth token has expired and needs to be refreshed.
105
+ # Backwards-compat alias for the 0.3 surface kept so existing user code
106
+ # `rescue Leash::NotConnectedError` keeps working.
107
+ NotConnectedError = ConnectionRequiredError
108
+
29
109
  class TokenExpiredError < Error
30
- def initialize(message = "Token expired", connect_url: nil)
31
- super(message, code: "token_expired", connect_url: connect_url)
110
+ def initialize(message = "Token expired.", **opts)
111
+ # Legacy code casing kept as snake_case to preserve 0.3 behavior for
112
+ # callers matching on err.code. New errors use SCREAMING_SNAKE.
113
+ opts[:code] ||= "token_expired"
114
+ super(message, **opts)
115
+ end
116
+ end
117
+
118
+ # Raised by `Leash::Auth.get_user` when the leash-auth cookie is missing /
119
+ # invalid. `Leash#auth.user` returns `nil` instead so it never raises.
120
+ class AuthError < Error
121
+ def initialize(message = "Authentication failed", **opts)
122
+ opts[:code] ||= "NO_AUTH_CONTEXT"
123
+ super(message, **opts)
32
124
  end
33
125
  end
34
126
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Leash
4
+ module Integrations
5
+ # @api private
6
+ # Internal base class — wraps a {Leash::Transport} bound to a provider id.
7
+ class Base
8
+ PROVIDER = nil
9
+
10
+ def initialize(transport)
11
+ @transport = transport
12
+ end
13
+
14
+ private
15
+
16
+ def call(action, params = nil)
17
+ @transport.call(self.class::PROVIDER, action, params)
18
+ end
19
+
20
+ # Drop nil values from a hash so we never send `{"foo":null}` payloads
21
+ # — mirrors the Python provider behaviour. Uses Hash#compact (Ruby 2.4+).
22
+ def compact_params(hash)
23
+ hash.compact
24
+ end
25
+ end
26
+
27
+ # Generic escape hatch — call any provider action without a typed wrapper.
28
+ # Returned by `leash.integrations.provider(name)`.
29
+ class Caller
30
+ def initialize(transport, name)
31
+ @transport = transport
32
+ @name = name
33
+ end
34
+
35
+ # Invoke `<provider>/<action>` with an optional JSON body.
36
+ #
37
+ # @param action [String] the wire action (e.g. `'post_message'`)
38
+ # @param body [Hash, nil] JSON body forwarded to the platform
39
+ def call(action, body: nil)
40
+ @transport.call(@name, action, body)
41
+ end
42
+
43
+ attr_reader :name
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Leash
6
+ module Integrations
7
+ # `leash.integrations.calendar` — mirrors TS `leash.integrations.calendar`.
8
+ # Wire provider id is `google_calendar` (also aliased as
9
+ # `leash.integrations.google_calendar`).
10
+ class Calendar < Base
11
+ PROVIDER = "google_calendar"
12
+
13
+ def list_calendars
14
+ call("list-calendars")
15
+ end
16
+
17
+ # @param calendar_id [String, nil]
18
+ # @param time_min [String, nil] ISO 8601 timestamp
19
+ # @param time_max [String, nil] ISO 8601 timestamp
20
+ # @param max_results [Integer, nil]
21
+ # @param query [String, nil]
22
+ # @param single_events [Boolean, nil]
23
+ # @param order_by [String, nil]
24
+ def list_events(calendar_id: nil, time_min: nil, time_max: nil,
25
+ max_results: nil, query: nil, single_events: nil,
26
+ order_by: nil)
27
+ params = compact_params(
28
+ "calendarId" => calendar_id,
29
+ "timeMin" => time_min,
30
+ "timeMax" => time_max,
31
+ "maxResults" => max_results,
32
+ "query" => query,
33
+ "singleEvents" => single_events,
34
+ "orderBy" => order_by
35
+ )
36
+ call("list-events", params.empty? ? nil : params)
37
+ end
38
+
39
+ # `end_time:` keyword is used here (not `end:`) to avoid clashing with
40
+ # the Ruby `end` keyword — but the wire payload uses `"end"`.
41
+ def create_event(summary:, start:, end_time: nil, **rest)
42
+ # Backward-compat: accept `:end` if callers pass it via a hash splat.
43
+ end_time = rest.delete(:end) if end_time.nil? && rest.key?(:end)
44
+
45
+ params = {
46
+ "summary" => summary,
47
+ "start" => start,
48
+ "end" => end_time
49
+ }
50
+ params["calendarId"] = rest[:calendar_id] if rest[:calendar_id]
51
+ params["description"] = rest[:description] if rest[:description]
52
+ params["location"] = rest[:location] if rest[:location]
53
+ params["attendees"] = rest[:attendees] if rest[:attendees]
54
+ call("create-event", params)
55
+ end
56
+
57
+ def get_event(event_id, calendar_id: nil)
58
+ params = { "eventId" => event_id }
59
+ params["calendarId"] = calendar_id unless calendar_id.nil?
60
+ call("get-event", params)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Leash
6
+ module Integrations
7
+ # `leash.integrations.drive` — mirrors TS `leash.integrations.drive`.
8
+ # Wire provider id is `google_drive` (also aliased as
9
+ # `leash.integrations.google_drive`).
10
+ class Drive < Base
11
+ PROVIDER = "google_drive"
12
+
13
+ def list_files(query: nil, max_results: nil, folder_id: nil)
14
+ params = compact_params(
15
+ "query" => query,
16
+ "maxResults" => max_results,
17
+ "folderId" => folder_id
18
+ )
19
+ call("list-files", params.empty? ? nil : params)
20
+ end
21
+
22
+ def get_file(file_id)
23
+ call("get-file", { "fileId" => file_id })
24
+ end
25
+
26
+ def download_file(file_id)
27
+ call("download-file", { "fileId" => file_id })
28
+ end
29
+
30
+ def create_folder(name, parent_id: nil)
31
+ params = { "name" => name }
32
+ params["parentId"] = parent_id unless parent_id.nil?
33
+ call("create-folder", params)
34
+ end
35
+
36
+ def upload_file(name:, content:, mime_type:, parent_id: nil)
37
+ params = { "name" => name, "content" => content, "mimeType" => mime_type }
38
+ params["parentId"] = parent_id unless parent_id.nil?
39
+ call("upload-file", params)
40
+ end
41
+
42
+ def delete_file(file_id)
43
+ call("delete-file", { "fileId" => file_id })
44
+ end
45
+
46
+ def search_files(query, max_results: nil)
47
+ params = { "query" => query }
48
+ params["maxResults"] = max_results unless max_results.nil?
49
+ call("search-files", params)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Leash
6
+ module Integrations
7
+ # `leash.integrations.gmail` — mirrors TS `leash.integrations.gmail`.
8
+ class Gmail < Base
9
+ PROVIDER = "gmail"
10
+
11
+ # @param query [String, nil] Gmail search query
12
+ # @param max_results [Integer, nil]
13
+ # @param label_ids [Array<String>, nil]
14
+ # @param page_token [String, nil]
15
+ def list_messages(query: nil, max_results: nil, label_ids: nil, page_token: nil)
16
+ params = compact_params(
17
+ "query" => query,
18
+ "maxResults" => max_results,
19
+ "labelIds" => label_ids,
20
+ "pageToken" => page_token
21
+ )
22
+ call("list-messages", params.empty? ? nil : params)
23
+ end
24
+
25
+ # @param message_id [String]
26
+ # @param format ['full','metadata','minimal','raw']
27
+ def get_message(message_id, format: "full")
28
+ call("get-message", { "messageId" => message_id, "format" => format })
29
+ end
30
+
31
+ # @param to [String]
32
+ # @param subject [String]
33
+ # @param body [String]
34
+ # @param cc [String, nil]
35
+ # @param bcc [String, nil]
36
+ def send_message(to:, subject:, body:, cc: nil, bcc: nil)
37
+ params = compact_params(
38
+ "to" => to, "subject" => subject, "body" => body, "cc" => cc, "bcc" => bcc
39
+ )
40
+ call("send-message", params)
41
+ end
42
+
43
+ # @param query [String]
44
+ # @param max_results [Integer, nil]
45
+ def search_messages(query, max_results: nil)
46
+ params = { "query" => query }
47
+ params["maxResults"] = max_results unless max_results.nil?
48
+ call("search-messages", params)
49
+ end
50
+
51
+ def list_labels
52
+ call("list-labels")
53
+ end
54
+
55
+ def get_profile
56
+ call("get-profile")
57
+ end
58
+ end
59
+ end
60
+ end