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,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
|