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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +935 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -110
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -106
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2025 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +696 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -255
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +458 -458
- data/lib/tina4ruby.rb +4 -4
- metadata +3 -3
data/lib/tina4/dev_mailbox.rb
CHANGED
|
@@ -1,191 +1,191 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "securerandom"
|
|
5
|
-
require "time"
|
|
6
|
-
require "fileutils"
|
|
7
|
-
require "base64"
|
|
8
|
-
|
|
9
|
-
module Tina4
|
|
10
|
-
class DevMailbox
|
|
11
|
-
attr_reader :mailbox_dir
|
|
12
|
-
|
|
13
|
-
def initialize(mailbox_dir: nil)
|
|
14
|
-
@mailbox_dir = mailbox_dir || ENV["TINA4_MAILBOX_DIR"] || "data/mailbox"
|
|
15
|
-
ensure_dirs
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
# Capture an outgoing email to the local filesystem instead of sending
|
|
19
|
-
def capture(to:, subject:, body:, html: false, cc: [], bcc: [],
|
|
20
|
-
reply_to: nil, from_address: nil, from_name: nil, attachments: [])
|
|
21
|
-
msg_id = SecureRandom.uuid
|
|
22
|
-
timestamp = Time.now
|
|
23
|
-
|
|
24
|
-
message = {
|
|
25
|
-
id: msg_id,
|
|
26
|
-
from: { name: from_name, email: from_address },
|
|
27
|
-
to: normalize_recipients(to),
|
|
28
|
-
cc: normalize_recipients(cc),
|
|
29
|
-
bcc: normalize_recipients(bcc),
|
|
30
|
-
reply_to: reply_to,
|
|
31
|
-
subject: subject,
|
|
32
|
-
body: body,
|
|
33
|
-
html: html,
|
|
34
|
-
attachments: store_attachments(msg_id, attachments),
|
|
35
|
-
read: false,
|
|
36
|
-
folder: "outbox",
|
|
37
|
-
created_at: timestamp.strftime("%Y-%m-%dT%H:%M:%S.%6N%:z"),
|
|
38
|
-
updated_at: timestamp.iso8601
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
write_message(msg_id, message)
|
|
42
|
-
|
|
43
|
-
Tina4::Log.debug("DevMailbox captured email: #{subject} -> #{Array(to).join(', ')}")
|
|
44
|
-
{ success: true, message: "Email captured to dev mailbox", id: msg_id }
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# List messages in the mailbox
|
|
48
|
-
def inbox(limit: 50, offset: 0, folder: nil)
|
|
49
|
-
messages = load_all_messages
|
|
50
|
-
messages = messages.select { |m| m[:folder] == folder } if folder
|
|
51
|
-
messages.sort_by { |m| m[:created_at] || "" }.reverse[offset, limit] || []
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Read a single message by ID
|
|
55
|
-
def read(msg_id)
|
|
56
|
-
path = message_path(msg_id)
|
|
57
|
-
return nil unless File.exist?(path)
|
|
58
|
-
|
|
59
|
-
message = JSON.parse(File.read(path), symbolize_names: true)
|
|
60
|
-
unless message[:read]
|
|
61
|
-
message[:read] = true
|
|
62
|
-
message[:updated_at] = Time.now.iso8601
|
|
63
|
-
File.write(path, JSON.pretty_generate(message))
|
|
64
|
-
end
|
|
65
|
-
message
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# Count unread messages
|
|
69
|
-
def unread_count
|
|
70
|
-
load_all_messages.count { |m| m[:read] == false }
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Delete a message by ID
|
|
74
|
-
def delete(msg_id)
|
|
75
|
-
path = message_path(msg_id)
|
|
76
|
-
return false unless File.exist?(path)
|
|
77
|
-
|
|
78
|
-
File.delete(path)
|
|
79
|
-
# Clean up attachments directory
|
|
80
|
-
att_dir = File.join(@mailbox_dir, "attachments", msg_id)
|
|
81
|
-
FileUtils.rm_rf(att_dir) if Dir.exist?(att_dir)
|
|
82
|
-
true
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# Clear all messages, optionally by folder
|
|
86
|
-
def clear(folder: nil)
|
|
87
|
-
if folder
|
|
88
|
-
load_all_messages.each do |msg|
|
|
89
|
-
delete(msg[:id]) if msg[:folder] == folder
|
|
90
|
-
end
|
|
91
|
-
else
|
|
92
|
-
messages_dir = File.join(@mailbox_dir, "messages")
|
|
93
|
-
FileUtils.rm_rf(messages_dir)
|
|
94
|
-
FileUtils.rm_rf(File.join(@mailbox_dir, "attachments"))
|
|
95
|
-
ensure_dirs
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
# Seed the mailbox with sample messages for development
|
|
100
|
-
def seed(count: 5)
|
|
101
|
-
fake = Tina4::FakeData.new
|
|
102
|
-
count.times do |i|
|
|
103
|
-
name = fake.name
|
|
104
|
-
email = fake.email(from_name: name)
|
|
105
|
-
capture(
|
|
106
|
-
to: "dev@localhost",
|
|
107
|
-
subject: fake.sentence(words: 4 + rand(4)),
|
|
108
|
-
body: Array.new(2 + rand(3)) { fake.sentence(words: 8 + rand(8)) }.join("\n\n"),
|
|
109
|
-
html: i.even?,
|
|
110
|
-
from_address: email,
|
|
111
|
-
from_name: name
|
|
112
|
-
)
|
|
113
|
-
end
|
|
114
|
-
Tina4::Log.info("DevMailbox seeded with #{count} messages")
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# Count messages by folder
|
|
118
|
-
# Returns { inbox: N, outbox: N, total: N }
|
|
119
|
-
def count(folder: nil)
|
|
120
|
-
messages = load_all_messages
|
|
121
|
-
if folder
|
|
122
|
-
n = messages.count { |m| m[:folder] == folder }
|
|
123
|
-
{ folder.to_sym => n, total: n }
|
|
124
|
-
else
|
|
125
|
-
inbox_count = messages.count { |m| m[:folder] == "inbox" }
|
|
126
|
-
outbox_count = messages.count { |m| m[:folder] == "outbox" }
|
|
127
|
-
{ inbox: inbox_count, outbox: outbox_count, total: messages.length }
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
private
|
|
132
|
-
|
|
133
|
-
def ensure_dirs
|
|
134
|
-
FileUtils.mkdir_p(File.join(@mailbox_dir, "messages"))
|
|
135
|
-
FileUtils.mkdir_p(File.join(@mailbox_dir, "attachments"))
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def message_path(msg_id)
|
|
139
|
-
File.join(@mailbox_dir, "messages", "#{msg_id}.json")
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def write_message(msg_id, message)
|
|
143
|
-
File.write(message_path(msg_id), JSON.pretty_generate(message))
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def load_all_messages
|
|
147
|
-
pattern = File.join(@mailbox_dir, "messages", "*.json")
|
|
148
|
-
Dir.glob(pattern).filter_map do |path|
|
|
149
|
-
JSON.parse(File.read(path), symbolize_names: true)
|
|
150
|
-
rescue JSON::ParserError => e
|
|
151
|
-
Tina4::Log.error("DevMailbox: corrupt message file #{path}: #{e.message}")
|
|
152
|
-
nil
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def normalize_recipients(value)
|
|
157
|
-
case value
|
|
158
|
-
when nil then []
|
|
159
|
-
when String then [value]
|
|
160
|
-
when Array then value.flatten.compact
|
|
161
|
-
else [value.to_s]
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def store_attachments(msg_id, attachments)
|
|
166
|
-
return [] if attachments.nil? || attachments.empty?
|
|
167
|
-
|
|
168
|
-
att_dir = File.join(@mailbox_dir, "attachments", msg_id)
|
|
169
|
-
FileUtils.mkdir_p(att_dir)
|
|
170
|
-
|
|
171
|
-
attachments.map do |attachment|
|
|
172
|
-
if attachment.is_a?(Hash)
|
|
173
|
-
filename = attachment[:filename] || attachment[:name] || "attachment"
|
|
174
|
-
content = attachment[:content] || ""
|
|
175
|
-
mime = attachment[:mime_type] || attachment[:content_type] || "application/octet-stream"
|
|
176
|
-
elsif attachment.is_a?(String) && File.exist?(attachment)
|
|
177
|
-
filename = File.basename(attachment)
|
|
178
|
-
content = File.binread(attachment)
|
|
179
|
-
mime = "application/octet-stream"
|
|
180
|
-
else
|
|
181
|
-
next nil
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
file_path = File.join(att_dir, filename)
|
|
185
|
-
File.binwrite(file_path, content)
|
|
186
|
-
|
|
187
|
-
{ filename: filename, mime_type: mime, size: content.bytesize, path: file_path }
|
|
188
|
-
end.compact
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "base64"
|
|
8
|
+
|
|
9
|
+
module Tina4
|
|
10
|
+
class DevMailbox
|
|
11
|
+
attr_reader :mailbox_dir
|
|
12
|
+
|
|
13
|
+
def initialize(mailbox_dir: nil)
|
|
14
|
+
@mailbox_dir = mailbox_dir || ENV["TINA4_MAILBOX_DIR"] || "data/mailbox"
|
|
15
|
+
ensure_dirs
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Capture an outgoing email to the local filesystem instead of sending
|
|
19
|
+
def capture(to:, subject:, body:, html: false, cc: [], bcc: [],
|
|
20
|
+
reply_to: nil, from_address: nil, from_name: nil, attachments: [])
|
|
21
|
+
msg_id = SecureRandom.uuid
|
|
22
|
+
timestamp = Time.now
|
|
23
|
+
|
|
24
|
+
message = {
|
|
25
|
+
id: msg_id,
|
|
26
|
+
from: { name: from_name, email: from_address },
|
|
27
|
+
to: normalize_recipients(to),
|
|
28
|
+
cc: normalize_recipients(cc),
|
|
29
|
+
bcc: normalize_recipients(bcc),
|
|
30
|
+
reply_to: reply_to,
|
|
31
|
+
subject: subject,
|
|
32
|
+
body: body,
|
|
33
|
+
html: html,
|
|
34
|
+
attachments: store_attachments(msg_id, attachments),
|
|
35
|
+
read: false,
|
|
36
|
+
folder: "outbox",
|
|
37
|
+
created_at: timestamp.strftime("%Y-%m-%dT%H:%M:%S.%6N%:z"),
|
|
38
|
+
updated_at: timestamp.iso8601
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
write_message(msg_id, message)
|
|
42
|
+
|
|
43
|
+
Tina4::Log.debug("DevMailbox captured email: #{subject} -> #{Array(to).join(', ')}")
|
|
44
|
+
{ success: true, message: "Email captured to dev mailbox", id: msg_id }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# List messages in the mailbox
|
|
48
|
+
def inbox(limit: 50, offset: 0, folder: nil)
|
|
49
|
+
messages = load_all_messages
|
|
50
|
+
messages = messages.select { |m| m[:folder] == folder } if folder
|
|
51
|
+
messages.sort_by { |m| m[:created_at] || "" }.reverse[offset, limit] || []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Read a single message by ID
|
|
55
|
+
def read(msg_id)
|
|
56
|
+
path = message_path(msg_id)
|
|
57
|
+
return nil unless File.exist?(path)
|
|
58
|
+
|
|
59
|
+
message = JSON.parse(File.read(path), symbolize_names: true)
|
|
60
|
+
unless message[:read]
|
|
61
|
+
message[:read] = true
|
|
62
|
+
message[:updated_at] = Time.now.iso8601
|
|
63
|
+
File.write(path, JSON.pretty_generate(message))
|
|
64
|
+
end
|
|
65
|
+
message
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Count unread messages
|
|
69
|
+
def unread_count
|
|
70
|
+
load_all_messages.count { |m| m[:read] == false }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Delete a message by ID
|
|
74
|
+
def delete(msg_id)
|
|
75
|
+
path = message_path(msg_id)
|
|
76
|
+
return false unless File.exist?(path)
|
|
77
|
+
|
|
78
|
+
File.delete(path)
|
|
79
|
+
# Clean up attachments directory
|
|
80
|
+
att_dir = File.join(@mailbox_dir, "attachments", msg_id)
|
|
81
|
+
FileUtils.rm_rf(att_dir) if Dir.exist?(att_dir)
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Clear all messages, optionally by folder
|
|
86
|
+
def clear(folder: nil)
|
|
87
|
+
if folder
|
|
88
|
+
load_all_messages.each do |msg|
|
|
89
|
+
delete(msg[:id]) if msg[:folder] == folder
|
|
90
|
+
end
|
|
91
|
+
else
|
|
92
|
+
messages_dir = File.join(@mailbox_dir, "messages")
|
|
93
|
+
FileUtils.rm_rf(messages_dir)
|
|
94
|
+
FileUtils.rm_rf(File.join(@mailbox_dir, "attachments"))
|
|
95
|
+
ensure_dirs
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Seed the mailbox with sample messages for development
|
|
100
|
+
def seed(count: 5)
|
|
101
|
+
fake = Tina4::FakeData.new
|
|
102
|
+
count.times do |i|
|
|
103
|
+
name = fake.name
|
|
104
|
+
email = fake.email(from_name: name)
|
|
105
|
+
capture(
|
|
106
|
+
to: "dev@localhost",
|
|
107
|
+
subject: fake.sentence(words: 4 + rand(4)),
|
|
108
|
+
body: Array.new(2 + rand(3)) { fake.sentence(words: 8 + rand(8)) }.join("\n\n"),
|
|
109
|
+
html: i.even?,
|
|
110
|
+
from_address: email,
|
|
111
|
+
from_name: name
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
Tina4::Log.info("DevMailbox seeded with #{count} messages")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Count messages by folder
|
|
118
|
+
# Returns { inbox: N, outbox: N, total: N }
|
|
119
|
+
def count(folder: nil)
|
|
120
|
+
messages = load_all_messages
|
|
121
|
+
if folder
|
|
122
|
+
n = messages.count { |m| m[:folder] == folder }
|
|
123
|
+
{ folder.to_sym => n, total: n }
|
|
124
|
+
else
|
|
125
|
+
inbox_count = messages.count { |m| m[:folder] == "inbox" }
|
|
126
|
+
outbox_count = messages.count { |m| m[:folder] == "outbox" }
|
|
127
|
+
{ inbox: inbox_count, outbox: outbox_count, total: messages.length }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def ensure_dirs
|
|
134
|
+
FileUtils.mkdir_p(File.join(@mailbox_dir, "messages"))
|
|
135
|
+
FileUtils.mkdir_p(File.join(@mailbox_dir, "attachments"))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def message_path(msg_id)
|
|
139
|
+
File.join(@mailbox_dir, "messages", "#{msg_id}.json")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def write_message(msg_id, message)
|
|
143
|
+
File.write(message_path(msg_id), JSON.pretty_generate(message))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def load_all_messages
|
|
147
|
+
pattern = File.join(@mailbox_dir, "messages", "*.json")
|
|
148
|
+
Dir.glob(pattern).filter_map do |path|
|
|
149
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
150
|
+
rescue JSON::ParserError => e
|
|
151
|
+
Tina4::Log.error("DevMailbox: corrupt message file #{path}: #{e.message}")
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def normalize_recipients(value)
|
|
157
|
+
case value
|
|
158
|
+
when nil then []
|
|
159
|
+
when String then [value]
|
|
160
|
+
when Array then value.flatten.compact
|
|
161
|
+
else [value.to_s]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def store_attachments(msg_id, attachments)
|
|
166
|
+
return [] if attachments.nil? || attachments.empty?
|
|
167
|
+
|
|
168
|
+
att_dir = File.join(@mailbox_dir, "attachments", msg_id)
|
|
169
|
+
FileUtils.mkdir_p(att_dir)
|
|
170
|
+
|
|
171
|
+
attachments.map do |attachment|
|
|
172
|
+
if attachment.is_a?(Hash)
|
|
173
|
+
filename = attachment[:filename] || attachment[:name] || "attachment"
|
|
174
|
+
content = attachment[:content] || ""
|
|
175
|
+
mime = attachment[:mime_type] || attachment[:content_type] || "application/octet-stream"
|
|
176
|
+
elsif attachment.is_a?(String) && File.exist?(attachment)
|
|
177
|
+
filename = File.basename(attachment)
|
|
178
|
+
content = File.binread(attachment)
|
|
179
|
+
mime = "application/octet-stream"
|
|
180
|
+
else
|
|
181
|
+
next nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
file_path = File.join(att_dir, filename)
|
|
185
|
+
File.binwrite(file_path, content)
|
|
186
|
+
|
|
187
|
+
{ filename: filename, mime_type: mime, size: content.bytesize, path: file_path }
|
|
188
|
+
end.compact
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -1,110 +1,124 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Tina4
|
|
4
|
-
module Drivers
|
|
5
|
-
class FirebirdDriver
|
|
6
|
-
attr_reader :connection
|
|
7
|
-
|
|
8
|
-
def connect(connection_string, username: nil, password: nil)
|
|
9
|
-
require "fb"
|
|
10
|
-
require "uri"
|
|
11
|
-
uri = URI.parse(connection_string)
|
|
12
|
-
host = uri.host
|
|
13
|
-
port = uri.port || 3050
|
|
14
|
-
db_path = uri.path&.sub(/^\//, "")
|
|
15
|
-
db_user = username || uri.user
|
|
16
|
-
db_pass = password || uri.password
|
|
17
|
-
|
|
18
|
-
database = if host
|
|
19
|
-
"#{host}/#{port}:#{db_path}"
|
|
20
|
-
else
|
|
21
|
-
db_path || connection_string.sub(/^firebird:\/\//, "")
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
opts = { database: database }
|
|
25
|
-
opts[:username] = db_user if db_user
|
|
26
|
-
opts[:password] = db_pass if db_pass
|
|
27
|
-
@connection = Fb::Database.new(**opts).connect
|
|
28
|
-
rescue LoadError
|
|
29
|
-
raise "Firebird driver requires the 'fb' gem. Install it with: gem install fb"
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def close
|
|
33
|
-
@connection&.close
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def execute_query(sql, params = [])
|
|
37
|
-
if params.empty?
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
rows
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
|
|
92
|
-
rows
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module Drivers
|
|
5
|
+
class FirebirdDriver
|
|
6
|
+
attr_reader :connection
|
|
7
|
+
|
|
8
|
+
def connect(connection_string, username: nil, password: nil)
|
|
9
|
+
require "fb"
|
|
10
|
+
require "uri"
|
|
11
|
+
uri = URI.parse(connection_string)
|
|
12
|
+
host = uri.host
|
|
13
|
+
port = uri.port || 3050
|
|
14
|
+
db_path = uri.path&.sub(/^\//, "")
|
|
15
|
+
db_user = username || uri.user
|
|
16
|
+
db_pass = password || uri.password
|
|
17
|
+
|
|
18
|
+
database = if host
|
|
19
|
+
"#{host}/#{port}:#{db_path}"
|
|
20
|
+
else
|
|
21
|
+
db_path || connection_string.sub(/^firebird:\/\//, "")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
opts = { database: database }
|
|
25
|
+
opts[:username] = db_user if db_user
|
|
26
|
+
opts[:password] = db_pass if db_pass
|
|
27
|
+
@connection = Fb::Database.new(**opts).connect
|
|
28
|
+
rescue LoadError
|
|
29
|
+
raise "Firebird driver requires the 'fb' gem. Install it with: gem install fb"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def close
|
|
33
|
+
@connection&.close
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def execute_query(sql, params = [])
|
|
37
|
+
rows = if params.empty?
|
|
38
|
+
@connection.query(:hash, sql)
|
|
39
|
+
else
|
|
40
|
+
@connection.query(:hash, sql, *params)
|
|
41
|
+
end
|
|
42
|
+
rows.map { |row| decode_blobs(stringify_keys(row)) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def execute(sql, params = [])
|
|
46
|
+
if params.empty?
|
|
47
|
+
@connection.execute(sql)
|
|
48
|
+
else
|
|
49
|
+
@connection.execute(sql, *params)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def last_insert_id
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def placeholder
|
|
58
|
+
"?"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def placeholders(count)
|
|
62
|
+
(["?"] * count).join(", ")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def apply_limit(sql, limit, offset = 0)
|
|
66
|
+
"SELECT FIRST #{limit} SKIP #{offset} * FROM (#{sql})"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def begin_transaction
|
|
70
|
+
@transaction = @connection.transaction
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def commit
|
|
74
|
+
@transaction&.commit
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def rollback
|
|
78
|
+
@transaction&.rollback
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def tables
|
|
82
|
+
sql = "SELECT RDB\$RELATION_NAME FROM RDB\$RELATIONS WHERE RDB\$SYSTEM_FLAG = 0 AND RDB\$VIEW_BLR IS NULL"
|
|
83
|
+
rows = execute_query(sql)
|
|
84
|
+
rows.map { |r| (r["RDB\$RELATION_NAME"] || r["rdb\$relation_name"] || "").strip }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def columns(table_name)
|
|
88
|
+
sql = "SELECT RF.RDB\$FIELD_NAME, F.RDB\$FIELD_TYPE, RF.RDB\$NULL_FLAG, RF.RDB\$DEFAULT_SOURCE " \
|
|
89
|
+
"FROM RDB\$RELATION_FIELDS RF " \
|
|
90
|
+
"JOIN RDB\$FIELDS F ON RF.RDB\$FIELD_SOURCE = F.RDB\$FIELD_NAME " \
|
|
91
|
+
"WHERE RF.RDB\$RELATION_NAME = ?"
|
|
92
|
+
rows = execute_query(sql, [table_name.upcase])
|
|
93
|
+
rows.map do |r|
|
|
94
|
+
{
|
|
95
|
+
name: (r["RDB\$FIELD_NAME"] || r["rdb\$field_name"] || "").strip,
|
|
96
|
+
type: r["RDB\$FIELD_TYPE"] || r["rdb\$field_type"],
|
|
97
|
+
nullable: (r["RDB\$NULL_FLAG"] || r["rdb\$null_flag"]).nil?,
|
|
98
|
+
default: r["RDB\$DEFAULT_SOURCE"] || r["rdb\$default_source"],
|
|
99
|
+
primary_key: false
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def stringify_keys(hash)
|
|
107
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Ensure Firebird BLOB columns are proper byte strings.
|
|
111
|
+
# The Fb gem may return BLOBs as resource handles or IO objects —
|
|
112
|
+
# read them into strings if needed.
|
|
113
|
+
def decode_blobs(row)
|
|
114
|
+
row.each do |key, value|
|
|
115
|
+
if value.respond_to?(:read)
|
|
116
|
+
row[key] = value.read
|
|
117
|
+
value.close if value.respond_to?(:close)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
row
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|