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.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +35 -0
  3. data/CONNECTORS_FRAMEWORK.md +799 -0
  4. data/CONTRIBUTING.md +60 -0
  5. data/MCP_CLIENT.md +168 -0
  6. data/MIT-LICENSE +20 -0
  7. data/README.md +146 -0
  8. data/app/connectors/clickup/connector.rb +13 -0
  9. data/app/connectors/gmail/api.rb +114 -0
  10. data/app/connectors/gmail/connector.rb +921 -0
  11. data/app/connectors/gmail/mime_builder.rb +261 -0
  12. data/app/connectors/gmail/mime_parser.rb +106 -0
  13. data/app/connectors/gmail/polling.rb +154 -0
  14. data/app/connectors/remote_mcp/connector.rb +18 -0
  15. data/app/connectors/resend/connector.rb +218 -0
  16. data/app/controllers/concerns/connectors/grant_access.rb +43 -0
  17. data/app/controllers/connectors/actions_controller.rb +66 -0
  18. data/app/controllers/connectors/application_controller.rb +5 -0
  19. data/app/controllers/connectors/credentials_controller.rb +245 -0
  20. data/app/controllers/connectors/grants_controller.rb +123 -0
  21. data/app/controllers/connectors/mcp_controller.rb +88 -0
  22. data/app/controllers/connectors/oauth_controller.rb +134 -0
  23. data/app/controllers/connectors/types_controller.rb +144 -0
  24. data/app/controllers/connectors/webhooks_controller.rb +105 -0
  25. data/app/jobs/connectors/application_job.rb +4 -0
  26. data/app/jobs/connectors/deliver_webhook_job.rb +27 -0
  27. data/app/jobs/connectors/poll_job.rb +49 -0
  28. data/app/models/connectors/application_record.rb +5 -0
  29. data/app/models/connectors/credential_share.rb +31 -0
  30. data/app/models/connectors/grant.rb +103 -0
  31. data/app/models/connectors/mcp_authorization.rb +6 -0
  32. data/app/models/connectors/mcp_interaction.rb +6 -0
  33. data/app/models/connectors/poll_state.rb +17 -0
  34. data/app/models/connectors/webhook_event.rb +19 -0
  35. data/config/routes.rb +68 -0
  36. data/db/migrate/20260518210324_create_connectors_grants.rb +49 -0
  37. data/db/migrate/20260518214609_create_connectors_webhook_events.rb +35 -0
  38. data/db/migrate/20260521140000_create_connectors_credential_shares.rb +26 -0
  39. data/db/migrate/20260922120000_create_connectors_poll_states.rb +12 -0
  40. data/db/migrate/20260922130000_create_connectors_mcp_transactions.rb +18 -0
  41. data/docs/adding-connectors.md +92 -0
  42. data/docs/architecture.md +71 -0
  43. data/docs/releasing.md +60 -0
  44. data/lib/connectors/action.rb +90 -0
  45. data/lib/connectors/action_builder.rb +125 -0
  46. data/lib/connectors/action_runner.rb +99 -0
  47. data/lib/connectors/auth/scheme/api_key.rb +51 -0
  48. data/lib/connectors/auth/scheme/oauth2.rb +23 -0
  49. data/lib/connectors/auth/scheme.rb +35 -0
  50. data/lib/connectors/auth.rb +4 -0
  51. data/lib/connectors/auth_injection.rb +65 -0
  52. data/lib/connectors/client_builder.rb +87 -0
  53. data/lib/connectors/configuration.rb +138 -0
  54. data/lib/connectors/connector.rb +565 -0
  55. data/lib/connectors/credential_schema.rb +219 -0
  56. data/lib/connectors/credential_tester.rb +85 -0
  57. data/lib/connectors/credential_type_registry.rb +162 -0
  58. data/lib/connectors/credential_types/http_auth.rb +171 -0
  59. data/lib/connectors/engine.rb +123 -0
  60. data/lib/connectors/errors.rb +88 -0
  61. data/lib/connectors/grant_policy.rb +13 -0
  62. data/lib/connectors/mcp/access.rb +50 -0
  63. data/lib/connectors/mcp/authorization.rb +177 -0
  64. data/lib/connectors/mcp/authorization_context.rb +25 -0
  65. data/lib/connectors/mcp/authorization_discovery.rb +42 -0
  66. data/lib/connectors/mcp/cancellation.rb +36 -0
  67. data/lib/connectors/mcp/client.rb +137 -0
  68. data/lib/connectors/mcp/connection_config.rb +48 -0
  69. data/lib/connectors/mcp/http.rb +108 -0
  70. data/lib/connectors/mcp/interaction.rb +82 -0
  71. data/lib/connectors/mcp/pending_transaction.rb +26 -0
  72. data/lib/connectors/mcp/protocol/2026-07-28.json +3963 -0
  73. data/lib/connectors/mcp/protocol/LICENSE +216 -0
  74. data/lib/connectors/mcp/protocol/README.md +8 -0
  75. data/lib/connectors/mcp/protocol_schema.rb +30 -0
  76. data/lib/connectors/mcp/schema.rb +45 -0
  77. data/lib/connectors/mcp/settings.rb +27 -0
  78. data/lib/connectors/mcp/token_endpoint.rb +48 -0
  79. data/lib/connectors/mcp/transport.rb +105 -0
  80. data/lib/connectors/mcp.rb +69 -0
  81. data/lib/connectors/middleware/authenticate_generic.rb +79 -0
  82. data/lib/connectors/middleware/auto_refresh.rb +71 -0
  83. data/lib/connectors/middleware/error_normalization.rb +45 -0
  84. data/lib/connectors/middleware/grant_status.rb +19 -0
  85. data/lib/connectors/middleware/pre_authentication.rb +54 -0
  86. data/lib/connectors/middleware/rate_limit.rb +40 -0
  87. data/lib/connectors/oauth/authorize_url.rb +78 -0
  88. data/lib/connectors/oauth/client_authentication.rb +24 -0
  89. data/lib/connectors/oauth/client_credentials.rb +43 -0
  90. data/lib/connectors/oauth/grant_writer.rb +73 -0
  91. data/lib/connectors/oauth/pkce.rb +32 -0
  92. data/lib/connectors/oauth/revoke.rb +72 -0
  93. data/lib/connectors/oauth/state.rb +41 -0
  94. data/lib/connectors/oauth/token_exchange.rb +69 -0
  95. data/lib/connectors/oauth/token_response.rb +40 -0
  96. data/lib/connectors/oauth.rb +4 -0
  97. data/lib/connectors/oauth1.rb +151 -0
  98. data/lib/connectors/permission_check.rb +33 -0
  99. data/lib/connectors/poll_runner.rb +50 -0
  100. data/lib/connectors/pre_authentication_helpers.rb +76 -0
  101. data/lib/connectors/registry.rb +38 -0
  102. data/lib/connectors/version.rb +3 -0
  103. data/lib/connectors/webhook_context.rb +62 -0
  104. data/lib/connectors/webhook_lifecycle.rb +69 -0
  105. data/lib/connectors/webhook_methods.rb +48 -0
  106. data/lib/connectors/webhooks/verifier.rb +31 -0
  107. data/lib/connectors/webhooks.rb +5 -0
  108. data/lib/connectors.rb +50 -0
  109. data/lib/tasks/connectors_mcp.rake +9 -0
  110. data/lib/tasks/connectors_tasks.rake +4 -0
  111. data/openapi.yaml +1099 -0
  112. 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