gmcp 0.5.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 +96 -0
- data/LICENSE.txt +21 -0
- data/README.md +248 -0
- data/bin/gmail_survey +167 -0
- data/bin/gmcp +33 -0
- data/bin/gmcp-auth +68 -0
- data/config/capabilities.yml +148 -0
- data/config/scopes.yml +14 -0
- data/lib/gmcp/account_registry.rb +68 -0
- data/lib/gmcp/api_binding.rb +75 -0
- data/lib/gmcp/api_error.rb +33 -0
- data/lib/gmcp/apis.rb +46 -0
- data/lib/gmcp/auth.rb +189 -0
- data/lib/gmcp/bearer_middleware.rb +17 -0
- data/lib/gmcp/calendar/calendar.rb +16 -0
- data/lib/gmcp/calendar/event.rb +95 -0
- data/lib/gmcp/calendar/tools.rb +150 -0
- data/lib/gmcp/capabilities.rb +133 -0
- data/lib/gmcp/drive/file.rb +145 -0
- data/lib/gmcp/drive/tools.rb +64 -0
- data/lib/gmcp/gmail/draft.rb +21 -0
- data/lib/gmcp/gmail/label.rb +16 -0
- data/lib/gmcp/gmail/message.rb +407 -0
- data/lib/gmcp/gmail/mime.rb +108 -0
- data/lib/gmcp/gmail/tools.rb +364 -0
- data/lib/gmcp/gmail/unsubscribe.rb +96 -0
- data/lib/gmcp/server.rb +90 -0
- data/lib/gmcp/tool_helpers.rb +105 -0
- data/lib/gmcp/version.rb +3 -0
- data/lib/gmcp/voice/chrome.rb +111 -0
- data/lib/gmcp/voice/conversation.rb +65 -0
- data/lib/gmcp/voice/folder.rb +67 -0
- data/lib/gmcp/voice/message.rb +81 -0
- data/lib/gmcp/voice/session.rb +126 -0
- data/lib/gmcp/voice/tools.rb +178 -0
- data/lib/gmcp/voice.rb +34 -0
- data/lib/gmcp.rb +43 -0
- metadata +226 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
module GMCP
|
|
2
|
+
module Gmail
|
|
3
|
+
class Message
|
|
4
|
+
include Him::Model
|
|
5
|
+
|
|
6
|
+
collection_path 'messages'
|
|
7
|
+
primary_key :id
|
|
8
|
+
|
|
9
|
+
parse_root_in_json true
|
|
10
|
+
root_element :messages
|
|
11
|
+
|
|
12
|
+
attributes :id, :threadId, :labelIds, :snippet, :payload,
|
|
13
|
+
:sizeEstimate, :historyId, :internalDate
|
|
14
|
+
|
|
15
|
+
# Gmail caps batchModify at 1000 ids per call.
|
|
16
|
+
BATCH_LIMIT = 1000
|
|
17
|
+
|
|
18
|
+
# Gmail hands back headers as a flat array of {name:, value:} pairs and
|
|
19
|
+
# the body as base64url leaves of a MIME tree. A caller that receives
|
|
20
|
+
# either raw has to do this decoding itself, which is exactly what
|
|
21
|
+
# happened before these existed.
|
|
22
|
+
#
|
|
23
|
+
# Keys arrive as symbols through `him`, but hand-built and fixture
|
|
24
|
+
# payloads use strings, so every reader below tolerates both.
|
|
25
|
+
|
|
26
|
+
SUMMARY_HEADERS = %w[From To Cc Bcc Reply-To Date Subject].freeze
|
|
27
|
+
|
|
28
|
+
# Every header on the message, keyed by the name Gmail reported.
|
|
29
|
+
def headers
|
|
30
|
+
@headers ||= (payload_fetch(:headers) || []).each_with_object({}) do |entry, acc|
|
|
31
|
+
next unless entry.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
name = entry[:name] || entry['name']
|
|
34
|
+
acc[name.to_s] = (entry[:value] || entry['value']).to_s if name
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Senders disagree about header casing — the same header arrives as
|
|
39
|
+
# Message-ID, Message-Id and message-id depending on who sent it — so an
|
|
40
|
+
# exact-match lookup silently misses.
|
|
41
|
+
def header(name)
|
|
42
|
+
key = headers.keys.find { |candidate| candidate.casecmp?(name.to_s) }
|
|
43
|
+
key && headers[key]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The message body as readable text. Prefers text/plain; falls back to
|
|
47
|
+
# text/html with markup stripped, because a great deal of mail (most
|
|
48
|
+
# marketing mail) ships no plain part at all, and returning nil for it
|
|
49
|
+
# would make this useless for the mail it is most often pointed at.
|
|
50
|
+
def body_text
|
|
51
|
+
part_text('text/plain') || strip_html(part_text('text/html'))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# What a caller actually wants from "get me this message": the ids needed
|
|
55
|
+
# for follow-up calls, the headers worth reading, and a decoded body —
|
|
56
|
+
# not the raw MIME tree.
|
|
57
|
+
def to_summary
|
|
58
|
+
summary = {
|
|
59
|
+
id: id,
|
|
60
|
+
thread_id: threadId,
|
|
61
|
+
label_ids: labelIds,
|
|
62
|
+
snippet: snippet
|
|
63
|
+
}
|
|
64
|
+
SUMMARY_HEADERS.each do |name|
|
|
65
|
+
value = header(name)
|
|
66
|
+
summary[name.downcase.tr('-', '_').to_sym] = value if value
|
|
67
|
+
end
|
|
68
|
+
summary[:body] = body_text
|
|
69
|
+
summary[:headers] = headers
|
|
70
|
+
summary
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def trash!
|
|
74
|
+
self.class.post_raw("messages/#{id}/trash", {})
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def archive!
|
|
78
|
+
modify!(removeLabelIds: ['INBOX'])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def modify!(addLabelIds: [], removeLabelIds: [])
|
|
82
|
+
self.class.post_raw("messages/#{id}/modify", {
|
|
83
|
+
addLabelIds: addLabelIds,
|
|
84
|
+
removeLabelIds: removeLabelIds
|
|
85
|
+
})
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Headers a reply needs from the original, and nothing else.
|
|
89
|
+
REPLY_HEADERS = %w[From Reply-To Subject Message-ID References].freeze
|
|
90
|
+
|
|
91
|
+
# Threading needs both halves: Gmail files the reply by threadId, while
|
|
92
|
+
# every other client on the thread goes by In-Reply-To and References,
|
|
93
|
+
# which name the original's RFC Message-ID — not Gmail's own hex id.
|
|
94
|
+
def reply!(body:)
|
|
95
|
+
to = unfold(header('Reply-To') || header('From'))
|
|
96
|
+
raise ArgumentError, "message #{id} has no From or Reply-To to reply to" if to.empty?
|
|
97
|
+
|
|
98
|
+
subject = unfold(header('Subject'))
|
|
99
|
+
subject = "Re: #{subject}" unless subject.match?(/\Are:/i)
|
|
100
|
+
|
|
101
|
+
threading = {}
|
|
102
|
+
if (message_id = header('Message-ID'))
|
|
103
|
+
threading['In-Reply-To'] = unfold(message_id)
|
|
104
|
+
threading['References'] = unfold("#{header('References')} #{message_id}")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
self.class.post_raw('messages/send', {
|
|
108
|
+
raw: Mime.build(to: to, subject: subject, body: body, headers: threading),
|
|
109
|
+
threadId: threadId
|
|
110
|
+
})
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# A long header from the original may still carry its folding. Mime
|
|
116
|
+
# refuses line breaks, so collapse them here rather than fail the reply.
|
|
117
|
+
def unfold(value)
|
|
118
|
+
value.to_s.gsub(/\s+/, ' ').strip
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def payload_fetch(key)
|
|
122
|
+
hash = payload
|
|
123
|
+
return nil unless hash.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
hash[key] || hash[key.to_s]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Depth-first search for the first non-blank part of `mime_type`.
|
|
129
|
+
def part_text(mime_type, part = payload)
|
|
130
|
+
return nil unless part.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
mime = part[:mimeType] || part['mimeType']
|
|
133
|
+
if mime == mime_type
|
|
134
|
+
body = part[:body] || part['body'] || {}
|
|
135
|
+
decoded = decode_part(body[:data] || body['data'], part)
|
|
136
|
+
return decoded if decoded
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
(part[:parts] || part['parts'] || []).each do |sub|
|
|
140
|
+
found = part_text(mime_type, sub)
|
|
141
|
+
return found if found
|
|
142
|
+
end
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A part carrying undecodable bytes should not take the whole tool call
|
|
147
|
+
# down, and a blank part should not mask a populated one of another type.
|
|
148
|
+
def decode_part(data, part = nil)
|
|
149
|
+
return nil if data.nil? || data.to_s.empty?
|
|
150
|
+
|
|
151
|
+
text = to_utf8(Base64.urlsafe_decode64(data), charset_of(part))
|
|
152
|
+
text.strip.empty? ? nil : text
|
|
153
|
+
rescue ArgumentError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The part's own Content-Type carries the charset. Without it a
|
|
158
|
+
# Windows-1252 or Latin-1 body — still common in bulk mail — decodes to
|
|
159
|
+
# mojibake.
|
|
160
|
+
def charset_of(part)
|
|
161
|
+
return nil unless part.is_a?(Hash)
|
|
162
|
+
|
|
163
|
+
entries = part[:headers] || part['headers'] || []
|
|
164
|
+
entry = entries.find do |h|
|
|
165
|
+
h.is_a?(Hash) && (h[:name] || h['name']).to_s.casecmp?('Content-Type')
|
|
166
|
+
end
|
|
167
|
+
value = entry && (entry[:value] || entry['value'])
|
|
168
|
+
value.to_s[/charset=["']?([\w.:-]+)/i, 1]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Base64.urlsafe_decode64 hands back ASCII-8BIT whatever the bytes are.
|
|
172
|
+
# Left that way, JSON.generate warns on json 2.x and raises on 3.0.
|
|
173
|
+
def to_utf8(text, charset)
|
|
174
|
+
source = Encoding.find(charset || 'UTF-8')
|
|
175
|
+
text = text.dup.force_encoding(source)
|
|
176
|
+
|
|
177
|
+
# encode is a no-op when source == destination, so it will not scrub
|
|
178
|
+
# invalid bytes out of a part that merely claims to be UTF-8.
|
|
179
|
+
if source == Encoding::UTF_8
|
|
180
|
+
text.valid_encoding? ? text : text.scrub('')
|
|
181
|
+
else
|
|
182
|
+
text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
183
|
+
end
|
|
184
|
+
rescue ArgumentError, Encoding::ConverterNotFoundError
|
|
185
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub('')
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
HTML_ENTITIES = {
|
|
189
|
+
' ' => ' ', '<' => '<', '>' => '>',
|
|
190
|
+
'"' => '"', ''' => "'", ''' => "'"
|
|
191
|
+
}.freeze
|
|
192
|
+
|
|
193
|
+
# Enough to make an html-only message readable. Not a parser, and not
|
|
194
|
+
# trying to be — the alternative on offer is handing back raw markup.
|
|
195
|
+
def strip_html(html)
|
|
196
|
+
return nil if html.nil?
|
|
197
|
+
|
|
198
|
+
text = html.dup
|
|
199
|
+
text.gsub!(%r{<(script|style)\b[^>]*>.*?</\1>}mi, ' ')
|
|
200
|
+
text.gsub!(%r{<br\s*/?>}i, "\n")
|
|
201
|
+
text.gsub!(%r{</(p|div|tr|li|h[1-6]|blockquote|table)>}i, "\n\n")
|
|
202
|
+
text.gsub!(/<[^>]*>/m, '')
|
|
203
|
+
HTML_ENTITIES.each { |entity, char| text.gsub!(entity, char) }
|
|
204
|
+
text.gsub!('&', '&') # last, so &lt; does not become <
|
|
205
|
+
text.gsub!(/[ \t]+/, ' ')
|
|
206
|
+
text.gsub!(/ *\n */, "\n")
|
|
207
|
+
text.gsub!(/\n{3,}/, "\n\n")
|
|
208
|
+
text.strip!
|
|
209
|
+
text.empty? ? nil : text
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Partial response for a metadata fetch: the ids and the requested
|
|
213
|
+
# headers, without the rest of the payload.
|
|
214
|
+
METADATA_FIELDS = 'id,threadId,labelIds,payload/headers'
|
|
215
|
+
|
|
216
|
+
# Partial response for .attachments: the part tree without the inline
|
|
217
|
+
# body data of every text part. A fields mask cannot recurse, so it
|
|
218
|
+
# spells the nesting out; ten levels is deeper than real mail goes.
|
|
219
|
+
ATTACHMENT_FIELDS = begin
|
|
220
|
+
part = 'partId,filename,mimeType,body(size,attachmentId)'
|
|
221
|
+
"payload(#{(1..10).reduce(part) { |inner, _| "#{part},parts(#{inner})" }})"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# What a search result line shows.
|
|
225
|
+
SEARCH_HEADERS = %w[From Subject Date].freeze
|
|
226
|
+
|
|
227
|
+
# Concurrent fetches per page. Enough to hide latency on a 20–100
|
|
228
|
+
# message page without leaning on Gmail's per-user rate limit.
|
|
229
|
+
FETCH_THREADS = 8
|
|
230
|
+
|
|
231
|
+
class << self
|
|
232
|
+
# Just the named headers, for callers that never read the body —
|
|
233
|
+
# a few hundred bytes instead of the whole MIME tree.
|
|
234
|
+
def metadata(message_id, headers:)
|
|
235
|
+
params = { format: 'metadata', metadataHeaders: headers, fields: METADATA_FIELDS }
|
|
236
|
+
get_raw("messages/#{message_id}", params) { |parsed, _response| new(parsed[:data] || {}) }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# .metadata for each id, in the order given, FETCH_THREADS at a time.
|
|
240
|
+
# Gmail's list endpoint returns bare ids, so a page of results is
|
|
241
|
+
# unreadable until each one is fetched; serially that is one round
|
|
242
|
+
# trip per message.
|
|
243
|
+
#
|
|
244
|
+
# A message deleted between the list and the fetch comes back as a
|
|
245
|
+
# bare id rather than failing the whole page. Any other failure is
|
|
246
|
+
# raised, after the remaining fetches are abandoned.
|
|
247
|
+
def metadata_for(ids, headers:)
|
|
248
|
+
apis = ApiBinding.current # fiber-local, so each worker rebinds it
|
|
249
|
+
results = Array.new(ids.length)
|
|
250
|
+
queue = Queue.new
|
|
251
|
+
ids.each_with_index { |id, index| queue << [id, index] }
|
|
252
|
+
queue.close
|
|
253
|
+
|
|
254
|
+
workers = Array.new([FETCH_THREADS, ids.length].min) do
|
|
255
|
+
::Thread.new do
|
|
256
|
+
::Thread.current.report_on_exception = false # re-raised by #value below
|
|
257
|
+
ApiBinding.with(apis) do
|
|
258
|
+
while (job = queue.pop)
|
|
259
|
+
id, index = job
|
|
260
|
+
results[index] = metadata_or_gone(id, headers)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
rescue StandardError
|
|
264
|
+
queue.clear
|
|
265
|
+
raise
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
workers.each(&:value)
|
|
269
|
+
results
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def search(query, max_results: 20)
|
|
273
|
+
get_collection('messages', q: query, maxResults: max_results)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Like .search, but surfaces Gmail's pagination cursor instead of
|
|
277
|
+
# discarding it. get_collection drops everything outside the message
|
|
278
|
+
# array, which is why callers previously had to drop to raw Faraday to
|
|
279
|
+
# walk a mailbox.
|
|
280
|
+
#
|
|
281
|
+
# Returns { messages:, next_page_token:, estimate: }. next_page_token is
|
|
282
|
+
# nil on the last page — that, not an empty page, is end-of-results.
|
|
283
|
+
# `estimate` is Gmail's resultSizeEstimate and is approximate; never
|
|
284
|
+
# treat it as a count.
|
|
285
|
+
def search_page(query = nil, max_results: 100, page_token: nil, label_ids: nil)
|
|
286
|
+
params = { maxResults: max_results }
|
|
287
|
+
params[:q] = query if query && !query.to_s.empty?
|
|
288
|
+
params[:pageToken] = page_token if page_token && !page_token.to_s.empty?
|
|
289
|
+
params[:labelIds] = label_ids if label_ids
|
|
290
|
+
|
|
291
|
+
get_raw('messages', params) do |parsed, _response|
|
|
292
|
+
data = parsed[:data] || {}
|
|
293
|
+
{
|
|
294
|
+
messages: (data[:messages] || []).map { |m| new(m) },
|
|
295
|
+
next_page_token: data[:nextPageToken],
|
|
296
|
+
estimate: data[:resultSizeEstimate]
|
|
297
|
+
}
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Walks every page of a query, yielding each page. Bounded by max_pages
|
|
302
|
+
# so a caller cannot accidentally spin forever on a mailbox that keeps
|
|
303
|
+
# handing back cursors.
|
|
304
|
+
def each_page(query = nil, max_results: 100, max_pages: 100)
|
|
305
|
+
return enum_for(:each_page, query, max_results: max_results, max_pages: max_pages) unless block_given?
|
|
306
|
+
|
|
307
|
+
token = nil
|
|
308
|
+
max_pages.times do
|
|
309
|
+
page = search_page(query, max_results: max_results, page_token: token)
|
|
310
|
+
yield page
|
|
311
|
+
token = page[:next_page_token]
|
|
312
|
+
break if token.nil? || token.to_s.empty?
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# One request for up to BATCH_LIMIT messages. Returns an empty body on
|
|
317
|
+
# success — Gmail reports nothing per-message, so a partial failure is
|
|
318
|
+
# not distinguishable here. Callers wanting per-message confirmation
|
|
319
|
+
# must re-read.
|
|
320
|
+
def batch_modify(ids:, add_label_ids: [], remove_label_ids: [])
|
|
321
|
+
ids = Array(ids)
|
|
322
|
+
raise ArgumentError, 'no message ids given' if ids.empty?
|
|
323
|
+
raise ArgumentError, "batch of #{ids.length} exceeds Gmail's limit of #{BATCH_LIMIT}" if ids.length > BATCH_LIMIT
|
|
324
|
+
|
|
325
|
+
post_raw('messages/batchModify', {
|
|
326
|
+
ids: ids,
|
|
327
|
+
addLabelIds: add_label_ids,
|
|
328
|
+
removeLabelIds: remove_label_ids
|
|
329
|
+
})
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Adding the TRASH label is how the batch endpoint expresses a trash;
|
|
333
|
+
# there is no messages/batchTrash. Recoverable for 30 days, same as a
|
|
334
|
+
# single-message trash.
|
|
335
|
+
def batch_trash(ids:)
|
|
336
|
+
batch_modify(ids: ids, add_label_ids: ['TRASH'])
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Archiving in Gmail is removing INBOX, nothing more.
|
|
340
|
+
def batch_archive(ids:)
|
|
341
|
+
batch_modify(ids: ids, remove_label_ids: ['INBOX'])
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Attachment parts carry a filename and a body.attachmentId; inline
|
|
345
|
+
# parts (and the text/plain + text/html bodies) carry neither, so this
|
|
346
|
+
# returns only things a user would recognise as an attachment.
|
|
347
|
+
#
|
|
348
|
+
# Gmail issues a fresh attachmentId on every fetch, so one cannot be
|
|
349
|
+
# matched against an earlier listing. partId is stable.
|
|
350
|
+
def attachments(message_id)
|
|
351
|
+
params = { format: 'full', fields: ATTACHMENT_FIELDS }
|
|
352
|
+
payload = get_raw("messages/#{message_id}", params) { |p, _r| (p[:data] || {})[:payload] } || {}
|
|
353
|
+
collect_attachment_parts(payload)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def attachment(message_id, part_id:)
|
|
357
|
+
attachments(message_id).find { |a| a[:part_id] == part_id.to_s } ||
|
|
358
|
+
raise(ArgumentError, "message #{message_id} has no attachment with part_id #{part_id}")
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Returns the raw decoded bytes. Callers decide where they land — the
|
|
362
|
+
# model does not write to disk.
|
|
363
|
+
def download_attachment(message_id:, attachment_id:)
|
|
364
|
+
get_raw("messages/#{message_id}/attachments/#{attachment_id}") do |parsed, _response|
|
|
365
|
+
encoded = (parsed[:data] || {})[:data]
|
|
366
|
+
raise "attachment #{attachment_id} returned no data" if encoded.nil?
|
|
367
|
+
|
|
368
|
+
Base64.urlsafe_decode64(encoded)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def send_message(to:, subject:, body:)
|
|
373
|
+
post_raw('messages/send', { raw: Mime.build(to: to, subject: subject, body: body) })
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
private
|
|
377
|
+
|
|
378
|
+
def metadata_or_gone(id, headers)
|
|
379
|
+
metadata(id, headers: headers)
|
|
380
|
+
rescue ApiError => e
|
|
381
|
+
raise unless e.status == 404
|
|
382
|
+
|
|
383
|
+
new(id: id)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def collect_attachment_parts(part, acc = [])
|
|
387
|
+
body = part[:body] || part['body'] || {}
|
|
388
|
+
filename = part[:filename] || part['filename']
|
|
389
|
+
att_id = body[:attachmentId] || body['attachmentId']
|
|
390
|
+
|
|
391
|
+
if att_id && filename && !filename.to_s.empty?
|
|
392
|
+
acc << {
|
|
393
|
+
part_id: part[:partId] || part['partId'],
|
|
394
|
+
filename: filename,
|
|
395
|
+
mime_type: part[:mimeType] || part['mimeType'],
|
|
396
|
+
size: body[:size] || body['size'],
|
|
397
|
+
attachment_id: att_id
|
|
398
|
+
}
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
(part[:parts] || part['parts'] || []).each { |p| collect_attachment_parts(p, acc) }
|
|
402
|
+
acc
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GMCP
|
|
4
|
+
module Gmail
|
|
5
|
+
# The one place outgoing mail becomes RFC 5322 text. Send, draft and reply
|
|
6
|
+
# all go through here, so the safety rules are applied once:
|
|
7
|
+
#
|
|
8
|
+
# * A CR or LF in any header value is refused. Header values come from
|
|
9
|
+
# the caller (and, for a reply, from whoever sent the original), and a
|
|
10
|
+
# bare newline would let either inject headers of their own — a Bcc,
|
|
11
|
+
# say.
|
|
12
|
+
# * Non-ASCII header text is RFC 2047 encoded. Raw UTF-8 in a Subject
|
|
13
|
+
# arrives as mojibake in clients that follow the RFC.
|
|
14
|
+
# * The body is declared UTF-8 and base64 encoded, so its bytes survive
|
|
15
|
+
# transport whatever they are.
|
|
16
|
+
module Mime
|
|
17
|
+
# Gmail's own `raw` field is base64url of the whole message.
|
|
18
|
+
def self.build(to:, subject:, body:, cc: nil, headers: {})
|
|
19
|
+
fields = { 'To' => to }
|
|
20
|
+
fields['Cc'] = cc unless cc.nil? || cc.to_s.empty?
|
|
21
|
+
fields['Subject'] = subject
|
|
22
|
+
fields.merge!(headers.transform_keys(&:to_s))
|
|
23
|
+
|
|
24
|
+
lines = fields.map { |name, value| field(name, value) }
|
|
25
|
+
lines << 'MIME-Version: 1.0'
|
|
26
|
+
lines << 'Content-Type: text/plain; charset=UTF-8'
|
|
27
|
+
lines << 'Content-Transfer-Encoding: base64'
|
|
28
|
+
|
|
29
|
+
Base64.urlsafe_encode64("#{lines.join("\r\n")}\r\n\r\n#{encode_body(body)}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
ADDRESS_FIELDS = %w[to cc bcc from reply-to].freeze
|
|
33
|
+
|
|
34
|
+
# Encoded words cap at 75 characters; the =?UTF-8?B? ... ?= wrapper
|
|
35
|
+
# takes 12, leaving 63 for base64, which holds 47 bytes. Rounded down to
|
|
36
|
+
# whole base64 groups.
|
|
37
|
+
MAX_WORD_BYTES = 45
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def field(name, value)
|
|
43
|
+
name = name.to_s
|
|
44
|
+
raise ArgumentError, "invalid header name: #{name.inspect}" unless name.match?(/\A[!-9;-~]+\z/)
|
|
45
|
+
|
|
46
|
+
value = utf8(value)
|
|
47
|
+
raise ArgumentError, "#{name} header must not contain a line break" if value.match?(/[\r\n]/)
|
|
48
|
+
|
|
49
|
+
encoded = ADDRESS_FIELDS.include?(name.downcase) ? encode_addresses(value) : encode_text(value)
|
|
50
|
+
fold("#{name}: #{encoded}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# encode is a no-op on a string already tagged UTF-8, hence the scrub.
|
|
54
|
+
def utf8(value)
|
|
55
|
+
value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).scrub
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# An address cannot be wrapped in an encoded word — the recipient
|
|
59
|
+
# would see the whole field as a display name with no address — so
|
|
60
|
+
# only the display name of each entry is encoded.
|
|
61
|
+
def encode_addresses(value)
|
|
62
|
+
return value if value.ascii_only?
|
|
63
|
+
|
|
64
|
+
value.scan(/(?:"[^"]*"|[^,])+/).map do |entry|
|
|
65
|
+
match = entry.strip.match(/\A"?(?<name>.*?)"?\s*<(?<addr>[^>]*)>\z/)
|
|
66
|
+
next entry.strip unless match && !match[:name].ascii_only?
|
|
67
|
+
|
|
68
|
+
"#{encoded_words(match[:name]).join(' ')} <#{match[:addr]}>"
|
|
69
|
+
end.join(', ')
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def encode_text(value)
|
|
73
|
+
value.ascii_only? ? value : encoded_words(value).join(' ')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Whitespace between adjacent encoded words is dropped on decode, so a
|
|
77
|
+
# value split across several words reassembles exactly.
|
|
78
|
+
def encoded_words(text)
|
|
79
|
+
chunks = text.each_char.each_with_object([+'']) do |char, acc|
|
|
80
|
+
acc << +'' if acc.last.bytesize + char.bytesize > MAX_WORD_BYTES
|
|
81
|
+
acc.last << char
|
|
82
|
+
end
|
|
83
|
+
chunks.map { |chunk| "=?UTF-8?B?#{Base64.strict_encode64(chunk)}?=" }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# RFC 5322 recommends 78 characters a line. Folding puts a CRLF in
|
|
87
|
+
# front of existing whitespace and nothing else, so unfolding gives
|
|
88
|
+
# back the exact value — which matters for References on a long thread.
|
|
89
|
+
def fold(line)
|
|
90
|
+
return line if line.length <= 78
|
|
91
|
+
|
|
92
|
+
line.split(/(?= )/).each_with_object([]) do |token, out|
|
|
93
|
+
if out.empty? || out.last.length + token.length > 78
|
|
94
|
+
out << +token
|
|
95
|
+
else
|
|
96
|
+
out.last << token
|
|
97
|
+
end
|
|
98
|
+
end.join("\r\n")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Canonical text/plain line endings are CRLF.
|
|
102
|
+
def encode_body(body)
|
|
103
|
+
Base64.encode64(utf8(body).gsub(/\r?\n/, "\r\n")).gsub("\n", "\r\n")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|