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.
@@ -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
+ '&nbsp;' => ' ', '&lt;' => '<', '&gt;' => '>',
190
+ '&quot;' => '"', '&#39;' => "'", '&apos;' => "'"
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!('&amp;', '&') # last, so &amp;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