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.
Files changed (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +57 -7
  12. data/lib/tina4/auth.rb +18 -12
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +6 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +6 -0
  26. data/lib/tina4/cli.rb +22 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +215 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +8 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +6 -0
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +196 -56
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +28 -27
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +6 -0
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +15 -8
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +24 -9
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +9 -3
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +6 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
  112. data/lib/tina4/rack_app.rb +11 -1
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +6 -0
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +85 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +6 -0
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +25 -8
  146. data/lib/tina4/swagger.rb +6 -0
  147. data/lib/tina4/template.rb +6 -0
  148. data/lib/tina4/test.rb +8 -2
  149. data/lib/tina4/test_client.rb +8 -1
  150. data/lib/tina4/testing.rb +8 -1
  151. data/lib/tina4/validator.rb +6 -0
  152. data/lib/tina4/version.rb +7 -1
  153. data/lib/tina4/webserver.rb +25 -143
  154. data/lib/tina4/websocket.rb +10 -4
  155. data/lib/tina4/websocket_backplane.rb +19 -2
  156. data/lib/tina4/wsdl.rb +31 -16
  157. data/lib/tina4/xml_parser.rb +259 -0
  158. data/lib/tina4.rb +87 -31
  159. data/lib/tina4ruby.rb +6 -0
  160. 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
@@ -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
 
@@ -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 = JSON.parse(data)
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? ? {} : (JSON.parse(str) rescue nil)
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) ? JSON.parse(params) : params
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) ? JSON.parse(params) : params
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) ? JSON.parse(headers) : headers
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) ? JSON.parse(data) : data
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
- require "base64"
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