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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1 -1
- data/README.md +360 -559
- data/exe/{tina4 → tina4ruby} +1 -0
- data/lib/tina4/ai.rb +312 -0
- data/lib/tina4/auth.rb +44 -3
- data/lib/tina4/auto_crud.rb +163 -0
- data/lib/tina4/cli.rb +242 -77
- data/lib/tina4/constants.rb +46 -0
- data/lib/tina4/cors.rb +74 -0
- data/lib/tina4/database/sqlite3_adapter.rb +139 -0
- data/lib/tina4/database.rb +43 -7
- data/lib/tina4/debug.rb +4 -79
- data/lib/tina4/dev_admin.rb +1162 -0
- data/lib/tina4/dev_mailbox.rb +191 -0
- data/lib/tina4/dev_reload.rb +9 -9
- data/lib/tina4/drivers/firebird_driver.rb +19 -3
- data/lib/tina4/drivers/mssql_driver.rb +3 -3
- data/lib/tina4/drivers/mysql_driver.rb +4 -4
- data/lib/tina4/drivers/postgres_driver.rb +9 -2
- data/lib/tina4/drivers/sqlite_driver.rb +1 -1
- data/lib/tina4/env.rb +42 -2
- data/lib/tina4/error_overlay.rb +252 -0
- data/lib/tina4/events.rb +90 -0
- data/lib/tina4/field_types.rb +4 -0
- data/lib/tina4/frond.rb +1336 -0
- data/lib/tina4/gallery/auth/meta.json +1 -0
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
- data/lib/tina4/gallery/database/meta.json +1 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
- data/lib/tina4/gallery/error-overlay/meta.json +1 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
- data/lib/tina4/gallery/orm/meta.json +1 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
- data/lib/tina4/gallery/queue/meta.json +1 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +27 -0
- data/lib/tina4/gallery/rest-api/meta.json +1 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
- data/lib/tina4/gallery/templates/meta.json +1 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
- data/lib/tina4/health.rb +39 -0
- data/lib/tina4/html_element.rb +148 -0
- data/lib/tina4/localization.rb +2 -2
- data/lib/tina4/log.rb +203 -0
- data/lib/tina4/messenger.rb +484 -0
- data/lib/tina4/migration.rb +132 -29
- data/lib/tina4/orm.rb +337 -31
- data/lib/tina4/public/css/tina4.css +178 -1
- data/lib/tina4/public/css/tina4.min.css +1 -2
- data/lib/tina4/public/favicon.ico +0 -0
- data/lib/tina4/public/images/logo.svg +5 -0
- data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
- data/lib/tina4/public/js/frond.min.js +420 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
- data/lib/tina4/public/js/tina4.min.js +93 -0
- data/lib/tina4/public/swagger/index.html +90 -0
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
- data/lib/tina4/queue.rb +40 -4
- data/lib/tina4/queue_backends/lite_backend.rb +88 -0
- data/lib/tina4/rack_app.rb +314 -23
- data/lib/tina4/rate_limiter.rb +123 -0
- data/lib/tina4/request.rb +61 -15
- data/lib/tina4/response.rb +54 -24
- data/lib/tina4/response_cache.rb +134 -0
- data/lib/tina4/router.rb +90 -15
- data/lib/tina4/scss_compiler.rb +2 -2
- data/lib/tina4/seeder.rb +56 -61
- data/lib/tina4/service_runner.rb +303 -0
- data/lib/tina4/session.rb +85 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
- data/lib/tina4/shutdown.rb +84 -0
- data/lib/tina4/sql_translation.rb +295 -0
- data/lib/tina4/template.rb +36 -6
- data/lib/tina4/templates/base.twig +2 -2
- data/lib/tina4/templates/errors/302.twig +14 -0
- data/lib/tina4/templates/errors/401.twig +9 -0
- data/lib/tina4/templates/errors/403.twig +22 -15
- data/lib/tina4/templates/errors/404.twig +22 -15
- data/lib/tina4/templates/errors/500.twig +31 -15
- data/lib/tina4/templates/errors/502.twig +9 -0
- data/lib/tina4/templates/errors/503.twig +12 -0
- data/lib/tina4/templates/errors/base.twig +37 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -18
- data/lib/tina4.rb +57 -21
- metadata +51 -19
- data/lib/tina4/public/js/tina4.js +0 -134
- 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
|