tina4ruby 3.13.93 → 3.13.94

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f7b916f1f99717b6933e8d4376b760d2c975daefec3b5507d9827480522cfe8f
4
- data.tar.gz: e800d0a184690267eb84976193744593eea98b2ec355405f15a32214d12e31a0
3
+ metadata.gz: a56dae3f83633c16250748c98a0cc2507c1309db74552c55914555da4b34356d
4
+ data.tar.gz: 661237a63c06e576343655d847088e47becdd1c934e0f34f1f3691494993b09a
5
5
  SHA512:
6
- metadata.gz: 6428126904b059c914f08ca0fc446622a07447075c23e4a20ba12b01f93cf6a5c48454cab6cebfdc98da615f9fbb9ba57d4e3021d6b26d688cb92cdf5a553cb3
7
- data.tar.gz: 7fb13ab4b8cae5c60ad932ed1a9de7750881381451b294747b6f90441b682dd5658f49394fd29707144fe2e4ae95a5639cb47cc50a6a1a87aae6b4d60eab0e29
6
+ metadata.gz: ec26f2cbe92f9acd95ec969a62aff4586c7b2f97cdee9cef39173bf3e34532ff0273dc42df079c0aec0ed89f67a46b0ab802c963d220aeecf8bee9e09e7bbbbd
7
+ data.tar.gz: 12fff1300e24b224979b14ce160e8cf1f0f0878faa5c1845861f5f27eb9063e71af13cafb1f026058cab0617c0c3975df7789e91fe9679f78de430c596b25689
data/lib/tina4/cli.rb CHANGED
@@ -3223,10 +3223,18 @@ module Tina4
3223
3223
 
3224
3224
  EXPOSE 7147
3225
3225
 
3226
- # Swagger defaults (override with env vars in docker-compose/k8s if needed)
3227
- ENV SWAGGER_TITLE="Tina4 API"
3228
- ENV SWAGGER_VERSION="0.1.0"
3229
- ENV SWAGGER_DESCRIPTION="Auto-generated API documentation"
3226
+ # Swagger defaults (override with env vars in docker-compose/k8s if needed).
3227
+ # The TINA4_ prefix is REQUIRED: the un-prefixed names are the legacy
3228
+ # v2/v3.11 forms, and check_legacy_env_vars! refuses to boot when it
3229
+ # finds one, so a generated image would exit 2 during startup.
3230
+ ENV TINA4_SWAGGER_TITLE="Tina4 API"
3231
+ ENV TINA4_SWAGGER_VERSION="0.1.0"
3232
+ ENV TINA4_SWAGGER_DESCRIPTION="Auto-generated API documentation"
3233
+
3234
+ # Required: WebServer#start exits 1 unless the tina4 CLI launched it
3235
+ # (--managed) or this is set. A container has no CLI supervising it.
3236
+ ENV TINA4_OVERRIDE_CLIENT=true
3237
+ ENV TINA4_DEBUG=false
3230
3238
 
3231
3239
  # Start the server on all interfaces
3232
3240
  CMD ["bundle", "exec", "tina4ruby", "start", "-p", "7147", "-h", "0.0.0.0", "--production"]
@@ -5,6 +5,15 @@ require "digest"
5
5
  require "weakref"
6
6
 
7
7
  module Tina4
8
+ # Raised at the first USE of a database whose connect failed.
9
+ #
10
+ # Connecting is deliberately non-fatal at boot (log loud, then degrade - the same
11
+ # policy the session backends follow), so the failure has to resurface somewhere.
12
+ # It used to resurface as a nil dereference inside the driver
13
+ # ("NoMethodError: private method `exec' called for nil:NilClass"), which named
14
+ # neither the database nor the reason. This carries both.
15
+ class DatabaseConnectionError < StandardError; end
16
+
8
17
  # Thread-safe connection pool with round-robin rotation.
9
18
  # Connections are created lazily on first use.
10
19
  class ConnectionPool
@@ -191,6 +200,9 @@ module Tina4
191
200
  pool
192
201
  end
193
202
  @connected = false
203
+ # Set by #connect when connecting fails; re-raised with context by
204
+ # #current_driver so the first real call says what went wrong.
205
+ @connect_error = nil
194
206
 
195
207
  # Per-instance thread-local key for the transaction adapter pin.
196
208
  # Without this pin, every Database method call rotates to a different
@@ -301,9 +313,21 @@ module Tina4
301
313
  @driver.autocommit = @autocommit if @driver.respond_to?(:autocommit=)
302
314
 
303
315
  Tina4::Log.info("Database connected: #{@driver_name}")
316
+ @connect_error = nil
304
317
  rescue => e
305
318
  Tina4::Log.error("Database connection failed: #{e.message}")
306
319
  @connected = false
320
+ # REMEMBER the cause. Logging alone is not enough: the driver's own
321
+ # connection stays nil, so the next call used to die inside the driver as
322
+ # NoMethodError: private method `exec' called for nil:NilClass
323
+ # which names neither the database it failed to reach nor why. That cost
324
+ # real debugging time when a missing test database looked like a driver
325
+ # bug. current_driver re-raises this with context instead.
326
+ #
327
+ # Boot still does NOT crash on an unreachable database (deliberate, and
328
+ # the same log-loud-then-degrade policy the session backends use) - the
329
+ # error surfaces at the first actual USE.
330
+ @connect_error = e
307
331
  end
308
332
 
309
333
  def close
@@ -324,6 +348,11 @@ module Tina4
324
348
  def current_driver
325
349
  pinned = Thread.current[@tx_pin_key]
326
350
  return pinned if pinned
351
+
352
+ # Fail with the REAL reason, at the point of use. Without this the caller
353
+ # gets a nil dereference from deep inside the driver and has to guess.
354
+ raise Tina4::DatabaseConnectionError, connect_error_message if @connect_error
355
+
327
356
  if @pool
328
357
  @pool.checkout
329
358
  else
@@ -331,6 +360,22 @@ module Tina4
331
360
  end
332
361
  end
333
362
 
363
+ # A message that says which database, on which driver, and why - the three
364
+ # things the old nil-dereference told you nothing about.
365
+ def connect_error_message
366
+ target = safe_connection_target
367
+ "Database not connected (#{@driver_name}#{target.empty? ? '' : " -> #{target}"}): " \
368
+ "#{@connect_error.class}: #{@connect_error.message}"
369
+ end
370
+
371
+ # The connection string with any password stripped, so a raised error can name
372
+ # the target without leaking a credential into a log or an HTTP 500 body.
373
+ def safe_connection_target
374
+ return '' if @connection_string.nil? || @connection_string.empty?
375
+
376
+ @connection_string.sub(%r{://([^:/@]+):[^@]*@}, '://\1:***@')
377
+ end
378
+
334
379
  # ── Query Cache ──────────────────────────────────────────────
335
380
 
336
381
  def cache_stats
@@ -542,57 +587,122 @@ module Tina4
542
587
  end
543
588
  private :write_affected
544
589
 
590
+ # The table's primary-key columns, introspected once and cached.
591
+ #
592
+ # Returns an ARRAY because a primary key may span several columns. A
593
+ # composite key is still one primary key; it just has more than one column.
594
+ # Returns [] when the table has no primary key or cannot be introspected.
595
+ #
596
+ # Uses the cross-engine columns() contract (v3.13.14, #48), which reports
597
+ # :primary_key per column on every driver.
598
+ def primary_key(table)
599
+ @pk_cache ||= {}
600
+ unless @pk_cache.key?(table)
601
+ @pk_cache[table] = begin
602
+ columns(table).select { |c| c[:primary_key] }.map { |c| c[:name].to_s }
603
+ rescue StandardError
604
+ []
605
+ end
606
+ end
607
+ @pk_cache[table]
608
+ end
609
+
610
+ # Normalise a filter to [sql, params], accepting a Hash or a String.
611
+ def as_where(filter, params)
612
+ return ["", []] if filter.nil?
613
+
614
+ if filter.is_a?(Hash)
615
+ return ["", []] if filter.empty?
616
+
617
+ drv = current_driver
618
+ [filter.keys.map { |k| "#{k} = #{drv.placeholder}" }.join(" AND "), filter.values]
619
+ else
620
+ [filter.to_s, Array(params)]
621
+ end
622
+ end
623
+ private :as_where
624
+
625
+ # Update rows. A write with no filter is an error, not a full-table write.
626
+ #
627
+ # With no explicit filter the primary key is taken out of `data` and used as
628
+ # the WHERE clause. With neither a filter nor a primary key in `data` this
629
+ # raises rather than overwriting every row (audit feature 4, P1).
545
630
  def update(table, data, filter = {}, params = nil)
546
- cache_invalidate if @cache_enabled
547
- drv = current_driver
631
+ where_sql, where_params = as_where(filter, params)
632
+ data = data.dup
633
+
634
+ if where_sql.empty?
635
+ pk_columns = primary_key(table)
636
+ # Resolve each key column to whichever form the caller used (String or
637
+ # Symbol); nil marks one that is absent from the data.
638
+ pk_keys = pk_columns.map do |col|
639
+ if data.key?(col) then col
640
+ elsif data.key?(col.to_sym) then col.to_sym
641
+ end
642
+ end
643
+ missing = pk_columns.each_with_index.reject { |_, i| pk_keys[i] }.map(&:first)
644
+
645
+ if pk_columns.empty? || !missing.empty?
646
+ raise ArgumentError,
647
+ "update requires a filter or the complete primary key in the data; " \
648
+ "pass a filter explicitly to update multiple rows " \
649
+ "(table=#{table.inspect}, primary key=#{pk_columns.inspect}, " \
650
+ "missing from data=#{missing.inspect}). " \
651
+ "To empty a table use truncate(#{table.inspect})."
652
+ end
548
653
 
549
- # String filter with explicit params array
550
- if filter.is_a?(String) && !params.nil?
551
- set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
552
- sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
553
- sql += " WHERE #{filter}" unless filter.empty?
554
- drv.execute(sql, data.values + Array(params))
555
- autocommit_standalone_write(drv)
556
- return Tina4::DatabaseResult.new([], affected_rows: write_affected(drv))
654
+ # EVERY key column goes into the WHERE. A composite key built from only
655
+ # its first column would match every row sharing that value - the
656
+ # data-loss bug this method exists to prevent, reintroduced.
657
+ where_params = pk_keys.map { |k| data.delete(k) }
658
+ if data.empty?
659
+ raise ArgumentError,
660
+ "update was given only the primary key #{pk_columns.inspect} and no " \
661
+ "columns to set (table=#{table.inspect})"
662
+ end
663
+
664
+ where_sql = pk_columns.map { |c| "#{c} = #{current_driver.placeholder}" }.join(" AND ")
557
665
  end
558
666
 
667
+ cache_invalidate if @cache_enabled
668
+ drv = current_driver
559
669
  set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
560
- where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
561
- sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
562
- sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
563
- values = data.values + filter.values
564
- drv.execute(sql, values)
670
+ sql = "UPDATE #{table} SET #{set_parts.join(', ')} WHERE #{where_sql}"
671
+ drv.execute(sql, data.values + where_params)
565
672
  autocommit_standalone_write(drv)
566
- Tina4::DatabaseResult.new([], affected_rows: write_affected(drv))
673
+ Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
567
674
  end
568
675
 
676
+ # Delete rows. A filterless delete raises; use truncate() to empty a table.
569
677
  def delete(table, filter = {}, params = nil)
570
- cache_invalidate if @cache_enabled
571
- drv = current_driver
572
-
573
678
  # List of hashes — delete each row
574
679
  if filter.is_a?(Array)
575
680
  total = 0
576
681
  filter.each { |row| total += delete(table, row).affected_rows }
577
- return Tina4::DatabaseResult.new([], affected_rows: total)
682
+ return Tina4::DatabaseResult.new([], affected_rows: total, last_id: nil)
578
683
  end
579
684
 
580
- # String filter — raw WHERE clause with optional params
581
- if filter.is_a?(String)
582
- sql = "DELETE FROM #{table}"
583
- sql += " WHERE #{filter}" unless filter.empty?
584
- drv.execute(sql, Array(params))
585
- autocommit_standalone_write(drv)
586
- return Tina4::DatabaseResult.new([], affected_rows: write_affected(drv))
685
+ where_sql, where_params = as_where(filter, params)
686
+ if where_sql.empty?
687
+ raise ArgumentError,
688
+ "delete requires a filter (table=#{table.inspect}). " \
689
+ "To remove every row use truncate(#{table.inspect})."
587
690
  end
588
691
 
589
- # Hash filter — build WHERE from keys
590
- where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
591
- sql = "DELETE FROM #{table}"
592
- sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
593
- drv.execute(sql, filter.values)
692
+ cache_invalidate if @cache_enabled
693
+ drv = current_driver
694
+ drv.execute("DELETE FROM #{table} WHERE #{where_sql}", where_params)
695
+ autocommit_standalone_write(drv)
696
+ Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
697
+ end
698
+
699
+ # Remove every row. The explicit spelling of a whole-table delete.
700
+ def truncate(table)
701
+ cache_invalidate if @cache_enabled
702
+ drv = current_driver
703
+ drv.execute("DELETE FROM #{table} WHERE 1 = 1", [])
594
704
  autocommit_standalone_write(drv)
595
- Tina4::DatabaseResult.new([], affected_rows: write_affected(drv))
705
+ Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
596
706
  end
597
707
 
598
708
  # Return the last execute() error message, or nil.
@@ -16,7 +16,10 @@ module Tina4
16
16
  end
17
17
 
18
18
  # Capture an outgoing email to the local filesystem instead of sending
19
- def capture(to:, subject:, body:, html: false, cc: [], bcc: [],
19
+ # text: carries the plain-text alternative onto the dev path. Every framework
20
+ # used to drop it, so what you inspected in the mailbox was not what would have
21
+ # been sent -- a mailbox that shows you a different message is worse than none.
22
+ def capture(to:, subject:, body:, html: false, text: nil, cc: [], bcc: [],
20
23
  reply_to: nil, from_address: nil, from_name: nil, attachments: [])
21
24
  msg_id = SecureRandom.uuid
22
25
  timestamp = Time.now
@@ -30,6 +33,7 @@ module Tina4
30
33
  reply_to: reply_to,
31
34
  subject: subject,
32
35
  body: body,
36
+ text: text,
33
37
  html: html,
34
38
  attachments: store_attachments(msg_id, attachments),
35
39
  read: false,
@@ -169,7 +169,10 @@ module Tina4
169
169
  type: r[:type],
170
170
  nullable: r[:notnull] == 0,
171
171
  default: r[:dflt_value],
172
- primary_key: r[:pk] == 1
172
+ # PRAGMA table_info reports `pk` as the 1-BASED POSITION within the
173
+ # primary key, not a boolean: a composite key gives pk=1, pk=2, ...
174
+ # Testing `== 1` reported only the first column of a composite key.
175
+ primary_key: r[:pk].to_i.positive?
173
176
  }
174
177
  end
175
178
  end
data/lib/tina4/frond.rb CHANGED
@@ -163,6 +163,29 @@ module Tina4
163
163
  elif else elseif endautoescape endblock endcache endfor endif endlive
164
164
  endmacro endraw endset endspaceless
165
165
  ].freeze
166
+
167
+ # Author-written tags the sandbox allow-list governs. Mirrors Python's
168
+ # _GATEABLE_TAGS and PHP's GATEABLE_TAGS. A tag absent from this list is
169
+ # structural, not an author capability, and is never gated -- `block` and
170
+ # `extends` are template inheritance, and `raw` is consumed by the tokenizer.
171
+ # Both spellings of set ({% set x = 1 %} and {% set x %}...{% endset %})
172
+ # dispatch under "set", so one entry covers the pair.
173
+ GATEABLE_TAGS = %w[
174
+ autoescape cache for from if import include live macro set spaceless
175
+ ].freeze
176
+
177
+ # Gateable tags that OWN A BODY, mapped to the terminator closing it. A
178
+ # denied tag has to consume its body -- see skip_block.
179
+ BLOCK_TAG_ENDS = {
180
+ "autoescape" => "endautoescape",
181
+ "cache" => "endcache",
182
+ "for" => "endfor",
183
+ "if" => "endif",
184
+ "live" => "endlive",
185
+ "macro" => "endmacro",
186
+ "set" => "endset",
187
+ "spaceless" => "endspaceless"
188
+ }.freeze
166
189
  AUTOESCAPE_RE = /\Aautoescape\s+(false|true)/
167
190
  STRIPTAGS_RE = /<[^>]+>/
168
191
  THOUSANDS_RE = /(\d)(?=(\d{3})+(?!\d))/
@@ -444,6 +467,28 @@ module Tina4
444
467
  self
445
468
  end
446
469
 
470
+ # May this filter RUN under the current sandbox?
471
+ #
472
+ # The escaping decision has to ask this rather than read the filter name out
473
+ # of the source: a denied `raw` that still marked its value safe made the
474
+ # allow-list entry governing XSS escaping inert.
475
+ def filter_permitted?(name)
476
+ return true unless @sandbox && @allowed_filters
477
+
478
+ @allowed_filters.include?(name.to_s)
479
+ end
480
+
481
+ # May this tag run under the current sandbox?
482
+ #
483
+ # One gate for every tag, so the allow-list governs the whole tag vocabulary
484
+ # instead of whichever names somebody remembered to check individually.
485
+ def tag_permitted?(tag)
486
+ return true unless @sandbox && @allowed_tags
487
+ return true unless GATEABLE_TAGS.include?(tag)
488
+
489
+ @allowed_tags.include?(tag)
490
+ end
491
+
447
492
  # Utility: HTML escape
448
493
  def self.escape_html(str)
449
494
  str.to_s.gsub(HTML_ESCAPE_RE, HTML_ESCAPE_MAP)
@@ -692,6 +737,43 @@ module Tina4
692
737
  # Token renderer
693
738
  # -----------------------------------------------------------------------
694
739
 
740
+ # Consume a denied tag WITHOUT running it, returning the index past its body.
741
+ #
742
+ # Ruby walks a token stream, so a denied tag cannot simply be dropped the way
743
+ # an AST node can. Advancing by one token would leave the body's tokens to
744
+ # render at the TOP level, leaking exactly the content the sandbox denied.
745
+ # Running the handler and discarding its output is no better: handle_set binds
746
+ # its variable before it returns. Consuming the block gives all three
747
+ # properties at once -- no output, no side effects, correct index.
748
+ def skip_block(tokens, i, tag, content)
749
+ terminator = BLOCK_TAG_ENDS[tag]
750
+ # {% set x = 1 %} is an assignment and owns no body; {% set x %}...{% endset %}
751
+ # captures one. Same exact-"=" test the dispatch uses.
752
+ terminator = nil if tag == "set" && content.include?("=")
753
+ return i + 1 if terminator.nil?
754
+
755
+ depth = 1
756
+ j = i + 1
757
+ while j < tokens.length
758
+ if tokens[j][0] == BLOCK
759
+ inner = strip_tag(tokens[j][1])[0]
760
+ case inner.split[0]
761
+ when tag
762
+ # A nested assignment-form set opens no body, so it must not nest.
763
+ depth += 1 unless tag == "set" && inner.include?("=")
764
+ when terminator
765
+ depth -= 1
766
+ return j + 1 if depth.zero?
767
+ end
768
+ end
769
+ j += 1
770
+ end
771
+
772
+ # Unterminated block: consume to the end. Nothing renders, which is the
773
+ # safe answer -- the alternative leaks the body of a denied tag.
774
+ tokens.length
775
+ end
776
+
695
777
  def render_tokens(tokens, context)
696
778
  output = []
697
779
  i = 0
@@ -725,6 +807,18 @@ module Tina4
725
807
 
726
808
  tag = content.split[0] || ""
727
809
 
810
+ # ONE sandbox gate for the whole tag vocabulary. Previously only
811
+ # `include` was checked, so every other tag ignored the allow-list --
812
+ # {% autoescape false %} could switch escaping off from inside a
813
+ # sandbox whose tags were restricted to something else entirely.
814
+ unless tag_permitted?(tag)
815
+ i = skip_block(tokens, i, tag, content)
816
+ if strip_a && i < tokens.length && tokens[i][0] == TEXT
817
+ tokens[i] = [TEXT, tokens[i][1].lstrip]
818
+ end
819
+ next
820
+ end
821
+
728
822
  case tag
729
823
  when "if"
730
824
  result, i = handle_if(tokens, i, context)
@@ -745,12 +839,8 @@ module Tina4
745
839
  i = handle_set_block(tokens, i, context)
746
840
  end
747
841
  when "include"
748
- if @sandbox && @allowed_tags && !@allowed_tags.include?("include")
749
- i += 1
750
- else
751
- output << handle_include(content, context)
752
- i += 1
753
- end
842
+ output << handle_include(content, context)
843
+ i += 1
754
844
  when "macro"
755
845
  i = handle_macro(tokens, i, context)
756
846
  when "import"
@@ -913,14 +1003,16 @@ module Tina4
913
1003
  is_safe = false
914
1004
  filters.each do |fname, args|
915
1005
  if fname == "raw" || fname == "safe"
916
- is_safe = true
1006
+ # Decide from what was permitted to RUN, not from what the source
1007
+ # asked for. Marking the value safe here regardless meant a DENIED
1008
+ # raw produced byte-identical output to an allowed one -- the
1009
+ # allow-list entry that governs XSS escaping did nothing at all.
1010
+ is_safe = true if filter_permitted?(fname)
917
1011
  next
918
1012
  end
919
1013
 
920
1014
  # Sandbox: check filter access
921
- if @sandbox && @allowed_filters && !@allowed_filters.include?(fname)
922
- next
923
- end
1015
+ next if @sandbox && @allowed_filters && !@allowed_filters.include?(fname)
924
1016
 
925
1017
  # Filter + property-access chain: `first.groupSummary` — apply
926
1018
  # the filter, then traverse the path on the result. Done BEFORE
@@ -71,6 +71,28 @@ module Tina4
71
71
  # mail.send(to: "user@test.com", subject: "Welcome", body: "<h1>Hello!</h1>", html: true, text: "Hello!")
72
72
  #
73
73
  class Messenger
74
+ # Factory: returns a Messenger configured for the current environment.
75
+ #
76
+ # Returns ONE concrete type, always. It used to return either a Messenger or a
77
+ # DevMessengerProxy; both happened to expose #send, so Ruby escaped the crash that
78
+ # nodejs#41 describes by luck of naming rather than by design -- but the proxy's
79
+ # #send took no text: keyword, so the documented call raised ArgumentError on a dev
80
+ # messenger. Capture is now a branch inside Messenger#send.
81
+ #
82
+ # The gate is availability, not verbosity: capture when no SMTP host is configured,
83
+ # send when one is EVEN WITH TINA4_DEBUG ON, and TINA4_MAIL_CAPTURE forces capture.
84
+ def self.create_messenger(**options)
85
+ mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
86
+ messenger = Messenger.new(**options)
87
+ messenger.instance_variable_set(:@mailbox_dir, mailbox_dir)
88
+
89
+ # Attach the mailbox eagerly when this messenger will capture, so callers (and
90
+ # the dev dashboard) can inspect it before the first send.
91
+ messenger.dev_mailbox = DevMailbox.new(mailbox_dir: mailbox_dir) if messenger.should_capture?
92
+
93
+ messenger
94
+ end
95
+
74
96
  attr_reader :host, :port, :username, :from_address, :from_name,
75
97
  :imap_host, :imap_port, :use_tls, :encryption,
76
98
  :imap_encryption, :imap_use_tls
@@ -80,6 +102,14 @@ module Tina4
80
102
  def initialize(host: nil, port: nil, username: nil, password: nil,
81
103
  from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
82
104
  imap_host: nil, imap_port: nil, imap_encryption: nil)
105
+ # Whether a host was actually CONFIGURED, which is not the same as @host being
106
+ # set: it falls back to "localhost", so it is never nil and cannot answer
107
+ # "can this messenger send?". The capture gate needs that answer, so record it
108
+ # here while the real inputs are still in scope.
109
+ configured_host = host || ENV["TINA4_MAIL_HOST"]
110
+ @smtp_configured = !configured_host.nil? && !configured_host.to_s.empty?
111
+ @mailbox_dir = nil
112
+ @dev_mailbox = nil
83
113
  @host = host || ENV["TINA4_MAIL_HOST"] || "localhost"
84
114
  @port = (port || ENV["TINA4_MAIL_PORT"] || 587).to_i
85
115
  @username = username || ENV["TINA4_MAIL_USERNAME"]
@@ -113,8 +143,47 @@ module Tina4
113
143
 
114
144
  # Send email using Ruby's Net::SMTP
115
145
  # Returns { success: true/false, message: "...", id: "..." }
146
+ # The local mailbox, present only once this messenger has captured something
147
+ # (or eagerly, when create_messenger knows it will).
148
+ attr_accessor :dev_mailbox
149
+
150
+ # Should send capture locally instead of talking to SMTP?
151
+ #
152
+ # Availability decides, not verbosity. With no SMTP host configured sending is
153
+ # impossible, so simulate it into a folder rather than failing -- that is what
154
+ # makes a laptop with no mail server usable, and it is the original Tina4
155
+ # "messages folder" behaviour restored. TINA4_MAIL_CAPTURE forces capture even
156
+ # when a host IS configured.
157
+ #
158
+ # TINA4_DEBUG deliberately does NOT gate this. Debug must still be able to send:
159
+ # tying capture to it means nobody can test a real send from a dev box. The old
160
+ # gate required debug AND no SMTP host, so a dev box with neither set went
161
+ # straight to localhost:587 and failed.
162
+ def should_capture?
163
+ return true if Tina4::Env.is_truthy(ENV["TINA4_MAIL_CAPTURE"])
164
+
165
+ !@smtp_configured
166
+ end
167
+
168
+ def dev_mailbox
169
+ @dev_mailbox ||= DevMailbox.new(mailbox_dir: @mailbox_dir)
170
+ end
171
+
116
172
  def send(to:, subject:, body:, html: false, text: nil, cc: [], bcc: [],
117
173
  reply_to: nil, attachments: [], headers: {})
174
+ # Dev capture is a BRANCH here, not a different object handed back by the
175
+ # factory. create_messenger used to return a DevMessengerProxy whose #send had
176
+ # no text: keyword at all, so the documented call raised ArgumentError and the
177
+ # plain-text alternative was silently dropped from the captured message.
178
+ if should_capture?
179
+ return dev_mailbox.capture(
180
+ to: to, subject: subject, body: body, html: html, text: text,
181
+ cc: cc, bcc: bcc, reply_to: reply_to,
182
+ from_address: @from_address, from_name: @from_name,
183
+ attachments: attachments
184
+ )
185
+ end
186
+
118
187
  message_id = "<#{SecureRandom.uuid}@#{@host}>"
119
188
  raw = build_message(
120
189
  to: to, subject: subject, body: body, html: html, text: text,
@@ -649,50 +718,4 @@ module Tina4
649
718
  end
650
719
  end
651
720
 
652
- # Factory: returns a DevMailbox-intercepting messenger in dev mode,
653
- # or a real Messenger in production.
654
- def self.create_messenger(**options)
655
- dev_mode = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
656
-
657
- smtp_configured = ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?
658
-
659
- if dev_mode && !smtp_configured
660
- mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
661
- mailbox = DevMailbox.new(mailbox_dir: mailbox_dir)
662
- DevMessengerProxy.new(mailbox, **options)
663
- else
664
- Messenger.new(**options)
665
- end
666
- end
667
-
668
- # Proxy that wraps DevMailbox with the same interface as Messenger#send
669
- class DevMessengerProxy
670
- attr_reader :mailbox
671
-
672
- def initialize(mailbox, **options)
673
- @mailbox = mailbox
674
- @from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] || "dev@localhost"
675
- @from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] || "Dev Mailer"
676
- end
677
-
678
- def send(to:, subject:, body:, html: false, cc: [], bcc: [],
679
- reply_to: nil, attachments: [], headers: {})
680
- @mailbox.capture(
681
- to: to, subject: subject, body: body, html: html,
682
- cc: cc, bcc: bcc, reply_to: reply_to,
683
- from_address: @from_address, from_name: @from_name,
684
- attachments: attachments
685
- )
686
- end
687
-
688
- def test_connection
689
- { success: true, message: "DevMailbox mode — no SMTP connection needed" }
690
- end
691
-
692
- def inbox(**args) = @mailbox.inbox(**args)
693
- def read(...) = @mailbox.read(...)
694
- def unread(...) = @mailbox.unread_count
695
- def search(**args) = @mailbox.inbox(**args)
696
- def folders = ["inbox", "outbox"]
697
- end
698
721
  end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.93"
4
+ VERSION = "3.13.94"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -127,7 +127,18 @@ module Tina4
127
127
  autoload :Messenger, File.expand_path("tina4/messenger", __dir__)
128
128
  autoload :MessengerError, File.expand_path("tina4/messenger", __dir__)
129
129
  autoload :MessengerConnectionError, File.expand_path("tina4/messenger", __dir__)
130
- autoload :DevMessengerProxy, File.expand_path("tina4/messenger", __dir__)
130
+
131
+ # Factory for a Messenger configured from the environment.
132
+ #
133
+ # Defined HERE, eagerly, and not in messenger.rb: `autoload` only fires on a
134
+ # CONSTANT reference, and a module function is not a constant. A cold
135
+ # `require "tina4"; Tina4.create_messenger` therefore raised NoMethodError until
136
+ # something else happened to touch Tina4::Messenger first -- the documented entry
137
+ # point was unreachable on a fresh process. Naming the constant below is what
138
+ # triggers the autoload.
139
+ def self.create_messenger(**options)
140
+ Tina4::Messenger.create_messenger(**options)
141
+ end
131
142
  autoload :IMAP_CONNECTION_ERRORS, File.expand_path("tina4/messenger", __dir__)
132
143
  autoload :DocStore, File.expand_path("tina4/docstore", __dir__)
133
144
  autoload :ScssCompiler, File.expand_path("tina4/scss_compiler", __dir__)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.93
4
+ version: 3.13.94
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-27 00:00:00.000000000 Z
11
+ date: 2026-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack