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