tina4ruby 3.13.94 → 3.13.97

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +256 -33
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. data/lib/tina4/scss_compiler.rb +0 -349
@@ -95,13 +95,14 @@ module Tina4
95
95
 
96
96
  attr_reader :host, :port, :username, :from_address, :from_name,
97
97
  :imap_host, :imap_port, :use_tls, :encryption,
98
- :imap_encryption, :imap_use_tls
98
+ :imap_encryption, :imap_use_tls, :imap_username, :imap_password
99
99
 
100
100
  # Initialize with SMTP config.
101
101
  # Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults
102
102
  def initialize(host: nil, port: nil, username: nil, password: nil,
103
103
  from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
104
- imap_host: nil, imap_port: nil, imap_encryption: nil)
104
+ imap_host: nil, imap_port: nil, imap_encryption: nil,
105
+ imap_username: nil, imap_password: nil)
105
106
  # Whether a host was actually CONFIGURED, which is not the same as @host being
106
107
  # set: it falls back to "localhost", so it is never nil and cannot answer
107
108
  # "can this messenger send?". The capture gate needs that answer, so record it
@@ -139,6 +140,14 @@ module Tina4
139
140
  env_imap_enc = imap_encryption || ENV["TINA4_MAIL_IMAP_ENCRYPTION"]
140
141
  @imap_encryption = (env_imap_enc && !env_imap_enc.to_s.empty?) ? env_imap_enc.to_s.downcase : "tls"
141
142
  @imap_use_tls = %w[tls starttls ssl].include?(@imap_encryption)
143
+
144
+ # IMAP credentials, independent of SMTP. Dedicated
145
+ # TINA4_MAIL_IMAP_USERNAME/_PASSWORD, falling back to the SMTP
146
+ # TINA4_MAIL_USERNAME/_PASSWORD. Ruby authenticated IMAP to the SMTP
147
+ # account, so an app whose mailbox for READING differs from its SMTP relay
148
+ # account read the wrong mailbox. Explicit constructor args win (ADR-0041).
149
+ @imap_username = imap_username || ENV["TINA4_MAIL_IMAP_USERNAME"] || @username
150
+ @imap_password = imap_password || ENV["TINA4_MAIL_IMAP_PASSWORD"] || @password
142
151
  end
143
152
 
144
153
  # Send email using Ruby's Net::SMTP
@@ -210,6 +219,24 @@ module Tina4
210
219
  { success: false, message: e.message, id: nil }
211
220
  end
212
221
 
222
+ # Send an email whose HTML body is rendered from a Frond template string.
223
+ # Parity with the Python master's send_template. Extra keyword args (cc, bcc,
224
+ # attachments, reply_to, headers, text) pass straight through to #send. If
225
+ # Frond is unusable, the raw template string is sent as-is (a graceful
226
+ # fallback, mirroring Python's ImportError branch).
227
+ #
228
+ # mail.send_template(to: "u@x.com", subject: "Hi {{ name }}",
229
+ # template: "<h1>Hi {{ name }}</h1>", data: { "name" => "Al" })
230
+ def send_template(to:, subject:, template:, data: {}, **kwargs)
231
+ body = begin
232
+ Tina4::Frond.new.render_string(template, data || {})
233
+ rescue StandardError => e
234
+ Tina4::Log.error("Messenger send_template render failed: #{e.message}")
235
+ template
236
+ end
237
+ send(to: to, subject: subject, body: body, html: true, **kwargs)
238
+ end
239
+
213
240
  # Test SMTP connection
214
241
  # Returns { success: true/false, message: "..." }
215
242
  def test_connection
@@ -227,10 +254,21 @@ module Tina4
227
254
 
228
255
  # List messages in a folder.
229
256
  #
257
+ # Callable POSITIONALLY — inbox("INBOX", 10, 0) — or by keyword —
258
+ # inbox(folder: "INBOX", limit: 10). Node moved to folder-first in 3.13.95
259
+ # to satisfy a contract Ruby could not satisfy at all; this closes that loop.
260
+ #
261
+ # Each item is EXACTLY {uid:String, subject, from:String, to:String,
262
+ # date:ISO-8601, snippet, seen:Boolean} — the settled cross-framework shape.
263
+ #
230
264
  # Raises Tina4::MessengerConnectionError on a connection/auth/protocol
231
265
  # failure (FAILS LOUD — never returns [] to hide it). A successful fetch
232
266
  # from an empty folder returns [] (that is NOT an error).
233
- def inbox(folder: "INBOX", limit: 20, offset: 0)
267
+ def inbox(folder = "INBOX", limit = 20, offset = 0, **opts)
268
+ folder = opts[:folder] if opts.key?(:folder)
269
+ limit = opts[:limit] if opts.key?(:limit)
270
+ offset = opts[:offset] if opts.key?(:offset)
271
+
234
272
  imap = imap_open("inbox")
235
273
  begin
236
274
  imap.select(folder)
@@ -239,8 +277,18 @@ module Tina4
239
277
  page = uids[offset, limit] || []
240
278
  return [] if page.empty?
241
279
 
242
- envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
243
- (envelopes || []).map { |msg| parse_envelope(msg) }
280
+ # BODY.PEEK[] fetches the full message WITHOUT setting \Seen — listing an
281
+ # inbox must never mark messages read — so parse_envelope can build a
282
+ # decoded, transfer-decoded, tag-stripped snippet. tina4: heavier than a
283
+ # header-only fetch for large mailboxes; fetch BODYSTRUCTURE + the text
284
+ # part only if this ever gets hot.
285
+ envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "BODY.PEEK[]"])
286
+ # uid_fetch returns rows in SERVER (ascending) order however the uids
287
+ # were asked for, so the newest-first page above was silently re-sorted
288
+ # and inbox(limit: 1) handed back the OLDEST message. Selection was
289
+ # right; presentation was not. Restore the requested order.
290
+ by_uid = (envelopes || []).each_with_object({}) { |m, h| h[m.attr["UID"]] = m }
291
+ page.filter_map { |uid| by_uid[uid] }.map { |msg| parse_envelope(msg) }
244
292
  rescue *IMAP_CONNECTION_ERRORS => e
245
293
  raise imap_fail("inbox", e)
246
294
  ensure
@@ -250,9 +298,19 @@ module Tina4
250
298
 
251
299
  # Read a single message by UID.
252
300
  #
301
+ # Callable POSITIONALLY — read(uid, "INBOX") — or by keyword —
302
+ # read(uid, folder: "INBOX"). Returns EXACTLY these 10 keys: {uid, subject,
303
+ # from:String, to:String, cc:String, date:ISO-8601, body_text, body_html,
304
+ # attachments, headers}. Message-ID lives in headers, never a top-level key.
305
+ # Each attachments item is {filename, content_type, size, content} where
306
+ # content is the RAW DECODED BYTES of the part (issue #69).
307
+ #
253
308
  # Raises Tina4::MessengerConnectionError on a connection/protocol failure.
254
309
  # A successful fetch for a non-existent UID returns nil (that is NOT an error).
255
- def read(uid, folder: "INBOX", mark_read: true)
310
+ def read(uid, folder = "INBOX", mark_read = true, **opts)
311
+ folder = opts[:folder] if opts.key?(:folder)
312
+ mark_read = opts[:mark_read] if opts.key?(:mark_read)
313
+
256
314
  imap = imap_open("read")
257
315
  begin
258
316
  imap.select(folder)
@@ -307,8 +365,15 @@ module Tina4
307
365
  page = uids[0, limit] || []
308
366
  return [] if page.empty?
309
367
 
310
- envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
311
- (envelopes || []).map { |msg| parse_envelope(msg) }
368
+ # BODY.PEEK[] (no \Seen) so search results carry the same decoded snippet
369
+ # as inbox(); search shares parse_envelope and therefore the item shape.
370
+ envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "BODY.PEEK[]"])
371
+ # uid_fetch returns rows in SERVER (ascending) order however the uids
372
+ # were asked for, so the newest-first page above was silently re-sorted
373
+ # and inbox(limit: 1) handed back the OLDEST message. Selection was
374
+ # right; presentation was not. Restore the requested order.
375
+ by_uid = (envelopes || []).each_with_object({}) { |m, h| h[m.attr["UID"]] = m }
376
+ page.filter_map { |uid| by_uid[uid] }.map { |msg| parse_envelope(msg) }
312
377
  rescue *IMAP_CONNECTION_ERRORS => e
313
378
  raise imap_fail("search", e)
314
379
  ensure
@@ -344,6 +409,42 @@ module Tina4
344
409
  Tina4::Log.error("IMAP mark_read failed: #{e.message}")
345
410
  end
346
411
 
412
+ # Mark a message as unread (clear \Seen flag). Mirror of mark_read; same
413
+ # result-style error handling (logs, returns nil) — the two are a matched
414
+ # pair of idempotent flag toggles.
415
+ #
416
+ # @param uid [String, Integer] message UID
417
+ # @param folder [String] IMAP folder name
418
+ def mark_unread(uid, folder: "INBOX")
419
+ imap_connect do |imap|
420
+ imap.select(folder)
421
+ imap.uid_store(uid.to_i, "-FLAGS", [:Seen])
422
+ end
423
+ rescue => e
424
+ Tina4::Log.error("IMAP mark_unread failed: #{e.message}")
425
+ end
426
+
427
+ # Delete a message: flag it \Deleted and expunge. Destructive, so it FAILS
428
+ # LOUD — a connection/protocol failure raises MessengerConnectionError rather
429
+ # than silently reporting success (parity with the Python master, which also
430
+ # raises). Returns true when the expunge completes.
431
+ #
432
+ # @param uid [String, Integer] message UID
433
+ # @param folder [String] IMAP folder name
434
+ def delete(uid, folder: "INBOX")
435
+ imap = imap_open("delete")
436
+ begin
437
+ imap.select(folder)
438
+ imap.uid_store(uid, "+FLAGS", [:Deleted])
439
+ imap.expunge
440
+ true
441
+ rescue *IMAP_CONNECTION_ERRORS => e
442
+ raise imap_fail("delete", e)
443
+ ensure
444
+ imap_cleanup(imap)
445
+ end
446
+ end
447
+
347
448
  # Test IMAP connectivity without reading messages.
348
449
  #
349
450
  # @return [Hash] { success: Boolean, message: String }
@@ -528,7 +629,7 @@ module Tina4
528
629
  # rather than swallowing the error into an empty result.
529
630
  def imap_open(method)
530
631
  imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
531
- imap.login(@username, @password)
632
+ imap.login(@imap_username, @imap_password)
532
633
  imap
533
634
  rescue *IMAP_CONNECTION_ERRORS => e
534
635
  raise imap_fail(method, e)
@@ -569,64 +670,145 @@ module Tina4
569
670
  # they can fail loud.
570
671
  def imap_connect(&block)
571
672
  imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
572
- imap.login(@username, @password)
673
+ imap.login(@imap_username, @imap_password)
573
674
  result = block.call(imap)
574
675
  imap.logout
575
676
  imap.disconnect
576
677
  result
577
678
  end
578
679
 
680
+ # An inbox() item — EXACTLY these seven keys (decisions doc G4):
681
+ # uid:String, subject, from:String, to:String, date:ISO-8601, snippet, seen:Boolean
682
+ # from/to are header STRINGS ("Name <email>"), NOT arrays of {name,email};
683
+ # date is ISO-8601; snippet is decoded/transfer-decoded/tag-stripped plain
684
+ # text (<= 200 chars); seen is a Boolean. No flags/size.
579
685
  def parse_envelope(fetch_data)
580
686
  env = fetch_data.attr["ENVELOPE"]
581
687
  flags = fetch_data.attr["FLAGS"] || []
582
- size = fetch_data.attr["RFC822.SIZE"] || 0
688
+ raw_body = fetch_data.attr["BODY[]"] || ""
583
689
 
584
690
  {
585
- uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"] : nil,
691
+ # String in all four frameworks. Ruby alone returned Integer, so
692
+ # `uid == "3"` was false here and true everywhere else.
693
+ uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"].to_s : nil,
586
694
  subject: env.subject ? decode_mime_header(env.subject) : "",
587
- from: format_imap_address(env.from),
588
- to: format_imap_address(env.to),
589
- date: env.date,
590
- flags: flags.map(&:to_s),
591
- read: flags.include?(:Seen),
592
- size: size
695
+ from: format_address_string(env.from),
696
+ to: format_address_string(env.to),
697
+ date: format_date_iso8601(env.date),
698
+ snippet: build_snippet(raw_body),
699
+ seen: flags.include?(:Seen)
593
700
  }
594
701
  end
595
702
 
703
+ # A read() item (decisions doc G5) — EXACTLY these 10 keys: uid, subject,
704
+ # from/to/cc (header STRINGS), date, body_text/body_html (was body/html),
705
+ # attachments, headers (the full header Hash). Message-ID lives in
706
+ # headers["Message-ID"], never a top-level key. Each attachments item is
707
+ # {filename, content_type, size, content} — content is the RAW DECODED BYTES
708
+ # of the part (issue #69), so an attachment is downloadable straight from
709
+ # read() and size is that byte length.
596
710
  def parse_full_message(fetch_data)
597
711
  env = fetch_data.attr["ENVELOPE"]
598
- flags = fetch_data.attr["FLAGS"] || []
599
712
  raw_body = fetch_data.attr["BODY[]"] || ""
600
713
 
601
714
  body_text, body_html = extract_body_parts(raw_body)
602
715
 
603
716
  {
604
- uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"] : nil,
717
+ uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"].to_s : nil,
605
718
  subject: env.subject ? decode_mime_header(env.subject) : "",
606
- from: format_imap_address(env.from),
607
- to: format_imap_address(env.to),
608
- cc: format_imap_address(env.cc),
609
- date: env.date,
610
- message_id: env.message_id,
611
- flags: flags.map(&:to_s),
612
- read: flags.include?(:Seen),
613
- body: body_text,
614
- html: body_html,
615
- raw: raw_body
719
+ from: format_address_string(env.from),
720
+ to: format_address_string(env.to),
721
+ cc: format_address_string(env.cc),
722
+ date: format_date_iso8601(env.date),
723
+ body_text: body_text,
724
+ body_html: body_html,
725
+ attachments: extract_attachments(raw_body),
726
+ headers: parse_headers(raw_body)
616
727
  }
617
728
  end
618
729
 
619
- def format_imap_address(addresses)
620
- return [] if addresses.nil?
730
+ # Format an ENVELOPE address list into the header STRING shape the other three
731
+ # frameworks return: "Name <email>" per address (bare "email" when unnamed),
732
+ # comma-joined. Empty string when there are no addresses.
733
+ def format_address_string(addresses)
734
+ return "" if addresses.nil? || addresses.empty?
621
735
 
622
736
  addresses.map do |addr|
623
737
  email = "#{addr.mailbox}@#{addr.host}"
624
- if addr.name && !addr.name.empty?
625
- { name: decode_mime_header(addr.name), email: email }
626
- else
627
- { name: nil, email: email }
738
+ name = addr.name && !addr.name.to_s.empty? ? decode_mime_header(addr.name) : nil
739
+ name ? "#{name} <#{email}>" : email
740
+ end.join(", ")
741
+ end
742
+
743
+ # ISO-8601 string from an ENVELOPE date. Mirrors Python's
744
+ # parsedate_to_datetime(...).isoformat(): parse the RFC5322 date and emit
745
+ # ISO-8601; fall back to the raw value on a parse failure, "" when absent.
746
+ def format_date_iso8601(raw)
747
+ return "" if raw.nil? || raw.to_s.strip.empty?
748
+
749
+ begin
750
+ Time.parse(raw.to_s).iso8601
751
+ rescue ArgumentError, TypeError
752
+ raw.to_s
753
+ end
754
+ end
755
+
756
+ # Decoded, transfer-decoded, tag-stripped, whitespace-collapsed plain text,
757
+ # truncated to 200 chars — the settled snippet shape (G3). Reuses the
758
+ # existing multipart/transfer-decoding; prefers the text part, falls back to
759
+ # the HTML part with its tags stripped.
760
+ def build_snippet(raw_body)
761
+ text, html = extract_body_parts(raw_body.to_s)
762
+ source = text.nil? || text.empty? ? html.to_s : text
763
+ plain = source.gsub(/<[^>]+>/, " ").gsub(/\s+/, " ").strip
764
+ plain.length > 200 ? plain[0, 200] : plain
765
+ end
766
+
767
+ # Attachments parsed from a raw MIME message: an array of
768
+ # {filename, content_type, size, content}. A part is an attachment when it
769
+ # carries a Content-Disposition of attachment. content is the RAW DECODED
770
+ # BYTES of the part (transfer-decoded, binary encoding — the same convention
771
+ # as request.files' "content"), so an attachment is downloadable straight
772
+ # from read() (issue #69); size is that byte length. [] when the message is
773
+ # not multipart or has no attachment parts.
774
+ def extract_attachments(raw)
775
+ raw = raw.to_s
776
+ return [] unless raw =~ %r{Content-Type:\s*multipart/\w+;\s*boundary="?([^"\s;]+)"?}i
777
+
778
+ boundary = Regexp.last_match(1)
779
+ raw.split("--#{boundary}").each_with_object([]) do |part, acc|
780
+ next unless part =~ /Content-Disposition:\s*attachment/i
781
+
782
+ filename = part[/filename="?([^"\r\n;]+)"?/i, 1] ||
783
+ part[/name="?([^"\r\n;]+)"?/i, 1] || "attachment"
784
+ content_type = part[%r{Content-Type:\s*([^\s;]+)}i, 1] || "application/octet-stream"
785
+ content = extract_part_bytes(part)
786
+ acc << {
787
+ filename: filename.strip,
788
+ content_type: content_type.strip,
789
+ size: content.bytesize,
790
+ content: content
791
+ }
792
+ end
793
+ end
794
+
795
+ # Parse the top-level RFC5322 header block of a raw message into a Hash
796
+ # (name => value). Mirrors Python's dict(msg.items()); a repeated header keeps
797
+ # the last value; folded continuation lines are unfolded.
798
+ def parse_headers(raw)
799
+ header_block = raw.to_s.split(/\r?\n\r?\n/, 2).first.to_s
800
+ headers = {}
801
+ current_key = nil
802
+ header_block.each_line do |raw_line|
803
+ line = raw_line.chomp
804
+ if current_key && line =~ /\A[ \t]/
805
+ headers[current_key] = "#{headers[current_key]} #{line.strip}"
806
+ elsif (m = line.match(/\A([\w!\#$%&'*+\-.^_`|~]+):[ \t]?(.*)\z/))
807
+ current_key = m[1]
808
+ headers[current_key] = m[2]
628
809
  end
629
810
  end
811
+ headers
630
812
  end
631
813
 
632
814
  def decode_mime_header(value)
@@ -693,6 +875,36 @@ module Tina4
693
875
  end
694
876
  end
695
877
 
878
+ # The RAW DECODED BYTES of a MIME part: transfer-decoded (base64 /
879
+ # quoted-printable → the original bytes) in BINARY (ASCII-8BIT) encoding, so a
880
+ # non-text attachment (image, PDF, zip) round-trips byte-for-byte. This is the
881
+ # attachment analogue of extract_part_body, which force-encodes UTF-8 for the
882
+ # text/HTML BODIES and strips them; an attachment must stay raw, unstripped
883
+ # bytes, so the two are kept separate rather than shared. tina4: if a third
884
+ # raw-part reader appears, fold the shared "split headers/body + read
885
+ # Content-Transfer-Encoding" step into one helper the two call with different
886
+ # post-decode handling.
887
+ def extract_part_bytes(part)
888
+ header_body = part.to_s.split(/\r?\n\r?\n/, 2)
889
+ return "".b unless header_body.length > 1
890
+
891
+ headers = header_body[0]
892
+ # The single CRLF immediately before the next boundary delimiter is MIME
893
+ # framing, not payload, so drop it before decoding (Python's
894
+ # get_payload(decode=True) drops it too). base64 decoding ignores it
895
+ # anyway; quoted-printable and raw 7bit/8bit would otherwise keep it.
896
+ body = header_body[1].sub(/\r?\n\z/, "")
897
+
898
+ if headers =~ /Content-Transfer-Encoding:\s*base64/i
899
+ Base64.decode64(body)
900
+ elsif headers =~ /Content-Transfer-Encoding:\s*quoted-printable/i
901
+ body.gsub(/=\r?\n/, "").gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }.b
902
+ else
903
+ # 7bit/8bit/none: the part body IS the bytes.
904
+ body.b
905
+ end
906
+ end
907
+
696
908
  def build_search_criteria(subject:, sender:, since:, before:, unseen_only:)
697
909
  criteria = []
698
910
  criteria.push("SUBJECT", subject) if subject