tina4ruby 3.13.137 → 3.13.138
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 +36 -0
- data/COMMERCIAL-LICENSE.md +8 -0
- data/LICENSE +373 -0
- data/LICENSE.txt +373 -21
- data/NOTICE +11 -0
- data/README.md +11 -7
- data/exe/tina4ruby +6 -0
- data/lib/tina4/ai.rb +6 -0
- data/lib/tina4/ai_client.rb +10 -3
- data/lib/tina4/api.rb +57 -7
- data/lib/tina4/auth.rb +18 -12
- data/lib/tina4/auto_crud.rb +83 -27
- data/lib/tina4/background.rb +7 -1
- data/lib/tina4/base64.rb +59 -0
- data/lib/tina4/cache.rb +6 -0
- data/lib/tina4/cache_backends/base_backend.rb +6 -0
- data/lib/tina4/cache_backends/database_backend.rb +6 -0
- data/lib/tina4/cache_backends/file_backend.rb +6 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
- data/lib/tina4/cache_backends/memory_backend.rb +6 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
- data/lib/tina4/cache_backends/redis_backend.rb +6 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
- data/lib/tina4/cache_backends.rb +6 -0
- data/lib/tina4/cli.rb +22 -7
- data/lib/tina4/constants.rb +12 -8
- data/lib/tina4/container.rb +6 -0
- data/lib/tina4/context/chunker.rb +6 -0
- data/lib/tina4/context.rb +13 -5
- data/lib/tina4/cors.rb +66 -10
- data/lib/tina4/crud.rb +78 -9
- data/lib/tina4/database/sqlite3_adapter.rb +9 -1
- data/lib/tina4/database.rb +260 -44
- data/lib/tina4/database_adapter.rb +6 -0
- data/lib/tina4/database_result.rb +6 -0
- data/lib/tina4/database_url.rb +6 -0
- data/lib/tina4/debug.rb +6 -0
- data/lib/tina4/dev.rb +6 -0
- data/lib/tina4/dev_admin.rb +215 -89
- data/lib/tina4/dev_mailbox.rb +6 -1
- data/lib/tina4/dispatch_pipeline.rb +86 -1
- data/lib/tina4/docs.rb +6 -0
- data/lib/tina4/docstore.rb +23 -4
- data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
- data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
- data/lib/tina4/drivers/firebird_driver.rb +95 -6
- data/lib/tina4/drivers/mongodb_driver.rb +6 -0
- data/lib/tina4/drivers/mssql_driver.rb +54 -39
- data/lib/tina4/drivers/mysql_driver.rb +6 -0
- data/lib/tina4/drivers/odbc_driver.rb +6 -0
- data/lib/tina4/drivers/postgres_driver.rb +35 -8
- data/lib/tina4/drivers/schema_split.rb +6 -0
- data/lib/tina4/drivers/sqlite_driver.rb +8 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +6 -0
- data/lib/tina4/error_overlay.rb +6 -0
- data/lib/tina4/events.rb +6 -0
- data/lib/tina4/feedback.rb +9 -2
- data/lib/tina4/field_types.rb +6 -0
- data/lib/tina4/form_parser.rb +241 -0
- data/lib/tina4/frond.rb +196 -56
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
- data/lib/tina4/graph.rb +6 -0
- data/lib/tina4/graphql.rb +28 -27
- data/lib/tina4/health.rb +6 -0
- data/lib/tina4/html_element.rb +6 -0
- data/lib/tina4/http_server.rb +690 -0
- data/lib/tina4/imap_client.rb +352 -0
- data/lib/tina4/import_helper.rb +6 -0
- data/lib/tina4/job.rb +6 -0
- data/lib/tina4/localization.rb +6 -0
- data/lib/tina4/log.rb +6 -0
- data/lib/tina4/mail_socket.rb +124 -0
- data/lib/tina4/mcp.rb +15 -8
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +24 -9
- data/lib/tina4/migration.rb +6 -0
- data/lib/tina4/model_collection.rb +6 -0
- data/lib/tina4/mqtt.rb +9 -2
- data/lib/tina4/mqtt_message.rb +6 -0
- data/lib/tina4/orm.rb +72 -16
- data/lib/tina4/parse_json.rb +25 -0
- data/lib/tina4/plan.rb +9 -2
- data/lib/tina4/point.rb +6 -0
- data/lib/tina4/port_takeover.rb +6 -0
- data/lib/tina4/project_index.rb +6 -0
- data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
- data/lib/tina4/public/bundled-component-provenance.json +48 -0
- data/lib/tina4/public/js/tina4js.min.js +13 -3
- data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
- data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
- data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
- data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
- data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
- data/lib/tina4/push.rb +9 -3
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
- data/lib/tina4/queue_backends/lite_backend.rb +6 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
- data/lib/tina4/rack_app.rb +11 -1
- data/lib/tina4/rate_limiter.rb +6 -0
- data/lib/tina4/realtime/attachment.rb +6 -0
- data/lib/tina4/realtime/channel.rb +6 -0
- data/lib/tina4/realtime/channel_member.rb +6 -0
- data/lib/tina4/realtime/local_storage.rb +6 -0
- data/lib/tina4/realtime/message.rb +6 -0
- data/lib/tina4/realtime/s3_storage.rb +6 -0
- data/lib/tina4/realtime/storage.rb +6 -0
- data/lib/tina4/realtime/storage_backend.rb +6 -0
- data/lib/tina4/realtime/workspace.rb +6 -0
- data/lib/tina4/realtime.rb +10 -3
- data/lib/tina4/request.rb +85 -127
- data/lib/tina4/response.rb +71 -9
- data/lib/tina4/response_cache.rb +6 -0
- data/lib/tina4/router.rb +6 -0
- data/lib/tina4/secret_file.rb +34 -0
- data/lib/tina4/seeder.rb +9 -3
- data/lib/tina4/service.rb +8 -2
- data/lib/tina4/service_runner.rb +6 -0
- data/lib/tina4/session.rb +6 -0
- data/lib/tina4/session_handlers/database_handler.rb +6 -0
- data/lib/tina4/session_handlers/file_handler.rb +6 -0
- data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
- data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
- data/lib/tina4/session_handlers/redis_handler.rb +6 -0
- data/lib/tina4/session_handlers/resp_client.rb +6 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
- data/lib/tina4/shutdown.rb +17 -14
- data/lib/tina4/smtp_client.rb +222 -0
- data/lib/tina4/sql_translator.rb +93 -0
- data/lib/tina4/sqlite3_gem.rb +27 -0
- data/lib/tina4/sso.rb +25 -8
- data/lib/tina4/swagger.rb +6 -0
- data/lib/tina4/template.rb +6 -0
- data/lib/tina4/test.rb +8 -2
- data/lib/tina4/test_client.rb +8 -1
- data/lib/tina4/testing.rb +8 -1
- data/lib/tina4/validator.rb +6 -0
- data/lib/tina4/version.rb +7 -1
- data/lib/tina4/webserver.rb +25 -143
- data/lib/tina4/websocket.rb +10 -4
- data/lib/tina4/websocket_backplane.rb +19 -2
- data/lib/tina4/wsdl.rb +31 -16
- data/lib/tina4/xml_parser.rb +259 -0
- data/lib/tina4.rb +87 -31
- data/lib/tina4ruby.rb +6 -0
- metadata +43 -140
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
require_relative "mail_socket"
|
|
10
|
+
|
|
11
|
+
module Tina4
|
|
12
|
+
class Messenger
|
|
13
|
+
# Tina4's own IMAP4rev1 client (RFC 3501), replacing the net-imap gem. It
|
|
14
|
+
# speaks exactly the commands Messenger uses -- LOGIN, SELECT, UID SEARCH,
|
|
15
|
+
# UID FETCH, UID STORE, EXPUNGE, LIST, LOGOUT, plus STARTTLS -- and returns
|
|
16
|
+
# the shapes Messenger already consumed from Net::IMAP: FetchData#attr with
|
|
17
|
+
# "UID" (Integer), "FLAGS" (system flags as Symbols, :Seen), "ENVELOPE"
|
|
18
|
+
# (Envelope / Address structs) and "BODY[]" (the raw message, binary).
|
|
19
|
+
#
|
|
20
|
+
# imap = ImapClient.new("imap.example.com", port: 993, tls: true)
|
|
21
|
+
# imap.login("user", "secret")
|
|
22
|
+
# imap.select("INBOX")
|
|
23
|
+
# imap.uid_search(["UNSEEN"]) # => [4, 7]
|
|
24
|
+
class ImapClient
|
|
25
|
+
# A NO / BAD / BYE from the server (#status says which; the message is the
|
|
26
|
+
# server's text, as Net::IMAP::ResponseError gave), or an unparseable reply.
|
|
27
|
+
class Error < StandardError
|
|
28
|
+
attr_reader :status
|
|
29
|
+
|
|
30
|
+
def initialize(message = nil, status: nil)
|
|
31
|
+
@status = status
|
|
32
|
+
super(message)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
FetchData = Struct.new(:seqno, :attr)
|
|
37
|
+
Envelope = Struct.new(:date, :subject, :from, :sender, :reply_to,
|
|
38
|
+
:to, :cc, :bcc, :in_reply_to, :message_id)
|
|
39
|
+
Address = Struct.new(:name, :route, :mailbox, :host)
|
|
40
|
+
|
|
41
|
+
SEQUENCE_SET = /\A(?:\d+|\*)(?::(?:\d+|\*))?(?:,(?:\d+|\*)(?::(?:\d+|\*))?)*\z/
|
|
42
|
+
|
|
43
|
+
# tls: implicit TLS from the first byte (IMAPS). starttls: plain connect,
|
|
44
|
+
# then STARTTLS before anything else is said; the upgrade is REQUIRED.
|
|
45
|
+
def initialize(host, port:, tls: false, starttls: false, timeout: 30)
|
|
46
|
+
@host = host
|
|
47
|
+
@tag_number = 0
|
|
48
|
+
@socket = MailSocket.open(host, port, tls: tls, timeout: timeout)
|
|
49
|
+
greeting = read_response
|
|
50
|
+
raise Error, "#{host}:#{port} refused the connection: #{text(greeting.strip)}" if greeting.start_with?("* BYE")
|
|
51
|
+
raise Error, "#{host}:#{port} is not an IMAP server: #{text(greeting.strip)}" unless greeting.start_with?("* ")
|
|
52
|
+
|
|
53
|
+
start_tls if starttls && !tls
|
|
54
|
+
rescue StandardError
|
|
55
|
+
disconnect
|
|
56
|
+
raise
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def login(username, password)
|
|
60
|
+
command("LOGIN", astring(username), astring(password))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def select(mailbox)
|
|
64
|
+
command("SELECT", astring(mailbox))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# UID SEARCH with Net::IMAP-style criteria: ["SUBJECT", "hello", "UNSEEN"].
|
|
68
|
+
# Non-ASCII criteria are sent as literals under CHARSET UTF-8.
|
|
69
|
+
def uid_search(criteria)
|
|
70
|
+
arguments = Array(criteria).map { |criterion| criterion.is_a?(Integer) ? criterion.to_s : astring(criterion.to_s) }
|
|
71
|
+
arguments.unshift("CHARSET", "UTF-8") if Array(criteria).any? { |c| !c.to_s.ascii_only? }
|
|
72
|
+
command("UID SEARCH", *arguments).flat_map do |line|
|
|
73
|
+
next [] unless line.start_with?("* SEARCH")
|
|
74
|
+
|
|
75
|
+
line.sub("* SEARCH", "").split.filter_map { |number| Integer(number, exception: false) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# UID FETCH. +set+ is a UID, an Array of UIDs or a sequence-set String;
|
|
80
|
+
# +items+ e.g. ["ENVELOPE", "FLAGS", "BODY.PEEK[]"]. Returns FetchData rows
|
|
81
|
+
# in the order the server sent them (servers answer in ascending order).
|
|
82
|
+
def uid_fetch(set, items)
|
|
83
|
+
lines = command("UID FETCH", sequence_set(set), "(#{Array(items).join(' ')})")
|
|
84
|
+
lines.filter_map do |line|
|
|
85
|
+
match = line.match(/\A\* (\d+) FETCH /)
|
|
86
|
+
next unless match
|
|
87
|
+
|
|
88
|
+
pairs = Parser.new(line, match.end(0)).value
|
|
89
|
+
attr = {}
|
|
90
|
+
pairs.each_slice(2) do |name, value|
|
|
91
|
+
key = name.to_s.upcase.force_encoding(Encoding::UTF_8)
|
|
92
|
+
attr[key] = fetch_value(key, value)
|
|
93
|
+
end
|
|
94
|
+
next unless attr.key?("UID") # an unsolicited flag update for another message
|
|
95
|
+
|
|
96
|
+
FetchData.new(match[1].to_i, attr)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# UID STORE set +FLAGS|-FLAGS (\Seen ...). flags: [:Seen] or ["\\Seen"].
|
|
101
|
+
def uid_store(set, action, flags)
|
|
102
|
+
flag_list = Array(flags).map { |flag| flag.is_a?(Symbol) ? "\\#{flag}" : flag.to_s }
|
|
103
|
+
if flag_list.any? { |flag| !flag.match?(/\A\\?[A-Za-z0-9$_.\-]+\z/) }
|
|
104
|
+
raise Error, "invalid flag list #{flags.inspect}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
raise Error, "invalid STORE action #{action.inspect}" unless action.to_s.match?(/\A[+-]?FLAGS(\.SILENT)?\z/i)
|
|
108
|
+
|
|
109
|
+
command("UID STORE", sequence_set(set), action.to_s, "(#{flag_list.join(' ')})")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def expunge
|
|
113
|
+
command("EXPUNGE")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# LIST reference pattern. Returns the mailbox names (INBOX normalised).
|
|
117
|
+
def list(reference, pattern)
|
|
118
|
+
command("LIST", astring(reference), astring(pattern)).filter_map do |line|
|
|
119
|
+
next unless line.start_with?("* LIST ")
|
|
120
|
+
|
|
121
|
+
parser = Parser.new(line, "* LIST ".length)
|
|
122
|
+
parser.value # attributes
|
|
123
|
+
parser.value # hierarchy delimiter
|
|
124
|
+
name = text(parser.value)
|
|
125
|
+
name.casecmp?("INBOX") ? "INBOX" : name
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def logout
|
|
130
|
+
command("LOGOUT")
|
|
131
|
+
rescue EOFError
|
|
132
|
+
nil # some servers close straight after the BYE
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def disconnect
|
|
136
|
+
@socket&.close
|
|
137
|
+
@socket = nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def start_tls
|
|
143
|
+
command("STARTTLS")
|
|
144
|
+
@socket.start_tls
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Send a tagged command and collect the untagged lines until its completion.
|
|
148
|
+
# Arguments are already encoded (atoms, quoted strings or literals).
|
|
149
|
+
def command(name, *arguments)
|
|
150
|
+
raise Error, "not connected" unless @socket
|
|
151
|
+
|
|
152
|
+
tag = "T#{@tag_number += 1}"
|
|
153
|
+
send_command(tag, [name, *arguments])
|
|
154
|
+
untagged = []
|
|
155
|
+
loop do
|
|
156
|
+
response = read_response
|
|
157
|
+
if response.start_with?("#{tag} ")
|
|
158
|
+
status, detail = response[(tag.length + 1)..].chomp.split(" ", 2)
|
|
159
|
+
return untagged if status.casecmp?("OK")
|
|
160
|
+
|
|
161
|
+
raise Error.new(text(detail.to_s.strip), status: status.upcase)
|
|
162
|
+
end
|
|
163
|
+
if response.start_with?("* BYE") && name != "LOGOUT"
|
|
164
|
+
raise Error.new(text(response.chomp.sub(/\A\* BYE ?/, "")), status: "BYE")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
untagged << response
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Literal arguments ({n}) go out one chunk at a time, each after the
|
|
172
|
+
# server's "+" continuation, exactly as RFC 3501 7.5 requires.
|
|
173
|
+
def send_command(tag, parts)
|
|
174
|
+
buffer = +"#{tag}"
|
|
175
|
+
parts.each do |part|
|
|
176
|
+
buffer << " "
|
|
177
|
+
if part.is_a?(Literal)
|
|
178
|
+
@socket.write("#{buffer}{#{part.bytes.bytesize}}\r\n")
|
|
179
|
+
await_continuation
|
|
180
|
+
buffer = part.bytes.dup
|
|
181
|
+
else
|
|
182
|
+
buffer << part
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
@socket.write("#{buffer}\r\n")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def await_continuation
|
|
189
|
+
loop do
|
|
190
|
+
response = read_response
|
|
191
|
+
return if response.start_with?("+")
|
|
192
|
+
raise Error, text(response.chomp) if response.match?(/\AT\d+ (NO|BAD)/)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# One complete server response, literals inlined: a line ending in {n}
|
|
197
|
+
# is followed by n raw bytes and then the rest of the response.
|
|
198
|
+
def read_response
|
|
199
|
+
response = @socket.read_line
|
|
200
|
+
while (match = response.match(/\{(\d+)\+?\}\r\n\z/n))
|
|
201
|
+
response << @socket.read_bytes(match[1].to_i)
|
|
202
|
+
response << @socket.read_line
|
|
203
|
+
end
|
|
204
|
+
response
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
Literal = Struct.new(:bytes)
|
|
208
|
+
|
|
209
|
+
# astring (RFC 3501): an atom when it is safe, a quoted string when it has
|
|
210
|
+
# specials, a literal when it has 8-bit bytes or CR/LF (the rule Net::IMAP
|
|
211
|
+
# follows, with "]" also quoted).
|
|
212
|
+
def astring(value)
|
|
213
|
+
string = value.to_s
|
|
214
|
+
return '""' if string.empty?
|
|
215
|
+
return Literal.new(string.b) if string.b.match?(/[\x80-\xff\r\n\x00]/n)
|
|
216
|
+
return %("#{string.gsub(/["\\]/) { |c| "\\#{c}" }}") if string.match?(/[(){ \x00-\x1f\x7f%*"\\\]]/)
|
|
217
|
+
|
|
218
|
+
string
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def sequence_set(set)
|
|
222
|
+
text = Array(set).map(&:to_s).join(",")
|
|
223
|
+
raise Error, "invalid UID set #{set.inspect}" unless text.match?(SEQUENCE_SET)
|
|
224
|
+
|
|
225
|
+
text
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def fetch_value(name, value)
|
|
229
|
+
case name
|
|
230
|
+
when "UID", "RFC822.SIZE" then value.to_i
|
|
231
|
+
when "FLAGS" then Array(value).map { |flag| flag.start_with?("\\") ? flag[1..].capitalize.to_sym : flag }
|
|
232
|
+
when "ENVELOPE" then envelope(value)
|
|
233
|
+
else value
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def envelope(fields)
|
|
238
|
+
return nil unless fields.is_a?(Array)
|
|
239
|
+
|
|
240
|
+
date, subject, from, sender, reply_to, to, cc, bcc, in_reply_to, message_id = fields
|
|
241
|
+
Envelope.new(text(date), text(subject), addresses(from), addresses(sender), addresses(reply_to),
|
|
242
|
+
addresses(to), addresses(cc), addresses(bcc), text(in_reply_to), text(message_id))
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def addresses(list)
|
|
246
|
+
return nil unless list.is_a?(Array)
|
|
247
|
+
|
|
248
|
+
list.filter_map do |parts|
|
|
249
|
+
next unless parts.is_a?(Array)
|
|
250
|
+
|
|
251
|
+
name, route, mailbox, host = parts.map { |part| text(part) }
|
|
252
|
+
Address.new(name, route, mailbox, host)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Envelope text is 7-bit by RFC, but servers relay raw 8-bit headers; make
|
|
257
|
+
# it valid UTF-8 so the MIME-word decoding downstream can never raise.
|
|
258
|
+
def text(value)
|
|
259
|
+
return nil if value.nil?
|
|
260
|
+
|
|
261
|
+
value.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Parses one IMAP value (parenthesised list, quoted string, literal, NIL,
|
|
265
|
+
# or atom -- including "BODY[...]" section atoms) from a response line.
|
|
266
|
+
# Strings stay binary; the caller decides their encoding.
|
|
267
|
+
class Parser
|
|
268
|
+
def initialize(line, position)
|
|
269
|
+
@line = line.b
|
|
270
|
+
@position = position
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def value
|
|
274
|
+
skip_spaces
|
|
275
|
+
case @line.getbyte(@position)
|
|
276
|
+
when 0x28 then list # (
|
|
277
|
+
when 0x22 then quoted # "
|
|
278
|
+
when 0x7B then literal # {
|
|
279
|
+
else atom_or_nil
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
private
|
|
284
|
+
|
|
285
|
+
def skip_spaces
|
|
286
|
+
@position += 1 while @line.getbyte(@position) == 0x20
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def list
|
|
290
|
+
@position += 1
|
|
291
|
+
items = []
|
|
292
|
+
loop do
|
|
293
|
+
skip_spaces
|
|
294
|
+
byte = @line.getbyte(@position)
|
|
295
|
+
raise Error, "unterminated list in IMAP response" if byte.nil?
|
|
296
|
+
|
|
297
|
+
if byte == 0x29 # )
|
|
298
|
+
@position += 1
|
|
299
|
+
return items
|
|
300
|
+
end
|
|
301
|
+
items << value
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def quoted
|
|
306
|
+
@position += 1
|
|
307
|
+
result = +"".b
|
|
308
|
+
loop do
|
|
309
|
+
byte = @line.getbyte(@position)
|
|
310
|
+
raise Error, "unterminated quoted string in IMAP response" if byte.nil?
|
|
311
|
+
|
|
312
|
+
@position += 1
|
|
313
|
+
return result if byte == 0x22
|
|
314
|
+
|
|
315
|
+
if byte == 0x5C # backslash escapes \ and "
|
|
316
|
+
result << @line.byteslice(@position, 1)
|
|
317
|
+
@position += 1
|
|
318
|
+
else
|
|
319
|
+
result << byte
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def literal
|
|
325
|
+
close = @line.index("}", @position) or raise Error, "malformed literal in IMAP response"
|
|
326
|
+
size = @line.byteslice(@position + 1, close - @position - 1).delete("+").to_i
|
|
327
|
+
start = close + 3 # past "}\r\n"
|
|
328
|
+
@position = start + size
|
|
329
|
+
@line.byteslice(start, size)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def atom_or_nil
|
|
333
|
+
start = @position
|
|
334
|
+
depth = 0
|
|
335
|
+
loop do
|
|
336
|
+
byte = @line.getbyte(@position)
|
|
337
|
+
break if byte.nil? || byte == 0x0D || byte == 0x0A
|
|
338
|
+
break if depth.zero? && [0x20, 0x28, 0x29].include?(byte)
|
|
339
|
+
|
|
340
|
+
depth += 1 if byte == 0x5B # [ ... ] may hold spaces and parentheses
|
|
341
|
+
depth -= 1 if byte == 0x5D && depth.positive?
|
|
342
|
+
@position += 1
|
|
343
|
+
end
|
|
344
|
+
atom = @line.byteslice(start, @position - start)
|
|
345
|
+
raise Error, "unexpected character in IMAP response at #{start}" if atom.empty?
|
|
346
|
+
|
|
347
|
+
atom.casecmp?("NIL") ? nil : atom
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
data/lib/tina4/import_helper.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
|
|
3
9
|
# lib/tina4/import_helper.rb
|
|
4
10
|
#
|
data/lib/tina4/job.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
require "json"
|
|
3
9
|
require "securerandom"
|
|
4
10
|
|
data/lib/tina4/localization.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
require "json"
|
|
3
9
|
|
|
4
10
|
module Tina4
|
data/lib/tina4/log.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
|
|
3
9
|
require "fileutils"
|
|
4
10
|
require "json"
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
require "socket"
|
|
10
|
+
require "openssl"
|
|
11
|
+
require "ipaddr"
|
|
12
|
+
|
|
13
|
+
module Tina4
|
|
14
|
+
class Messenger
|
|
15
|
+
# A line-oriented TCP connection with optional TLS, shared by Tina4's own SMTP
|
|
16
|
+
# and IMAP clients (which replace the net-smtp and net-imap gems).
|
|
17
|
+
#
|
|
18
|
+
# TLS is VERIFIED, as it was under Net::SMTP / Net::IMAP: the peer certificate
|
|
19
|
+
# must chain to the default trust store (OpenSSL honours SSL_CERT_FILE and
|
|
20
|
+
# SSL_CERT_DIR) and match the host name, or the handshake fails.
|
|
21
|
+
#
|
|
22
|
+
# Every read is bounded by the timeout, so a silent server raises
|
|
23
|
+
# MailSocket::TimeoutError (an IOError) instead of hanging the caller.
|
|
24
|
+
class MailSocket
|
|
25
|
+
class TimeoutError < IOError; end
|
|
26
|
+
|
|
27
|
+
READ_CHUNK = 16_384
|
|
28
|
+
|
|
29
|
+
def self.open(host, port, tls: false, timeout: 30)
|
|
30
|
+
socket = new(Socket.tcp(host, port, connect_timeout: timeout), host, timeout)
|
|
31
|
+
socket.start_tls if tls
|
|
32
|
+
socket
|
|
33
|
+
rescue StandardError
|
|
34
|
+
socket&.close
|
|
35
|
+
raise
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(io, host, timeout)
|
|
39
|
+
@io = io
|
|
40
|
+
@host = host
|
|
41
|
+
@timeout = timeout
|
|
42
|
+
@buffer = +"".b
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def tls?
|
|
46
|
+
@io.is_a?(OpenSSL::SSL::SSLSocket)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Upgrade to TLS in place (implicit TLS right after connect, or STARTTLS).
|
|
50
|
+
def start_tls
|
|
51
|
+
# Bytes the server sent before the handshake would be read as if they came
|
|
52
|
+
# over TLS -- the STARTTLS command-injection attack (CVE-2011-0411 class).
|
|
53
|
+
raise IOError, "#{@host} sent data before the TLS handshake" unless @buffer.empty?
|
|
54
|
+
|
|
55
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
56
|
+
context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
57
|
+
ssl = OpenSSL::SSL::SSLSocket.new(@io, context)
|
|
58
|
+
ssl.sync_close = true
|
|
59
|
+
ssl.hostname = @host unless ip_address?(@host) # SNI never carries an IP literal
|
|
60
|
+
until (state = ssl.connect_nonblock(exception: false)) == ssl
|
|
61
|
+
wait(state)
|
|
62
|
+
end
|
|
63
|
+
ssl.post_connection_check(@host)
|
|
64
|
+
@io = ssl
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# One line, CRLF included, as binary. Raises EOFError if the peer closes.
|
|
68
|
+
def read_line
|
|
69
|
+
until (newline = @buffer.index("\n"))
|
|
70
|
+
fill
|
|
71
|
+
end
|
|
72
|
+
@buffer.slice!(0..newline)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Exactly +count+ bytes (an IMAP literal), as binary.
|
|
76
|
+
def read_bytes(count)
|
|
77
|
+
fill while @buffer.bytesize < count
|
|
78
|
+
@buffer.slice!(0, count)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def write(data)
|
|
82
|
+
@io.write(data.b)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def buffered?
|
|
86
|
+
!@buffer.empty?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def close
|
|
90
|
+
@io.close
|
|
91
|
+
rescue StandardError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def fill
|
|
98
|
+
loop do
|
|
99
|
+
chunk = @io.read_nonblock(READ_CHUNK, exception: false)
|
|
100
|
+
case chunk
|
|
101
|
+
when :wait_readable, :wait_writable then wait(chunk)
|
|
102
|
+
when nil then raise EOFError, "#{@host} closed the connection"
|
|
103
|
+
else
|
|
104
|
+
@buffer << chunk
|
|
105
|
+
return
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def wait(state)
|
|
111
|
+
raw = @io.to_io
|
|
112
|
+
ready = state == :wait_writable ? IO.select(nil, [raw], nil, @timeout) : IO.select([raw], nil, nil, @timeout)
|
|
113
|
+
raise TimeoutError, "#{@host} did not respond within #{@timeout}s" unless ready
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def ip_address?(host)
|
|
117
|
+
IPAddr.new(host)
|
|
118
|
+
true
|
|
119
|
+
rescue ArgumentError # IPAddr::InvalidAddressError / AddressFamilyError
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
|
|
3
9
|
# Tina4 MCP Server -- Model Context Protocol for AI tool integration.
|
|
4
10
|
#
|
|
@@ -23,6 +29,7 @@ require "socket"
|
|
|
23
29
|
require "fileutils"
|
|
24
30
|
require "open3"
|
|
25
31
|
require "securerandom"
|
|
32
|
+
require_relative "parse_json"
|
|
26
33
|
|
|
27
34
|
module Tina4
|
|
28
35
|
# ── JSON-RPC 2.0 codec ────────────────────────────────────────────
|
|
@@ -69,7 +76,7 @@ module Tina4
|
|
|
69
76
|
case data
|
|
70
77
|
when String
|
|
71
78
|
begin
|
|
72
|
-
msg =
|
|
79
|
+
msg = Tina4.parse_json(data)
|
|
73
80
|
rescue JSON::ParserError => e
|
|
74
81
|
raise ArgumentError, "Invalid JSON: #{e.message}"
|
|
75
82
|
end
|
|
@@ -453,7 +460,7 @@ module Tina4
|
|
|
453
460
|
raw_data
|
|
454
461
|
else
|
|
455
462
|
str = raw_data.to_s
|
|
456
|
-
str.empty? ? {} : (
|
|
463
|
+
str.empty? ? {} : (Tina4.parse_json(str) rescue nil)
|
|
457
464
|
end
|
|
458
465
|
obj.is_a?(Hash) ? obj["method"] : nil
|
|
459
466
|
end
|
|
@@ -828,7 +835,7 @@ module Tina4
|
|
|
828
835
|
server.register_tool("database_query", lambda { |sql:, params: "[]"|
|
|
829
836
|
db = Tina4.database
|
|
830
837
|
return { "error" => "No database connection" } if db.nil?
|
|
831
|
-
param_list = params.is_a?(String) ?
|
|
838
|
+
param_list = params.is_a?(String) ? Tina4.parse_json(params) : params
|
|
832
839
|
param_list = [] unless param_list.is_a?(Array)
|
|
833
840
|
# Defense-in-depth: this tool is read-only. Strip comments, reject
|
|
834
841
|
# multiple statements, and require a leading SELECT/WITH so it can
|
|
@@ -851,7 +858,7 @@ module Tina4
|
|
|
851
858
|
server.register_tool("database_execute", lambda { |sql:, params: "[]"|
|
|
852
859
|
db = Tina4.database
|
|
853
860
|
return { "error" => "No database connection" } if db.nil?
|
|
854
|
-
param_list = params.is_a?(String) ?
|
|
861
|
+
param_list = params.is_a?(String) ? Tina4.parse_json(params) : params
|
|
855
862
|
# db.execute() now RAISES on a SQL error (it no longer returns false).
|
|
856
863
|
# Catch it and return a clean { error: } payload instead of letting the
|
|
857
864
|
# exception escape the tool handler.
|
|
@@ -897,7 +904,7 @@ module Tina4
|
|
|
897
904
|
|
|
898
905
|
server.register_tool("route_test", lambda { |method:, path:, body: "", headers: "{}"|
|
|
899
906
|
client = Tina4::TestClient.new
|
|
900
|
-
header_hash = headers.is_a?(String) ?
|
|
907
|
+
header_hash = headers.is_a?(String) ? Tina4.parse_json(headers) : headers
|
|
901
908
|
m = method.upcase
|
|
902
909
|
r = case m
|
|
903
910
|
when "GET" then client.get(path, headers: header_hash)
|
|
@@ -915,7 +922,7 @@ module Tina4
|
|
|
915
922
|
|
|
916
923
|
# ── Template Tools ────────────────────────────────
|
|
917
924
|
server.register_tool("template_render", lambda { |template:, data: "{}"|
|
|
918
|
-
ctx = data.is_a?(String) ?
|
|
925
|
+
ctx = data.is_a?(String) ? Tina4.parse_json(data) : data
|
|
919
926
|
# render_string is an INSTANCE method of Frond (Tina4::Template has none).
|
|
920
927
|
# Mirrors Python tools.py: Frond("src/templates").render_string(...).
|
|
921
928
|
Tina4::Frond.new(template_dir: "src/templates").render_string(template, ctx)
|
|
@@ -998,8 +1005,8 @@ module Tina4
|
|
|
998
1005
|
target = safe_path.call("src/public/#{filename}")
|
|
999
1006
|
FileUtils.mkdir_p(File.dirname(target))
|
|
1000
1007
|
if encoding == "base64"
|
|
1001
|
-
|
|
1002
|
-
File.binwrite(target, Base64.decode64(content))
|
|
1008
|
+
require_relative "base64"
|
|
1009
|
+
File.binwrite(target, Tina4::Base64.decode64(content))
|
|
1003
1010
|
else
|
|
1004
1011
|
File.write(target, content, encoding: "utf-8")
|
|
1005
1012
|
end
|