connectors 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +35 -0
- data/CONNECTORS_FRAMEWORK.md +799 -0
- data/CONTRIBUTING.md +60 -0
- data/MCP_CLIENT.md +168 -0
- data/MIT-LICENSE +20 -0
- data/README.md +146 -0
- data/app/connectors/clickup/connector.rb +13 -0
- data/app/connectors/gmail/api.rb +114 -0
- data/app/connectors/gmail/connector.rb +921 -0
- data/app/connectors/gmail/mime_builder.rb +261 -0
- data/app/connectors/gmail/mime_parser.rb +106 -0
- data/app/connectors/gmail/polling.rb +154 -0
- data/app/connectors/remote_mcp/connector.rb +18 -0
- data/app/connectors/resend/connector.rb +218 -0
- data/app/controllers/concerns/connectors/grant_access.rb +43 -0
- data/app/controllers/connectors/actions_controller.rb +66 -0
- data/app/controllers/connectors/application_controller.rb +5 -0
- data/app/controllers/connectors/credentials_controller.rb +245 -0
- data/app/controllers/connectors/grants_controller.rb +123 -0
- data/app/controllers/connectors/mcp_controller.rb +88 -0
- data/app/controllers/connectors/oauth_controller.rb +134 -0
- data/app/controllers/connectors/types_controller.rb +144 -0
- data/app/controllers/connectors/webhooks_controller.rb +105 -0
- data/app/jobs/connectors/application_job.rb +4 -0
- data/app/jobs/connectors/deliver_webhook_job.rb +27 -0
- data/app/jobs/connectors/poll_job.rb +49 -0
- data/app/models/connectors/application_record.rb +5 -0
- data/app/models/connectors/credential_share.rb +31 -0
- data/app/models/connectors/grant.rb +103 -0
- data/app/models/connectors/mcp_authorization.rb +6 -0
- data/app/models/connectors/mcp_interaction.rb +6 -0
- data/app/models/connectors/poll_state.rb +17 -0
- data/app/models/connectors/webhook_event.rb +19 -0
- data/config/routes.rb +68 -0
- data/db/migrate/20260518210324_create_connectors_grants.rb +49 -0
- data/db/migrate/20260518214609_create_connectors_webhook_events.rb +35 -0
- data/db/migrate/20260521140000_create_connectors_credential_shares.rb +26 -0
- data/db/migrate/20260922120000_create_connectors_poll_states.rb +12 -0
- data/db/migrate/20260922130000_create_connectors_mcp_transactions.rb +18 -0
- data/docs/adding-connectors.md +92 -0
- data/docs/architecture.md +71 -0
- data/docs/releasing.md +60 -0
- data/lib/connectors/action.rb +90 -0
- data/lib/connectors/action_builder.rb +125 -0
- data/lib/connectors/action_runner.rb +99 -0
- data/lib/connectors/auth/scheme/api_key.rb +51 -0
- data/lib/connectors/auth/scheme/oauth2.rb +23 -0
- data/lib/connectors/auth/scheme.rb +35 -0
- data/lib/connectors/auth.rb +4 -0
- data/lib/connectors/auth_injection.rb +65 -0
- data/lib/connectors/client_builder.rb +87 -0
- data/lib/connectors/configuration.rb +138 -0
- data/lib/connectors/connector.rb +565 -0
- data/lib/connectors/credential_schema.rb +219 -0
- data/lib/connectors/credential_tester.rb +85 -0
- data/lib/connectors/credential_type_registry.rb +162 -0
- data/lib/connectors/credential_types/http_auth.rb +171 -0
- data/lib/connectors/engine.rb +123 -0
- data/lib/connectors/errors.rb +88 -0
- data/lib/connectors/grant_policy.rb +13 -0
- data/lib/connectors/mcp/access.rb +50 -0
- data/lib/connectors/mcp/authorization.rb +177 -0
- data/lib/connectors/mcp/authorization_context.rb +25 -0
- data/lib/connectors/mcp/authorization_discovery.rb +42 -0
- data/lib/connectors/mcp/cancellation.rb +36 -0
- data/lib/connectors/mcp/client.rb +137 -0
- data/lib/connectors/mcp/connection_config.rb +48 -0
- data/lib/connectors/mcp/http.rb +108 -0
- data/lib/connectors/mcp/interaction.rb +82 -0
- data/lib/connectors/mcp/pending_transaction.rb +26 -0
- data/lib/connectors/mcp/protocol/2026-07-28.json +3963 -0
- data/lib/connectors/mcp/protocol/LICENSE +216 -0
- data/lib/connectors/mcp/protocol/README.md +8 -0
- data/lib/connectors/mcp/protocol_schema.rb +30 -0
- data/lib/connectors/mcp/schema.rb +45 -0
- data/lib/connectors/mcp/settings.rb +27 -0
- data/lib/connectors/mcp/token_endpoint.rb +48 -0
- data/lib/connectors/mcp/transport.rb +105 -0
- data/lib/connectors/mcp.rb +69 -0
- data/lib/connectors/middleware/authenticate_generic.rb +79 -0
- data/lib/connectors/middleware/auto_refresh.rb +71 -0
- data/lib/connectors/middleware/error_normalization.rb +45 -0
- data/lib/connectors/middleware/grant_status.rb +19 -0
- data/lib/connectors/middleware/pre_authentication.rb +54 -0
- data/lib/connectors/middleware/rate_limit.rb +40 -0
- data/lib/connectors/oauth/authorize_url.rb +78 -0
- data/lib/connectors/oauth/client_authentication.rb +24 -0
- data/lib/connectors/oauth/client_credentials.rb +43 -0
- data/lib/connectors/oauth/grant_writer.rb +73 -0
- data/lib/connectors/oauth/pkce.rb +32 -0
- data/lib/connectors/oauth/revoke.rb +72 -0
- data/lib/connectors/oauth/state.rb +41 -0
- data/lib/connectors/oauth/token_exchange.rb +69 -0
- data/lib/connectors/oauth/token_response.rb +40 -0
- data/lib/connectors/oauth.rb +4 -0
- data/lib/connectors/oauth1.rb +151 -0
- data/lib/connectors/permission_check.rb +33 -0
- data/lib/connectors/poll_runner.rb +50 -0
- data/lib/connectors/pre_authentication_helpers.rb +76 -0
- data/lib/connectors/registry.rb +38 -0
- data/lib/connectors/version.rb +3 -0
- data/lib/connectors/webhook_context.rb +62 -0
- data/lib/connectors/webhook_lifecycle.rb +69 -0
- data/lib/connectors/webhook_methods.rb +48 -0
- data/lib/connectors/webhooks/verifier.rb +31 -0
- data/lib/connectors/webhooks.rb +5 -0
- data/lib/connectors.rb +50 -0
- data/lib/tasks/connectors_mcp.rake +9 -0
- data/lib/tasks/connectors_tasks.rake +4 -0
- data/openapi.yaml +1099 -0
- metadata +263 -0
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
require_dependency Connectors::Engine.root.join("app/connectors/gmail/api.rb").to_s
|
|
2
|
+
require_dependency Connectors::Engine.root.join("app/connectors/gmail/mime_builder.rb").to_s
|
|
3
|
+
require_dependency Connectors::Engine.root.join("app/connectors/gmail/mime_parser.rb").to_s
|
|
4
|
+
require_dependency Connectors::Engine.root.join("app/connectors/gmail/polling.rb").to_s
|
|
5
|
+
|
|
6
|
+
module Gmail
|
|
7
|
+
# Gmail OAuth2 connector. Talks to gmail.googleapis.com using a Bearer
|
|
8
|
+
# access_token. Auth is the standard Google OAuth2 redirect dance with
|
|
9
|
+
# PKCE (Google enforces it for confidential clients) and refresh_token
|
|
10
|
+
# rotation. Per Jackson's call: the scope set declared here is a
|
|
11
|
+
# SUGGESTION — the host can override per-grant by passing `?scope=...`
|
|
12
|
+
# to /authorize, so multi-tenant apps can ask for narrower / wider sets.
|
|
13
|
+
#
|
|
14
|
+
# n8n parity: packages/nodes-base/credentials/GmailOAuth2.credentials.ts
|
|
15
|
+
# + packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts. Action
|
|
16
|
+
# surface mirrors n8n's Message / Label / Thread / Draft resources.
|
|
17
|
+
class Connector < Connectors::Connector
|
|
18
|
+
DEFAULT_SCOPES = [
|
|
19
|
+
"openid",
|
|
20
|
+
"email",
|
|
21
|
+
"https://www.googleapis.com/auth/gmail.modify",
|
|
22
|
+
"https://www.googleapis.com/auth/gmail.labels"
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
connector key: :gmail,
|
|
26
|
+
auth: :oauth2,
|
|
27
|
+
base_url: "https://gmail.googleapis.com/gmail/v1",
|
|
28
|
+
display_name: "Gmail",
|
|
29
|
+
icon: "https://www.gstatic.com/images/branding/product/2x/gmail_2020q4_48dp.png",
|
|
30
|
+
icon_color: "#EA4335",
|
|
31
|
+
documentation_url: "https://developers.google.com/gmail/api/reference/rest",
|
|
32
|
+
llm_docs: "https://developers.google.com/gmail/api/reference/rest"
|
|
33
|
+
|
|
34
|
+
oauth2 authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
35
|
+
token_url: "https://oauth2.googleapis.com/token",
|
|
36
|
+
scope: DEFAULT_SCOPES.join(" "),
|
|
37
|
+
grant_type: "pkce",
|
|
38
|
+
authentication: "body",
|
|
39
|
+
extra_authorize_params: {
|
|
40
|
+
access_type: "offline",
|
|
41
|
+
prompt: "consent",
|
|
42
|
+
include_granted_scopes: "true"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
revoke_token_url "https://oauth2.googleapis.com/revoke"
|
|
46
|
+
|
|
47
|
+
credentials do
|
|
48
|
+
extends :oauth2
|
|
49
|
+
|
|
50
|
+
field :authorization_url, type: "hidden",
|
|
51
|
+
default: "https://accounts.google.com/o/oauth2/v2/auth"
|
|
52
|
+
field :access_token_url, type: "hidden",
|
|
53
|
+
default: "https://oauth2.googleapis.com/token"
|
|
54
|
+
field :grant_type, type: "hidden", default: "pkce"
|
|
55
|
+
field :scope, type: "hidden", default: DEFAULT_SCOPES.join(" ")
|
|
56
|
+
|
|
57
|
+
field :client_id, type: "hidden"
|
|
58
|
+
field :client_secret, type: "hidden"
|
|
59
|
+
field :auth_query_parameters, type: "hidden"
|
|
60
|
+
field :authentication, type: "hidden", default: "header"
|
|
61
|
+
|
|
62
|
+
field :jwe_enabled, type: "hidden", default: false
|
|
63
|
+
field :jwks_uri, type: "hidden", default: ""
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
rate_limit 2, per: 1.second
|
|
67
|
+
|
|
68
|
+
def self.post_token_exchange(raw_response, normalized)
|
|
69
|
+
email = email_from_id_token(raw_response["id_token"])
|
|
70
|
+
normalized.merge("email" => email).compact
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.external_account_id_from_credentials(credentials)
|
|
74
|
+
credentials["email"]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.email_from_id_token(id_token)
|
|
78
|
+
return nil unless id_token.is_a?(String)
|
|
79
|
+
payload = id_token.split(".")[1]
|
|
80
|
+
return nil if payload.to_s.empty?
|
|
81
|
+
padded = payload + "=" * ((4 - payload.length % 4) % 4)
|
|
82
|
+
decoded = Base64.urlsafe_decode64(padded)
|
|
83
|
+
JSON.parse(decoded)["email"]
|
|
84
|
+
rescue StandardError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def refresh!
|
|
89
|
+
new_tokens = Connectors::OAuth::TokenExchange.refresh(
|
|
90
|
+
self.class, refresh_token: grant.credentials_hash.fetch("refresh_token")
|
|
91
|
+
)
|
|
92
|
+
grant.update_credentials!(new_tokens)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
test_request method: :get, url: "users/me/profile"
|
|
96
|
+
|
|
97
|
+
# =========================================================================
|
|
98
|
+
# POLLING TRIGGER — Gmail-as-trigger via users.messages.list?q=after:<ts>
|
|
99
|
+
# n8n parity: packages/nodes-base/nodes/Google/Gmail/GmailTrigger.node.ts
|
|
100
|
+
# Cursor: { last_checked_at: <unix>, possible_duplicates: [<id>, ...] }
|
|
101
|
+
# Filters supplied by the workflow author at runtime (passed through
|
|
102
|
+
# `grant.static_data["polling"]["filters"]` if set, otherwise nil).
|
|
103
|
+
# =========================================================================
|
|
104
|
+
polling do |grant, sd|
|
|
105
|
+
Gmail::Polling.new(grant, sd).run
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# =========================================================================
|
|
109
|
+
# MESSAGE ACTIONS
|
|
110
|
+
# =========================================================================
|
|
111
|
+
action :send_message,
|
|
112
|
+
display_name: "Send Email",
|
|
113
|
+
description: "Compose and send an email via Gmail (users.messages.send)." do
|
|
114
|
+
field :to, type: "string", display_name: "To", required: true,
|
|
115
|
+
type_options: { multiple_values: true }
|
|
116
|
+
field :subject, type: "string", display_name: "Subject", required: true
|
|
117
|
+
field :html, type: "string", display_name: "HTML Body",
|
|
118
|
+
type_options: { editor: "html" }
|
|
119
|
+
field :text, type: "string", display_name: "Plain Text Body",
|
|
120
|
+
type_options: { rows: 6 }
|
|
121
|
+
field :cc, type: "string", display_name: "CC",
|
|
122
|
+
type_options: { multiple_values: true }
|
|
123
|
+
field :bcc, type: "string", display_name: "BCC",
|
|
124
|
+
type_options: { multiple_values: true }
|
|
125
|
+
field :reply_to, type: "string", display_name: "Reply To",
|
|
126
|
+
type_options: { multiple_values: true }
|
|
127
|
+
field :from, type: "string", display_name: "From",
|
|
128
|
+
description: "Defaults to the authenticated user — only use to override with a configured 'Send mail as' alias."
|
|
129
|
+
field :thread_id, type: "string", display_name: "Thread ID",
|
|
130
|
+
description: "Reply in an existing thread by setting its Gmail thread id."
|
|
131
|
+
field :in_reply_to, type: "string", display_name: "In-Reply-To"
|
|
132
|
+
field :references, type: "string", display_name: "References"
|
|
133
|
+
field :attachments, type: "json", display_name: "Attachments"
|
|
134
|
+
field :headers, type: "json", display_name: "Custom Headers"
|
|
135
|
+
|
|
136
|
+
output do
|
|
137
|
+
field :id, type: "string"
|
|
138
|
+
field :thread_id, type: "string"
|
|
139
|
+
field :label_ids, type: "string"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
execute { |input| send_message(**input.symbolize_keys) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
action :reply_to_message,
|
|
146
|
+
display_name: "Reply to Email",
|
|
147
|
+
description: "Reply to an existing message, preserving thread + In-Reply-To/References headers (mirrors n8n's `Message > Reply`)." do
|
|
148
|
+
field :message_id, type: "string", display_name: "Message ID", required: true,
|
|
149
|
+
description: "The Gmail id of the message you're replying to."
|
|
150
|
+
field :html, type: "string", display_name: "HTML Body",
|
|
151
|
+
type_options: { editor: "html" }
|
|
152
|
+
field :text, type: "string", display_name: "Plain Text Body",
|
|
153
|
+
type_options: { rows: 6 }
|
|
154
|
+
field :cc, type: "string", display_name: "CC",
|
|
155
|
+
type_options: { multiple_values: true }
|
|
156
|
+
field :bcc, type: "string", display_name: "BCC",
|
|
157
|
+
type_options: { multiple_values: true }
|
|
158
|
+
field :sender_name, type: "string", display_name: "Sender Name",
|
|
159
|
+
description: "Optional friendly name to prefix the From header."
|
|
160
|
+
field :reply_to_sender_only, type: "boolean", display_name: "Reply to Sender Only",
|
|
161
|
+
default: false
|
|
162
|
+
field :reply_to_recipients_only, type: "boolean", display_name: "Reply to Recipients Only",
|
|
163
|
+
default: false
|
|
164
|
+
field :attachments, type: "json", display_name: "Attachments"
|
|
165
|
+
|
|
166
|
+
output do
|
|
167
|
+
field :id, type: "string"
|
|
168
|
+
field :thread_id, type: "string"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
execute { |input| reply_to_message(**input.symbolize_keys) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
action :list_messages,
|
|
175
|
+
display_name: "List Messages",
|
|
176
|
+
description: "Search the user's mailbox using Gmail search syntax (users.messages.list)." do
|
|
177
|
+
field :q, type: "string", display_name: "Query",
|
|
178
|
+
placeholder: "is:unread from:boss@example.com newer_than:7d"
|
|
179
|
+
field :label_ids, type: "string", display_name: "Label IDs",
|
|
180
|
+
type_options: { multiple_values: true }
|
|
181
|
+
field :max_results, type: "number", display_name: "Max Results", default: 100
|
|
182
|
+
field :include_spam_trash, type: "boolean", display_name: "Include Spam & Trash", default: false
|
|
183
|
+
field :page_token, type: "string", display_name: "Page Token"
|
|
184
|
+
|
|
185
|
+
output do
|
|
186
|
+
field :messages, type: "json"
|
|
187
|
+
field :next_page_token, type: "string"
|
|
188
|
+
field :result_size_estimate, type: "number"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
execute { |input| list_messages(**input.symbolize_keys) }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
action :list_all_messages,
|
|
195
|
+
display_name: "List All Messages (auto-paginate)",
|
|
196
|
+
description: "Same as List Messages but walks every `nextPageToken` and returns the full set." do
|
|
197
|
+
field :q, type: "string", display_name: "Query"
|
|
198
|
+
field :label_ids, type: "string", display_name: "Label IDs", type_options: { multiple_values: true }
|
|
199
|
+
field :include_spam_trash, type: "boolean", display_name: "Include Spam & Trash", default: false
|
|
200
|
+
|
|
201
|
+
output { field :messages, type: "json" }
|
|
202
|
+
|
|
203
|
+
execute { |input| list_all_messages(**input.symbolize_keys) }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
action :get_message,
|
|
207
|
+
display_name: "Get Message",
|
|
208
|
+
description: "Fetch a single message by id. With format=full, returns a parsed envelope (from/to/subject/text/html/attachments)." do
|
|
209
|
+
field :id, type: "string", display_name: "Message ID", required: true
|
|
210
|
+
field :format, type: "options", display_name: "Format", default: "full",
|
|
211
|
+
options: [
|
|
212
|
+
{ name: "Full (parsed envelope)", value: "full" },
|
|
213
|
+
{ name: "Metadata (headers only)", value: "metadata" },
|
|
214
|
+
{ name: "Minimal (id + labels)", value: "minimal" },
|
|
215
|
+
{ name: "Raw (full MIME)", value: "raw" }
|
|
216
|
+
]
|
|
217
|
+
field :metadata_headers, type: "string", display_name: "Metadata Headers",
|
|
218
|
+
type_options: { multiple_values: true },
|
|
219
|
+
display_options: { show: { format: [ "metadata" ] } }
|
|
220
|
+
|
|
221
|
+
output do
|
|
222
|
+
field :id, type: "string"
|
|
223
|
+
field :thread_id, type: "string"
|
|
224
|
+
field :label_ids, type: "string"
|
|
225
|
+
field :snippet, type: "string"
|
|
226
|
+
field :envelope, type: "json", description: "Parsed from/to/subject/html/text/attachments (only when format=full)."
|
|
227
|
+
field :payload, type: "json", description: "Google's raw payload tree (always present unless format=minimal)."
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
execute { |input| get_message(**input.symbolize_keys) }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
action :delete_message,
|
|
234
|
+
display_name: "Delete Message (permanent)",
|
|
235
|
+
description: "Permanently delete a message. Prefer Trash Message in most cases." do
|
|
236
|
+
field :id, type: "string", display_name: "Message ID", required: true
|
|
237
|
+
output { field :success, type: "boolean" }
|
|
238
|
+
execute { |input| delete_message(id: input["id"]) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
action :trash_message,
|
|
242
|
+
display_name: "Trash Message",
|
|
243
|
+
description: "Move a message to the Trash folder (users.messages.trash)." do
|
|
244
|
+
field :id, type: "string", display_name: "Message ID", required: true
|
|
245
|
+
output do
|
|
246
|
+
field :id, type: "string"
|
|
247
|
+
field :label_ids, type: "string"
|
|
248
|
+
end
|
|
249
|
+
execute { |input| trash_message(id: input["id"]) }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
action :untrash_message,
|
|
253
|
+
display_name: "Untrash Message",
|
|
254
|
+
description: "Restore a message from Trash (users.messages.untrash)." do
|
|
255
|
+
field :id, type: "string", display_name: "Message ID", required: true
|
|
256
|
+
output do
|
|
257
|
+
field :id, type: "string"
|
|
258
|
+
field :label_ids, type: "string"
|
|
259
|
+
end
|
|
260
|
+
execute { |input| untrash_message(id: input["id"]) }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
action :mark_message_as_read,
|
|
264
|
+
display_name: "Mark Message as Read",
|
|
265
|
+
description: "Remove the UNREAD label (users.messages.modify)." do
|
|
266
|
+
field :id, type: "string", display_name: "Message ID", required: true
|
|
267
|
+
output do
|
|
268
|
+
field :id, type: "string"
|
|
269
|
+
field :label_ids, type: "string"
|
|
270
|
+
end
|
|
271
|
+
execute { |input| mark_message_as_read(id: input["id"]) }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
action :mark_message_as_unread,
|
|
275
|
+
display_name: "Mark Message as Unread",
|
|
276
|
+
description: "Add the UNREAD label (users.messages.modify)." do
|
|
277
|
+
field :id, type: "string", display_name: "Message ID", required: true
|
|
278
|
+
output do
|
|
279
|
+
field :id, type: "string"
|
|
280
|
+
field :label_ids, type: "string"
|
|
281
|
+
end
|
|
282
|
+
execute { |input| mark_message_as_unread(id: input["id"]) }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
action :add_labels,
|
|
286
|
+
display_name: "Add Labels to Message",
|
|
287
|
+
description: "Add one or more labels to a message (users.messages.modify)." do
|
|
288
|
+
field :id, type: "string", display_name: "Message ID", required: true
|
|
289
|
+
field :label_ids, type: "string", display_name: "Label IDs",
|
|
290
|
+
type_options: { multiple_values: true }, required: true
|
|
291
|
+
|
|
292
|
+
output do
|
|
293
|
+
field :id, type: "string"
|
|
294
|
+
field :label_ids, type: "string"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
execute { |input| modify_message_labels(id: input["id"], add_label_ids: input["label_ids"], remove_label_ids: nil) }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
action :remove_labels,
|
|
301
|
+
display_name: "Remove Labels from Message",
|
|
302
|
+
description: "Remove one or more labels from a message (users.messages.modify)." do
|
|
303
|
+
field :id, type: "string", display_name: "Message ID", required: true
|
|
304
|
+
field :label_ids, type: "string", display_name: "Label IDs",
|
|
305
|
+
type_options: { multiple_values: true }, required: true
|
|
306
|
+
|
|
307
|
+
output do
|
|
308
|
+
field :id, type: "string"
|
|
309
|
+
field :label_ids, type: "string"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
execute { |input| modify_message_labels(id: input["id"], add_label_ids: nil, remove_label_ids: input["label_ids"]) }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# =========================================================================
|
|
316
|
+
# THREAD ACTIONS
|
|
317
|
+
# =========================================================================
|
|
318
|
+
action :list_threads,
|
|
319
|
+
display_name: "List Threads",
|
|
320
|
+
description: "Search threads using Gmail search syntax (users.threads.list)." do
|
|
321
|
+
field :q, type: "string", display_name: "Query"
|
|
322
|
+
field :label_ids, type: "string", display_name: "Label IDs", type_options: { multiple_values: true }
|
|
323
|
+
field :max_results, type: "number", display_name: "Max Results", default: 100
|
|
324
|
+
field :include_spam_trash, type: "boolean", display_name: "Include Spam & Trash", default: false
|
|
325
|
+
field :page_token, type: "string", display_name: "Page Token"
|
|
326
|
+
|
|
327
|
+
output do
|
|
328
|
+
field :threads, type: "json"
|
|
329
|
+
field :next_page_token, type: "string"
|
|
330
|
+
field :result_size_estimate, type: "number"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
execute { |input| list_threads(**input.symbolize_keys) }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
action :list_all_threads,
|
|
337
|
+
display_name: "List All Threads (auto-paginate)",
|
|
338
|
+
description: "Same as List Threads but walks every `nextPageToken`." do
|
|
339
|
+
field :q, type: "string", display_name: "Query"
|
|
340
|
+
field :label_ids, type: "string", display_name: "Label IDs", type_options: { multiple_values: true }
|
|
341
|
+
field :include_spam_trash, type: "boolean", display_name: "Include Spam & Trash", default: false
|
|
342
|
+
|
|
343
|
+
output { field :threads, type: "json" }
|
|
344
|
+
|
|
345
|
+
execute { |input| list_all_threads(**input.symbolize_keys) }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
action :get_thread,
|
|
349
|
+
display_name: "Get Thread",
|
|
350
|
+
description: "Fetch a thread + its messages (users.threads.get)." do
|
|
351
|
+
field :id, type: "string", display_name: "Thread ID", required: true
|
|
352
|
+
field :format, type: "options", display_name: "Format", default: "full",
|
|
353
|
+
options: [
|
|
354
|
+
{ name: "Full", value: "full" },
|
|
355
|
+
{ name: "Metadata", value: "metadata" },
|
|
356
|
+
{ name: "Minimal", value: "minimal" }
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
output do
|
|
360
|
+
field :id, type: "string"
|
|
361
|
+
field :messages, type: "json"
|
|
362
|
+
field :history_id, type: "string"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
execute { |input| get_thread(**input.symbolize_keys) }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
action :delete_thread,
|
|
369
|
+
display_name: "Delete Thread (permanent)",
|
|
370
|
+
description: "Permanently delete a thread and all its messages." do
|
|
371
|
+
field :id, type: "string", display_name: "Thread ID", required: true
|
|
372
|
+
output { field :success, type: "boolean" }
|
|
373
|
+
execute { |input| delete_thread(id: input["id"]) }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
action :trash_thread,
|
|
377
|
+
display_name: "Trash Thread",
|
|
378
|
+
description: "Move a thread to the Trash folder." do
|
|
379
|
+
field :id, type: "string", display_name: "Thread ID", required: true
|
|
380
|
+
output do
|
|
381
|
+
field :id, type: "string"
|
|
382
|
+
end
|
|
383
|
+
execute { |input| trash_thread(id: input["id"]) }
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
action :untrash_thread,
|
|
387
|
+
display_name: "Untrash Thread",
|
|
388
|
+
description: "Restore a thread from Trash." do
|
|
389
|
+
field :id, type: "string", display_name: "Thread ID", required: true
|
|
390
|
+
output { field :id, type: "string" }
|
|
391
|
+
execute { |input| untrash_thread(id: input["id"]) }
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
action :add_labels_to_thread,
|
|
395
|
+
display_name: "Add Labels to Thread",
|
|
396
|
+
description: "Add one or more labels to every message in a thread." do
|
|
397
|
+
field :id, type: "string", display_name: "Thread ID", required: true
|
|
398
|
+
field :label_ids, type: "string", display_name: "Label IDs",
|
|
399
|
+
type_options: { multiple_values: true }, required: true
|
|
400
|
+
|
|
401
|
+
output { field :id, type: "string" }
|
|
402
|
+
|
|
403
|
+
execute { |input| modify_thread_labels(id: input["id"], add_label_ids: input["label_ids"], remove_label_ids: nil) }
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
action :remove_labels_from_thread,
|
|
407
|
+
display_name: "Remove Labels from Thread",
|
|
408
|
+
description: "Remove one or more labels from every message in a thread." do
|
|
409
|
+
field :id, type: "string", display_name: "Thread ID", required: true
|
|
410
|
+
field :label_ids, type: "string", display_name: "Label IDs",
|
|
411
|
+
type_options: { multiple_values: true }, required: true
|
|
412
|
+
|
|
413
|
+
output { field :id, type: "string" }
|
|
414
|
+
|
|
415
|
+
execute { |input| modify_thread_labels(id: input["id"], add_label_ids: nil, remove_label_ids: input["label_ids"]) }
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# =========================================================================
|
|
419
|
+
# LABEL ACTIONS
|
|
420
|
+
# =========================================================================
|
|
421
|
+
action :list_labels,
|
|
422
|
+
display_name: "List Labels",
|
|
423
|
+
description: "Returns the user's labels — including system labels like INBOX, SPAM." do
|
|
424
|
+
output { field :labels, type: "json" }
|
|
425
|
+
execute { |_input| list_labels }
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
action :get_label,
|
|
429
|
+
display_name: "Get Label",
|
|
430
|
+
description: "Fetch a single label by id." do
|
|
431
|
+
field :id, type: "string", display_name: "Label ID", required: true
|
|
432
|
+
output do
|
|
433
|
+
field :id, type: "string"
|
|
434
|
+
field :name, type: "string"
|
|
435
|
+
field :type, type: "string"
|
|
436
|
+
field :messages_total, type: "number"
|
|
437
|
+
field :messages_unread, type: "number"
|
|
438
|
+
end
|
|
439
|
+
execute { |input| get_label(id: input["id"]) }
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
action :create_label,
|
|
443
|
+
display_name: "Create Label",
|
|
444
|
+
description: "Create a new user label (users.labels.create). Nested labels use slash-separated names (e.g. 'Work/Important')." do
|
|
445
|
+
field :name, type: "string", display_name: "Name", required: true,
|
|
446
|
+
placeholder: "Work/Important"
|
|
447
|
+
field :label_list_visibility, type: "options", display_name: "Label List Visibility",
|
|
448
|
+
default: "labelShow",
|
|
449
|
+
options: [
|
|
450
|
+
{ name: "Show", value: "labelShow" },
|
|
451
|
+
{ name: "Hide", value: "labelHide" },
|
|
452
|
+
{ name: "Show If Unread", value: "labelShowIfUnread" }
|
|
453
|
+
]
|
|
454
|
+
field :message_list_visibility, type: "options", display_name: "Message List Visibility",
|
|
455
|
+
default: "show",
|
|
456
|
+
options: [
|
|
457
|
+
{ name: "Show", value: "show" },
|
|
458
|
+
{ name: "Hide", value: "hide" }
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
output do
|
|
462
|
+
field :id, type: "string"
|
|
463
|
+
field :name, type: "string"
|
|
464
|
+
field :type, type: "string"
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
execute { |input| create_label(**input.symbolize_keys) }
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
action :delete_label,
|
|
471
|
+
display_name: "Delete Label",
|
|
472
|
+
description: "Delete a user label." do
|
|
473
|
+
field :id, type: "string", display_name: "Label ID", required: true
|
|
474
|
+
output { field :success, type: "boolean" }
|
|
475
|
+
execute { |input| delete_label(id: input["id"]) }
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# =========================================================================
|
|
479
|
+
# DRAFT ACTIONS
|
|
480
|
+
# =========================================================================
|
|
481
|
+
action :create_draft,
|
|
482
|
+
display_name: "Create Draft",
|
|
483
|
+
description: "Create an email draft (users.drafts.create)." do
|
|
484
|
+
field :to, type: "string", display_name: "To",
|
|
485
|
+
type_options: { multiple_values: true }
|
|
486
|
+
field :subject, type: "string", display_name: "Subject"
|
|
487
|
+
field :html, type: "string", display_name: "HTML Body", type_options: { editor: "html" }
|
|
488
|
+
field :text, type: "string", display_name: "Plain Text Body", type_options: { rows: 6 }
|
|
489
|
+
field :cc, type: "string", display_name: "CC", type_options: { multiple_values: true }
|
|
490
|
+
field :bcc, type: "string", display_name: "BCC", type_options: { multiple_values: true }
|
|
491
|
+
field :reply_to, type: "string", display_name: "Reply To", type_options: { multiple_values: true }
|
|
492
|
+
field :from, type: "string", display_name: "From (alias)"
|
|
493
|
+
field :thread_id, type: "string", display_name: "Thread ID",
|
|
494
|
+
description: "Attach the draft to an existing thread."
|
|
495
|
+
field :attachments, type: "json", display_name: "Attachments"
|
|
496
|
+
field :headers, type: "json", display_name: "Custom Headers"
|
|
497
|
+
|
|
498
|
+
output do
|
|
499
|
+
field :id, type: "string"
|
|
500
|
+
field :message, type: "json"
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
execute { |input| create_draft(**input.symbolize_keys) }
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
action :get_draft,
|
|
507
|
+
display_name: "Get Draft",
|
|
508
|
+
description: "Fetch a single draft by id." do
|
|
509
|
+
field :id, type: "string", display_name: "Draft ID", required: true
|
|
510
|
+
field :format, type: "options", display_name: "Format", default: "full",
|
|
511
|
+
options: [
|
|
512
|
+
{ name: "Full", value: "full" },
|
|
513
|
+
{ name: "Metadata", value: "metadata" },
|
|
514
|
+
{ name: "Minimal", value: "minimal" },
|
|
515
|
+
{ name: "Raw", value: "raw" }
|
|
516
|
+
]
|
|
517
|
+
|
|
518
|
+
output do
|
|
519
|
+
field :id, type: "string"
|
|
520
|
+
field :message, type: "json"
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
execute { |input| get_draft(**input.symbolize_keys) }
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
action :list_drafts,
|
|
527
|
+
display_name: "List Drafts",
|
|
528
|
+
description: "Returns the user's drafts (users.drafts.list)." do
|
|
529
|
+
field :q, type: "string", display_name: "Query"
|
|
530
|
+
field :max_results, type: "number", display_name: "Max Results", default: 100
|
|
531
|
+
field :include_spam_trash, type: "boolean", display_name: "Include Spam & Trash", default: false
|
|
532
|
+
field :page_token, type: "string", display_name: "Page Token"
|
|
533
|
+
|
|
534
|
+
output do
|
|
535
|
+
field :drafts, type: "json"
|
|
536
|
+
field :next_page_token, type: "string"
|
|
537
|
+
field :result_size_estimate, type: "number"
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
execute { |input| list_drafts(**input.symbolize_keys) }
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
action :list_all_drafts,
|
|
544
|
+
display_name: "List All Drafts (auto-paginate)",
|
|
545
|
+
description: "Same as List Drafts but walks every `nextPageToken`." do
|
|
546
|
+
field :q, type: "string", display_name: "Query"
|
|
547
|
+
|
|
548
|
+
output { field :drafts, type: "json" }
|
|
549
|
+
|
|
550
|
+
execute { |input| list_all_drafts(**input.symbolize_keys) }
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
action :delete_draft,
|
|
554
|
+
display_name: "Delete Draft",
|
|
555
|
+
description: "Permanently delete a draft." do
|
|
556
|
+
field :id, type: "string", display_name: "Draft ID", required: true
|
|
557
|
+
output { field :success, type: "boolean" }
|
|
558
|
+
execute { |input| delete_draft(id: input["id"]) }
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# =========================================================================
|
|
562
|
+
# API METHODS — thin wrappers around Gmail REST endpoints. Actions
|
|
563
|
+
# delegate to these; downstream automations and curl users can call
|
|
564
|
+
# them directly via `grant.connector.<method>`.
|
|
565
|
+
# =========================================================================
|
|
566
|
+
|
|
567
|
+
def send_message(to:, subject:, html: nil, text: nil,
|
|
568
|
+
cc: nil, bcc: nil, reply_to: nil, from: nil,
|
|
569
|
+
thread_id: nil, in_reply_to: nil, references: nil,
|
|
570
|
+
attachments: nil, headers: nil)
|
|
571
|
+
raw = Gmail::MimeBuilder.build(
|
|
572
|
+
from: from,
|
|
573
|
+
to: to,
|
|
574
|
+
cc: cc,
|
|
575
|
+
bcc: bcc,
|
|
576
|
+
reply_to: reply_to,
|
|
577
|
+
subject: subject,
|
|
578
|
+
html: html,
|
|
579
|
+
text: text,
|
|
580
|
+
attachments: attachments,
|
|
581
|
+
headers: headers,
|
|
582
|
+
in_reply_to: in_reply_to,
|
|
583
|
+
references: references
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
body = { "raw" => raw }
|
|
587
|
+
body["threadId"] = thread_id if thread_id
|
|
588
|
+
|
|
589
|
+
response = Api.request(client, :post, "users/me/messages/send", body: body, resource: "message")
|
|
590
|
+
normalize_message(response)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# n8n parity: packages/nodes-base/nodes/Google/Gmail/utils/replyToEmail.ts
|
|
594
|
+
# Fetches the parent message's headers (Message-ID, Subject, From, To,
|
|
595
|
+
# Reply-To), assembles a properly threaded reply via MimeBuilder, sends.
|
|
596
|
+
def reply_to_message(message_id:, html: nil, text: nil,
|
|
597
|
+
cc: nil, bcc: nil, sender_name: nil,
|
|
598
|
+
reply_to_sender_only: false,
|
|
599
|
+
reply_to_recipients_only: false,
|
|
600
|
+
attachments: nil)
|
|
601
|
+
raise Connectors::ApiError.new("`reply_to_sender_only` and `reply_to_recipients_only` are mutually exclusive") \
|
|
602
|
+
if reply_to_sender_only && reply_to_recipients_only
|
|
603
|
+
raise Connectors::ApiError.new("reply requires html or text") if html.nil? && text.nil?
|
|
604
|
+
|
|
605
|
+
parent = Api.request(
|
|
606
|
+
client, :get,
|
|
607
|
+
"users/me/messages/#{message_id}",
|
|
608
|
+
query: { format: "metadata", metadataHeaders: %w[From To Cc Reply-To Subject Message-ID References] },
|
|
609
|
+
resource: "message"
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
headers = header_hash(parent.dig("payload", "headers"))
|
|
613
|
+
thread_id = parent["threadId"]
|
|
614
|
+
message_gid = headers["message-id"]
|
|
615
|
+
subject = headers["subject"].to_s
|
|
616
|
+
reply_subject = subject.start_with?(/re:\s/i) ? subject : "Re: #{subject}"
|
|
617
|
+
|
|
618
|
+
# Build the To: list — same precedence rules as n8n's replyToEmail.
|
|
619
|
+
profile = Api.request(client, :get, "users/me/profile", resource: "profile")
|
|
620
|
+
my_email = profile["emailAddress"].to_s
|
|
621
|
+
|
|
622
|
+
reply_to_header = headers["reply-to"]
|
|
623
|
+
to = []
|
|
624
|
+
unless reply_to_recipients_only
|
|
625
|
+
primary = reply_to_header.presence || headers["from"]
|
|
626
|
+
to.concat(parse_recipient_list(primary)) if primary
|
|
627
|
+
end
|
|
628
|
+
unless reply_to_sender_only
|
|
629
|
+
to.concat(parse_recipient_list(headers["to"])) if headers["to"]
|
|
630
|
+
end
|
|
631
|
+
to = to.reject { |addr| addr.include?(my_email) }.uniq
|
|
632
|
+
|
|
633
|
+
from = sender_name ? "#{sender_name} <#{my_email}>" : nil
|
|
634
|
+
|
|
635
|
+
references_chain = [ headers["references"], message_gid ].compact.join(" ").strip
|
|
636
|
+
|
|
637
|
+
raw = Gmail::MimeBuilder.build(
|
|
638
|
+
from: from,
|
|
639
|
+
to: to,
|
|
640
|
+
cc: cc,
|
|
641
|
+
bcc: bcc,
|
|
642
|
+
subject: reply_subject,
|
|
643
|
+
html: html,
|
|
644
|
+
text: text,
|
|
645
|
+
attachments: attachments,
|
|
646
|
+
in_reply_to: message_gid,
|
|
647
|
+
references: references_chain.presence
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
response = Api.request(
|
|
651
|
+
client, :post, "users/me/messages/send",
|
|
652
|
+
body: { "raw" => raw, "threadId" => thread_id },
|
|
653
|
+
resource: "message"
|
|
654
|
+
)
|
|
655
|
+
normalize_message(response)
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def list_messages(q: nil, label_ids: nil, max_results: nil,
|
|
659
|
+
include_spam_trash: nil, page_token: nil)
|
|
660
|
+
body = Api.request(
|
|
661
|
+
client, :get, "users/me/messages",
|
|
662
|
+
query: list_query(q: q, label_ids: label_ids, max_results: max_results,
|
|
663
|
+
include_spam_trash: include_spam_trash, page_token: page_token),
|
|
664
|
+
resource: "message"
|
|
665
|
+
)
|
|
666
|
+
{
|
|
667
|
+
"messages" => Array(body["messages"]).map { |m| { "id" => m["id"], "thread_id" => m["threadId"] } },
|
|
668
|
+
"next_page_token" => body["nextPageToken"],
|
|
669
|
+
"result_size_estimate" => body["resultSizeEstimate"]
|
|
670
|
+
}.compact
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def list_all_messages(q: nil, label_ids: nil, include_spam_trash: nil)
|
|
674
|
+
raw = Api.request_all(
|
|
675
|
+
client, "messages", :get, "users/me/messages",
|
|
676
|
+
query: list_query(q: q, label_ids: label_ids, include_spam_trash: include_spam_trash),
|
|
677
|
+
resource: "message"
|
|
678
|
+
)
|
|
679
|
+
{ "messages" => raw.map { |m| { "id" => m["id"], "thread_id" => m["threadId"] } } }
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def get_message(id:, format: "full", metadata_headers: nil)
|
|
683
|
+
query = { format: format }
|
|
684
|
+
Array(metadata_headers).each_with_index { |h, i| query["metadataHeaders[#{i}]"] = h }
|
|
685
|
+
body = Api.request(client, :get, "users/me/messages/#{id}", query: query, resource: "message")
|
|
686
|
+
|
|
687
|
+
result = normalize_message(body)
|
|
688
|
+
result["envelope"] = MimeParser.parse(body["payload"]) if format.to_s == "full" && body["payload"]
|
|
689
|
+
result
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def delete_message(id:)
|
|
693
|
+
Api.request(client, :delete, "users/me/messages/#{id}", resource: "message")
|
|
694
|
+
{ "success" => true }
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def trash_message(id:)
|
|
698
|
+
body = Api.request(client, :post, "users/me/messages/#{id}/trash", body: {}, resource: "message")
|
|
699
|
+
normalize_message(body)
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def untrash_message(id:)
|
|
703
|
+
body = Api.request(client, :post, "users/me/messages/#{id}/untrash", body: {}, resource: "message")
|
|
704
|
+
normalize_message(body)
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def mark_message_as_read(id:)
|
|
708
|
+
modify_message_labels(id: id, add_label_ids: nil, remove_label_ids: [ "UNREAD" ])
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def mark_message_as_unread(id:)
|
|
712
|
+
modify_message_labels(id: id, add_label_ids: [ "UNREAD" ], remove_label_ids: nil)
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def modify_message_labels(id:, add_label_ids: nil, remove_label_ids: nil)
|
|
716
|
+
body = {}
|
|
717
|
+
body["addLabelIds"] = Array(add_label_ids) if add_label_ids
|
|
718
|
+
body["removeLabelIds"] = Array(remove_label_ids) if remove_label_ids
|
|
719
|
+
response = Api.request(client, :post, "users/me/messages/#{id}/modify", body: body, resource: "message")
|
|
720
|
+
normalize_message(response)
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# ----- threads -----
|
|
724
|
+
def list_threads(q: nil, label_ids: nil, max_results: nil,
|
|
725
|
+
include_spam_trash: nil, page_token: nil)
|
|
726
|
+
body = Api.request(
|
|
727
|
+
client, :get, "users/me/threads",
|
|
728
|
+
query: list_query(q: q, label_ids: label_ids, max_results: max_results,
|
|
729
|
+
include_spam_trash: include_spam_trash, page_token: page_token),
|
|
730
|
+
resource: "thread"
|
|
731
|
+
)
|
|
732
|
+
{
|
|
733
|
+
"threads" => Array(body["threads"]).map { |t| normalize_thread_stub(t) },
|
|
734
|
+
"next_page_token" => body["nextPageToken"],
|
|
735
|
+
"result_size_estimate" => body["resultSizeEstimate"]
|
|
736
|
+
}.compact
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def list_all_threads(q: nil, label_ids: nil, include_spam_trash: nil)
|
|
740
|
+
raw = Api.request_all(
|
|
741
|
+
client, "threads", :get, "users/me/threads",
|
|
742
|
+
query: list_query(q: q, label_ids: label_ids, include_spam_trash: include_spam_trash),
|
|
743
|
+
resource: "thread"
|
|
744
|
+
)
|
|
745
|
+
{ "threads" => raw.map { |t| normalize_thread_stub(t) } }
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def get_thread(id:, format: "full")
|
|
749
|
+
body = Api.request(client, :get, "users/me/threads/#{id}", query: { format: format }, resource: "thread")
|
|
750
|
+
{
|
|
751
|
+
"id" => body["id"],
|
|
752
|
+
"history_id" => body["historyId"],
|
|
753
|
+
"messages" => Array(body["messages"]).map { |m| normalize_message(m) }
|
|
754
|
+
}
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def delete_thread(id:)
|
|
758
|
+
Api.request(client, :delete, "users/me/threads/#{id}", resource: "thread")
|
|
759
|
+
{ "success" => true }
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def trash_thread(id:)
|
|
763
|
+
body = Api.request(client, :post, "users/me/threads/#{id}/trash", body: {}, resource: "thread")
|
|
764
|
+
{ "id" => body["id"] }
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def untrash_thread(id:)
|
|
768
|
+
body = Api.request(client, :post, "users/me/threads/#{id}/untrash", body: {}, resource: "thread")
|
|
769
|
+
{ "id" => body["id"] }
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def modify_thread_labels(id:, add_label_ids: nil, remove_label_ids: nil)
|
|
773
|
+
body = {}
|
|
774
|
+
body["addLabelIds"] = Array(add_label_ids) if add_label_ids
|
|
775
|
+
body["removeLabelIds"] = Array(remove_label_ids) if remove_label_ids
|
|
776
|
+
response = Api.request(client, :post, "users/me/threads/#{id}/modify", body: body, resource: "thread")
|
|
777
|
+
{ "id" => response["id"] }
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# ----- labels -----
|
|
781
|
+
def list_labels
|
|
782
|
+
body = Api.request(client, :get, "users/me/labels", resource: "label")
|
|
783
|
+
{ "labels" => Array(body["labels"]) }
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
def get_label(id:)
|
|
787
|
+
body = Api.request(client, :get, "users/me/labels/#{id}", resource: "label")
|
|
788
|
+
{
|
|
789
|
+
"id" => body["id"],
|
|
790
|
+
"name" => body["name"],
|
|
791
|
+
"type" => body["type"],
|
|
792
|
+
"messages_total" => body["messagesTotal"],
|
|
793
|
+
"messages_unread" => body["messagesUnread"]
|
|
794
|
+
}
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def create_label(name:, label_list_visibility: "labelShow", message_list_visibility: "show")
|
|
798
|
+
body = Api.request(
|
|
799
|
+
client, :post, "users/me/labels",
|
|
800
|
+
body: {
|
|
801
|
+
"name" => name,
|
|
802
|
+
"labelListVisibility" => label_list_visibility,
|
|
803
|
+
"messageListVisibility" => message_list_visibility
|
|
804
|
+
},
|
|
805
|
+
resource: "label"
|
|
806
|
+
)
|
|
807
|
+
body
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
def delete_label(id:)
|
|
811
|
+
Api.request(client, :delete, "users/me/labels/#{id}", resource: "label")
|
|
812
|
+
{ "success" => true }
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# ----- drafts -----
|
|
816
|
+
def create_draft(to: nil, subject: nil, html: nil, text: nil,
|
|
817
|
+
cc: nil, bcc: nil, reply_to: nil, from: nil,
|
|
818
|
+
thread_id: nil, attachments: nil, headers: nil)
|
|
819
|
+
# Drafts allow empty bodies (Gmail itself does), so don't reject html+text=nil.
|
|
820
|
+
raw = Gmail::MimeBuilder.build_draft(
|
|
821
|
+
from: from,
|
|
822
|
+
to: to,
|
|
823
|
+
cc: cc,
|
|
824
|
+
bcc: bcc,
|
|
825
|
+
reply_to: reply_to,
|
|
826
|
+
subject: subject.to_s,
|
|
827
|
+
html: html,
|
|
828
|
+
text: text,
|
|
829
|
+
attachments: attachments,
|
|
830
|
+
headers: headers
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
message_body = { "raw" => raw }
|
|
834
|
+
message_body["threadId"] = thread_id if thread_id
|
|
835
|
+
|
|
836
|
+
response = Api.request(
|
|
837
|
+
client, :post, "users/me/drafts",
|
|
838
|
+
body: { "message" => message_body },
|
|
839
|
+
resource: "draft"
|
|
840
|
+
)
|
|
841
|
+
{ "id" => response["id"], "message" => normalize_message(response["message"]) }
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
def get_draft(id:, format: "full")
|
|
845
|
+
body = Api.request(client, :get, "users/me/drafts/#{id}", query: { format: format }, resource: "draft")
|
|
846
|
+
{ "id" => body["id"], "message" => body["message"].is_a?(Hash) ? normalize_message(body["message"]) : nil }
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
def list_drafts(q: nil, max_results: nil, include_spam_trash: nil, page_token: nil)
|
|
850
|
+
body = Api.request(
|
|
851
|
+
client, :get, "users/me/drafts",
|
|
852
|
+
query: list_query(q: q, max_results: max_results, include_spam_trash: include_spam_trash, page_token: page_token),
|
|
853
|
+
resource: "draft"
|
|
854
|
+
)
|
|
855
|
+
{
|
|
856
|
+
"drafts" => Array(body["drafts"]).map { |d| { "id" => d["id"], "message" => normalize_message(d["message"]) } },
|
|
857
|
+
"next_page_token" => body["nextPageToken"],
|
|
858
|
+
"result_size_estimate" => body["resultSizeEstimate"]
|
|
859
|
+
}.compact
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
def list_all_drafts(q: nil)
|
|
863
|
+
raw = Api.request_all(client, "drafts", :get, "users/me/drafts", query: list_query(q: q), resource: "draft")
|
|
864
|
+
{ "drafts" => raw.map { |d| { "id" => d["id"], "message" => normalize_message(d["message"]) } } }
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
def delete_draft(id:)
|
|
868
|
+
Api.request(client, :delete, "users/me/drafts/#{id}", resource: "draft")
|
|
869
|
+
{ "success" => true }
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
def handle_webhook(event)
|
|
873
|
+
Rails.logger.info("[gmail] webhook event=#{event.id} payload=#{event.payload_hash.inspect}")
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
private
|
|
877
|
+
|
|
878
|
+
def list_query(q: nil, label_ids: nil, max_results: nil, include_spam_trash: nil, page_token: nil)
|
|
879
|
+
query = {}
|
|
880
|
+
query[:q] = q if q && !q.to_s.empty?
|
|
881
|
+
query["labelIds[]"] = Array(label_ids) if label_ids
|
|
882
|
+
query[:maxResults] = max_results if max_results
|
|
883
|
+
query[:includeSpamTrash] = include_spam_trash unless include_spam_trash.nil?
|
|
884
|
+
query[:pageToken] = page_token if page_token
|
|
885
|
+
query
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
def normalize_message(body)
|
|
889
|
+
return nil if body.nil?
|
|
890
|
+
return body unless body.is_a?(Hash)
|
|
891
|
+
{
|
|
892
|
+
"id" => body["id"],
|
|
893
|
+
"thread_id" => body["threadId"],
|
|
894
|
+
"label_ids" => body["labelIds"],
|
|
895
|
+
"snippet" => body["snippet"],
|
|
896
|
+
"payload" => body["payload"]
|
|
897
|
+
}.compact
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def normalize_thread_stub(t)
|
|
901
|
+
{
|
|
902
|
+
"id" => t["id"],
|
|
903
|
+
"history_id" => t["historyId"],
|
|
904
|
+
"snippet" => t["snippet"]
|
|
905
|
+
}.compact
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def header_hash(arr)
|
|
909
|
+
Array(arr).each_with_object({}) do |h, acc|
|
|
910
|
+
next unless h.is_a?(Hash) && h["name"]
|
|
911
|
+
acc[h["name"].downcase] = h["value"]
|
|
912
|
+
end
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
# Splits "Foo Bar <foo@bar>, Baz <baz@qux>" into proper RFC 5322 addrs.
|
|
916
|
+
def parse_recipient_list(value)
|
|
917
|
+
return [] if value.nil?
|
|
918
|
+
value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
919
|
+
end
|
|
920
|
+
end
|
|
921
|
+
end
|