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,261 @@
1
+ require "base64"
2
+ require "securerandom"
3
+ require "time"
4
+
5
+ module Gmail
6
+ # Minimal RFC 5322 / RFC 2045 / RFC 2047 / RFC 2231 builder for the Gmail
7
+ # `users.messages.send` and `users.drafts.create` endpoints. Gmail wants
8
+ # the entire message as base64url-encoded MIME in a `raw` field — there's
9
+ # no JSON shortcut for `body`/`subject`/`attachments`.
10
+ #
11
+ # Handles:
12
+ # 1. text only → text/plain
13
+ # 2. html only → text/html
14
+ # 3. html + text → multipart/alternative
15
+ # 4. (any of the above) + attachments → multipart/mixed
16
+ # 5. empty body (drafts) → empty text/plain
17
+ #
18
+ # Non-ASCII data is handled via:
19
+ # - Subject + From/To/Cc display-name phrases: RFC 2047 encoded-word
20
+ # - Attachment filenames: RFC 2231 continuation (filename*=UTF-8''…)
21
+ # - Bodies: base64 transfer-encoded UTF-8
22
+ #
23
+ # n8n parity: their Gmail node uses nodemailer's mail-composer for the
24
+ # same job. We hand-roll because Gmail only needs the simple subset.
25
+ class MimeBuilder
26
+ CRLF = "\r\n".freeze
27
+
28
+ def self.build(**opts)
29
+ new(**opts).build
30
+ end
31
+
32
+ # Like `.build`, but allows an empty body (drafts only — Gmail's
33
+ # `users.drafts.create` accepts empty messages).
34
+ def self.build_draft(**opts)
35
+ new(**opts.merge(allow_empty: true)).build
36
+ end
37
+
38
+ def initialize(to: nil, subject: nil, from: nil, cc: nil, bcc: nil, reply_to: nil,
39
+ html: nil, text: nil, attachments: nil, headers: nil,
40
+ thread_id: nil, in_reply_to: nil, references: nil,
41
+ allow_empty: false)
42
+ @to = Array(to).reject { |v| v.to_s.empty? }
43
+ @cc = Array(cc).reject { |v| v.to_s.empty? }
44
+ @bcc = Array(bcc).reject { |v| v.to_s.empty? }
45
+ @reply_to = Array(reply_to).reject { |v| v.to_s.empty? }
46
+ @from = from
47
+ @subject = subject.to_s
48
+ @html = html
49
+ @text = text
50
+ @attachments = Array(attachments)
51
+ @headers = headers || {}
52
+ @thread_id = thread_id
53
+ @in_reply_to = in_reply_to
54
+ @references = references
55
+ @allow_empty = allow_empty
56
+
57
+ return if allow_empty
58
+
59
+ raise ArgumentError, "send_message requires html or text" if @html.nil? && @text.nil?
60
+ raise ArgumentError, "send_message requires at least one recipient" if @to.empty?
61
+ end
62
+
63
+ # Returns the full MIME message as a base64url-encoded string, ready to
64
+ # drop into Gmail's `raw` field.
65
+ def build
66
+ Base64.urlsafe_encode64(raw_message, padding: false)
67
+ end
68
+
69
+ def raw_message
70
+ if @attachments.any?
71
+ build_multipart_mixed
72
+ elsif @html && @text
73
+ build_multipart_alternative
74
+ elsif @html
75
+ build_part(content_type: "text/html; charset=UTF-8", body: @html, with_headers: true)
76
+ else
77
+ # Covers text-only AND empty-body drafts (text becomes "").
78
+ build_part(content_type: "text/plain; charset=UTF-8", body: @text || "", with_headers: true)
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def build_multipart_alternative
85
+ boundary = "alt_#{SecureRandom.hex(8)}"
86
+ body = +""
87
+ body << "--#{boundary}" << CRLF
88
+ body << inline_part(content_type: "text/plain; charset=UTF-8", body: @text)
89
+ body << "--#{boundary}" << CRLF
90
+ body << inline_part(content_type: "text/html; charset=UTF-8", body: @html)
91
+ body << "--#{boundary}--" << CRLF
92
+
93
+ headers_lines("multipart/alternative; boundary=\"#{boundary}\"") + CRLF + body
94
+ end
95
+
96
+ def build_multipart_mixed
97
+ boundary = "mix_#{SecureRandom.hex(8)}"
98
+ body = +""
99
+
100
+ body << "--#{boundary}" << CRLF
101
+ if @html && @text
102
+ body << build_multipart_alternative_without_headers
103
+ elsif @html
104
+ body << inline_part(content_type: "text/html; charset=UTF-8", body: @html)
105
+ else
106
+ body << inline_part(content_type: "text/plain; charset=UTF-8", body: @text || "")
107
+ end
108
+
109
+ @attachments.each do |att|
110
+ body << "--#{boundary}" << CRLF
111
+ body << inline_attachment(att)
112
+ end
113
+ body << "--#{boundary}--" << CRLF
114
+
115
+ headers_lines("multipart/mixed; boundary=\"#{boundary}\"") + CRLF + body
116
+ end
117
+
118
+ def build_multipart_alternative_without_headers
119
+ boundary = "alt_#{SecureRandom.hex(8)}"
120
+ part = +""
121
+ part << "Content-Type: multipart/alternative; boundary=\"#{boundary}\"" << CRLF << CRLF
122
+ part << "--#{boundary}" << CRLF
123
+ part << inline_part(content_type: "text/plain; charset=UTF-8", body: @text || "")
124
+ part << "--#{boundary}" << CRLF
125
+ part << inline_part(content_type: "text/html; charset=UTF-8", body: @html || "")
126
+ part << "--#{boundary}--" << CRLF
127
+ part
128
+ end
129
+
130
+ def build_part(content_type:, body:, with_headers:)
131
+ header_block = with_headers ? headers_lines(content_type) : ""
132
+ "#{header_block}#{CRLF}#{base64_wrapped(body)}"
133
+ end
134
+
135
+ def inline_part(content_type:, body:)
136
+ part = +""
137
+ part << "Content-Type: #{content_type}" << CRLF
138
+ part << "Content-Transfer-Encoding: base64" << CRLF << CRLF
139
+ part << base64_wrapped(body) << CRLF
140
+ part
141
+ end
142
+
143
+ def inline_attachment(att)
144
+ filename = att[:filename] || att["filename"] || "attachment"
145
+ content_type = att[:content_type] || att["content_type"] || "application/octet-stream"
146
+ content = att[:content] || att["content"]
147
+ raise ArgumentError, "attachment #{filename.inspect} missing `content`" if content.to_s.empty?
148
+
149
+ cid = att[:content_id] || att["content_id"]
150
+ disposition = (att[:disposition] || att["disposition"] || "attachment").to_s
151
+
152
+ part = +""
153
+ part << "Content-Type: #{content_type}; #{filename_param(filename)}" << CRLF
154
+ part << "Content-Disposition: #{disposition}; #{filename_param(filename, disposition: true)}" << CRLF
155
+ part << "Content-ID: <#{cid}>" << CRLF if cid
156
+ part << "Content-Transfer-Encoding: base64" << CRLF << CRLF
157
+ part << base64_normalize(content) << CRLF
158
+ part
159
+ end
160
+
161
+ def headers_lines(content_type)
162
+ lines = []
163
+ lines << "MIME-Version: 1.0"
164
+ lines << "Date: #{Time.now.utc.rfc2822}"
165
+ lines << "From: #{encode_address(@from)}" if @from
166
+ lines << "To: #{format_address_list(@to)}" if @to.any?
167
+ lines << "Cc: #{format_address_list(@cc)}" if @cc.any?
168
+ lines << "Bcc: #{format_address_list(@bcc)}" if @bcc.any?
169
+ lines << "Reply-To: #{format_address_list(@reply_to)}" if @reply_to.any?
170
+ lines << "Subject: #{encode_rfc2047(@subject)}"
171
+ lines << "In-Reply-To: #{@in_reply_to}" if @in_reply_to
172
+ lines << "References: #{@references}" if @references
173
+ lines << "Message-ID: <#{SecureRandom.uuid}@flow>"
174
+ @headers.each { |k, v| lines << "#{k}: #{v}" }
175
+ lines << "Content-Type: #{content_type}"
176
+ # multipart containers don't carry a transfer-encoding line — each
177
+ # sub-part declares its own.
178
+ lines << "Content-Transfer-Encoding: base64" unless content_type.start_with?("multipart")
179
+ lines.join(CRLF) + CRLF
180
+ end
181
+
182
+ def format_address_list(list)
183
+ list.map { |a| encode_address(a) }.join(", ")
184
+ end
185
+
186
+ # If the address has a display-name phrase, RFC 2047-encode the phrase
187
+ # when it carries non-ASCII chars. Examples:
188
+ # "héllo@example.com" → "héllo@example.com" (ASCII addr-spec, passes through)
189
+ # "Héllo <h@example.com>" → "=?UTF-8?B?SMOpbGxv?= <h@example.com>"
190
+ # "h@example.com" → "h@example.com"
191
+ def encode_address(address)
192
+ addr = address.to_s
193
+ if (m = addr.match(/\A(.+?)\s*<(.+)>\z/))
194
+ phrase, mailbox = m[1].strip, m[2].strip
195
+ encoded_phrase = encode_rfc2047(phrase)
196
+ "#{encoded_phrase} <#{mailbox}>"
197
+ else
198
+ addr
199
+ end
200
+ end
201
+
202
+ # RFC 2047 encoded-word for non-ASCII text. ASCII strings pass through
203
+ # unchanged. Long inputs are chunked into multiple encoded-words at
204
+ # 70-byte boundaries (the RFC allows up to 75; 70 gives breathing room).
205
+ def encode_rfc2047(text)
206
+ str = text.to_s
207
+ return str if str.ascii_only?
208
+
209
+ bytes = str.dup.force_encoding(Encoding::UTF_8).bytes
210
+ chunks = []
211
+ chunks << [] while false # placeholder; built below
212
+ buf = []
213
+ bytes.each do |b|
214
+ if buf.length >= 45 # 45 raw bytes ≈ 60 base64 chars + wrapper → ~75 total
215
+ chunks << buf
216
+ buf = []
217
+ end
218
+ buf << b
219
+ end
220
+ chunks << buf unless buf.empty?
221
+
222
+ chunks.map { |chunk|
223
+ "=?UTF-8?B?#{Base64.strict_encode64(chunk.pack('C*'))}?="
224
+ }.join(" ")
225
+ end
226
+
227
+ # Builds `name="ascii"` for plain ASCII filenames; RFC 2231-continuation
228
+ # `filename*=UTF-8''…percent…encoded…` for non-ASCII. `name` (Content-
229
+ # Type) vs `filename` (Content-Disposition) handled via `disposition:`.
230
+ def filename_param(filename, disposition: false)
231
+ key = disposition ? "filename" : "name"
232
+ if filename.ascii_only?
233
+ %Q(#{key}="#{filename.gsub('"', '\\"')}")
234
+ else
235
+ encoded = percent_encode(filename)
236
+ %Q(#{key}*=UTF-8''#{encoded})
237
+ end
238
+ end
239
+
240
+ def percent_encode(str)
241
+ str.b.unpack("C*").map { |b|
242
+ # Allow only the RFC 2231 attribute-char set; percent-encode everything else.
243
+ if (b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5A) || (b >= 0x61 && b <= 0x7A) || [ 0x2D, 0x2E, 0x5F, 0x7E ].include?(b)
244
+ b.chr
245
+ else
246
+ format("%%%02X", b)
247
+ end
248
+ }.join
249
+ end
250
+
251
+ def base64_wrapped(body)
252
+ str = body.to_s.dup.force_encoding(Encoding::UTF_8)
253
+ Base64.encode64(str).gsub("\n", CRLF)
254
+ end
255
+
256
+ def base64_normalize(b64)
257
+ stripped = b64.to_s.delete("\r\n ")
258
+ stripped.scan(/.{1,76}/).join(CRLF)
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,106 @@
1
+ require "base64"
2
+
3
+ module Gmail
4
+ # Parses Google's `messages.get?format=full` payload tree into a clean,
5
+ # downstream-friendly shape. Mirrors the work `parseRawEmail` does in
6
+ # n8n's `Gmail/GenericFunctions.ts` (which delegates to mailparser).
7
+ #
8
+ # Google returns:
9
+ # { payload: {
10
+ # headers: [{name, value}, ...],
11
+ # mimeType: "...",
12
+ # body: { data: <base64url>, attachmentId: ... },
13
+ # parts: [ recursive parts ... ]
14
+ # } }
15
+ #
16
+ # We flatten that into:
17
+ # { from:, to:, cc:, bcc:, reply_to:, subject:, date:,
18
+ # headers: { ... lowercased keys ... },
19
+ # text:, html:, attachments: [{filename, content_type, size, attachment_id}, ...] }
20
+ class MimeParser
21
+ HEADERS_TO_LIFT = %w[from to cc bcc reply-to subject date].freeze
22
+
23
+ def self.parse(payload)
24
+ new(payload).parse
25
+ end
26
+
27
+ def initialize(payload)
28
+ @payload = payload || {}
29
+ end
30
+
31
+ def parse
32
+ headers = headers_hash(@payload["headers"])
33
+
34
+ result = {
35
+ "headers" => headers,
36
+ "subject" => headers["subject"],
37
+ "from" => headers["from"],
38
+ "to" => split_list(headers["to"]),
39
+ "cc" => split_list(headers["cc"]),
40
+ "bcc" => split_list(headers["bcc"]),
41
+ "reply_to" => split_list(headers["reply-to"]),
42
+ "date" => headers["date"],
43
+ "text" => nil,
44
+ "html" => nil,
45
+ "attachments" => []
46
+ }
47
+
48
+ walk(@payload, result)
49
+ result.compact
50
+ end
51
+
52
+ private
53
+
54
+ def walk(part, result)
55
+ mime_type = part["mimeType"].to_s
56
+ filename = part["filename"].to_s
57
+ body = part["body"] || {}
58
+
59
+ if filename.empty?
60
+ case mime_type
61
+ when "text/plain"
62
+ result["text"] ||= decode_body_data(body["data"])
63
+ when "text/html"
64
+ result["html"] ||= decode_body_data(body["data"])
65
+ end
66
+ elsif body["attachmentId"]
67
+ result["attachments"] << {
68
+ "filename" => filename,
69
+ "content_type" => mime_type,
70
+ "size" => body["size"],
71
+ "attachment_id" => body["attachmentId"]
72
+ }
73
+ elsif body["data"]
74
+ # Inline attachment with body data already present.
75
+ result["attachments"] << {
76
+ "filename" => filename,
77
+ "content_type" => mime_type,
78
+ "size" => body["size"],
79
+ "content" => body["data"]
80
+ }
81
+ end
82
+
83
+ Array(part["parts"]).each { |child| walk(child, result) }
84
+ end
85
+
86
+ def headers_hash(arr)
87
+ Array(arr).each_with_object({}) do |h, acc|
88
+ next unless h.is_a?(Hash) && h["name"]
89
+ acc[h["name"].downcase] = h["value"]
90
+ end
91
+ end
92
+
93
+ def split_list(value)
94
+ return nil if value.nil?
95
+ value.to_s.split(",").map(&:strip).reject(&:empty?)
96
+ end
97
+
98
+ def decode_body_data(b64)
99
+ return nil if b64.to_s.empty?
100
+ padded = b64 + "=" * ((4 - b64.length % 4) % 4)
101
+ Base64.urlsafe_decode64(padded)
102
+ rescue StandardError
103
+ nil
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,154 @@
1
+ module Gmail
2
+ # Polling trigger logic. Invoked once per scheduler tick by
3
+ # `Connectors::PollRunner.run(grant)`, which threads in the per-grant
4
+ # scratch hash stored at `grant.static_data["polling"]`.
5
+ #
6
+ # n8n parity: `packages/nodes-base/nodes/Google/Gmail/GmailTrigger.node.ts`.
7
+ # Key invariants we copy 1:1:
8
+ #
9
+ # * Cursor = `last_checked_at` (unix seconds). First-ever poll bootstraps
10
+ # the cursor to `now` and emits nothing.
11
+ # * Query: `after:<last_checked_at> -in:scheduled` plus any author-
12
+ # supplied filters. Gmail's `after:` is INCLUSIVE at the second
13
+ # boundary, so we maintain `possible_duplicates` — ids emitted at
14
+ # exactly the cursor second — and exclude them from the next list.
15
+ # * For each new id, fetch full message via Api.request, run through
16
+ # MimeParser, return the parsed envelope.
17
+ # * Drafts (`labelIds includes DRAFT`) skipped unless `include_drafts`.
18
+ # * Sent-but-not-Inbox messages skipped (Gmail emits them as both
19
+ # SENT and INBOX when the user is the recipient; we want the inbound
20
+ # copy, not the outbound).
21
+ #
22
+ # Filters are passed via `grant.static_data["polling"]["filters"]` so the
23
+ # workflow author can configure them per-trigger when the automations
24
+ # engine hooks this up:
25
+ #
26
+ # {
27
+ # "q" => "from:boss has:attachment", # raw Gmail search
28
+ # "label_ids" => ["INBOX", "Label_42"],
29
+ # "sender" => "alerts@stripe.com",
30
+ # "read_status" => "unread", # unread | read | both
31
+ # "include_spam_trash" => false,
32
+ # "include_drafts" => false,
33
+ # "max_results" => 25 # per poll
34
+ # }
35
+ class Polling
36
+ DEFAULT_MAX_RESULTS = 25
37
+
38
+ def initialize(grant, static_data, now: nil)
39
+ @grant = grant
40
+ @sd = static_data
41
+ @now = now || Time.now.to_i
42
+ @connector = grant.connector
43
+ @client = @connector.client
44
+ @filters = (static_data["filters"] || {}).transform_keys(&:to_s)
45
+ @max_results = (@filters["max_results"] || DEFAULT_MAX_RESULTS).to_i
46
+ end
47
+
48
+ def run
49
+ first_run = @sd["last_checked_at"].nil?
50
+
51
+ if first_run
52
+ @sd["last_checked_at"] = @now
53
+ @sd["possible_duplicates"] = []
54
+ return []
55
+ end
56
+
57
+ list_body = Api.request(
58
+ @client, :get, "users/me/messages",
59
+ query: build_query,
60
+ resource: "message"
61
+ )
62
+
63
+ ids = Array(list_body["messages"]).map { |m| m["id"] }
64
+ duplicates = Array(@sd["possible_duplicates"])
65
+ ids -= duplicates
66
+ return [] if ids.empty?
67
+
68
+ messages = []
69
+ max_internal_date = 0
70
+
71
+ ids.first(@max_results).each do |id|
72
+ full = Api.request(@client, :get, "users/me/messages/#{id}",
73
+ query: { format: "full" }, resource: "message")
74
+ next if skip?(full)
75
+
76
+ envelope = MimeParser.parse(full["payload"]) if full["payload"]
77
+ internal_date = full["internalDate"].to_i / 1000 # Gmail uses ms
78
+
79
+ messages << {
80
+ "id" => full["id"],
81
+ "thread_id" => full["threadId"],
82
+ "label_ids" => full["labelIds"],
83
+ "snippet" => full["snippet"],
84
+ "envelope" => envelope,
85
+ "internal_date" => internal_date
86
+ }.compact
87
+
88
+ max_internal_date = internal_date if internal_date > max_internal_date
89
+ end
90
+
91
+ advance_cursor(messages, max_internal_date)
92
+ messages
93
+ end
94
+
95
+ private
96
+
97
+ # Gmail search syntax. n8n's prepareQuery is the reference; the order
98
+ # of clauses doesn't matter to Gmail, but keep the same join scheme so
99
+ # diffs against n8n are easy to read.
100
+ def build_query
101
+ qs = {}
102
+ qs["labelIds[]"] = Array(@filters["label_ids"]) if @filters["label_ids"]
103
+ qs[:maxResults] = @max_results
104
+ qs[:includeSpamTrash] = true if @filters["include_spam_trash"]
105
+
106
+ q_parts = []
107
+ q_parts << @filters["q"] if @filters["q"].to_s.length.positive?
108
+ q_parts << "from:#{@filters['sender']}" if @filters["sender"].to_s.length.positive?
109
+
110
+ status = @filters["read_status"].to_s
111
+ q_parts << "is:#{status}" if status == "unread" || status == "read"
112
+
113
+ # Boundary-inclusive `after:` — same behavior n8n leans on; we
114
+ # de-dupe via possible_duplicates rather than narrowing the window.
115
+ q_parts << "after:#{@sd['last_checked_at']}"
116
+
117
+ # `-in:scheduled` matches n8n's v1.4+ guard (scheduled-send drafts
118
+ # appear in users.messages.list but aren't real inbound mail yet).
119
+ q_parts << "-in:scheduled"
120
+
121
+ qs[:q] = q_parts.join(" ")
122
+ qs
123
+ end
124
+
125
+ def skip?(message)
126
+ label_ids = Array(message["labelIds"])
127
+ return true if label_ids.include?("DRAFT") && !@filters["include_drafts"]
128
+ return true if label_ids.include?("SENT") && !label_ids.include?("INBOX")
129
+ false
130
+ end
131
+
132
+ # Advance `last_checked_at` to the newest message we just emitted,
133
+ # and capture the ids at that boundary for next-poll deduplication.
134
+ def advance_cursor(messages, max_internal_date)
135
+ return if messages.empty?
136
+
137
+ previous_cursor = @sd["last_checked_at"].to_i
138
+ next_cursor = [ max_internal_date, previous_cursor ].max
139
+
140
+ ids_at_boundary = messages.select { |m| m["internal_date"] == next_cursor }
141
+ .map { |m| m["id"] }
142
+
143
+ if next_cursor == previous_cursor
144
+ # Cursor didn't move — same second as last time. Append new ids
145
+ # to the existing dedupe set rather than replacing it.
146
+ existing = Array(@sd["possible_duplicates"])
147
+ @sd["possible_duplicates"] = (existing + ids_at_boundary).uniq
148
+ else
149
+ @sd["last_checked_at"] = next_cursor
150
+ @sd["possible_duplicates"] = ids_at_boundary
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,18 @@
1
+ module RemoteMcp
2
+ class Connector < Connectors::Connector
3
+ connector key: :mcp, auth: :api_key, base_url: nil, display_name: "MCP server",
4
+ documentation_url: "https://modelcontextprotocol.io/specification/2026-07-28"
5
+
6
+ mcp
7
+
8
+ credentials do
9
+ field :server_url, type: "string", required: true, display_name: "Server URL"
10
+ field :auth_mode, type: "options", required: true, default: "none", display_name: "Authentication mode",
11
+ options: [ { name: "Public server", value: "none" }, { name: "Bearer token", value: "bearer" }, { name: "Custom headers", value: "headers" }, { name: "OAuth", value: "oauth" } ]
12
+ field :bearer_token, type: "string", secret: true, display_name: "Bearer token", display_options: { show: { auth_mode: [ "bearer" ] } }
13
+ field :headers, type: "json", secret: true, display_name: "Custom headers", display_options: { show: { auth_mode: [ "headers" ] } }
14
+ field :client_information, type: "json", secret: true, display_name: "Pre-registered OAuth client (optional)", display_options: { show: { auth_mode: [ "oauth" ] } }
15
+ field :authorization_server, type: "string", display_name: "Authorization server (optional)", display_options: { show: { auth_mode: [ "oauth" ] } }
16
+ end
17
+ end
18
+ end