tina4ruby 3.13.37 → 3.13.38

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.
data/lib/tina4/env.rb CHANGED
@@ -74,13 +74,17 @@ module Tina4
74
74
  end
75
75
 
76
76
  module Env
77
+ # NOTE: TINA4_SECRET is deliberately ABSENT here. The default signing
78
+ # secret must never become a guessable built-in. A blank secret is the
79
+ # signal for Auth.ensure_dev_secret to mint a per-machine random dev secret
80
+ # (saved to gitignored .env.local) in dev, or to emit the actionable
81
+ # "set TINA4_SECRET" warning in CI/prod. Parity with the Python master.
77
82
  DEFAULT_ENV = {
78
83
  "PROJECT_NAME" => "Tina4 Ruby Project",
79
84
  "TINA4_SWAGGER_VERSION" => "1.0.0",
80
85
  "TINA4_LOCALE" => "en",
81
86
  "TINA4_DEBUG" => "true",
82
- "TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]",
83
- "TINA4_SECRET" => "tina4-secret-change-me"
87
+ "TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]"
84
88
  }.freeze
85
89
 
86
90
  # Typed env-var coercion — parity with tina4_python's Env class,
@@ -157,9 +161,27 @@ module Tina4
157
161
  unless File.exist?(env_file)
158
162
  create_default_env(env_file)
159
163
  end
164
+ # Precedence: real-env > .env.local > .env. Both loads are first-wins
165
+ # (override=false / `ENV[key] ||= value`), so a key already present in
166
+ # the real process environment is NEVER clobbered. .env.local loads
167
+ # FIRST so its values beat .env, but a real env var set before boot
168
+ # still wins over both. This is the security-correct ordering: a stray
169
+ # gitignored .env.local (e.g. a stale auto-generated dev secret) must
170
+ # not override an explicitly-set real TINA4_SECRET. The ensure-dev-secret
171
+ # bootstrap runs AFTER this (only mints a secret if still unset in dev).
172
+ load_local_env(root_dir)
160
173
  parse_env_file(env_file)
161
174
  end
162
175
 
176
+ # Load .env.local with first-wins semantics (override=false). A real
177
+ # process env var already present wins; this only fills keys not already
178
+ # set. Loaded BEFORE .env so .env.local beats .env (real-env > .env.local
179
+ # > .env). No-op when the file is absent (common for fresh checkouts).
180
+ def load_local_env(root_dir = Dir.pwd)
181
+ local_file = File.join(root_dir, ".env.local")
182
+ parse_env_file(local_file) if File.exist?(local_file)
183
+ end
184
+
163
185
  # Get an env var value, with optional default
164
186
  def get_env(key, default = nil)
165
187
  ENV[key.to_s] || default
@@ -213,7 +235,17 @@ module Tina4
213
235
  File.write(path, content)
214
236
  end
215
237
 
216
- def parse_env_file(path)
238
+ # Parse a dotenv file into ENV.
239
+ #
240
+ # override=false (default): `ENV[key] ||= value` — first-wins; a key
241
+ # already present (real env var, or a higher-precedence file loaded
242
+ # earlier) is never clobbered. Both .env.local and .env are loaded this
243
+ # way; ordering (.env.local first) gives the precedence real-env >
244
+ # .env.local > .env.
245
+ # override=true: `ENV[key] = value` — unconditional set. NOT used by the
246
+ # boot load sequence; kept only as an explicit opt-in for callers that
247
+ # genuinely need to force values.
248
+ def parse_env_file(path, override: false)
217
249
  return unless File.exist?(path)
218
250
  File.readlines(path).each do |line|
219
251
  line = line.strip
@@ -221,7 +253,11 @@ module Tina4
221
253
  if (match = line.match(/\A([A-Za-z_][A-Za-z0-9_]*)=["']?(.*)["']?\z/))
222
254
  key = match[1]
223
255
  value = match[2].gsub(/["']\z/, "")
224
- ENV[key] ||= value
256
+ if override
257
+ ENV[key] = value
258
+ else
259
+ ENV[key] ||= value
260
+ end
225
261
  @loaded_keys ||= []
226
262
  @loaded_keys << key
227
263
  end
data/lib/tina4/events.rb CHANGED
@@ -60,13 +60,28 @@ module Tina4
60
60
  #
61
61
  # results = Tina4::Events.emit("user.created", user_data)
62
62
  #
63
- def emit(event, *args)
63
+ # Listener isolation (visible-but-resilient): each listener is called
64
+ # inside a rescue so ONE throwing listener never aborts the rest of the
65
+ # emit. A failed listener is LOGGED (never silent) and contributes a nil
66
+ # slot, so N listeners always yield N results in priority order. Pass
67
+ # strict: true to RE-RAISE on the first listener error instead of
68
+ # isolating (later listeners then do NOT run).
69
+ def emit(event, *args, strict: false)
64
70
  entries = @listeners[event].dup
65
71
  results = []
66
72
  entries.each do |entry|
67
- # Remove one-time listeners before calling so re-entrant emits are safe
73
+ # Remove one-time listeners before calling so re-entrant emits are
74
+ # safe AND so the one-shot is gone before any throw — cleanup stays
75
+ # correct under isolation.
68
76
  @listeners[event].delete(entry) if entry[:once]
69
- results << entry[:callback].call(*args)
77
+ begin
78
+ results << entry[:callback].call(*args)
79
+ rescue StandardError, ScriptError => error
80
+ raise if strict
81
+
82
+ log_listener_error(event, error)
83
+ results << nil
84
+ end
70
85
  end
71
86
  results
72
87
  end
@@ -82,19 +97,29 @@ module Tina4
82
97
  end
83
98
 
84
99
  # Fire an event asynchronously. Each listener runs in its own thread.
85
- # Errors in listeners are silently caught.
86
100
  #
87
101
  # Tina4::Events.emit_async("user.created", user_data)
88
102
  #
89
- def emit_async(event, *args)
103
+ # Listener isolation: each threaded listener is wrapped so one rejection
104
+ # never aborts the others. A failed listener is LOGGED (never silent).
105
+ # Pass strict: true to RE-RAISE the first listener error on the main
106
+ # thread (the thread that raised aborts on join) instead of isolating.
107
+ def emit_async(event, *args, strict: false)
90
108
  return unless @listeners&.key?(event)
91
109
 
92
- @listeners[event].sort_by { |l| -(l[:priority] || 0) }.each do |listener|
110
+ @listeners[event].sort_by { |l| -(l[:priority] || 0) }.map do |listener|
93
111
  Thread.new do
112
+ # In strict mode the error is re-raised to surface on #join; mute
113
+ # the per-thread auto-report so the deliberate raise doesn't spam
114
+ # stderr (the caller still sees it via join).
115
+ Thread.current.report_on_exception = false if strict
94
116
  begin
95
117
  listener[:callback].call(*args)
96
- rescue => e
97
- # Async emit silently catches errors
118
+ rescue StandardError, ScriptError => error
119
+ raise if strict
120
+
121
+ log_listener_error(event, error)
122
+ nil
98
123
  end
99
124
  end
100
125
  end
@@ -104,6 +129,27 @@ module Tina4
104
129
  def clear
105
130
  @listeners.clear
106
131
  end
132
+
133
+ private
134
+
135
+ # NEVER silently swallow a listener error — always surface it. Logs via
136
+ # Tina4::Log.warning with BOTH the event name and the error class+message.
137
+ # Wrapped so a broken logger can't break the bus: on any logger failure it
138
+ # falls back to $stderr so the error is still seen.
139
+ def log_listener_error(event, error)
140
+ Tina4::Log.warning(
141
+ "Event listener for '#{event}' raised #{error.class.name}: #{error.message}"
142
+ )
143
+ rescue StandardError
144
+ begin
145
+ $stderr.puts(
146
+ "Event listener for '#{event}' raised #{error.class.name}: #{error.message}"
147
+ )
148
+ $stderr.flush
149
+ rescue StandardError
150
+ # Last-resort: never let logging break the event bus.
151
+ end
152
+ end
107
153
  end
108
154
  end
109
155
  end
data/lib/tina4/graphql.rb CHANGED
@@ -463,12 +463,33 @@ module Tina4
463
463
  arguments = parse_arguments
464
464
  end
465
465
 
466
+ # Directives (@skip, @include, @auth, @role, @guest, ...) appear after the
467
+ # field's arguments and before its selection set. Without parsing them the
468
+ # executor's check_directives never fires — @skip/@include are silently
469
+ # ignored. Each directive is { name:, arguments: } to match check_directives.
470
+ directives = parse_directives
471
+
466
472
  selection_set = nil
467
473
  if current&.value == "{"
468
474
  selection_set = parse_selection_set
469
475
  end
470
476
 
471
- { kind: :field, name: field_name, alias: alias_name, arguments: arguments, selection_set: selection_set }
477
+ { kind: :field, name: field_name, alias: alias_name, arguments: arguments, directives: directives, selection_set: selection_set }
478
+ end
479
+
480
+ # Parse a run of leading @directive(args) tokens into an array of
481
+ # { name:, arguments: } hashes (the shape GraphQLExecutor#check_directives
482
+ # consumes). Returns [] when no directive is present.
483
+ def parse_directives
484
+ directives = []
485
+ while current && current.type == :punct && current.value == "@"
486
+ advance # consume '@'
487
+ name = expect(:name).value
488
+ args = {}
489
+ args = parse_arguments if current&.value == "("
490
+ directives << { name: name, arguments: args }
491
+ end
492
+ directives
472
493
  end
473
494
 
474
495
  def parse_arguments
@@ -575,8 +596,13 @@ module Tina4
575
596
  # ─── Executor ─────────────────────────────────────────────────────────
576
597
 
577
598
  class GraphQLExecutor
578
- def initialize(schema)
599
+ # max_depth bounds selection-set nesting (DoS / stack-overflow guard).
600
+ # Threaded down from the owning GraphQL instance; <= 0 disables the guard.
601
+ attr_accessor :max_depth
602
+
603
+ def initialize(schema, max_depth: 50)
579
604
  @schema = schema
605
+ @max_depth = max_depth
580
606
  end
581
607
 
582
608
  def execute(document, variables: {}, context: {}, operation_name: nil)
@@ -618,8 +644,12 @@ module Tina4
618
644
  data = {}
619
645
  errors = []
620
646
 
647
+ # Top-level selections start at depth 1. Depth is incremented on every
648
+ # recursive entry (sub-selections, fragment spreads, inline fragments) so
649
+ # an over-deep query OR a circular fragment is caught before the
650
+ # interpreter stack overflows.
621
651
  operation[:selection_set].each do |selection|
622
- resolve_selection(selection, root_fields, nil, resolved_vars, context, fragments, data, errors)
652
+ resolve_selection(selection, root_fields, nil, resolved_vars, context, fragments, data, errors, 1)
623
653
  end
624
654
 
625
655
  result = { "data" => data }
@@ -629,25 +659,31 @@ module Tina4
629
659
 
630
660
  private
631
661
 
632
- def resolve_selection(selection, fields, parent, variables, context, fragments, data, errors)
662
+ def resolve_selection(selection, fields, parent, variables, context, fragments, data, errors, depth = 1)
663
+ # Depth guard — append a structured error and stop recursing this branch.
664
+ if @max_depth && @max_depth > 0 && depth > @max_depth
665
+ errors << { "message" => "Query exceeds maximum depth of #{@max_depth}" }
666
+ return
667
+ end
668
+
633
669
  case selection[:kind]
634
670
  when :field
635
- resolve_field(selection, fields, parent, variables, context, fragments, data, errors)
671
+ resolve_field(selection, fields, parent, variables, context, fragments, data, errors, depth)
636
672
  when :fragment_spread
637
673
  frag = fragments[selection[:name]]
638
674
  if frag
639
675
  frag[:selection_set].each do |sel|
640
- resolve_selection(sel, fields, parent, variables, context, fragments, data, errors)
676
+ resolve_selection(sel, fields, parent, variables, context, fragments, data, errors, depth + 1)
641
677
  end
642
678
  end
643
679
  when :inline_fragment
644
680
  selection[:selection_set].each do |sel|
645
- resolve_selection(sel, fields, parent, variables, context, fragments, data, errors)
681
+ resolve_selection(sel, fields, parent, variables, context, fragments, data, errors, depth + 1)
646
682
  end
647
683
  end
648
684
  end
649
685
 
650
- def resolve_field(selection, fields, parent, variables, context, fragments, data, errors)
686
+ def resolve_field(selection, fields, parent, variables, context, fragments, data, errors, depth = 1)
651
687
  field_name = selection[:name]
652
688
  output_name = selection[:alias] || field_name
653
689
 
@@ -687,7 +723,7 @@ module Tina4
687
723
  nested = {}
688
724
  sub_fields = item.is_a?(Hash) ? item_fields(item) : {}
689
725
  selection[:selection_set].each do |sel|
690
- resolve_selection(sel, sub_fields, item, variables, context, fragments, nested, errors)
726
+ resolve_selection(sel, sub_fields, item, variables, context, fragments, nested, errors, depth + 1)
691
727
  end
692
728
  nested
693
729
  end
@@ -695,7 +731,7 @@ module Tina4
695
731
  nested = {}
696
732
  sub_fields = item_fields(value)
697
733
  selection[:selection_set].each do |sel|
698
- resolve_selection(sel, sub_fields, value, variables, context, fragments, nested, errors)
734
+ resolve_selection(sel, sub_fields, value, variables, context, fragments, nested, errors, depth + 1)
699
735
  end
700
736
  data[output_name] = nested
701
737
  else
@@ -705,7 +741,12 @@ module Tina4
705
741
  data[output_name] = coerce_value(value)
706
742
  end
707
743
  rescue => e
708
- errors << { "message" => e.message, "path" => [output_name] }
744
+ # Log the real cause; only surface the detail to the client in debug
745
+ # mode — a resolver exception can carry internal state (DB errors,
746
+ # credentials) that must not leak. Mirrors the Python master.
747
+ Tina4::Log.error("GraphQL resolver '#{field_name}' failed: #{e.message}")
748
+ detail = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"]) ? e.message : "Internal server error"
749
+ errors << { "message" => detail, "path" => [output_name] }
709
750
  data[output_name] = nil
710
751
  end
711
752
  end
@@ -845,6 +886,16 @@ module Tina4
845
886
  class GraphQL
846
887
  attr_reader :schema
847
888
 
889
+ # Maximum selection-set nesting depth (DoS / stack-overflow guard).
890
+ # Read from TINA4_GRAPHQL_MAX_DEPTH (default 50; <= 0 disables). Exposed so
891
+ # tests/app code can override it; the writer keeps the executor in sync.
892
+ attr_reader :max_depth
893
+
894
+ def max_depth=(value)
895
+ @max_depth = value
896
+ @executor&.max_depth = value
897
+ end
898
+
848
899
  # Class-level toggle for ORM auto-schema generation. Defaults to true,
849
900
  # can be disabled via TINA4_GRAPHQL_AUTO_SCHEMA=false. Initializers and
850
901
  # user app code can branch on this before calling `schema.from_orm(...)`.
@@ -912,7 +963,12 @@ module Tina4
912
963
 
913
964
  def initialize(schema = nil)
914
965
  @schema = schema || GraphQLSchema.new
915
- @executor = GraphQLExecutor.new(@schema)
966
+ # Maximum selection-set nesting depth. A deeply nested query (or a
967
+ # circular fragment) would otherwise recurse without bound — a classic
968
+ # GraphQL DoS / stack-overflow vector. Default 50 is far beyond any
969
+ # legitimate query; TINA4_GRAPHQL_MAX_DEPTH <= 0 disables the guard.
970
+ @max_depth = Integer(ENV.fetch("TINA4_GRAPHQL_MAX_DEPTH", "50"), exception: false) || 50
971
+ @executor = GraphQLExecutor.new(@schema, max_depth: @max_depth)
916
972
  @field_resolvers = {}
917
973
 
918
974
  # Drain any resolvers registered via the class-level GraphQL.resolve()
@@ -1,6 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
+ # Marker for trusted, pre-sanitised HTML that must render UNESCAPED.
5
+ #
6
+ # String/scalar children of an HtmlElement are HTML-escaped by default to
7
+ # prevent stored/reflected XSS. Wrap a value in Raw to opt out of escaping
8
+ # when (and only when) you have already sanitised it yourself.
9
+ #
10
+ # Tina4::HtmlElement.new("div").call("<b>x</b>") # &lt;b&gt;x&lt;/b&gt; (escaped)
11
+ # Tina4::HtmlElement.new("div").call(Tina4::Raw.new("<b>x</b>")) # <b>x</b> (raw)
12
+ #
13
+ # Raw is the primary name; SafeString is the alias (and is the same class
14
+ # Frond already uses to mark trusted output, so a SafeString returned by a
15
+ # Frond filter also renders raw as an HtmlElement child). Defined defensively
16
+ # in case this file is ever loaded before Frond.
17
+ SafeString = Class.new(String) unless defined?(Tina4::SafeString)
18
+
19
+ # Primary name for the trusted-markup wrapper — an alias of SafeString.
20
+ Raw = SafeString
21
+
22
+ # Convenience constructor so callers can write Tina4::Raw("<b>x</b>")
23
+ # in addition to Tina4::Raw.new("<b>x</b>").
24
+ def self.Raw(value)
25
+ Raw.new(value.to_s)
26
+ end
27
+
4
28
  # Programmatic HTML builder — avoids string concatenation.
5
29
  #
6
30
  # Usage:
@@ -14,6 +38,9 @@ module Tina4
14
38
  # include Tina4::HtmlHelpers
15
39
  # html = _div({ class: "card" }, _p("Hello"))
16
40
  #
41
+ # String/scalar children are HTML-escaped by default to defeat XSS; nested
42
+ # HtmlElement children render themselves (already escaped, no double-escape);
43
+ # a Raw / SafeString child renders verbatim (explicit opt-in for trusted markup).
17
44
  class HtmlElement
18
45
  VOID_TAGS = %w[
19
46
  area base br col embed hr img input
@@ -90,12 +117,7 @@ module Tina4
90
117
  when false, nil
91
118
  next
92
119
  else
93
- escaped = value.to_s
94
- .gsub("&", "&amp;")
95
- .gsub('"', "&quot;")
96
- .gsub("<", "&lt;")
97
- .gsub(">", "&gt;")
98
- html << " #{key}=\"#{escaped}\""
120
+ html << " #{key}=\"#{escape_text(value.to_s)}\""
99
121
  end
100
122
  end
101
123
 
@@ -107,12 +129,38 @@ module Tina4
107
129
  html << ">"
108
130
 
109
131
  @children.each do |child|
110
- html << child.to_s
132
+ case child
133
+ when HtmlElement
134
+ # Nested elements render themselves (they already escape their own
135
+ # children) — emit as-is to avoid double-escaping.
136
+ html << child.to_s
137
+ when Raw
138
+ # Explicitly trusted markup — emit unescaped. Raw subclasses String,
139
+ # so this branch MUST come before the generic scalar escape below.
140
+ html << child.to_s
141
+ else
142
+ # Plain string/scalar child — escape to defeat stored/reflected XSS.
143
+ html << escape_text(child.to_s)
144
+ end
111
145
  end
112
146
 
113
147
  html << "</#{@tag}>"
114
148
  html
115
149
  end
150
+
151
+ private
152
+
153
+ # HTML-escape a string for safe inclusion in text content or a
154
+ # double-quoted attribute value. Mirrors Python's html.escape(quote=True):
155
+ # & < > " ' are all encoded.
156
+ def escape_text(value)
157
+ value
158
+ .gsub("&", "&amp;")
159
+ .gsub("<", "&lt;")
160
+ .gsub(">", "&gt;")
161
+ .gsub('"', "&quot;")
162
+ .gsub("'", "&#x27;")
163
+ end
116
164
  end
117
165
 
118
166
  # Module providing _div, _p, _span, etc. helper methods.
data/lib/tina4/mcp.rb CHANGED
@@ -651,9 +651,16 @@ module Tina4
651
651
  db = Tina4.database
652
652
  return { "error" => "No database connection" } if db.nil?
653
653
  param_list = params.is_a?(String) ? JSON.parse(params) : params
654
- result = db.execute(sql, param_list)
655
- db.commit rescue nil
656
- { "success" => true, "affected_rows" => (result.respond_to?(:count) ? result.count : 0) }
654
+ # db.execute() now RAISES on a SQL error (it no longer returns false).
655
+ # Catch it and return a clean { error: } payload instead of letting the
656
+ # exception escape the tool handler.
657
+ begin
658
+ result = db.execute(sql, param_list)
659
+ db.commit rescue nil
660
+ { "success" => true, "affected_rows" => (result.respond_to?(:count) ? result.count : 0) }
661
+ rescue => e
662
+ { "error" => db.get_error || e.message }
663
+ end
657
664
  }, "Execute arbitrary SQL (INSERT/UPDATE/DELETE/DDL)")
658
665
 
659
666
  server.register_tool("database_tables", lambda {
@@ -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
- imap_connect do |imap|
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
- imap_connect do |imap|
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
- imap_connect do |imap|
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
- imap_connect do |imap|
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
- imap_connect do |imap|
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)