tina4ruby 3.13.94 → 3.13.97
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 +23 -39
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +322 -22
- 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/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 +21 -2
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- 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 +248 -36
- 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 +208 -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 +256 -33
- 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 +289 -37
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/messenger.rb
CHANGED
|
@@ -95,13 +95,14 @@ module Tina4
|
|
|
95
95
|
|
|
96
96
|
attr_reader :host, :port, :username, :from_address, :from_name,
|
|
97
97
|
:imap_host, :imap_port, :use_tls, :encryption,
|
|
98
|
-
:imap_encryption, :imap_use_tls
|
|
98
|
+
:imap_encryption, :imap_use_tls, :imap_username, :imap_password
|
|
99
99
|
|
|
100
100
|
# Initialize with SMTP config.
|
|
101
101
|
# Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults
|
|
102
102
|
def initialize(host: nil, port: nil, username: nil, password: nil,
|
|
103
103
|
from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
|
|
104
|
-
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)
|
|
105
106
|
# Whether a host was actually CONFIGURED, which is not the same as @host being
|
|
106
107
|
# set: it falls back to "localhost", so it is never nil and cannot answer
|
|
107
108
|
# "can this messenger send?". The capture gate needs that answer, so record it
|
|
@@ -139,6 +140,14 @@ module Tina4
|
|
|
139
140
|
env_imap_enc = imap_encryption || ENV["TINA4_MAIL_IMAP_ENCRYPTION"]
|
|
140
141
|
@imap_encryption = (env_imap_enc && !env_imap_enc.to_s.empty?) ? env_imap_enc.to_s.downcase : "tls"
|
|
141
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
|
|
142
151
|
end
|
|
143
152
|
|
|
144
153
|
# Send email using Ruby's Net::SMTP
|
|
@@ -210,6 +219,24 @@ module Tina4
|
|
|
210
219
|
{ success: false, message: e.message, id: nil }
|
|
211
220
|
end
|
|
212
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
|
+
|
|
213
240
|
# Test SMTP connection
|
|
214
241
|
# Returns { success: true/false, message: "..." }
|
|
215
242
|
def test_connection
|
|
@@ -227,10 +254,21 @@ module Tina4
|
|
|
227
254
|
|
|
228
255
|
# List messages in a folder.
|
|
229
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
|
+
#
|
|
230
264
|
# Raises Tina4::MessengerConnectionError on a connection/auth/protocol
|
|
231
265
|
# failure (FAILS LOUD — never returns [] to hide it). A successful fetch
|
|
232
266
|
# from an empty folder returns [] (that is NOT an error).
|
|
233
|
-
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
|
+
|
|
234
272
|
imap = imap_open("inbox")
|
|
235
273
|
begin
|
|
236
274
|
imap.select(folder)
|
|
@@ -239,8 +277,18 @@ module Tina4
|
|
|
239
277
|
page = uids[offset, limit] || []
|
|
240
278
|
return [] if page.empty?
|
|
241
279
|
|
|
242
|
-
|
|
243
|
-
|
|
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) }
|
|
244
292
|
rescue *IMAP_CONNECTION_ERRORS => e
|
|
245
293
|
raise imap_fail("inbox", e)
|
|
246
294
|
ensure
|
|
@@ -250,9 +298,19 @@ module Tina4
|
|
|
250
298
|
|
|
251
299
|
# Read a single message by UID.
|
|
252
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
|
+
#
|
|
253
308
|
# Raises Tina4::MessengerConnectionError on a connection/protocol failure.
|
|
254
309
|
# A successful fetch for a non-existent UID returns nil (that is NOT an error).
|
|
255
|
-
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
|
+
|
|
256
314
|
imap = imap_open("read")
|
|
257
315
|
begin
|
|
258
316
|
imap.select(folder)
|
|
@@ -307,8 +365,15 @@ module Tina4
|
|
|
307
365
|
page = uids[0, limit] || []
|
|
308
366
|
return [] if page.empty?
|
|
309
367
|
|
|
310
|
-
|
|
311
|
-
|
|
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) }
|
|
312
377
|
rescue *IMAP_CONNECTION_ERRORS => e
|
|
313
378
|
raise imap_fail("search", e)
|
|
314
379
|
ensure
|
|
@@ -344,6 +409,42 @@ module Tina4
|
|
|
344
409
|
Tina4::Log.error("IMAP mark_read failed: #{e.message}")
|
|
345
410
|
end
|
|
346
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
|
+
|
|
347
448
|
# Test IMAP connectivity without reading messages.
|
|
348
449
|
#
|
|
349
450
|
# @return [Hash] { success: Boolean, message: String }
|
|
@@ -528,7 +629,7 @@ module Tina4
|
|
|
528
629
|
# rather than swallowing the error into an empty result.
|
|
529
630
|
def imap_open(method)
|
|
530
631
|
imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
|
|
531
|
-
imap.login(@
|
|
632
|
+
imap.login(@imap_username, @imap_password)
|
|
532
633
|
imap
|
|
533
634
|
rescue *IMAP_CONNECTION_ERRORS => e
|
|
534
635
|
raise imap_fail(method, e)
|
|
@@ -569,64 +670,145 @@ module Tina4
|
|
|
569
670
|
# they can fail loud.
|
|
570
671
|
def imap_connect(&block)
|
|
571
672
|
imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
|
|
572
|
-
imap.login(@
|
|
673
|
+
imap.login(@imap_username, @imap_password)
|
|
573
674
|
result = block.call(imap)
|
|
574
675
|
imap.logout
|
|
575
676
|
imap.disconnect
|
|
576
677
|
result
|
|
577
678
|
end
|
|
578
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.
|
|
579
685
|
def parse_envelope(fetch_data)
|
|
580
686
|
env = fetch_data.attr["ENVELOPE"]
|
|
581
687
|
flags = fetch_data.attr["FLAGS"] || []
|
|
582
|
-
|
|
688
|
+
raw_body = fetch_data.attr["BODY[]"] || ""
|
|
583
689
|
|
|
584
690
|
{
|
|
585
|
-
|
|
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,
|
|
586
694
|
subject: env.subject ? decode_mime_header(env.subject) : "",
|
|
587
|
-
from:
|
|
588
|
-
to:
|
|
589
|
-
date: env.date,
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
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)
|
|
593
700
|
}
|
|
594
701
|
end
|
|
595
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.
|
|
596
710
|
def parse_full_message(fetch_data)
|
|
597
711
|
env = fetch_data.attr["ENVELOPE"]
|
|
598
|
-
flags = fetch_data.attr["FLAGS"] || []
|
|
599
712
|
raw_body = fetch_data.attr["BODY[]"] || ""
|
|
600
713
|
|
|
601
714
|
body_text, body_html = extract_body_parts(raw_body)
|
|
602
715
|
|
|
603
716
|
{
|
|
604
|
-
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,
|
|
605
718
|
subject: env.subject ? decode_mime_header(env.subject) : "",
|
|
606
|
-
from:
|
|
607
|
-
to:
|
|
608
|
-
cc:
|
|
609
|
-
date: env.date,
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
html: body_html,
|
|
615
|
-
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)
|
|
616
727
|
}
|
|
617
728
|
end
|
|
618
729
|
|
|
619
|
-
|
|
620
|
-
|
|
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?
|
|
621
735
|
|
|
622
736
|
addresses.map do |addr|
|
|
623
737
|
email = "#{addr.mailbox}@#{addr.host}"
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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]
|
|
628
809
|
end
|
|
629
810
|
end
|
|
811
|
+
headers
|
|
630
812
|
end
|
|
631
813
|
|
|
632
814
|
def decode_mime_header(value)
|
|
@@ -693,6 +875,36 @@ module Tina4
|
|
|
693
875
|
end
|
|
694
876
|
end
|
|
695
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
|
+
|
|
696
908
|
def build_search_criteria(subject:, sender:, since:, before:, unseen_only:)
|
|
697
909
|
criteria = []
|
|
698
910
|
criteria.push("SUBJECT", subject) if subject
|