tina4ruby 3.13.93 → 3.13.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +35 -43
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +458 -48
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dev_mailbox.rb +5 -1
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +25 -3
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/frond.rb +102 -10
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +317 -82
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +243 -29
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +301 -38
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/messenger.rb
CHANGED
|
@@ -71,15 +71,46 @@ module Tina4
|
|
|
71
71
|
# mail.send(to: "user@test.com", subject: "Welcome", body: "<h1>Hello!</h1>", html: true, text: "Hello!")
|
|
72
72
|
#
|
|
73
73
|
class Messenger
|
|
74
|
+
# Factory: returns a Messenger configured for the current environment.
|
|
75
|
+
#
|
|
76
|
+
# Returns ONE concrete type, always. It used to return either a Messenger or a
|
|
77
|
+
# DevMessengerProxy; both happened to expose #send, so Ruby escaped the crash that
|
|
78
|
+
# nodejs#41 describes by luck of naming rather than by design -- but the proxy's
|
|
79
|
+
# #send took no text: keyword, so the documented call raised ArgumentError on a dev
|
|
80
|
+
# messenger. Capture is now a branch inside Messenger#send.
|
|
81
|
+
#
|
|
82
|
+
# The gate is availability, not verbosity: capture when no SMTP host is configured,
|
|
83
|
+
# send when one is EVEN WITH TINA4_DEBUG ON, and TINA4_MAIL_CAPTURE forces capture.
|
|
84
|
+
def self.create_messenger(**options)
|
|
85
|
+
mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
|
|
86
|
+
messenger = Messenger.new(**options)
|
|
87
|
+
messenger.instance_variable_set(:@mailbox_dir, mailbox_dir)
|
|
88
|
+
|
|
89
|
+
# Attach the mailbox eagerly when this messenger will capture, so callers (and
|
|
90
|
+
# the dev dashboard) can inspect it before the first send.
|
|
91
|
+
messenger.dev_mailbox = DevMailbox.new(mailbox_dir: mailbox_dir) if messenger.should_capture?
|
|
92
|
+
|
|
93
|
+
messenger
|
|
94
|
+
end
|
|
95
|
+
|
|
74
96
|
attr_reader :host, :port, :username, :from_address, :from_name,
|
|
75
97
|
:imap_host, :imap_port, :use_tls, :encryption,
|
|
76
|
-
:imap_encryption, :imap_use_tls
|
|
98
|
+
:imap_encryption, :imap_use_tls, :imap_username, :imap_password
|
|
77
99
|
|
|
78
100
|
# Initialize with SMTP config.
|
|
79
101
|
# Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults
|
|
80
102
|
def initialize(host: nil, port: nil, username: nil, password: nil,
|
|
81
103
|
from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
|
|
82
|
-
imap_host: nil, imap_port: nil, imap_encryption: nil
|
|
104
|
+
imap_host: nil, imap_port: nil, imap_encryption: nil,
|
|
105
|
+
imap_username: nil, imap_password: nil)
|
|
106
|
+
# Whether a host was actually CONFIGURED, which is not the same as @host being
|
|
107
|
+
# set: it falls back to "localhost", so it is never nil and cannot answer
|
|
108
|
+
# "can this messenger send?". The capture gate needs that answer, so record it
|
|
109
|
+
# here while the real inputs are still in scope.
|
|
110
|
+
configured_host = host || ENV["TINA4_MAIL_HOST"]
|
|
111
|
+
@smtp_configured = !configured_host.nil? && !configured_host.to_s.empty?
|
|
112
|
+
@mailbox_dir = nil
|
|
113
|
+
@dev_mailbox = nil
|
|
83
114
|
@host = host || ENV["TINA4_MAIL_HOST"] || "localhost"
|
|
84
115
|
@port = (port || ENV["TINA4_MAIL_PORT"] || 587).to_i
|
|
85
116
|
@username = username || ENV["TINA4_MAIL_USERNAME"]
|
|
@@ -109,12 +140,59 @@ module Tina4
|
|
|
109
140
|
env_imap_enc = imap_encryption || ENV["TINA4_MAIL_IMAP_ENCRYPTION"]
|
|
110
141
|
@imap_encryption = (env_imap_enc && !env_imap_enc.to_s.empty?) ? env_imap_enc.to_s.downcase : "tls"
|
|
111
142
|
@imap_use_tls = %w[tls starttls ssl].include?(@imap_encryption)
|
|
143
|
+
|
|
144
|
+
# IMAP credentials, independent of SMTP. Dedicated
|
|
145
|
+
# TINA4_MAIL_IMAP_USERNAME/_PASSWORD, falling back to the SMTP
|
|
146
|
+
# TINA4_MAIL_USERNAME/_PASSWORD. Ruby authenticated IMAP to the SMTP
|
|
147
|
+
# account, so an app whose mailbox for READING differs from its SMTP relay
|
|
148
|
+
# account read the wrong mailbox. Explicit constructor args win (ADR-0041).
|
|
149
|
+
@imap_username = imap_username || ENV["TINA4_MAIL_IMAP_USERNAME"] || @username
|
|
150
|
+
@imap_password = imap_password || ENV["TINA4_MAIL_IMAP_PASSWORD"] || @password
|
|
112
151
|
end
|
|
113
152
|
|
|
114
153
|
# Send email using Ruby's Net::SMTP
|
|
115
154
|
# Returns { success: true/false, message: "...", id: "..." }
|
|
155
|
+
# The local mailbox, present only once this messenger has captured something
|
|
156
|
+
# (or eagerly, when create_messenger knows it will).
|
|
157
|
+
attr_accessor :dev_mailbox
|
|
158
|
+
|
|
159
|
+
# Should send capture locally instead of talking to SMTP?
|
|
160
|
+
#
|
|
161
|
+
# Availability decides, not verbosity. With no SMTP host configured sending is
|
|
162
|
+
# impossible, so simulate it into a folder rather than failing -- that is what
|
|
163
|
+
# makes a laptop with no mail server usable, and it is the original Tina4
|
|
164
|
+
# "messages folder" behaviour restored. TINA4_MAIL_CAPTURE forces capture even
|
|
165
|
+
# when a host IS configured.
|
|
166
|
+
#
|
|
167
|
+
# TINA4_DEBUG deliberately does NOT gate this. Debug must still be able to send:
|
|
168
|
+
# tying capture to it means nobody can test a real send from a dev box. The old
|
|
169
|
+
# gate required debug AND no SMTP host, so a dev box with neither set went
|
|
170
|
+
# straight to localhost:587 and failed.
|
|
171
|
+
def should_capture?
|
|
172
|
+
return true if Tina4::Env.is_truthy(ENV["TINA4_MAIL_CAPTURE"])
|
|
173
|
+
|
|
174
|
+
!@smtp_configured
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def dev_mailbox
|
|
178
|
+
@dev_mailbox ||= DevMailbox.new(mailbox_dir: @mailbox_dir)
|
|
179
|
+
end
|
|
180
|
+
|
|
116
181
|
def send(to:, subject:, body:, html: false, text: nil, cc: [], bcc: [],
|
|
117
182
|
reply_to: nil, attachments: [], headers: {})
|
|
183
|
+
# Dev capture is a BRANCH here, not a different object handed back by the
|
|
184
|
+
# factory. create_messenger used to return a DevMessengerProxy whose #send had
|
|
185
|
+
# no text: keyword at all, so the documented call raised ArgumentError and the
|
|
186
|
+
# plain-text alternative was silently dropped from the captured message.
|
|
187
|
+
if should_capture?
|
|
188
|
+
return dev_mailbox.capture(
|
|
189
|
+
to: to, subject: subject, body: body, html: html, text: text,
|
|
190
|
+
cc: cc, bcc: bcc, reply_to: reply_to,
|
|
191
|
+
from_address: @from_address, from_name: @from_name,
|
|
192
|
+
attachments: attachments
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
118
196
|
message_id = "<#{SecureRandom.uuid}@#{@host}>"
|
|
119
197
|
raw = build_message(
|
|
120
198
|
to: to, subject: subject, body: body, html: html, text: text,
|
|
@@ -141,6 +219,24 @@ module Tina4
|
|
|
141
219
|
{ success: false, message: e.message, id: nil }
|
|
142
220
|
end
|
|
143
221
|
|
|
222
|
+
# Send an email whose HTML body is rendered from a Frond template string.
|
|
223
|
+
# Parity with the Python master's send_template. Extra keyword args (cc, bcc,
|
|
224
|
+
# attachments, reply_to, headers, text) pass straight through to #send. If
|
|
225
|
+
# Frond is unusable, the raw template string is sent as-is (a graceful
|
|
226
|
+
# fallback, mirroring Python's ImportError branch).
|
|
227
|
+
#
|
|
228
|
+
# mail.send_template(to: "u@x.com", subject: "Hi {{ name }}",
|
|
229
|
+
# template: "<h1>Hi {{ name }}</h1>", data: { "name" => "Al" })
|
|
230
|
+
def send_template(to:, subject:, template:, data: {}, **kwargs)
|
|
231
|
+
body = begin
|
|
232
|
+
Tina4::Frond.new.render_string(template, data || {})
|
|
233
|
+
rescue StandardError => e
|
|
234
|
+
Tina4::Log.error("Messenger send_template render failed: #{e.message}")
|
|
235
|
+
template
|
|
236
|
+
end
|
|
237
|
+
send(to: to, subject: subject, body: body, html: true, **kwargs)
|
|
238
|
+
end
|
|
239
|
+
|
|
144
240
|
# Test SMTP connection
|
|
145
241
|
# Returns { success: true/false, message: "..." }
|
|
146
242
|
def test_connection
|
|
@@ -158,10 +254,21 @@ module Tina4
|
|
|
158
254
|
|
|
159
255
|
# List messages in a folder.
|
|
160
256
|
#
|
|
257
|
+
# Callable POSITIONALLY — inbox("INBOX", 10, 0) — or by keyword —
|
|
258
|
+
# inbox(folder: "INBOX", limit: 10). Node moved to folder-first in 3.13.95
|
|
259
|
+
# to satisfy a contract Ruby could not satisfy at all; this closes that loop.
|
|
260
|
+
#
|
|
261
|
+
# Each item is EXACTLY {uid:String, subject, from:String, to:String,
|
|
262
|
+
# date:ISO-8601, snippet, seen:Boolean} — the settled cross-framework shape.
|
|
263
|
+
#
|
|
161
264
|
# Raises Tina4::MessengerConnectionError on a connection/auth/protocol
|
|
162
265
|
# failure (FAILS LOUD — never returns [] to hide it). A successful fetch
|
|
163
266
|
# from an empty folder returns [] (that is NOT an error).
|
|
164
|
-
def inbox(folder
|
|
267
|
+
def inbox(folder = "INBOX", limit = 20, offset = 0, **opts)
|
|
268
|
+
folder = opts[:folder] if opts.key?(:folder)
|
|
269
|
+
limit = opts[:limit] if opts.key?(:limit)
|
|
270
|
+
offset = opts[:offset] if opts.key?(:offset)
|
|
271
|
+
|
|
165
272
|
imap = imap_open("inbox")
|
|
166
273
|
begin
|
|
167
274
|
imap.select(folder)
|
|
@@ -170,8 +277,18 @@ module Tina4
|
|
|
170
277
|
page = uids[offset, limit] || []
|
|
171
278
|
return [] if page.empty?
|
|
172
279
|
|
|
173
|
-
|
|
174
|
-
|
|
280
|
+
# BODY.PEEK[] fetches the full message WITHOUT setting \Seen — listing an
|
|
281
|
+
# inbox must never mark messages read — so parse_envelope can build a
|
|
282
|
+
# decoded, transfer-decoded, tag-stripped snippet. tina4: heavier than a
|
|
283
|
+
# header-only fetch for large mailboxes; fetch BODYSTRUCTURE + the text
|
|
284
|
+
# part only if this ever gets hot.
|
|
285
|
+
envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "BODY.PEEK[]"])
|
|
286
|
+
# uid_fetch returns rows in SERVER (ascending) order however the uids
|
|
287
|
+
# were asked for, so the newest-first page above was silently re-sorted
|
|
288
|
+
# and inbox(limit: 1) handed back the OLDEST message. Selection was
|
|
289
|
+
# right; presentation was not. Restore the requested order.
|
|
290
|
+
by_uid = (envelopes || []).each_with_object({}) { |m, h| h[m.attr["UID"]] = m }
|
|
291
|
+
page.filter_map { |uid| by_uid[uid] }.map { |msg| parse_envelope(msg) }
|
|
175
292
|
rescue *IMAP_CONNECTION_ERRORS => e
|
|
176
293
|
raise imap_fail("inbox", e)
|
|
177
294
|
ensure
|
|
@@ -181,9 +298,19 @@ module Tina4
|
|
|
181
298
|
|
|
182
299
|
# Read a single message by UID.
|
|
183
300
|
#
|
|
301
|
+
# Callable POSITIONALLY — read(uid, "INBOX") — or by keyword —
|
|
302
|
+
# read(uid, folder: "INBOX"). Returns EXACTLY these 10 keys: {uid, subject,
|
|
303
|
+
# from:String, to:String, cc:String, date:ISO-8601, body_text, body_html,
|
|
304
|
+
# attachments, headers}. Message-ID lives in headers, never a top-level key.
|
|
305
|
+
# Each attachments item is {filename, content_type, size, content} where
|
|
306
|
+
# content is the RAW DECODED BYTES of the part (issue #69).
|
|
307
|
+
#
|
|
184
308
|
# Raises Tina4::MessengerConnectionError on a connection/protocol failure.
|
|
185
309
|
# A successful fetch for a non-existent UID returns nil (that is NOT an error).
|
|
186
|
-
def read(uid, folder
|
|
310
|
+
def read(uid, folder = "INBOX", mark_read = true, **opts)
|
|
311
|
+
folder = opts[:folder] if opts.key?(:folder)
|
|
312
|
+
mark_read = opts[:mark_read] if opts.key?(:mark_read)
|
|
313
|
+
|
|
187
314
|
imap = imap_open("read")
|
|
188
315
|
begin
|
|
189
316
|
imap.select(folder)
|
|
@@ -238,8 +365,15 @@ module Tina4
|
|
|
238
365
|
page = uids[0, limit] || []
|
|
239
366
|
return [] if page.empty?
|
|
240
367
|
|
|
241
|
-
|
|
242
|
-
|
|
368
|
+
# BODY.PEEK[] (no \Seen) so search results carry the same decoded snippet
|
|
369
|
+
# as inbox(); search shares parse_envelope and therefore the item shape.
|
|
370
|
+
envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "BODY.PEEK[]"])
|
|
371
|
+
# uid_fetch returns rows in SERVER (ascending) order however the uids
|
|
372
|
+
# were asked for, so the newest-first page above was silently re-sorted
|
|
373
|
+
# and inbox(limit: 1) handed back the OLDEST message. Selection was
|
|
374
|
+
# right; presentation was not. Restore the requested order.
|
|
375
|
+
by_uid = (envelopes || []).each_with_object({}) { |m, h| h[m.attr["UID"]] = m }
|
|
376
|
+
page.filter_map { |uid| by_uid[uid] }.map { |msg| parse_envelope(msg) }
|
|
243
377
|
rescue *IMAP_CONNECTION_ERRORS => e
|
|
244
378
|
raise imap_fail("search", e)
|
|
245
379
|
ensure
|
|
@@ -275,6 +409,42 @@ module Tina4
|
|
|
275
409
|
Tina4::Log.error("IMAP mark_read failed: #{e.message}")
|
|
276
410
|
end
|
|
277
411
|
|
|
412
|
+
# Mark a message as unread (clear \Seen flag). Mirror of mark_read; same
|
|
413
|
+
# result-style error handling (logs, returns nil) — the two are a matched
|
|
414
|
+
# pair of idempotent flag toggles.
|
|
415
|
+
#
|
|
416
|
+
# @param uid [String, Integer] message UID
|
|
417
|
+
# @param folder [String] IMAP folder name
|
|
418
|
+
def mark_unread(uid, folder: "INBOX")
|
|
419
|
+
imap_connect do |imap|
|
|
420
|
+
imap.select(folder)
|
|
421
|
+
imap.uid_store(uid.to_i, "-FLAGS", [:Seen])
|
|
422
|
+
end
|
|
423
|
+
rescue => e
|
|
424
|
+
Tina4::Log.error("IMAP mark_unread failed: #{e.message}")
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Delete a message: flag it \Deleted and expunge. Destructive, so it FAILS
|
|
428
|
+
# LOUD — a connection/protocol failure raises MessengerConnectionError rather
|
|
429
|
+
# than silently reporting success (parity with the Python master, which also
|
|
430
|
+
# raises). Returns true when the expunge completes.
|
|
431
|
+
#
|
|
432
|
+
# @param uid [String, Integer] message UID
|
|
433
|
+
# @param folder [String] IMAP folder name
|
|
434
|
+
def delete(uid, folder: "INBOX")
|
|
435
|
+
imap = imap_open("delete")
|
|
436
|
+
begin
|
|
437
|
+
imap.select(folder)
|
|
438
|
+
imap.uid_store(uid, "+FLAGS", [:Deleted])
|
|
439
|
+
imap.expunge
|
|
440
|
+
true
|
|
441
|
+
rescue *IMAP_CONNECTION_ERRORS => e
|
|
442
|
+
raise imap_fail("delete", e)
|
|
443
|
+
ensure
|
|
444
|
+
imap_cleanup(imap)
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
278
448
|
# Test IMAP connectivity without reading messages.
|
|
279
449
|
#
|
|
280
450
|
# @return [Hash] { success: Boolean, message: String }
|
|
@@ -459,7 +629,7 @@ module Tina4
|
|
|
459
629
|
# rather than swallowing the error into an empty result.
|
|
460
630
|
def imap_open(method)
|
|
461
631
|
imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
|
|
462
|
-
imap.login(@
|
|
632
|
+
imap.login(@imap_username, @imap_password)
|
|
463
633
|
imap
|
|
464
634
|
rescue *IMAP_CONNECTION_ERRORS => e
|
|
465
635
|
raise imap_fail(method, e)
|
|
@@ -500,64 +670,145 @@ module Tina4
|
|
|
500
670
|
# they can fail loud.
|
|
501
671
|
def imap_connect(&block)
|
|
502
672
|
imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
|
|
503
|
-
imap.login(@
|
|
673
|
+
imap.login(@imap_username, @imap_password)
|
|
504
674
|
result = block.call(imap)
|
|
505
675
|
imap.logout
|
|
506
676
|
imap.disconnect
|
|
507
677
|
result
|
|
508
678
|
end
|
|
509
679
|
|
|
680
|
+
# An inbox() item — EXACTLY these seven keys (decisions doc G4):
|
|
681
|
+
# uid:String, subject, from:String, to:String, date:ISO-8601, snippet, seen:Boolean
|
|
682
|
+
# from/to are header STRINGS ("Name <email>"), NOT arrays of {name,email};
|
|
683
|
+
# date is ISO-8601; snippet is decoded/transfer-decoded/tag-stripped plain
|
|
684
|
+
# text (<= 200 chars); seen is a Boolean. No flags/size.
|
|
510
685
|
def parse_envelope(fetch_data)
|
|
511
686
|
env = fetch_data.attr["ENVELOPE"]
|
|
512
687
|
flags = fetch_data.attr["FLAGS"] || []
|
|
513
|
-
|
|
688
|
+
raw_body = fetch_data.attr["BODY[]"] || ""
|
|
514
689
|
|
|
515
690
|
{
|
|
516
|
-
|
|
691
|
+
# String in all four frameworks. Ruby alone returned Integer, so
|
|
692
|
+
# `uid == "3"` was false here and true everywhere else.
|
|
693
|
+
uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"].to_s : nil,
|
|
517
694
|
subject: env.subject ? decode_mime_header(env.subject) : "",
|
|
518
|
-
from:
|
|
519
|
-
to:
|
|
520
|
-
date: env.date,
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
size: size
|
|
695
|
+
from: format_address_string(env.from),
|
|
696
|
+
to: format_address_string(env.to),
|
|
697
|
+
date: format_date_iso8601(env.date),
|
|
698
|
+
snippet: build_snippet(raw_body),
|
|
699
|
+
seen: flags.include?(:Seen)
|
|
524
700
|
}
|
|
525
701
|
end
|
|
526
702
|
|
|
703
|
+
# A read() item (decisions doc G5) — EXACTLY these 10 keys: uid, subject,
|
|
704
|
+
# from/to/cc (header STRINGS), date, body_text/body_html (was body/html),
|
|
705
|
+
# attachments, headers (the full header Hash). Message-ID lives in
|
|
706
|
+
# headers["Message-ID"], never a top-level key. Each attachments item is
|
|
707
|
+
# {filename, content_type, size, content} — content is the RAW DECODED BYTES
|
|
708
|
+
# of the part (issue #69), so an attachment is downloadable straight from
|
|
709
|
+
# read() and size is that byte length.
|
|
527
710
|
def parse_full_message(fetch_data)
|
|
528
711
|
env = fetch_data.attr["ENVELOPE"]
|
|
529
|
-
flags = fetch_data.attr["FLAGS"] || []
|
|
530
712
|
raw_body = fetch_data.attr["BODY[]"] || ""
|
|
531
713
|
|
|
532
714
|
body_text, body_html = extract_body_parts(raw_body)
|
|
533
715
|
|
|
534
716
|
{
|
|
535
|
-
uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"] : nil,
|
|
717
|
+
uid: fetch_data.attr.keys.include?("UID") ? fetch_data.attr["UID"].to_s : nil,
|
|
536
718
|
subject: env.subject ? decode_mime_header(env.subject) : "",
|
|
537
|
-
from:
|
|
538
|
-
to:
|
|
539
|
-
cc:
|
|
540
|
-
date: env.date,
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
html: body_html,
|
|
546
|
-
raw: raw_body
|
|
719
|
+
from: format_address_string(env.from),
|
|
720
|
+
to: format_address_string(env.to),
|
|
721
|
+
cc: format_address_string(env.cc),
|
|
722
|
+
date: format_date_iso8601(env.date),
|
|
723
|
+
body_text: body_text,
|
|
724
|
+
body_html: body_html,
|
|
725
|
+
attachments: extract_attachments(raw_body),
|
|
726
|
+
headers: parse_headers(raw_body)
|
|
547
727
|
}
|
|
548
728
|
end
|
|
549
729
|
|
|
550
|
-
|
|
551
|
-
|
|
730
|
+
# Format an ENVELOPE address list into the header STRING shape the other three
|
|
731
|
+
# frameworks return: "Name <email>" per address (bare "email" when unnamed),
|
|
732
|
+
# comma-joined. Empty string when there are no addresses.
|
|
733
|
+
def format_address_string(addresses)
|
|
734
|
+
return "" if addresses.nil? || addresses.empty?
|
|
552
735
|
|
|
553
736
|
addresses.map do |addr|
|
|
554
737
|
email = "#{addr.mailbox}@#{addr.host}"
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
738
|
+
name = addr.name && !addr.name.to_s.empty? ? decode_mime_header(addr.name) : nil
|
|
739
|
+
name ? "#{name} <#{email}>" : email
|
|
740
|
+
end.join(", ")
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# ISO-8601 string from an ENVELOPE date. Mirrors Python's
|
|
744
|
+
# parsedate_to_datetime(...).isoformat(): parse the RFC5322 date and emit
|
|
745
|
+
# ISO-8601; fall back to the raw value on a parse failure, "" when absent.
|
|
746
|
+
def format_date_iso8601(raw)
|
|
747
|
+
return "" if raw.nil? || raw.to_s.strip.empty?
|
|
748
|
+
|
|
749
|
+
begin
|
|
750
|
+
Time.parse(raw.to_s).iso8601
|
|
751
|
+
rescue ArgumentError, TypeError
|
|
752
|
+
raw.to_s
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# Decoded, transfer-decoded, tag-stripped, whitespace-collapsed plain text,
|
|
757
|
+
# truncated to 200 chars — the settled snippet shape (G3). Reuses the
|
|
758
|
+
# existing multipart/transfer-decoding; prefers the text part, falls back to
|
|
759
|
+
# the HTML part with its tags stripped.
|
|
760
|
+
def build_snippet(raw_body)
|
|
761
|
+
text, html = extract_body_parts(raw_body.to_s)
|
|
762
|
+
source = text.nil? || text.empty? ? html.to_s : text
|
|
763
|
+
plain = source.gsub(/<[^>]+>/, " ").gsub(/\s+/, " ").strip
|
|
764
|
+
plain.length > 200 ? plain[0, 200] : plain
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
# Attachments parsed from a raw MIME message: an array of
|
|
768
|
+
# {filename, content_type, size, content}. A part is an attachment when it
|
|
769
|
+
# carries a Content-Disposition of attachment. content is the RAW DECODED
|
|
770
|
+
# BYTES of the part (transfer-decoded, binary encoding — the same convention
|
|
771
|
+
# as request.files' "content"), so an attachment is downloadable straight
|
|
772
|
+
# from read() (issue #69); size is that byte length. [] when the message is
|
|
773
|
+
# not multipart or has no attachment parts.
|
|
774
|
+
def extract_attachments(raw)
|
|
775
|
+
raw = raw.to_s
|
|
776
|
+
return [] unless raw =~ %r{Content-Type:\s*multipart/\w+;\s*boundary="?([^"\s;]+)"?}i
|
|
777
|
+
|
|
778
|
+
boundary = Regexp.last_match(1)
|
|
779
|
+
raw.split("--#{boundary}").each_with_object([]) do |part, acc|
|
|
780
|
+
next unless part =~ /Content-Disposition:\s*attachment/i
|
|
781
|
+
|
|
782
|
+
filename = part[/filename="?([^"\r\n;]+)"?/i, 1] ||
|
|
783
|
+
part[/name="?([^"\r\n;]+)"?/i, 1] || "attachment"
|
|
784
|
+
content_type = part[%r{Content-Type:\s*([^\s;]+)}i, 1] || "application/octet-stream"
|
|
785
|
+
content = extract_part_bytes(part)
|
|
786
|
+
acc << {
|
|
787
|
+
filename: filename.strip,
|
|
788
|
+
content_type: content_type.strip,
|
|
789
|
+
size: content.bytesize,
|
|
790
|
+
content: content
|
|
791
|
+
}
|
|
792
|
+
end
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
# Parse the top-level RFC5322 header block of a raw message into a Hash
|
|
796
|
+
# (name => value). Mirrors Python's dict(msg.items()); a repeated header keeps
|
|
797
|
+
# the last value; folded continuation lines are unfolded.
|
|
798
|
+
def parse_headers(raw)
|
|
799
|
+
header_block = raw.to_s.split(/\r?\n\r?\n/, 2).first.to_s
|
|
800
|
+
headers = {}
|
|
801
|
+
current_key = nil
|
|
802
|
+
header_block.each_line do |raw_line|
|
|
803
|
+
line = raw_line.chomp
|
|
804
|
+
if current_key && line =~ /\A[ \t]/
|
|
805
|
+
headers[current_key] = "#{headers[current_key]} #{line.strip}"
|
|
806
|
+
elsif (m = line.match(/\A([\w!\#$%&'*+\-.^_`|~]+):[ \t]?(.*)\z/))
|
|
807
|
+
current_key = m[1]
|
|
808
|
+
headers[current_key] = m[2]
|
|
559
809
|
end
|
|
560
810
|
end
|
|
811
|
+
headers
|
|
561
812
|
end
|
|
562
813
|
|
|
563
814
|
def decode_mime_header(value)
|
|
@@ -624,6 +875,36 @@ module Tina4
|
|
|
624
875
|
end
|
|
625
876
|
end
|
|
626
877
|
|
|
878
|
+
# The RAW DECODED BYTES of a MIME part: transfer-decoded (base64 /
|
|
879
|
+
# quoted-printable → the original bytes) in BINARY (ASCII-8BIT) encoding, so a
|
|
880
|
+
# non-text attachment (image, PDF, zip) round-trips byte-for-byte. This is the
|
|
881
|
+
# attachment analogue of extract_part_body, which force-encodes UTF-8 for the
|
|
882
|
+
# text/HTML BODIES and strips them; an attachment must stay raw, unstripped
|
|
883
|
+
# bytes, so the two are kept separate rather than shared. tina4: if a third
|
|
884
|
+
# raw-part reader appears, fold the shared "split headers/body + read
|
|
885
|
+
# Content-Transfer-Encoding" step into one helper the two call with different
|
|
886
|
+
# post-decode handling.
|
|
887
|
+
def extract_part_bytes(part)
|
|
888
|
+
header_body = part.to_s.split(/\r?\n\r?\n/, 2)
|
|
889
|
+
return "".b unless header_body.length > 1
|
|
890
|
+
|
|
891
|
+
headers = header_body[0]
|
|
892
|
+
# The single CRLF immediately before the next boundary delimiter is MIME
|
|
893
|
+
# framing, not payload, so drop it before decoding (Python's
|
|
894
|
+
# get_payload(decode=True) drops it too). base64 decoding ignores it
|
|
895
|
+
# anyway; quoted-printable and raw 7bit/8bit would otherwise keep it.
|
|
896
|
+
body = header_body[1].sub(/\r?\n\z/, "")
|
|
897
|
+
|
|
898
|
+
if headers =~ /Content-Transfer-Encoding:\s*base64/i
|
|
899
|
+
Base64.decode64(body)
|
|
900
|
+
elsif headers =~ /Content-Transfer-Encoding:\s*quoted-printable/i
|
|
901
|
+
body.gsub(/=\r?\n/, "").gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }.b
|
|
902
|
+
else
|
|
903
|
+
# 7bit/8bit/none: the part body IS the bytes.
|
|
904
|
+
body.b
|
|
905
|
+
end
|
|
906
|
+
end
|
|
907
|
+
|
|
627
908
|
def build_search_criteria(subject:, sender:, since:, before:, unseen_only:)
|
|
628
909
|
criteria = []
|
|
629
910
|
criteria.push("SUBJECT", subject) if subject
|
|
@@ -649,50 +930,4 @@ module Tina4
|
|
|
649
930
|
end
|
|
650
931
|
end
|
|
651
932
|
|
|
652
|
-
# Factory: returns a DevMailbox-intercepting messenger in dev mode,
|
|
653
|
-
# or a real Messenger in production.
|
|
654
|
-
def self.create_messenger(**options)
|
|
655
|
-
dev_mode = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
656
|
-
|
|
657
|
-
smtp_configured = ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?
|
|
658
|
-
|
|
659
|
-
if dev_mode && !smtp_configured
|
|
660
|
-
mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
|
|
661
|
-
mailbox = DevMailbox.new(mailbox_dir: mailbox_dir)
|
|
662
|
-
DevMessengerProxy.new(mailbox, **options)
|
|
663
|
-
else
|
|
664
|
-
Messenger.new(**options)
|
|
665
|
-
end
|
|
666
|
-
end
|
|
667
|
-
|
|
668
|
-
# Proxy that wraps DevMailbox with the same interface as Messenger#send
|
|
669
|
-
class DevMessengerProxy
|
|
670
|
-
attr_reader :mailbox
|
|
671
|
-
|
|
672
|
-
def initialize(mailbox, **options)
|
|
673
|
-
@mailbox = mailbox
|
|
674
|
-
@from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] || "dev@localhost"
|
|
675
|
-
@from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] || "Dev Mailer"
|
|
676
|
-
end
|
|
677
|
-
|
|
678
|
-
def send(to:, subject:, body:, html: false, cc: [], bcc: [],
|
|
679
|
-
reply_to: nil, attachments: [], headers: {})
|
|
680
|
-
@mailbox.capture(
|
|
681
|
-
to: to, subject: subject, body: body, html: html,
|
|
682
|
-
cc: cc, bcc: bcc, reply_to: reply_to,
|
|
683
|
-
from_address: @from_address, from_name: @from_name,
|
|
684
|
-
attachments: attachments
|
|
685
|
-
)
|
|
686
|
-
end
|
|
687
|
-
|
|
688
|
-
def test_connection
|
|
689
|
-
{ success: true, message: "DevMailbox mode — no SMTP connection needed" }
|
|
690
|
-
end
|
|
691
|
-
|
|
692
|
-
def inbox(**args) = @mailbox.inbox(**args)
|
|
693
|
-
def read(...) = @mailbox.read(...)
|
|
694
|
-
def unread(...) = @mailbox.unread_count
|
|
695
|
-
def search(**args) = @mailbox.inbox(**args)
|
|
696
|
-
def folders = ["inbox", "outbox"]
|
|
697
|
-
end
|
|
698
933
|
end
|