tina4ruby 3.13.93 → 3.13.96

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 (69) 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 +35 -43
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +458 -48
  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/dev_mailbox.rb +5 -1
  22. data/lib/tina4/dispatch_pipeline.rb +605 -0
  23. data/lib/tina4/docstore.rb +274 -60
  24. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  25. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  26. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  27. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  28. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  29. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  30. data/lib/tina4/drivers/sqlite_driver.rb +25 -3
  31. data/lib/tina4/env.rb +176 -34
  32. data/lib/tina4/field_types.rb +12 -0
  33. data/lib/tina4/frond.rb +102 -10
  34. data/lib/tina4/health.rb +30 -14
  35. data/lib/tina4/job.rb +15 -5
  36. data/lib/tina4/log.rb +236 -32
  37. data/lib/tina4/mcp.rb +11 -5
  38. data/lib/tina4/messenger.rb +317 -82
  39. data/lib/tina4/metrics.rb +179 -891
  40. data/lib/tina4/middleware.rb +191 -56
  41. data/lib/tina4/migration.rb +17 -1
  42. data/lib/tina4/orm.rb +114 -17
  43. data/lib/tina4/public/css/tina4.min.css +1 -1
  44. data/lib/tina4/queue.rb +154 -9
  45. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  46. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  47. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  48. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  49. data/lib/tina4/rack_app.rb +94 -316
  50. data/lib/tina4/request.rb +48 -8
  51. data/lib/tina4/response.rb +42 -1
  52. data/lib/tina4/response_cache.rb +142 -24
  53. data/lib/tina4/router.rb +141 -12
  54. data/lib/tina4/session.rb +243 -29
  55. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  56. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  57. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  58. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  59. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  60. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  61. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  62. data/lib/tina4/shutdown.rb +180 -30
  63. data/lib/tina4/sql_translator.rb +110 -0
  64. data/lib/tina4/swagger.rb +50 -18
  65. data/lib/tina4/version.rb +1 -1
  66. data/lib/tina4/webserver.rb +28 -6
  67. data/lib/tina4.rb +301 -38
  68. metadata +35 -17
  69. data/lib/tina4/scss_compiler.rb +0 -349
@@ -71,15 +71,46 @@ module Tina4
71
71
  # mail.send(to: "user@test.com", subject: "Welcome", body: "<h1>Hello!</h1>", html: true, text: "Hello!")
72
72
  #
73
73
  class Messenger
74
+ # Factory: returns a Messenger configured for the current environment.
75
+ #
76
+ # Returns ONE concrete type, always. It used to return either a Messenger or a
77
+ # DevMessengerProxy; both happened to expose #send, so Ruby escaped the crash that
78
+ # nodejs#41 describes by luck of naming rather than by design -- but the proxy's
79
+ # #send took no text: keyword, so the documented call raised ArgumentError on a dev
80
+ # messenger. Capture is now a branch inside Messenger#send.
81
+ #
82
+ # The gate is availability, not verbosity: capture when no SMTP host is configured,
83
+ # send when one is EVEN WITH TINA4_DEBUG ON, and TINA4_MAIL_CAPTURE forces capture.
84
+ def self.create_messenger(**options)
85
+ mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
86
+ messenger = Messenger.new(**options)
87
+ messenger.instance_variable_set(:@mailbox_dir, mailbox_dir)
88
+
89
+ # Attach the mailbox eagerly when this messenger will capture, so callers (and
90
+ # the dev dashboard) can inspect it before the first send.
91
+ messenger.dev_mailbox = DevMailbox.new(mailbox_dir: mailbox_dir) if messenger.should_capture?
92
+
93
+ messenger
94
+ end
95
+
74
96
  attr_reader :host, :port, :username, :from_address, :from_name,
75
97
  :imap_host, :imap_port, :use_tls, :encryption,
76
- :imap_encryption, :imap_use_tls
98
+ :imap_encryption, :imap_use_tls, :imap_username, :imap_password
77
99
 
78
100
  # Initialize with SMTP config.
79
101
  # Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults
80
102
  def initialize(host: nil, port: nil, username: nil, password: nil,
81
103
  from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
82
- 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)
106
+ # Whether a host was actually CONFIGURED, which is not the same as @host being
107
+ # set: it falls back to "localhost", so it is never nil and cannot answer
108
+ # "can this messenger send?". The capture gate needs that answer, so record it
109
+ # here while the real inputs are still in scope.
110
+ configured_host = host || ENV["TINA4_MAIL_HOST"]
111
+ @smtp_configured = !configured_host.nil? && !configured_host.to_s.empty?
112
+ @mailbox_dir = nil
113
+ @dev_mailbox = nil
83
114
  @host = host || ENV["TINA4_MAIL_HOST"] || "localhost"
84
115
  @port = (port || ENV["TINA4_MAIL_PORT"] || 587).to_i
85
116
  @username = username || ENV["TINA4_MAIL_USERNAME"]
@@ -109,12 +140,59 @@ module Tina4
109
140
  env_imap_enc = imap_encryption || ENV["TINA4_MAIL_IMAP_ENCRYPTION"]
110
141
  @imap_encryption = (env_imap_enc && !env_imap_enc.to_s.empty?) ? env_imap_enc.to_s.downcase : "tls"
111
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
112
151
  end
113
152
 
114
153
  # Send email using Ruby's Net::SMTP
115
154
  # Returns { success: true/false, message: "...", id: "..." }
155
+ # The local mailbox, present only once this messenger has captured something
156
+ # (or eagerly, when create_messenger knows it will).
157
+ attr_accessor :dev_mailbox
158
+
159
+ # Should send capture locally instead of talking to SMTP?
160
+ #
161
+ # Availability decides, not verbosity. With no SMTP host configured sending is
162
+ # impossible, so simulate it into a folder rather than failing -- that is what
163
+ # makes a laptop with no mail server usable, and it is the original Tina4
164
+ # "messages folder" behaviour restored. TINA4_MAIL_CAPTURE forces capture even
165
+ # when a host IS configured.
166
+ #
167
+ # TINA4_DEBUG deliberately does NOT gate this. Debug must still be able to send:
168
+ # tying capture to it means nobody can test a real send from a dev box. The old
169
+ # gate required debug AND no SMTP host, so a dev box with neither set went
170
+ # straight to localhost:587 and failed.
171
+ def should_capture?
172
+ return true if Tina4::Env.is_truthy(ENV["TINA4_MAIL_CAPTURE"])
173
+
174
+ !@smtp_configured
175
+ end
176
+
177
+ def dev_mailbox
178
+ @dev_mailbox ||= DevMailbox.new(mailbox_dir: @mailbox_dir)
179
+ end
180
+
116
181
  def send(to:, subject:, body:, html: false, text: nil, cc: [], bcc: [],
117
182
  reply_to: nil, attachments: [], headers: {})
183
+ # Dev capture is a BRANCH here, not a different object handed back by the
184
+ # factory. create_messenger used to return a DevMessengerProxy whose #send had
185
+ # no text: keyword at all, so the documented call raised ArgumentError and the
186
+ # plain-text alternative was silently dropped from the captured message.
187
+ if should_capture?
188
+ return dev_mailbox.capture(
189
+ to: to, subject: subject, body: body, html: html, text: text,
190
+ cc: cc, bcc: bcc, reply_to: reply_to,
191
+ from_address: @from_address, from_name: @from_name,
192
+ attachments: attachments
193
+ )
194
+ end
195
+
118
196
  message_id = "<#{SecureRandom.uuid}@#{@host}>"
119
197
  raw = build_message(
120
198
  to: to, subject: subject, body: body, html: html, text: text,
@@ -141,6 +219,24 @@ module Tina4
141
219
  { success: false, message: e.message, id: nil }
142
220
  end
143
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
+
144
240
  # Test SMTP connection
145
241
  # Returns { success: true/false, message: "..." }
146
242
  def test_connection
@@ -158,10 +254,21 @@ module Tina4
158
254
 
159
255
  # List messages in a folder.
160
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
+ #
161
264
  # Raises Tina4::MessengerConnectionError on a connection/auth/protocol
162
265
  # failure (FAILS LOUD — never returns [] to hide it). A successful fetch
163
266
  # from an empty folder returns [] (that is NOT an error).
164
- 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
+
165
272
  imap = imap_open("inbox")
166
273
  begin
167
274
  imap.select(folder)
@@ -170,8 +277,18 @@ module Tina4
170
277
  page = uids[offset, limit] || []
171
278
  return [] if page.empty?
172
279
 
173
- envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
174
- (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) }
175
292
  rescue *IMAP_CONNECTION_ERRORS => e
176
293
  raise imap_fail("inbox", e)
177
294
  ensure
@@ -181,9 +298,19 @@ module Tina4
181
298
 
182
299
  # Read a single message by UID.
183
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
+ #
184
308
  # Raises Tina4::MessengerConnectionError on a connection/protocol failure.
185
309
  # A successful fetch for a non-existent UID returns nil (that is NOT an error).
186
- 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
+
187
314
  imap = imap_open("read")
188
315
  begin
189
316
  imap.select(folder)
@@ -238,8 +365,15 @@ module Tina4
238
365
  page = uids[0, limit] || []
239
366
  return [] if page.empty?
240
367
 
241
- envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
242
- (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) }
243
377
  rescue *IMAP_CONNECTION_ERRORS => e
244
378
  raise imap_fail("search", e)
245
379
  ensure
@@ -275,6 +409,42 @@ module Tina4
275
409
  Tina4::Log.error("IMAP mark_read failed: #{e.message}")
276
410
  end
277
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
+
278
448
  # Test IMAP connectivity without reading messages.
279
449
  #
280
450
  # @return [Hash] { success: Boolean, message: String }
@@ -459,7 +629,7 @@ module Tina4
459
629
  # rather than swallowing the error into an empty result.
460
630
  def imap_open(method)
461
631
  imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
462
- imap.login(@username, @password)
632
+ imap.login(@imap_username, @imap_password)
463
633
  imap
464
634
  rescue *IMAP_CONNECTION_ERRORS => e
465
635
  raise imap_fail(method, e)
@@ -500,64 +670,145 @@ module Tina4
500
670
  # they can fail loud.
501
671
  def imap_connect(&block)
502
672
  imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
503
- imap.login(@username, @password)
673
+ imap.login(@imap_username, @imap_password)
504
674
  result = block.call(imap)
505
675
  imap.logout
506
676
  imap.disconnect
507
677
  result
508
678
  end
509
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.
510
685
  def parse_envelope(fetch_data)
511
686
  env = fetch_data.attr["ENVELOPE"]
512
687
  flags = fetch_data.attr["FLAGS"] || []
513
- size = fetch_data.attr["RFC822.SIZE"] || 0
688
+ raw_body = fetch_data.attr["BODY[]"] || ""
514
689
 
515
690
  {
516
- 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,
517
694
  subject: env.subject ? decode_mime_header(env.subject) : "",
518
- from: format_imap_address(env.from),
519
- to: format_imap_address(env.to),
520
- date: env.date,
521
- flags: flags.map(&:to_s),
522
- read: flags.include?(:Seen),
523
- 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)
524
700
  }
525
701
  end
526
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.
527
710
  def parse_full_message(fetch_data)
528
711
  env = fetch_data.attr["ENVELOPE"]
529
- flags = fetch_data.attr["FLAGS"] || []
530
712
  raw_body = fetch_data.attr["BODY[]"] || ""
531
713
 
532
714
  body_text, body_html = extract_body_parts(raw_body)
533
715
 
534
716
  {
535
- 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,
536
718
  subject: env.subject ? decode_mime_header(env.subject) : "",
537
- from: format_imap_address(env.from),
538
- to: format_imap_address(env.to),
539
- cc: format_imap_address(env.cc),
540
- date: env.date,
541
- message_id: env.message_id,
542
- flags: flags.map(&:to_s),
543
- read: flags.include?(:Seen),
544
- body: body_text,
545
- html: body_html,
546
- 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)
547
727
  }
548
728
  end
549
729
 
550
- def format_imap_address(addresses)
551
- 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?
552
735
 
553
736
  addresses.map do |addr|
554
737
  email = "#{addr.mailbox}@#{addr.host}"
555
- if addr.name && !addr.name.empty?
556
- { name: decode_mime_header(addr.name), email: email }
557
- else
558
- { 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]
559
809
  end
560
810
  end
811
+ headers
561
812
  end
562
813
 
563
814
  def decode_mime_header(value)
@@ -624,6 +875,36 @@ module Tina4
624
875
  end
625
876
  end
626
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
+
627
908
  def build_search_criteria(subject:, sender:, since:, before:, unseen_only:)
628
909
  criteria = []
629
910
  criteria.push("SUBJECT", subject) if subject
@@ -649,50 +930,4 @@ module Tina4
649
930
  end
650
931
  end
651
932
 
652
- # Factory: returns a DevMailbox-intercepting messenger in dev mode,
653
- # or a real Messenger in production.
654
- def self.create_messenger(**options)
655
- dev_mode = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
656
-
657
- smtp_configured = ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?
658
-
659
- if dev_mode && !smtp_configured
660
- mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
661
- mailbox = DevMailbox.new(mailbox_dir: mailbox_dir)
662
- DevMessengerProxy.new(mailbox, **options)
663
- else
664
- Messenger.new(**options)
665
- end
666
- end
667
-
668
- # Proxy that wraps DevMailbox with the same interface as Messenger#send
669
- class DevMessengerProxy
670
- attr_reader :mailbox
671
-
672
- def initialize(mailbox, **options)
673
- @mailbox = mailbox
674
- @from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] || "dev@localhost"
675
- @from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] || "Dev Mailer"
676
- end
677
-
678
- def send(to:, subject:, body:, html: false, cc: [], bcc: [],
679
- reply_to: nil, attachments: [], headers: {})
680
- @mailbox.capture(
681
- to: to, subject: subject, body: body, html: html,
682
- cc: cc, bcc: bcc, reply_to: reply_to,
683
- from_address: @from_address, from_name: @from_name,
684
- attachments: attachments
685
- )
686
- end
687
-
688
- def test_connection
689
- { success: true, message: "DevMailbox mode — no SMTP connection needed" }
690
- end
691
-
692
- def inbox(**args) = @mailbox.inbox(**args)
693
- def read(...) = @mailbox.read(...)
694
- def unread(...) = @mailbox.unread_count
695
- def search(**args) = @mailbox.inbox(**args)
696
- def folders = ["inbox", "outbox"]
697
- end
698
933
  end