tina4ruby 0.5.2 → 3.2.1

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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +434 -544
  4. data/exe/{tina4 → tina4ruby} +1 -0
  5. data/lib/tina4/ai.rb +312 -0
  6. data/lib/tina4/auth.rb +44 -3
  7. data/lib/tina4/auto_crud.rb +163 -0
  8. data/lib/tina4/cli.rb +389 -97
  9. data/lib/tina4/constants.rb +46 -0
  10. data/lib/tina4/cors.rb +74 -0
  11. data/lib/tina4/database/sqlite3_adapter.rb +139 -0
  12. data/lib/tina4/database.rb +144 -7
  13. data/lib/tina4/debug.rb +4 -79
  14. data/lib/tina4/dev_admin.rb +1162 -0
  15. data/lib/tina4/dev_mailbox.rb +191 -0
  16. data/lib/tina4/dev_reload.rb +9 -9
  17. data/lib/tina4/drivers/firebird_driver.rb +19 -3
  18. data/lib/tina4/drivers/mssql_driver.rb +3 -3
  19. data/lib/tina4/drivers/mysql_driver.rb +4 -4
  20. data/lib/tina4/drivers/postgres_driver.rb +9 -2
  21. data/lib/tina4/drivers/sqlite_driver.rb +1 -1
  22. data/lib/tina4/env.rb +42 -2
  23. data/lib/tina4/error_overlay.rb +252 -0
  24. data/lib/tina4/events.rb +90 -0
  25. data/lib/tina4/field_types.rb +4 -0
  26. data/lib/tina4/frond.rb +1497 -0
  27. data/lib/tina4/gallery/auth/meta.json +1 -0
  28. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
  29. data/lib/tina4/gallery/database/meta.json +1 -0
  30. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
  31. data/lib/tina4/gallery/error-overlay/meta.json +1 -0
  32. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
  33. data/lib/tina4/gallery/orm/meta.json +1 -0
  34. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
  35. data/lib/tina4/gallery/queue/meta.json +1 -0
  36. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -0
  37. data/lib/tina4/gallery/rest-api/meta.json +1 -0
  38. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
  39. data/lib/tina4/gallery/templates/meta.json +1 -0
  40. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
  41. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
  42. data/lib/tina4/health.rb +39 -0
  43. data/lib/tina4/html_element.rb +148 -0
  44. data/lib/tina4/localization.rb +2 -2
  45. data/lib/tina4/log.rb +203 -0
  46. data/lib/tina4/messenger.rb +562 -0
  47. data/lib/tina4/migration.rb +132 -29
  48. data/lib/tina4/orm.rb +463 -35
  49. data/lib/tina4/public/css/tina4.css +178 -1
  50. data/lib/tina4/public/css/tina4.min.css +1 -2
  51. data/lib/tina4/public/favicon.ico +0 -0
  52. data/lib/tina4/public/images/logo.svg +5 -0
  53. data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
  54. data/lib/tina4/public/js/frond.min.js +420 -0
  55. data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
  56. data/lib/tina4/public/js/tina4.min.js +93 -0
  57. data/lib/tina4/public/swagger/index.html +90 -0
  58. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
  59. data/lib/tina4/queue.rb +162 -6
  60. data/lib/tina4/queue_backends/lite_backend.rb +88 -0
  61. data/lib/tina4/rack_app.rb +331 -27
  62. data/lib/tina4/rate_limiter.rb +123 -0
  63. data/lib/tina4/request.rb +61 -15
  64. data/lib/tina4/response.rb +54 -24
  65. data/lib/tina4/response_cache.rb +551 -0
  66. data/lib/tina4/router.rb +90 -15
  67. data/lib/tina4/scss_compiler.rb +2 -2
  68. data/lib/tina4/seeder.rb +56 -61
  69. data/lib/tina4/service_runner.rb +303 -0
  70. data/lib/tina4/session.rb +85 -0
  71. data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
  72. data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
  73. data/lib/tina4/shutdown.rb +84 -0
  74. data/lib/tina4/sql_translation.rb +295 -0
  75. data/lib/tina4/template.rb +36 -6
  76. data/lib/tina4/templates/base.twig +2 -2
  77. data/lib/tina4/templates/errors/302.twig +14 -0
  78. data/lib/tina4/templates/errors/401.twig +9 -0
  79. data/lib/tina4/templates/errors/403.twig +22 -15
  80. data/lib/tina4/templates/errors/404.twig +22 -15
  81. data/lib/tina4/templates/errors/500.twig +31 -15
  82. data/lib/tina4/templates/errors/502.twig +9 -0
  83. data/lib/tina4/templates/errors/503.twig +12 -0
  84. data/lib/tina4/templates/errors/base.twig +37 -0
  85. data/lib/tina4/version.rb +1 -1
  86. data/lib/tina4/webserver.rb +28 -18
  87. data/lib/tina4.rb +118 -21
  88. metadata +68 -8
  89. data/lib/tina4/public/js/tina4.js +0 -134
  90. data/lib/tina4/public/js/tina4helper.js +0 -387
@@ -0,0 +1,562 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "net/smtp"
5
+ rescue LoadError
6
+ # net-smtp gem needed on Ruby >= 4.0: gem install net-smtp
7
+ end
8
+ begin
9
+ require "net/imap"
10
+ rescue LoadError
11
+ # net-imap gem needed on Ruby >= 4.0: gem install net-imap
12
+ end
13
+ require "base64"
14
+ require "securerandom"
15
+ require "time"
16
+
17
+ module Tina4
18
+ # Tina4 Messenger — Email sending (SMTP) and reading (IMAP).
19
+ #
20
+ # Unified .env-driven configuration with constructor override.
21
+ # Priority: constructor params > .env (TINA4_MAIL_* with SMTP_* fallback) > sensible defaults
22
+ #
23
+ # # .env
24
+ # TINA4_MAIL_HOST=smtp.gmail.com
25
+ # TINA4_MAIL_PORT=587
26
+ # TINA4_MAIL_USERNAME=user@gmail.com
27
+ # TINA4_MAIL_PASSWORD=app-password
28
+ # TINA4_MAIL_FROM=noreply@myapp.com
29
+ # TINA4_MAIL_ENCRYPTION=tls
30
+ # TINA4_MAIL_IMAP_HOST=imap.gmail.com
31
+ # TINA4_MAIL_IMAP_PORT=993
32
+ #
33
+ # mail = Messenger.new # reads from .env
34
+ # mail = Messenger.new(host: "smtp.office365.com", port: 587) # override
35
+ # mail.send(to: "user@test.com", subject: "Welcome", body: "<h1>Hello!</h1>", html: true, text: "Hello!")
36
+ #
37
+ class Messenger
38
+ attr_reader :host, :port, :username, :from_address, :from_name,
39
+ :imap_host, :imap_port, :use_tls, :encryption
40
+
41
+ # Initialize with SMTP config.
42
+ # Priority: constructor params > ENV (TINA4_MAIL_* with SMTP_* fallback) > sensible defaults
43
+ def initialize(host: nil, port: nil, username: nil, password: nil,
44
+ from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
45
+ imap_host: nil, imap_port: nil)
46
+ @host = host || ENV["TINA4_MAIL_HOST"] || ENV["SMTP_HOST"] || "localhost"
47
+ @port = (port || ENV["TINA4_MAIL_PORT"] || ENV["SMTP_PORT"] || 587).to_i
48
+ @username = username || ENV["TINA4_MAIL_USERNAME"] || ENV["SMTP_USERNAME"]
49
+ @password = password || ENV["TINA4_MAIL_PASSWORD"] || ENV["SMTP_PASSWORD"]
50
+
51
+ resolved_from = from_address || ENV["TINA4_MAIL_FROM"] || ENV["SMTP_FROM"]
52
+ @from_address = resolved_from || @username || "noreply@localhost"
53
+
54
+ @from_name = from_name || ENV["TINA4_MAIL_FROM_NAME"] || ENV["SMTP_FROM_NAME"] || ""
55
+
56
+ # Encryption: constructor > .env > backward-compat use_tls > default "tls"
57
+ env_encryption = encryption || ENV["TINA4_MAIL_ENCRYPTION"]
58
+ if env_encryption
59
+ @encryption = env_encryption.downcase
60
+ elsif !use_tls.nil?
61
+ @encryption = use_tls ? "tls" : "none"
62
+ else
63
+ @encryption = "tls"
64
+ end
65
+ @use_tls = %w[tls starttls].include?(@encryption)
66
+
67
+ @imap_host = imap_host || ENV["TINA4_MAIL_IMAP_HOST"] || ENV["IMAP_HOST"] || @host
68
+ @imap_port = (imap_port || ENV["TINA4_MAIL_IMAP_PORT"] || ENV["IMAP_PORT"] || 993).to_i
69
+ end
70
+
71
+ # Send email using Ruby's Net::SMTP
72
+ # Returns { success: true/false, message: "...", id: "..." }
73
+ def send(to:, subject:, body:, html: false, text: nil, cc: [], bcc: [],
74
+ reply_to: nil, attachments: [], headers: {})
75
+ message_id = "<#{SecureRandom.uuid}@#{@host}>"
76
+ raw = build_message(
77
+ to: to, subject: subject, body: body, html: html, text: text,
78
+ cc: cc, bcc: bcc, reply_to: reply_to,
79
+ attachments: attachments, headers: headers,
80
+ message_id: message_id
81
+ )
82
+
83
+ all_recipients = normalize_recipients(to) +
84
+ normalize_recipients(cc) +
85
+ normalize_recipients(bcc)
86
+
87
+ smtp = Net::SMTP.new(@host, @port)
88
+ smtp.enable_starttls if @use_tls
89
+
90
+ smtp.start(@host, @username, @password, auth_method) do |conn|
91
+ conn.send_message(raw, @from_address, all_recipients)
92
+ end
93
+
94
+ Tina4::Log.info("Email sent to #{Array(to).join(', ')}: #{subject}")
95
+ { success: true, message: "Email sent successfully", id: message_id }
96
+ rescue => e
97
+ Tina4::Log.error("Email send failed: #{e.message}")
98
+ { success: false, message: e.message, id: nil }
99
+ end
100
+
101
+ # Test SMTP connection
102
+ # Returns { success: true/false, message: "..." }
103
+ def test_connection
104
+ smtp = Net::SMTP.new(@host, @port)
105
+ smtp.enable_starttls if @use_tls
106
+ smtp.start(@host, @username, @password, auth_method) do |_conn|
107
+ # connection succeeded
108
+ end
109
+ { success: true, message: "SMTP connection successful" }
110
+ rescue => e
111
+ { success: false, message: e.message }
112
+ end
113
+
114
+ # ── IMAP operations ──────────────────────────────────────────────────
115
+
116
+ # List messages in a folder
117
+ def inbox(folder: "INBOX", limit: 20, offset: 0)
118
+ imap_connect do |imap|
119
+ imap.select(folder)
120
+ uids = imap.uid_search(["ALL"])
121
+ uids = uids.reverse # newest first
122
+ page = uids[offset, limit] || []
123
+ return [] if page.empty?
124
+
125
+ envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
126
+ (envelopes || []).map { |msg| parse_envelope(msg) }
127
+ end
128
+ rescue => e
129
+ Tina4::Log.error("IMAP inbox failed: #{e.message}")
130
+ []
131
+ end
132
+
133
+ # Read a single message by UID
134
+ def read(uid, folder: "INBOX", mark_read: true)
135
+ imap_connect do |imap|
136
+ imap.select(folder)
137
+ data = imap.uid_fetch(uid, ["ENVELOPE", "FLAGS", "BODY[]", "RFC822.SIZE"])
138
+ return nil if data.nil? || data.empty?
139
+
140
+ if mark_read
141
+ imap.uid_store(uid, "+FLAGS", [:Seen])
142
+ end
143
+
144
+ msg = data.first
145
+ parse_full_message(msg)
146
+ end
147
+ rescue => e
148
+ Tina4::Log.error("IMAP read failed: #{e.message}")
149
+ nil
150
+ end
151
+
152
+ # Count unread messages
153
+ def unread(folder: "INBOX")
154
+ imap_connect do |imap|
155
+ imap.select(folder)
156
+ uids = imap.uid_search(["UNSEEN"])
157
+ uids.length
158
+ end
159
+ rescue => e
160
+ Tina4::Log.error("IMAP unread count failed: #{e.message}")
161
+ 0
162
+ end
163
+
164
+ # Search messages with filters
165
+ def search(folder: "INBOX", subject: nil, sender: nil, since: nil,
166
+ before: nil, unseen_only: false, limit: 20)
167
+ imap_connect do |imap|
168
+ imap.select(folder)
169
+ criteria = build_search_criteria(
170
+ subject: subject, sender: sender, since: since,
171
+ before: before, unseen_only: unseen_only
172
+ )
173
+ uids = imap.uid_search(criteria)
174
+ uids = uids.reverse
175
+ page = uids[0, limit] || []
176
+ return [] if page.empty?
177
+
178
+ envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
179
+ (envelopes || []).map { |msg| parse_envelope(msg) }
180
+ end
181
+ rescue => e
182
+ Tina4::Log.error("IMAP search failed: #{e.message}")
183
+ []
184
+ end
185
+
186
+ # List all IMAP folders
187
+ def folders
188
+ imap_connect do |imap|
189
+ boxes = imap.list("", "*")
190
+ (boxes || []).map(&:name)
191
+ end
192
+ rescue => e
193
+ Tina4::Log.error("IMAP folders failed: #{e.message}")
194
+ []
195
+ end
196
+
197
+ private
198
+
199
+ # ── SMTP helpers ─────────────────────────────────────────────────────
200
+
201
+ def auth_method
202
+ return :plain if @username && @password
203
+
204
+ nil
205
+ end
206
+
207
+ def normalize_recipients(value)
208
+ case value
209
+ when nil then []
210
+ when String then [value]
211
+ when Array then value.flatten.compact
212
+ else [value.to_s]
213
+ end
214
+ end
215
+
216
+ def format_address(address, name = nil)
217
+ if name && !name.empty?
218
+ "#{name} <#{address}>"
219
+ else
220
+ address
221
+ end
222
+ end
223
+
224
+ def build_message(to:, subject:, body:, html:, text: nil, cc:, bcc:, reply_to:,
225
+ attachments:, headers:, message_id:)
226
+ boundary = "----=_Tina4_#{SecureRandom.hex(16)}"
227
+ alt_boundary = "----=_Tina4Alt_#{SecureRandom.hex(16)}"
228
+ date = Time.now.strftime("%a, %d %b %Y %H:%M:%S %z")
229
+ has_text_alt = !text.nil? && html
230
+
231
+ parts = []
232
+ parts << "From: #{format_address(@from_address, @from_name)}"
233
+ parts << "To: #{Array(to).join(', ')}"
234
+ parts << "Cc: #{Array(cc).join(', ')}" unless Array(cc).empty?
235
+ parts << "Subject: #{encode_header(subject)}"
236
+ parts << "Date: #{date}"
237
+ parts << "Message-ID: #{message_id}"
238
+ parts << "MIME-Version: 1.0"
239
+ parts << "Reply-To: #{reply_to}" if reply_to
240
+
241
+ headers.each { |k, v| parts << "#{k}: #{v}" }
242
+
243
+ if !attachments.empty?
244
+ parts << "Content-Type: multipart/mixed; boundary=\"#{boundary}\""
245
+ parts << ""
246
+ parts << "--#{boundary}"
247
+
248
+ # Body part (with optional text alternative)
249
+ if has_text_alt
250
+ parts << "Content-Type: multipart/alternative; boundary=\"#{alt_boundary}\""
251
+ parts << ""
252
+ parts << "--#{alt_boundary}"
253
+ parts << "Content-Type: text/plain; charset=UTF-8"
254
+ parts << "Content-Transfer-Encoding: base64"
255
+ parts << ""
256
+ parts << Base64.encode64(text)
257
+ parts << "--#{alt_boundary}"
258
+ parts << "Content-Type: text/html; charset=UTF-8"
259
+ parts << "Content-Transfer-Encoding: base64"
260
+ parts << ""
261
+ parts << Base64.encode64(body)
262
+ parts << "--#{alt_boundary}--"
263
+ else
264
+ content_type = html ? "text/html" : "text/plain"
265
+ parts << "Content-Type: #{content_type}; charset=UTF-8"
266
+ parts << "Content-Transfer-Encoding: base64"
267
+ parts << ""
268
+ parts << Base64.encode64(body)
269
+ end
270
+
271
+ # Attachment parts
272
+ attachments.each do |attachment|
273
+ parts << "--#{boundary}"
274
+ parts.concat(build_attachment_part(attachment))
275
+ end
276
+ parts << "--#{boundary}--"
277
+ elsif has_text_alt
278
+ # Text alternative without attachments
279
+ parts << "Content-Type: multipart/alternative; boundary=\"#{alt_boundary}\""
280
+ parts << ""
281
+ parts << "--#{alt_boundary}"
282
+ parts << "Content-Type: text/plain; charset=UTF-8"
283
+ parts << "Content-Transfer-Encoding: base64"
284
+ parts << ""
285
+ parts << Base64.encode64(text)
286
+ parts << "--#{alt_boundary}"
287
+ parts << "Content-Type: text/html; charset=UTF-8"
288
+ parts << "Content-Transfer-Encoding: base64"
289
+ parts << ""
290
+ parts << Base64.encode64(body)
291
+ parts << "--#{alt_boundary}--"
292
+ else
293
+ content_type = html ? "text/html" : "text/plain"
294
+ parts << "Content-Type: #{content_type}; charset=UTF-8"
295
+ parts << "Content-Transfer-Encoding: base64"
296
+ parts << ""
297
+ parts << Base64.encode64(body)
298
+ end
299
+
300
+ parts.join("\r\n")
301
+ end
302
+
303
+ def build_attachment_part(attachment)
304
+ lines = []
305
+ if attachment.is_a?(Hash)
306
+ filename = attachment[:filename] || attachment[:name] || "attachment"
307
+ content = attachment[:content] || ""
308
+ mime = attachment[:mime_type] || attachment[:content_type] || "application/octet-stream"
309
+ elsif attachment.is_a?(String) && File.exist?(attachment)
310
+ filename = File.basename(attachment)
311
+ content = File.binread(attachment)
312
+ mime = guess_mime_type(filename)
313
+ else
314
+ return []
315
+ end
316
+
317
+ encoded = content.is_a?(String) && !content.ascii_only? ? Base64.encode64(content) : Base64.encode64(content.to_s)
318
+
319
+ lines << "Content-Type: #{mime}; name=\"#{filename}\""
320
+ lines << "Content-Disposition: attachment; filename=\"#{filename}\""
321
+ lines << "Content-Transfer-Encoding: base64"
322
+ lines << ""
323
+ lines << encoded
324
+ lines
325
+ end
326
+
327
+ def encode_header(value)
328
+ if value.ascii_only?
329
+ value
330
+ else
331
+ "=?UTF-8?B?#{Base64.strict_encode64(value)}?="
332
+ end
333
+ end
334
+
335
+ def guess_mime_type(filename)
336
+ ext = File.extname(filename).downcase
337
+ {
338
+ ".txt" => "text/plain",
339
+ ".html" => "text/html",
340
+ ".htm" => "text/html",
341
+ ".css" => "text/css",
342
+ ".js" => "application/javascript",
343
+ ".json" => "application/json",
344
+ ".xml" => "application/xml",
345
+ ".pdf" => "application/pdf",
346
+ ".zip" => "application/zip",
347
+ ".gz" => "application/gzip",
348
+ ".tar" => "application/x-tar",
349
+ ".png" => "image/png",
350
+ ".jpg" => "image/jpeg",
351
+ ".jpeg" => "image/jpeg",
352
+ ".gif" => "image/gif",
353
+ ".svg" => "image/svg+xml",
354
+ ".csv" => "text/csv",
355
+ ".doc" => "application/msword",
356
+ ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
357
+ ".xls" => "application/vnd.ms-excel",
358
+ ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
359
+ }.fetch(ext, "application/octet-stream")
360
+ end
361
+
362
+ # ── IMAP helpers ─────────────────────────────────────────────────────
363
+
364
+ def imap_connect(&block)
365
+ imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @use_tls)
366
+ imap.login(@username, @password)
367
+ result = block.call(imap)
368
+ imap.logout
369
+ imap.disconnect
370
+ result
371
+ end
372
+
373
+ def parse_envelope(fetch_data)
374
+ env = fetch_data.attr["ENVELOPE"]
375
+ flags = fetch_data.attr["FLAGS"] || []
376
+ size = fetch_data.attr["RFC822.SIZE"] || 0
377
+
378
+ {
379
+ uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"] : nil,
380
+ subject: env.subject ? decode_mime_header(env.subject) : "",
381
+ from: format_imap_address(env.from),
382
+ to: format_imap_address(env.to),
383
+ date: env.date,
384
+ flags: flags.map(&:to_s),
385
+ read: flags.include?(:Seen),
386
+ size: size
387
+ }
388
+ end
389
+
390
+ def parse_full_message(fetch_data)
391
+ env = fetch_data.attr["ENVELOPE"]
392
+ flags = fetch_data.attr["FLAGS"] || []
393
+ raw_body = fetch_data.attr["BODY[]"] || ""
394
+
395
+ body_text, body_html = extract_body_parts(raw_body)
396
+
397
+ {
398
+ uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"] : nil,
399
+ subject: env.subject ? decode_mime_header(env.subject) : "",
400
+ from: format_imap_address(env.from),
401
+ to: format_imap_address(env.to),
402
+ cc: format_imap_address(env.cc),
403
+ date: env.date,
404
+ message_id: env.message_id,
405
+ flags: flags.map(&:to_s),
406
+ read: flags.include?(:Seen),
407
+ body: body_text,
408
+ html: body_html,
409
+ raw: raw_body
410
+ }
411
+ end
412
+
413
+ def format_imap_address(addresses)
414
+ return [] if addresses.nil?
415
+
416
+ addresses.map do |addr|
417
+ email = "#{addr.mailbox}@#{addr.host}"
418
+ if addr.name && !addr.name.empty?
419
+ { name: decode_mime_header(addr.name), email: email }
420
+ else
421
+ { name: nil, email: email }
422
+ end
423
+ end
424
+ end
425
+
426
+ def decode_mime_header(value)
427
+ return "" if value.nil?
428
+
429
+ value.gsub(/=\?([^?]+)\?([BbQq])\?([^?]+)\?=/) do
430
+ charset = Regexp.last_match(1)
431
+ encoding = Regexp.last_match(2).upcase
432
+ encoded = Regexp.last_match(3)
433
+
434
+ decoded = case encoding
435
+ when "B"
436
+ Base64.decode64(encoded)
437
+ when "Q"
438
+ encoded.gsub("_", " ").gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
439
+ else
440
+ encoded
441
+ end
442
+
443
+ decoded.force_encoding(charset).encode("UTF-8", invalid: :replace, undef: :replace)
444
+ end
445
+ end
446
+
447
+ def extract_body_parts(raw)
448
+ text_body = nil
449
+ html_body = nil
450
+
451
+ # Check for multipart
452
+ if raw =~ /Content-Type:\s*multipart\/\w+;\s*boundary="?([^"\s;]+)"?/i
453
+ boundary = Regexp.last_match(1)
454
+ parts = raw.split("--#{boundary}")
455
+ parts.each do |part|
456
+ next if part.strip == "" || part.strip == "--"
457
+
458
+ if part =~ /Content-Type:\s*text\/plain/i
459
+ text_body = extract_part_body(part)
460
+ elsif part =~ /Content-Type:\s*text\/html/i
461
+ html_body = extract_part_body(part)
462
+ end
463
+ end
464
+ elsif raw =~ /Content-Type:\s*text\/html/i
465
+ html_body = extract_part_body(raw)
466
+ else
467
+ text_body = extract_part_body(raw)
468
+ end
469
+
470
+ [text_body || "", html_body || ""]
471
+ end
472
+
473
+ def extract_part_body(part)
474
+ # Split headers from body at double CRLF or double LF
475
+ header_body = part.split(/\r?\n\r?\n/, 2)
476
+ return "" unless header_body.length > 1
477
+
478
+ body = header_body[1].strip
479
+ headers = header_body[0]
480
+
481
+ if headers =~ /Content-Transfer-Encoding:\s*base64/i
482
+ Base64.decode64(body).force_encoding("UTF-8")
483
+ elsif headers =~ /Content-Transfer-Encoding:\s*quoted-printable/i
484
+ body.gsub(/=\r?\n/, "").gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
485
+ else
486
+ body
487
+ end
488
+ end
489
+
490
+ def build_search_criteria(subject:, sender:, since:, before:, unseen_only:)
491
+ criteria = []
492
+ criteria.push("SUBJECT", subject) if subject
493
+ criteria.push("FROM", sender) if sender
494
+ criteria.push("SINCE", format_imap_date(since)) if since
495
+ criteria.push("BEFORE", format_imap_date(before)) if before
496
+ criteria << "UNSEEN" if unseen_only
497
+ criteria << "ALL" if criteria.empty?
498
+ criteria
499
+ end
500
+
501
+ def format_imap_date(date)
502
+ case date
503
+ when Time, DateTime
504
+ date.strftime("%d-%b-%Y")
505
+ when Date
506
+ date.strftime("%d-%b-%Y")
507
+ when String
508
+ date
509
+ else
510
+ date.to_s
511
+ end
512
+ end
513
+ end
514
+
515
+ # Factory: returns a DevMailbox-intercepting messenger in dev mode,
516
+ # or a real Messenger in production.
517
+ def self.create_messenger(**options)
518
+ dev_mode = Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
519
+
520
+ smtp_configured = (ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?) ||
521
+ (ENV["SMTP_HOST"] && !ENV["SMTP_HOST"].empty?)
522
+
523
+ if dev_mode && !smtp_configured
524
+ mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
525
+ mailbox = DevMailbox.new(mailbox_dir: mailbox_dir)
526
+ DevMessengerProxy.new(mailbox, **options)
527
+ else
528
+ Messenger.new(**options)
529
+ end
530
+ end
531
+
532
+ # Proxy that wraps DevMailbox with the same interface as Messenger#send
533
+ class DevMessengerProxy
534
+ attr_reader :mailbox
535
+
536
+ def initialize(mailbox, **options)
537
+ @mailbox = mailbox
538
+ @from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] || ENV["SMTP_FROM"] || "dev@localhost"
539
+ @from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] || ENV["SMTP_FROM_NAME"] || "Dev Mailer"
540
+ end
541
+
542
+ def send(to:, subject:, body:, html: false, cc: [], bcc: [],
543
+ reply_to: nil, attachments: [], headers: {})
544
+ @mailbox.capture(
545
+ to: to, subject: subject, body: body, html: html,
546
+ cc: cc, bcc: bcc, reply_to: reply_to,
547
+ from_address: @from_address, from_name: @from_name,
548
+ attachments: attachments
549
+ )
550
+ end
551
+
552
+ def test_connection
553
+ { success: true, message: "DevMailbox mode — no SMTP connection needed" }
554
+ end
555
+
556
+ def inbox(**args) = @mailbox.inbox(**args)
557
+ def read(...) = @mailbox.read(...)
558
+ def unread(...) = @mailbox.unread_count
559
+ def search(**args) = @mailbox.inbox(**args)
560
+ def folders = ["inbox", "outbox"]
561
+ end
562
+ end