tina4ruby 3.13.37 → 3.13.39
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/README.md +7 -7
- data/lib/tina4/api.rb +43 -1
- data/lib/tina4/auth.rb +118 -7
- data/lib/tina4/cli.rb +110 -2
- data/lib/tina4/database.rb +407 -52
- data/lib/tina4/dev_admin.rb +47 -14
- data/lib/tina4/drivers/sqlite_driver.rb +23 -0
- data/lib/tina4/env.rb +40 -4
- data/lib/tina4/events.rb +54 -8
- data/lib/tina4/field_types.rb +5 -2
- data/lib/tina4/graphql.rb +68 -12
- data/lib/tina4/html_element.rb +55 -7
- data/lib/tina4/log.rb +86 -10
- data/lib/tina4/mcp.rb +35 -8
- data/lib/tina4/messenger.rb +130 -25
- data/lib/tina4/metrics.rb +351 -73
- data/lib/tina4/middleware.rb +136 -13
- data/lib/tina4/migration.rb +113 -24
- data/lib/tina4/orm.rb +196 -32
- data/lib/tina4/query_builder.rb +22 -3
- data/lib/tina4/queue_backends/kafka_backend.rb +39 -2
- data/lib/tina4/rack_app.rb +22 -10
- data/lib/tina4/response.rb +31 -11
- data/lib/tina4/router.rb +34 -4
- data/lib/tina4/seeder.rb +433 -84
- data/lib/tina4/session.rb +94 -17
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +458 -21
- data/lib/tina4/wsdl.rb +25 -2
- data/lib/tina4.rb +91 -12
- metadata +6 -47
data/lib/tina4/log.rb
CHANGED
|
@@ -12,11 +12,12 @@ module Tina4
|
|
|
12
12
|
"[TINA4_LOG_INFO]" => 1,
|
|
13
13
|
"[TINA4_LOG_WARNING]" => 2,
|
|
14
14
|
"[TINA4_LOG_ERROR]" => 3,
|
|
15
|
-
"[
|
|
15
|
+
"[TINA4_LOG_CRITICAL]" => 4,
|
|
16
|
+
"[TINA4_LOG_NONE]" => 5
|
|
16
17
|
}.freeze
|
|
17
18
|
|
|
18
19
|
SEVERITY_MAP = {
|
|
19
|
-
debug: 0, info: 1, warn: 2, error: 3
|
|
20
|
+
debug: 0, info: 1, warn: 2, error: 3, critical: 4
|
|
20
21
|
}.freeze
|
|
21
22
|
|
|
22
23
|
COLORS = {
|
|
@@ -65,15 +66,36 @@ module Tina4
|
|
|
65
66
|
@format = format_env && !format_env.empty? ? format_env.downcase : (production? ? "json" : "text")
|
|
66
67
|
@json_mode = @format == "json"
|
|
67
68
|
|
|
68
|
-
# TINA4_LOG_OUTPUT — "stdout", "file", or "both".
|
|
69
|
+
# TINA4_LOG_OUTPUT — "stdout", "file", or "both".
|
|
70
|
+
#
|
|
71
|
+
# Default (UNSET): stdout is ALWAYS on. The log FILE (tina4.log + any
|
|
72
|
+
# error log) is written ONLY in development — i.e. when TINA4_DEBUG is
|
|
73
|
+
# truthy. In production / containers (TINA4_DEBUG falsy) the logger is
|
|
74
|
+
# stdout-only: writing a log file inside a container just bloats the
|
|
75
|
+
# writable layer + disk, and 12-factor wants logs on stdout for the
|
|
76
|
+
# platform to capture. An explicit TINA4_LOG_OUTPUT=file/both (or an
|
|
77
|
+
# explicit TINA4_LOG_FILE path) overrides this and STILL writes a file.
|
|
78
|
+
# Mirrors the Python master (debug/__init__.py configure()).
|
|
79
|
+
# An explicit TINA4_LOG_FILE always wins: a path the operator named must
|
|
80
|
+
# be written even in production (parity with the Python master, where an
|
|
81
|
+
# explicit log_file builds a writer unconditionally), so the dev-gated
|
|
82
|
+
# default below resolves to "both" (stdout + file) rather than "stdout".
|
|
83
|
+
explicit_file = !(log_file_env.nil? || log_file_env.empty?)
|
|
84
|
+
default_output = if explicit_file || truthy?(ENV["TINA4_DEBUG"])
|
|
85
|
+
"both"
|
|
86
|
+
else
|
|
87
|
+
"stdout"
|
|
88
|
+
end
|
|
69
89
|
output_env = ENV["TINA4_LOG_OUTPUT"]
|
|
70
|
-
@output = output_env && !output_env.empty?
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
@output = if output_env && !output_env.empty?
|
|
91
|
+
output_env.downcase
|
|
92
|
+
else
|
|
93
|
+
default_output
|
|
94
|
+
end
|
|
95
|
+
@output = default_output unless %w[stdout file both].include?(@output)
|
|
74
96
|
|
|
75
|
-
#
|
|
76
|
-
@
|
|
97
|
+
# TINA4_LOG_STRICT — when true, raise on log write failures instead of swallowing.
|
|
98
|
+
@strict = truthy?(ENV["TINA4_LOG_STRICT"])
|
|
77
99
|
|
|
78
100
|
@console_level = resolve_level
|
|
79
101
|
@request_id = nil
|
|
@@ -122,6 +144,28 @@ module Tina4
|
|
|
122
144
|
@json_mode
|
|
123
145
|
end
|
|
124
146
|
|
|
147
|
+
# Would a message at `level` pass the configured MINIMUM CONSOLE LEVEL
|
|
148
|
+
# (TINA4_LOG_LEVEL)? Returns true iff `log` would print it to stdout —
|
|
149
|
+
# it reflects CONSOLE visibility only. The log FILE records every level
|
|
150
|
+
# regardless of this threshold, so this never gates file output.
|
|
151
|
+
#
|
|
152
|
+
# `level` accepts a String or Symbol and is case-insensitive
|
|
153
|
+
# ("INFO", :info, "Warning", :warning all work). Mirrors Python's
|
|
154
|
+
# Log.is_enabled. It REUSES the exact severity >= @console_level
|
|
155
|
+
# comparison the console branch in `log` uses (line ~167) via
|
|
156
|
+
# SEVERITY_MAP / resolve_level — it never re-implements level
|
|
157
|
+
# comparison, so it can never disagree with what the logger prints.
|
|
158
|
+
#
|
|
159
|
+
# "critical" is a FIRST-CLASS top-level severity (4 — above error 3),
|
|
160
|
+
# not a parity alias for error. It is evaluated with ordinary threshold
|
|
161
|
+
# logic (critical 4 >= @console_level), so it passes at every level
|
|
162
|
+
# except none (5) — matching the Python master.
|
|
163
|
+
def enabled?(level)
|
|
164
|
+
sym = normalize_level(level)
|
|
165
|
+
severity = SEVERITY_MAP[sym] || 0
|
|
166
|
+
severity >= console_level
|
|
167
|
+
end
|
|
168
|
+
|
|
125
169
|
def info(message, context = {})
|
|
126
170
|
log(:info, message, context)
|
|
127
171
|
end
|
|
@@ -138,6 +182,14 @@ module Tina4
|
|
|
138
182
|
log(:error, message, context)
|
|
139
183
|
end
|
|
140
184
|
|
|
185
|
+
# critical is the HIGHEST severity (4, above error). Like every other
|
|
186
|
+
# level it ALWAYS emits, subject only to the TINA4_LOG_LEVEL threshold
|
|
187
|
+
# (which critical passes at every level except none). A critical log is
|
|
188
|
+
# never a silent no-op. Mirrors the Python master.
|
|
189
|
+
def critical(message, context = {})
|
|
190
|
+
log(:critical, message, context)
|
|
191
|
+
end
|
|
192
|
+
|
|
141
193
|
# Test/teardown helper — closes the underlying Logger so the file
|
|
142
194
|
# handle is released (Windows / tmpdir cleanup).
|
|
143
195
|
def close_file_logger
|
|
@@ -181,6 +233,28 @@ module Tina4
|
|
|
181
233
|
@current_context = {}
|
|
182
234
|
end
|
|
183
235
|
|
|
236
|
+
# The current minimum console level as an integer (the same value
|
|
237
|
+
# the console branch in `log` compares against). Ensures the logger
|
|
238
|
+
# is configured so `enabled?` works before any log call has run.
|
|
239
|
+
def console_level
|
|
240
|
+
configure unless @initialized
|
|
241
|
+
@console_level
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Map a level (String or Symbol, case-insensitive) onto the symbol
|
|
245
|
+
# space used by SEVERITY_MAP. Accepts the public method names
|
|
246
|
+
# (debug/info/warning/error/critical) and the internal :warn symbol.
|
|
247
|
+
# critical is a FIRST-CLASS level (severity 4), not an alias for error.
|
|
248
|
+
# Unknown levels fall through to their own symbol and resolve to
|
|
249
|
+
# severity 0 in `enabled?`.
|
|
250
|
+
def normalize_level(level)
|
|
251
|
+
sym = level.to_s.strip.downcase.to_sym
|
|
252
|
+
case sym
|
|
253
|
+
when :warning then :warn
|
|
254
|
+
else sym
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
184
258
|
def resolve_level
|
|
185
259
|
# v3.13.14: default is INFO (was ALL) so a deployed app surfaces
|
|
186
260
|
# request/startup/warn/error without debug noise, matching
|
|
@@ -199,6 +273,7 @@ module Tina4
|
|
|
199
273
|
when :info then "INFO"
|
|
200
274
|
when :warn then "WARNING"
|
|
201
275
|
when :error then "ERROR"
|
|
276
|
+
when :critical then "CRITICAL"
|
|
202
277
|
else level.to_s.upcase
|
|
203
278
|
end
|
|
204
279
|
end
|
|
@@ -278,6 +353,7 @@ module Tina4
|
|
|
278
353
|
when :info then COLORS[:green]
|
|
279
354
|
when :warn then COLORS[:yellow]
|
|
280
355
|
when :error then COLORS[:red]
|
|
356
|
+
when :critical then COLORS[:magenta]
|
|
281
357
|
else COLORS[:reset]
|
|
282
358
|
end
|
|
283
359
|
"#{color}#{line}#{COLORS[:reset]}"
|
|
@@ -288,7 +364,7 @@ module Tina4
|
|
|
288
364
|
# Use << to bypass Logger's severity filtering — we already filtered above.
|
|
289
365
|
@file_logger << "#{line}\n"
|
|
290
366
|
rescue IOError, SystemCallError => e
|
|
291
|
-
raise if @
|
|
367
|
+
raise if @strict
|
|
292
368
|
# Don't crash on log write failure
|
|
293
369
|
end
|
|
294
370
|
end
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -139,15 +139,35 @@ module Tina4
|
|
|
139
139
|
|
|
140
140
|
# Resolve whether the built-in MCP dev server should be active.
|
|
141
141
|
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
142
|
+
# Resolution order (highest priority first):
|
|
143
|
+
# 1. TINA4_MCP set explicitly → use that (truthy/falsey). Honoured on ANY
|
|
144
|
+
# host. An explicit `true` is how a sysadmin opts a remote /
|
|
145
|
+
# debug-disabled deployment in (e.g. for a remote AI assistant); an
|
|
146
|
+
# explicit `false` force-disables it everywhere.
|
|
147
|
+
# 2. TINA4_DEBUG=true → implicit on for dev, but LOCALHOST-ONLY unless
|
|
148
|
+
# TINA4_MCP_REMOTE=true. The MCP dev tools expose powerful operations
|
|
149
|
+
# (DB query, file read/WRITE, route listing), so they never auto-expose
|
|
150
|
+
# on a non-localhost host without an explicit opt-in.
|
|
151
|
+
# 3. Otherwise off.
|
|
152
|
+
#
|
|
153
|
+
# Mirrors the Python master (tina4_python/mcp/__init__.py is_enabled). Before
|
|
154
|
+
# v3.13.39 is_localhost? was dead code and TINA4_MCP_REMOTE was never read, so
|
|
155
|
+
# the documented localhost guard was not actually enforced — a non-localhost
|
|
156
|
+
# TINA4_DEBUG=true deployment auto-exposed the dev tools. This wires it.
|
|
145
157
|
def self.mcp_enabled?
|
|
146
158
|
explicit = ENV["TINA4_MCP"]
|
|
147
159
|
if explicit && !explicit.empty?
|
|
148
|
-
return
|
|
160
|
+
return truthy?(explicit)
|
|
149
161
|
end
|
|
150
|
-
|
|
162
|
+
return false unless truthy?(ENV["TINA4_DEBUG"])
|
|
163
|
+
|
|
164
|
+
# Dev auto-enable: localhost only, unless explicitly opted into remote.
|
|
165
|
+
is_localhost? || truthy?(ENV["TINA4_MCP_REMOTE"])
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Case-insensitive truthiness for env values: true/1/yes/on.
|
|
169
|
+
def self.truthy?(val)
|
|
170
|
+
%w[true 1 yes on].include?(val.to_s.strip.downcase)
|
|
151
171
|
end
|
|
152
172
|
|
|
153
173
|
# Resolve the dedicated MCP port. Defaults to (server port + 2000) — keeps
|
|
@@ -651,9 +671,16 @@ module Tina4
|
|
|
651
671
|
db = Tina4.database
|
|
652
672
|
return { "error" => "No database connection" } if db.nil?
|
|
653
673
|
param_list = params.is_a?(String) ? JSON.parse(params) : params
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
674
|
+
# db.execute() now RAISES on a SQL error (it no longer returns false).
|
|
675
|
+
# Catch it and return a clean { error: } payload instead of letting the
|
|
676
|
+
# exception escape the tool handler.
|
|
677
|
+
begin
|
|
678
|
+
result = db.execute(sql, param_list)
|
|
679
|
+
db.commit rescue nil
|
|
680
|
+
{ "success" => true, "affected_rows" => (result.respond_to?(:count) ? result.count : 0) }
|
|
681
|
+
rescue => e
|
|
682
|
+
{ "error" => db.get_error || e.message }
|
|
683
|
+
end
|
|
657
684
|
}, "Execute arbitrary SQL (INSERT/UPDATE/DELETE/DDL)")
|
|
658
685
|
|
|
659
686
|
server.register_tool("database_tables", lambda {
|
data/lib/tina4/messenger.rb
CHANGED
|
@@ -13,8 +13,44 @@ end
|
|
|
13
13
|
require "base64"
|
|
14
14
|
require "securerandom"
|
|
15
15
|
require "time"
|
|
16
|
+
require "socket"
|
|
17
|
+
require "timeout"
|
|
18
|
+
begin
|
|
19
|
+
require "openssl"
|
|
20
|
+
rescue LoadError
|
|
21
|
+
# openssl is part of stdlib; absence only affects TLS error classification
|
|
22
|
+
end
|
|
16
23
|
|
|
17
24
|
module Tina4
|
|
25
|
+
# Raised on a messenger failure (base class).
|
|
26
|
+
class MessengerError < StandardError; end
|
|
27
|
+
|
|
28
|
+
# Raised when an IMAP read fails to connect, authenticate, or speak the
|
|
29
|
+
# protocol. Distinct from a successful fetch that simply has no messages —
|
|
30
|
+
# that still returns an empty result ([]/nil/0/{}), NOT an error.
|
|
31
|
+
#
|
|
32
|
+
# Subclasses MessengerError so existing `rescue Tina4::MessengerError`
|
|
33
|
+
# handlers still catch it.
|
|
34
|
+
class MessengerConnectionError < MessengerError; end
|
|
35
|
+
|
|
36
|
+
# Errors that mean "we could not talk to the mail server", as opposed to
|
|
37
|
+
# "we talked fine and the mailbox is empty". These must fail loud — LOG and
|
|
38
|
+
# RAISE — never be silently swallowed into an empty result. Mirrors the
|
|
39
|
+
# Python master's _IMAP_CONNECTION_ERRORS tuple.
|
|
40
|
+
#
|
|
41
|
+
# Built lazily so a missing net/imap gem (LoadError above) doesn't break
|
|
42
|
+
# loading this file.
|
|
43
|
+
IMAP_CONNECTION_ERRORS = [
|
|
44
|
+
SocketError, # DNS / host resolution failures
|
|
45
|
+
IOError, # closed/broken stream, EOF mid-conversation
|
|
46
|
+
SystemCallError, # Errno::ECONNREFUSED / ECONNRESET / ETIMEDOUT etc.
|
|
47
|
+
Timeout::Error, # connect/read timeout (Net::OpenTimeout descends from this)
|
|
48
|
+
MessengerError # our own protocol-failure signal (re-raised as-is)
|
|
49
|
+
].tap do |errors|
|
|
50
|
+
errors << Net::IMAP::Error if defined?(Net::IMAP::Error)
|
|
51
|
+
errors << OpenSSL::SSL::SSLError if defined?(OpenSSL::SSL::SSLError)
|
|
52
|
+
end.freeze
|
|
53
|
+
|
|
18
54
|
# Tina4 Messenger — Email sending (SMTP) and reading (IMAP).
|
|
19
55
|
#
|
|
20
56
|
# Unified .env-driven configuration with constructor override.
|
|
@@ -120,9 +156,14 @@ module Tina4
|
|
|
120
156
|
|
|
121
157
|
# ── IMAP operations ──────────────────────────────────────────────────
|
|
122
158
|
|
|
123
|
-
# List messages in a folder
|
|
159
|
+
# List messages in a folder.
|
|
160
|
+
#
|
|
161
|
+
# Raises Tina4::MessengerConnectionError on a connection/auth/protocol
|
|
162
|
+
# failure (FAILS LOUD — never returns [] to hide it). A successful fetch
|
|
163
|
+
# from an empty folder returns [] (that is NOT an error).
|
|
124
164
|
def inbox(folder: "INBOX", limit: 20, offset: 0)
|
|
125
|
-
|
|
165
|
+
imap = imap_open("inbox")
|
|
166
|
+
begin
|
|
126
167
|
imap.select(folder)
|
|
127
168
|
uids = imap.uid_search(["ALL"])
|
|
128
169
|
uids = uids.reverse # newest first
|
|
@@ -131,15 +172,20 @@ module Tina4
|
|
|
131
172
|
|
|
132
173
|
envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
|
|
133
174
|
(envelopes || []).map { |msg| parse_envelope(msg) }
|
|
175
|
+
rescue *IMAP_CONNECTION_ERRORS => e
|
|
176
|
+
raise imap_fail("inbox", e)
|
|
177
|
+
ensure
|
|
178
|
+
imap_cleanup(imap)
|
|
134
179
|
end
|
|
135
|
-
rescue => e
|
|
136
|
-
Tina4::Log.error("IMAP inbox failed: #{e.message}")
|
|
137
|
-
[]
|
|
138
180
|
end
|
|
139
181
|
|
|
140
|
-
# Read a single message by UID
|
|
182
|
+
# Read a single message by UID.
|
|
183
|
+
#
|
|
184
|
+
# Raises Tina4::MessengerConnectionError on a connection/protocol failure.
|
|
185
|
+
# A successful fetch for a non-existent UID returns nil (that is NOT an error).
|
|
141
186
|
def read(uid, folder: "INBOX", mark_read: true)
|
|
142
|
-
|
|
187
|
+
imap = imap_open("read")
|
|
188
|
+
begin
|
|
143
189
|
imap.select(folder)
|
|
144
190
|
data = imap.uid_fetch(uid, ["ENVELOPE", "FLAGS", "BODY[]", "RFC822.SIZE"])
|
|
145
191
|
return nil if data.nil? || data.empty?
|
|
@@ -150,28 +196,38 @@ module Tina4
|
|
|
150
196
|
|
|
151
197
|
msg = data.first
|
|
152
198
|
parse_full_message(msg)
|
|
199
|
+
rescue *IMAP_CONNECTION_ERRORS => e
|
|
200
|
+
raise imap_fail("read", e)
|
|
201
|
+
ensure
|
|
202
|
+
imap_cleanup(imap)
|
|
153
203
|
end
|
|
154
|
-
rescue => e
|
|
155
|
-
Tina4::Log.error("IMAP read failed: #{e.message}")
|
|
156
|
-
nil
|
|
157
204
|
end
|
|
158
205
|
|
|
159
|
-
# Count unread messages
|
|
206
|
+
# Count unread messages.
|
|
207
|
+
#
|
|
208
|
+
# Raises Tina4::MessengerConnectionError on a connection/protocol failure.
|
|
209
|
+
# A successful query with no unseen messages returns 0 (NOT an error).
|
|
160
210
|
def unread(folder: "INBOX")
|
|
161
|
-
|
|
211
|
+
imap = imap_open("unread")
|
|
212
|
+
begin
|
|
162
213
|
imap.select(folder)
|
|
163
214
|
uids = imap.uid_search(["UNSEEN"])
|
|
164
215
|
uids.length
|
|
216
|
+
rescue *IMAP_CONNECTION_ERRORS => e
|
|
217
|
+
raise imap_fail("unread", e)
|
|
218
|
+
ensure
|
|
219
|
+
imap_cleanup(imap)
|
|
165
220
|
end
|
|
166
|
-
rescue => e
|
|
167
|
-
Tina4::Log.error("IMAP unread count failed: #{e.message}")
|
|
168
|
-
0
|
|
169
221
|
end
|
|
170
222
|
|
|
171
|
-
# Search messages with filters
|
|
223
|
+
# Search messages with filters.
|
|
224
|
+
#
|
|
225
|
+
# Raises Tina4::MessengerConnectionError on a connection/protocol failure.
|
|
226
|
+
# A successful search with no matches returns [] (NOT an error).
|
|
172
227
|
def search(folder: "INBOX", subject: nil, sender: nil, since: nil,
|
|
173
228
|
before: nil, unseen_only: false, limit: 20)
|
|
174
|
-
|
|
229
|
+
imap = imap_open("search")
|
|
230
|
+
begin
|
|
175
231
|
imap.select(folder)
|
|
176
232
|
criteria = build_search_criteria(
|
|
177
233
|
subject: subject, sender: sender, since: since,
|
|
@@ -184,21 +240,26 @@ module Tina4
|
|
|
184
240
|
|
|
185
241
|
envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
|
|
186
242
|
(envelopes || []).map { |msg| parse_envelope(msg) }
|
|
243
|
+
rescue *IMAP_CONNECTION_ERRORS => e
|
|
244
|
+
raise imap_fail("search", e)
|
|
245
|
+
ensure
|
|
246
|
+
imap_cleanup(imap)
|
|
187
247
|
end
|
|
188
|
-
rescue => e
|
|
189
|
-
Tina4::Log.error("IMAP search failed: #{e.message}")
|
|
190
|
-
[]
|
|
191
248
|
end
|
|
192
249
|
|
|
193
|
-
# List all IMAP folders
|
|
250
|
+
# List all IMAP folders.
|
|
251
|
+
#
|
|
252
|
+
# Raises Tina4::MessengerConnectionError on a connection/protocol failure.
|
|
194
253
|
def folders
|
|
195
|
-
|
|
254
|
+
imap = imap_open("folders")
|
|
255
|
+
begin
|
|
196
256
|
boxes = imap.list("", "*")
|
|
197
257
|
(boxes || []).map(&:name)
|
|
258
|
+
rescue *IMAP_CONNECTION_ERRORS => e
|
|
259
|
+
raise imap_fail("folders", e)
|
|
260
|
+
ensure
|
|
261
|
+
imap_cleanup(imap)
|
|
198
262
|
end
|
|
199
|
-
rescue => e
|
|
200
|
-
Tina4::Log.error("IMAP folders failed: #{e.message}")
|
|
201
|
-
[]
|
|
202
263
|
end
|
|
203
264
|
|
|
204
265
|
# Mark a message as read (set \Seen flag).
|
|
@@ -393,6 +454,50 @@ module Tina4
|
|
|
393
454
|
|
|
394
455
|
# ── IMAP helpers ─────────────────────────────────────────────────────
|
|
395
456
|
|
|
457
|
+
# Open and authenticate an IMAP connection. On a connection/auth/protocol
|
|
458
|
+
# failure this FAILS LOUD — logs and raises MessengerConnectionError —
|
|
459
|
+
# rather than swallowing the error into an empty result.
|
|
460
|
+
def imap_open(method)
|
|
461
|
+
imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
|
|
462
|
+
imap.login(@username, @password)
|
|
463
|
+
imap
|
|
464
|
+
rescue *IMAP_CONNECTION_ERRORS => e
|
|
465
|
+
raise imap_fail(method, e)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Best-effort teardown of an IMAP connection. Never raises.
|
|
469
|
+
def imap_cleanup(imap)
|
|
470
|
+
return if imap.nil?
|
|
471
|
+
|
|
472
|
+
begin
|
|
473
|
+
imap.logout
|
|
474
|
+
rescue StandardError
|
|
475
|
+
# ignore — already closing
|
|
476
|
+
end
|
|
477
|
+
begin
|
|
478
|
+
imap.disconnect
|
|
479
|
+
rescue StandardError
|
|
480
|
+
# ignore — already closed
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Log an IMAP connection/protocol failure and return the error to raise.
|
|
485
|
+
# A genuinely empty mailbox is NOT an error and never reaches here.
|
|
486
|
+
# Re-raises a MessengerError as-is; otherwise wraps in
|
|
487
|
+
# MessengerConnectionError. Callers do `raise imap_fail(name, exc)`.
|
|
488
|
+
def imap_fail(method, exc)
|
|
489
|
+
Tina4::Log.error(
|
|
490
|
+
"Messenger IMAP #{method}() failed: #{exc.class}: #{exc.message}"
|
|
491
|
+
)
|
|
492
|
+
return exc if exc.is_a?(MessengerError)
|
|
493
|
+
|
|
494
|
+
MessengerConnectionError.new("IMAP #{method} failed: #{exc.message}")
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Connect, yield, then tear down — used by the mutators / connectivity test
|
|
498
|
+
# that keep their own result-style error handling (mark_read,
|
|
499
|
+
# test_imap_connection). Read methods use imap_open + ensure directly so
|
|
500
|
+
# they can fail loud.
|
|
396
501
|
def imap_connect(&block)
|
|
397
502
|
imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
|
|
398
503
|
imap.login(@username, @password)
|