tina4ruby 3.11.13 → 3.11.15

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
@@ -1,587 +1,587 @@
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
- # Mark a message as read (set \Seen flag).
198
- #
199
- # @param uid [String, Integer] message UID
200
- # @param folder [String] IMAP folder name
201
- def mark_read(uid, folder: "INBOX")
202
- imap_connect do |imap|
203
- imap.select(folder)
204
- imap.uid_store(uid.to_i, "+FLAGS", [:Seen])
205
- end
206
- rescue => e
207
- Tina4::Log.error("IMAP mark_read failed: #{e.message}")
208
- end
209
-
210
- # Test IMAP connectivity without reading messages.
211
- #
212
- # @return [Hash] { success: Boolean, message: String }
213
- def test_imap_connection
214
- imap_connect do |_imap|
215
- # Connection succeeded
216
- end
217
- { success: true, message: "Connected to #{@imap_host}:#{@imap_port}" }
218
- rescue => e
219
- { success: false, message: "IMAP connection failed: #{e.message}" }
220
- end
221
-
222
- private
223
-
224
- # ── SMTP helpers ─────────────────────────────────────────────────────
225
-
226
- def auth_method
227
- return :plain if @username && @password
228
-
229
- nil
230
- end
231
-
232
- def normalize_recipients(value)
233
- case value
234
- when nil then []
235
- when String then [value]
236
- when Array then value.flatten.compact
237
- else [value.to_s]
238
- end
239
- end
240
-
241
- def format_address(address, name = nil)
242
- if name && !name.empty?
243
- "#{name} <#{address}>"
244
- else
245
- address
246
- end
247
- end
248
-
249
- def build_message(to:, subject:, body:, html:, text: nil, cc:, bcc:, reply_to:,
250
- attachments:, headers:, message_id:)
251
- boundary = "----=_Tina4_#{SecureRandom.hex(16)}"
252
- alt_boundary = "----=_Tina4Alt_#{SecureRandom.hex(16)}"
253
- date = Time.now.strftime("%a, %d %b %Y %H:%M:%S %z")
254
- has_text_alt = !text.nil? && html
255
-
256
- parts = []
257
- parts << "From: #{format_address(@from_address, @from_name)}"
258
- parts << "To: #{Array(to).join(', ')}"
259
- parts << "Cc: #{Array(cc).join(', ')}" unless Array(cc).empty?
260
- parts << "Subject: #{encode_header(subject)}"
261
- parts << "Date: #{date}"
262
- parts << "Message-ID: #{message_id}"
263
- parts << "MIME-Version: 1.0"
264
- parts << "Reply-To: #{reply_to}" if reply_to
265
-
266
- headers.each { |k, v| parts << "#{k}: #{v}" }
267
-
268
- if !attachments.empty?
269
- parts << "Content-Type: multipart/mixed; boundary=\"#{boundary}\""
270
- parts << ""
271
- parts << "--#{boundary}"
272
-
273
- # Body part (with optional text alternative)
274
- if has_text_alt
275
- parts << "Content-Type: multipart/alternative; boundary=\"#{alt_boundary}\""
276
- parts << ""
277
- parts << "--#{alt_boundary}"
278
- parts << "Content-Type: text/plain; charset=UTF-8"
279
- parts << "Content-Transfer-Encoding: base64"
280
- parts << ""
281
- parts << Base64.encode64(text)
282
- parts << "--#{alt_boundary}"
283
- parts << "Content-Type: text/html; charset=UTF-8"
284
- parts << "Content-Transfer-Encoding: base64"
285
- parts << ""
286
- parts << Base64.encode64(body)
287
- parts << "--#{alt_boundary}--"
288
- else
289
- content_type = html ? "text/html" : "text/plain"
290
- parts << "Content-Type: #{content_type}; charset=UTF-8"
291
- parts << "Content-Transfer-Encoding: base64"
292
- parts << ""
293
- parts << Base64.encode64(body)
294
- end
295
-
296
- # Attachment parts
297
- attachments.each do |attachment|
298
- parts << "--#{boundary}"
299
- parts.concat(build_attachment_part(attachment))
300
- end
301
- parts << "--#{boundary}--"
302
- elsif has_text_alt
303
- # Text alternative without attachments
304
- parts << "Content-Type: multipart/alternative; boundary=\"#{alt_boundary}\""
305
- parts << ""
306
- parts << "--#{alt_boundary}"
307
- parts << "Content-Type: text/plain; charset=UTF-8"
308
- parts << "Content-Transfer-Encoding: base64"
309
- parts << ""
310
- parts << Base64.encode64(text)
311
- parts << "--#{alt_boundary}"
312
- parts << "Content-Type: text/html; charset=UTF-8"
313
- parts << "Content-Transfer-Encoding: base64"
314
- parts << ""
315
- parts << Base64.encode64(body)
316
- parts << "--#{alt_boundary}--"
317
- else
318
- content_type = html ? "text/html" : "text/plain"
319
- parts << "Content-Type: #{content_type}; charset=UTF-8"
320
- parts << "Content-Transfer-Encoding: base64"
321
- parts << ""
322
- parts << Base64.encode64(body)
323
- end
324
-
325
- parts.join("\r\n")
326
- end
327
-
328
- def build_attachment_part(attachment)
329
- lines = []
330
- if attachment.is_a?(Hash)
331
- filename = attachment[:filename] || attachment[:name] || "attachment"
332
- content = attachment[:content] || ""
333
- mime = attachment[:mime_type] || attachment[:content_type] || "application/octet-stream"
334
- elsif attachment.is_a?(String) && File.exist?(attachment)
335
- filename = File.basename(attachment)
336
- content = File.binread(attachment)
337
- mime = guess_mime_type(filename)
338
- else
339
- return []
340
- end
341
-
342
- encoded = content.is_a?(String) && !content.ascii_only? ? Base64.encode64(content) : Base64.encode64(content.to_s)
343
-
344
- lines << "Content-Type: #{mime}; name=\"#{filename}\""
345
- lines << "Content-Disposition: attachment; filename=\"#{filename}\""
346
- lines << "Content-Transfer-Encoding: base64"
347
- lines << ""
348
- lines << encoded
349
- lines
350
- end
351
-
352
- def encode_header(value)
353
- if value.ascii_only?
354
- value
355
- else
356
- "=?UTF-8?B?#{Base64.strict_encode64(value)}?="
357
- end
358
- end
359
-
360
- def guess_mime_type(filename)
361
- ext = File.extname(filename).downcase
362
- {
363
- ".txt" => "text/plain",
364
- ".html" => "text/html",
365
- ".htm" => "text/html",
366
- ".css" => "text/css",
367
- ".js" => "application/javascript",
368
- ".json" => "application/json",
369
- ".xml" => "application/xml",
370
- ".pdf" => "application/pdf",
371
- ".zip" => "application/zip",
372
- ".gz" => "application/gzip",
373
- ".tar" => "application/x-tar",
374
- ".png" => "image/png",
375
- ".jpg" => "image/jpeg",
376
- ".jpeg" => "image/jpeg",
377
- ".gif" => "image/gif",
378
- ".svg" => "image/svg+xml",
379
- ".csv" => "text/csv",
380
- ".doc" => "application/msword",
381
- ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
382
- ".xls" => "application/vnd.ms-excel",
383
- ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
384
- }.fetch(ext, "application/octet-stream")
385
- end
386
-
387
- # ── IMAP helpers ─────────────────────────────────────────────────────
388
-
389
- def imap_connect(&block)
390
- imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @use_tls)
391
- imap.login(@username, @password)
392
- result = block.call(imap)
393
- imap.logout
394
- imap.disconnect
395
- result
396
- end
397
-
398
- def parse_envelope(fetch_data)
399
- env = fetch_data.attr["ENVELOPE"]
400
- flags = fetch_data.attr["FLAGS"] || []
401
- size = fetch_data.attr["RFC822.SIZE"] || 0
402
-
403
- {
404
- uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"] : nil,
405
- subject: env.subject ? decode_mime_header(env.subject) : "",
406
- from: format_imap_address(env.from),
407
- to: format_imap_address(env.to),
408
- date: env.date,
409
- flags: flags.map(&:to_s),
410
- read: flags.include?(:Seen),
411
- size: size
412
- }
413
- end
414
-
415
- def parse_full_message(fetch_data)
416
- env = fetch_data.attr["ENVELOPE"]
417
- flags = fetch_data.attr["FLAGS"] || []
418
- raw_body = fetch_data.attr["BODY[]"] || ""
419
-
420
- body_text, body_html = extract_body_parts(raw_body)
421
-
422
- {
423
- uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"] : nil,
424
- subject: env.subject ? decode_mime_header(env.subject) : "",
425
- from: format_imap_address(env.from),
426
- to: format_imap_address(env.to),
427
- cc: format_imap_address(env.cc),
428
- date: env.date,
429
- message_id: env.message_id,
430
- flags: flags.map(&:to_s),
431
- read: flags.include?(:Seen),
432
- body: body_text,
433
- html: body_html,
434
- raw: raw_body
435
- }
436
- end
437
-
438
- def format_imap_address(addresses)
439
- return [] if addresses.nil?
440
-
441
- addresses.map do |addr|
442
- email = "#{addr.mailbox}@#{addr.host}"
443
- if addr.name && !addr.name.empty?
444
- { name: decode_mime_header(addr.name), email: email }
445
- else
446
- { name: nil, email: email }
447
- end
448
- end
449
- end
450
-
451
- def decode_mime_header(value)
452
- return "" if value.nil?
453
-
454
- value.gsub(/=\?([^?]+)\?([BbQq])\?([^?]+)\?=/) do
455
- charset = Regexp.last_match(1)
456
- encoding = Regexp.last_match(2).upcase
457
- encoded = Regexp.last_match(3)
458
-
459
- decoded = case encoding
460
- when "B"
461
- Base64.decode64(encoded)
462
- when "Q"
463
- encoded.gsub("_", " ").gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
464
- else
465
- encoded
466
- end
467
-
468
- decoded.force_encoding(charset).encode("UTF-8", invalid: :replace, undef: :replace)
469
- end
470
- end
471
-
472
- def extract_body_parts(raw)
473
- text_body = nil
474
- html_body = nil
475
-
476
- # Check for multipart
477
- if raw =~ /Content-Type:\s*multipart\/\w+;\s*boundary="?([^"\s;]+)"?/i
478
- boundary = Regexp.last_match(1)
479
- parts = raw.split("--#{boundary}")
480
- parts.each do |part|
481
- next if part.strip == "" || part.strip == "--"
482
-
483
- if part =~ /Content-Type:\s*text\/plain/i
484
- text_body = extract_part_body(part)
485
- elsif part =~ /Content-Type:\s*text\/html/i
486
- html_body = extract_part_body(part)
487
- end
488
- end
489
- elsif raw =~ /Content-Type:\s*text\/html/i
490
- html_body = extract_part_body(raw)
491
- else
492
- text_body = extract_part_body(raw)
493
- end
494
-
495
- [text_body || "", html_body || ""]
496
- end
497
-
498
- def extract_part_body(part)
499
- # Split headers from body at double CRLF or double LF
500
- header_body = part.split(/\r?\n\r?\n/, 2)
501
- return "" unless header_body.length > 1
502
-
503
- body = header_body[1].strip
504
- headers = header_body[0]
505
-
506
- if headers =~ /Content-Transfer-Encoding:\s*base64/i
507
- Base64.decode64(body).force_encoding("UTF-8")
508
- elsif headers =~ /Content-Transfer-Encoding:\s*quoted-printable/i
509
- body.gsub(/=\r?\n/, "").gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
510
- else
511
- body
512
- end
513
- end
514
-
515
- def build_search_criteria(subject:, sender:, since:, before:, unseen_only:)
516
- criteria = []
517
- criteria.push("SUBJECT", subject) if subject
518
- criteria.push("FROM", sender) if sender
519
- criteria.push("SINCE", format_imap_date(since)) if since
520
- criteria.push("BEFORE", format_imap_date(before)) if before
521
- criteria << "UNSEEN" if unseen_only
522
- criteria << "ALL" if criteria.empty?
523
- criteria
524
- end
525
-
526
- def format_imap_date(date)
527
- case date
528
- when Time, DateTime
529
- date.strftime("%d-%b-%Y")
530
- when Date
531
- date.strftime("%d-%b-%Y")
532
- when String
533
- date
534
- else
535
- date.to_s
536
- end
537
- end
538
- end
539
-
540
- # Factory: returns a DevMailbox-intercepting messenger in dev mode,
541
- # or a real Messenger in production.
542
- def self.create_messenger(**options)
543
- dev_mode = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
544
-
545
- smtp_configured = (ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?) ||
546
- (ENV["SMTP_HOST"] && !ENV["SMTP_HOST"].empty?)
547
-
548
- if dev_mode && !smtp_configured
549
- mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
550
- mailbox = DevMailbox.new(mailbox_dir: mailbox_dir)
551
- DevMessengerProxy.new(mailbox, **options)
552
- else
553
- Messenger.new(**options)
554
- end
555
- end
556
-
557
- # Proxy that wraps DevMailbox with the same interface as Messenger#send
558
- class DevMessengerProxy
559
- attr_reader :mailbox
560
-
561
- def initialize(mailbox, **options)
562
- @mailbox = mailbox
563
- @from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] || ENV["SMTP_FROM"] || "dev@localhost"
564
- @from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] || ENV["SMTP_FROM_NAME"] || "Dev Mailer"
565
- end
566
-
567
- def send(to:, subject:, body:, html: false, cc: [], bcc: [],
568
- reply_to: nil, attachments: [], headers: {})
569
- @mailbox.capture(
570
- to: to, subject: subject, body: body, html: html,
571
- cc: cc, bcc: bcc, reply_to: reply_to,
572
- from_address: @from_address, from_name: @from_name,
573
- attachments: attachments
574
- )
575
- end
576
-
577
- def test_connection
578
- { success: true, message: "DevMailbox mode — no SMTP connection needed" }
579
- end
580
-
581
- def inbox(**args) = @mailbox.inbox(**args)
582
- def read(...) = @mailbox.read(...)
583
- def unread(...) = @mailbox.unread_count
584
- def search(**args) = @mailbox.inbox(**args)
585
- def folders = ["inbox", "outbox"]
586
- end
587
- end
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
+ # Mark a message as read (set \Seen flag).
198
+ #
199
+ # @param uid [String, Integer] message UID
200
+ # @param folder [String] IMAP folder name
201
+ def mark_read(uid, folder: "INBOX")
202
+ imap_connect do |imap|
203
+ imap.select(folder)
204
+ imap.uid_store(uid.to_i, "+FLAGS", [:Seen])
205
+ end
206
+ rescue => e
207
+ Tina4::Log.error("IMAP mark_read failed: #{e.message}")
208
+ end
209
+
210
+ # Test IMAP connectivity without reading messages.
211
+ #
212
+ # @return [Hash] { success: Boolean, message: String }
213
+ def test_imap_connection
214
+ imap_connect do |_imap|
215
+ # Connection succeeded
216
+ end
217
+ { success: true, message: "Connected to #{@imap_host}:#{@imap_port}" }
218
+ rescue => e
219
+ { success: false, message: "IMAP connection failed: #{e.message}" }
220
+ end
221
+
222
+ private
223
+
224
+ # ── SMTP helpers ─────────────────────────────────────────────────────
225
+
226
+ def auth_method
227
+ return :plain if @username && @password
228
+
229
+ nil
230
+ end
231
+
232
+ def normalize_recipients(value)
233
+ case value
234
+ when nil then []
235
+ when String then [value]
236
+ when Array then value.flatten.compact
237
+ else [value.to_s]
238
+ end
239
+ end
240
+
241
+ def format_address(address, name = nil)
242
+ if name && !name.empty?
243
+ "#{name} <#{address}>"
244
+ else
245
+ address
246
+ end
247
+ end
248
+
249
+ def build_message(to:, subject:, body:, html:, text: nil, cc:, bcc:, reply_to:,
250
+ attachments:, headers:, message_id:)
251
+ boundary = "----=_Tina4_#{SecureRandom.hex(16)}"
252
+ alt_boundary = "----=_Tina4Alt_#{SecureRandom.hex(16)}"
253
+ date = Time.now.strftime("%a, %d %b %Y %H:%M:%S %z")
254
+ has_text_alt = !text.nil? && html
255
+
256
+ parts = []
257
+ parts << "From: #{format_address(@from_address, @from_name)}"
258
+ parts << "To: #{Array(to).join(', ')}"
259
+ parts << "Cc: #{Array(cc).join(', ')}" unless Array(cc).empty?
260
+ parts << "Subject: #{encode_header(subject)}"
261
+ parts << "Date: #{date}"
262
+ parts << "Message-ID: #{message_id}"
263
+ parts << "MIME-Version: 1.0"
264
+ parts << "Reply-To: #{reply_to}" if reply_to
265
+
266
+ headers.each { |k, v| parts << "#{k}: #{v}" }
267
+
268
+ if !attachments.empty?
269
+ parts << "Content-Type: multipart/mixed; boundary=\"#{boundary}\""
270
+ parts << ""
271
+ parts << "--#{boundary}"
272
+
273
+ # Body part (with optional text alternative)
274
+ if has_text_alt
275
+ parts << "Content-Type: multipart/alternative; boundary=\"#{alt_boundary}\""
276
+ parts << ""
277
+ parts << "--#{alt_boundary}"
278
+ parts << "Content-Type: text/plain; charset=UTF-8"
279
+ parts << "Content-Transfer-Encoding: base64"
280
+ parts << ""
281
+ parts << Base64.encode64(text)
282
+ parts << "--#{alt_boundary}"
283
+ parts << "Content-Type: text/html; charset=UTF-8"
284
+ parts << "Content-Transfer-Encoding: base64"
285
+ parts << ""
286
+ parts << Base64.encode64(body)
287
+ parts << "--#{alt_boundary}--"
288
+ else
289
+ content_type = html ? "text/html" : "text/plain"
290
+ parts << "Content-Type: #{content_type}; charset=UTF-8"
291
+ parts << "Content-Transfer-Encoding: base64"
292
+ parts << ""
293
+ parts << Base64.encode64(body)
294
+ end
295
+
296
+ # Attachment parts
297
+ attachments.each do |attachment|
298
+ parts << "--#{boundary}"
299
+ parts.concat(build_attachment_part(attachment))
300
+ end
301
+ parts << "--#{boundary}--"
302
+ elsif has_text_alt
303
+ # Text alternative without attachments
304
+ parts << "Content-Type: multipart/alternative; boundary=\"#{alt_boundary}\""
305
+ parts << ""
306
+ parts << "--#{alt_boundary}"
307
+ parts << "Content-Type: text/plain; charset=UTF-8"
308
+ parts << "Content-Transfer-Encoding: base64"
309
+ parts << ""
310
+ parts << Base64.encode64(text)
311
+ parts << "--#{alt_boundary}"
312
+ parts << "Content-Type: text/html; charset=UTF-8"
313
+ parts << "Content-Transfer-Encoding: base64"
314
+ parts << ""
315
+ parts << Base64.encode64(body)
316
+ parts << "--#{alt_boundary}--"
317
+ else
318
+ content_type = html ? "text/html" : "text/plain"
319
+ parts << "Content-Type: #{content_type}; charset=UTF-8"
320
+ parts << "Content-Transfer-Encoding: base64"
321
+ parts << ""
322
+ parts << Base64.encode64(body)
323
+ end
324
+
325
+ parts.join("\r\n")
326
+ end
327
+
328
+ def build_attachment_part(attachment)
329
+ lines = []
330
+ if attachment.is_a?(Hash)
331
+ filename = attachment[:filename] || attachment[:name] || "attachment"
332
+ content = attachment[:content] || ""
333
+ mime = attachment[:mime_type] || attachment[:content_type] || "application/octet-stream"
334
+ elsif attachment.is_a?(String) && File.exist?(attachment)
335
+ filename = File.basename(attachment)
336
+ content = File.binread(attachment)
337
+ mime = guess_mime_type(filename)
338
+ else
339
+ return []
340
+ end
341
+
342
+ encoded = content.is_a?(String) && !content.ascii_only? ? Base64.encode64(content) : Base64.encode64(content.to_s)
343
+
344
+ lines << "Content-Type: #{mime}; name=\"#{filename}\""
345
+ lines << "Content-Disposition: attachment; filename=\"#{filename}\""
346
+ lines << "Content-Transfer-Encoding: base64"
347
+ lines << ""
348
+ lines << encoded
349
+ lines
350
+ end
351
+
352
+ def encode_header(value)
353
+ if value.ascii_only?
354
+ value
355
+ else
356
+ "=?UTF-8?B?#{Base64.strict_encode64(value)}?="
357
+ end
358
+ end
359
+
360
+ def guess_mime_type(filename)
361
+ ext = File.extname(filename).downcase
362
+ {
363
+ ".txt" => "text/plain",
364
+ ".html" => "text/html",
365
+ ".htm" => "text/html",
366
+ ".css" => "text/css",
367
+ ".js" => "application/javascript",
368
+ ".json" => "application/json",
369
+ ".xml" => "application/xml",
370
+ ".pdf" => "application/pdf",
371
+ ".zip" => "application/zip",
372
+ ".gz" => "application/gzip",
373
+ ".tar" => "application/x-tar",
374
+ ".png" => "image/png",
375
+ ".jpg" => "image/jpeg",
376
+ ".jpeg" => "image/jpeg",
377
+ ".gif" => "image/gif",
378
+ ".svg" => "image/svg+xml",
379
+ ".csv" => "text/csv",
380
+ ".doc" => "application/msword",
381
+ ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
382
+ ".xls" => "application/vnd.ms-excel",
383
+ ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
384
+ }.fetch(ext, "application/octet-stream")
385
+ end
386
+
387
+ # ── IMAP helpers ─────────────────────────────────────────────────────
388
+
389
+ def imap_connect(&block)
390
+ imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @use_tls)
391
+ imap.login(@username, @password)
392
+ result = block.call(imap)
393
+ imap.logout
394
+ imap.disconnect
395
+ result
396
+ end
397
+
398
+ def parse_envelope(fetch_data)
399
+ env = fetch_data.attr["ENVELOPE"]
400
+ flags = fetch_data.attr["FLAGS"] || []
401
+ size = fetch_data.attr["RFC822.SIZE"] || 0
402
+
403
+ {
404
+ uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"] : nil,
405
+ subject: env.subject ? decode_mime_header(env.subject) : "",
406
+ from: format_imap_address(env.from),
407
+ to: format_imap_address(env.to),
408
+ date: env.date,
409
+ flags: flags.map(&:to_s),
410
+ read: flags.include?(:Seen),
411
+ size: size
412
+ }
413
+ end
414
+
415
+ def parse_full_message(fetch_data)
416
+ env = fetch_data.attr["ENVELOPE"]
417
+ flags = fetch_data.attr["FLAGS"] || []
418
+ raw_body = fetch_data.attr["BODY[]"] || ""
419
+
420
+ body_text, body_html = extract_body_parts(raw_body)
421
+
422
+ {
423
+ uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"] : nil,
424
+ subject: env.subject ? decode_mime_header(env.subject) : "",
425
+ from: format_imap_address(env.from),
426
+ to: format_imap_address(env.to),
427
+ cc: format_imap_address(env.cc),
428
+ date: env.date,
429
+ message_id: env.message_id,
430
+ flags: flags.map(&:to_s),
431
+ read: flags.include?(:Seen),
432
+ body: body_text,
433
+ html: body_html,
434
+ raw: raw_body
435
+ }
436
+ end
437
+
438
+ def format_imap_address(addresses)
439
+ return [] if addresses.nil?
440
+
441
+ addresses.map do |addr|
442
+ email = "#{addr.mailbox}@#{addr.host}"
443
+ if addr.name && !addr.name.empty?
444
+ { name: decode_mime_header(addr.name), email: email }
445
+ else
446
+ { name: nil, email: email }
447
+ end
448
+ end
449
+ end
450
+
451
+ def decode_mime_header(value)
452
+ return "" if value.nil?
453
+
454
+ value.gsub(/=\?([^?]+)\?([BbQq])\?([^?]+)\?=/) do
455
+ charset = Regexp.last_match(1)
456
+ encoding = Regexp.last_match(2).upcase
457
+ encoded = Regexp.last_match(3)
458
+
459
+ decoded = case encoding
460
+ when "B"
461
+ Base64.decode64(encoded)
462
+ when "Q"
463
+ encoded.gsub("_", " ").gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
464
+ else
465
+ encoded
466
+ end
467
+
468
+ decoded.force_encoding(charset).encode("UTF-8", invalid: :replace, undef: :replace)
469
+ end
470
+ end
471
+
472
+ def extract_body_parts(raw)
473
+ text_body = nil
474
+ html_body = nil
475
+
476
+ # Check for multipart
477
+ if raw =~ /Content-Type:\s*multipart\/\w+;\s*boundary="?([^"\s;]+)"?/i
478
+ boundary = Regexp.last_match(1)
479
+ parts = raw.split("--#{boundary}")
480
+ parts.each do |part|
481
+ next if part.strip == "" || part.strip == "--"
482
+
483
+ if part =~ /Content-Type:\s*text\/plain/i
484
+ text_body = extract_part_body(part)
485
+ elsif part =~ /Content-Type:\s*text\/html/i
486
+ html_body = extract_part_body(part)
487
+ end
488
+ end
489
+ elsif raw =~ /Content-Type:\s*text\/html/i
490
+ html_body = extract_part_body(raw)
491
+ else
492
+ text_body = extract_part_body(raw)
493
+ end
494
+
495
+ [text_body || "", html_body || ""]
496
+ end
497
+
498
+ def extract_part_body(part)
499
+ # Split headers from body at double CRLF or double LF
500
+ header_body = part.split(/\r?\n\r?\n/, 2)
501
+ return "" unless header_body.length > 1
502
+
503
+ body = header_body[1].strip
504
+ headers = header_body[0]
505
+
506
+ if headers =~ /Content-Transfer-Encoding:\s*base64/i
507
+ Base64.decode64(body).force_encoding("UTF-8")
508
+ elsif headers =~ /Content-Transfer-Encoding:\s*quoted-printable/i
509
+ body.gsub(/=\r?\n/, "").gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
510
+ else
511
+ body
512
+ end
513
+ end
514
+
515
+ def build_search_criteria(subject:, sender:, since:, before:, unseen_only:)
516
+ criteria = []
517
+ criteria.push("SUBJECT", subject) if subject
518
+ criteria.push("FROM", sender) if sender
519
+ criteria.push("SINCE", format_imap_date(since)) if since
520
+ criteria.push("BEFORE", format_imap_date(before)) if before
521
+ criteria << "UNSEEN" if unseen_only
522
+ criteria << "ALL" if criteria.empty?
523
+ criteria
524
+ end
525
+
526
+ def format_imap_date(date)
527
+ case date
528
+ when Time, DateTime
529
+ date.strftime("%d-%b-%Y")
530
+ when Date
531
+ date.strftime("%d-%b-%Y")
532
+ when String
533
+ date
534
+ else
535
+ date.to_s
536
+ end
537
+ end
538
+ end
539
+
540
+ # Factory: returns a DevMailbox-intercepting messenger in dev mode,
541
+ # or a real Messenger in production.
542
+ def self.create_messenger(**options)
543
+ dev_mode = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
544
+
545
+ smtp_configured = (ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?) ||
546
+ (ENV["SMTP_HOST"] && !ENV["SMTP_HOST"].empty?)
547
+
548
+ if dev_mode && !smtp_configured
549
+ mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
550
+ mailbox = DevMailbox.new(mailbox_dir: mailbox_dir)
551
+ DevMessengerProxy.new(mailbox, **options)
552
+ else
553
+ Messenger.new(**options)
554
+ end
555
+ end
556
+
557
+ # Proxy that wraps DevMailbox with the same interface as Messenger#send
558
+ class DevMessengerProxy
559
+ attr_reader :mailbox
560
+
561
+ def initialize(mailbox, **options)
562
+ @mailbox = mailbox
563
+ @from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] || ENV["SMTP_FROM"] || "dev@localhost"
564
+ @from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] || ENV["SMTP_FROM_NAME"] || "Dev Mailer"
565
+ end
566
+
567
+ def send(to:, subject:, body:, html: false, cc: [], bcc: [],
568
+ reply_to: nil, attachments: [], headers: {})
569
+ @mailbox.capture(
570
+ to: to, subject: subject, body: body, html: html,
571
+ cc: cc, bcc: bcc, reply_to: reply_to,
572
+ from_address: @from_address, from_name: @from_name,
573
+ attachments: attachments
574
+ )
575
+ end
576
+
577
+ def test_connection
578
+ { success: true, message: "DevMailbox mode — no SMTP connection needed" }
579
+ end
580
+
581
+ def inbox(**args) = @mailbox.inbox(**args)
582
+ def read(...) = @mailbox.read(...)
583
+ def unread(...) = @mailbox.unread_count
584
+ def search(**args) = @mailbox.inbox(**args)
585
+ def folders = ["inbox", "outbox"]
586
+ end
587
+ end