inquirex 0.5.0 → 0.6.1

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +88 -6
  4. data/lib/.DS_Store +0 -0
  5. data/lib/inquirex/accumulator.rb +29 -0
  6. data/lib/inquirex/actions/action.rb +68 -0
  7. data/lib/inquirex/actions/base.rb +41 -0
  8. data/lib/inquirex/actions/custom.rb +31 -0
  9. data/lib/inquirex/actions/outbox.rb +57 -0
  10. data/lib/inquirex/actions/runner.rb +52 -0
  11. data/lib/inquirex/actions/send_email.rb +174 -0
  12. data/lib/inquirex/actions/template.rb +95 -0
  13. data/lib/inquirex/actions/webhook.rb +139 -0
  14. data/lib/inquirex/actions.rb +57 -0
  15. data/lib/inquirex/answers.rb +13 -0
  16. data/lib/inquirex/completion_metadata.rb +82 -0
  17. data/lib/inquirex/definition.rb +67 -5
  18. data/lib/inquirex/dsl/action_builder.rb +53 -0
  19. data/lib/inquirex/dsl/flow_builder.rb +49 -6
  20. data/lib/inquirex/engine/state_serializer.rb +7 -4
  21. data/lib/inquirex/engine.rb +136 -6
  22. data/lib/inquirex/errors.rb +5 -0
  23. data/lib/inquirex/graph/mermaid_exporter.rb +2 -0
  24. data/lib/inquirex/node.rb +2 -0
  25. data/lib/inquirex/rules/all.rb +12 -0
  26. data/lib/inquirex/rules/any.rb +11 -0
  27. data/lib/inquirex/rules/base.rb +6 -0
  28. data/lib/inquirex/rules/contains.rb +12 -0
  29. data/lib/inquirex/rules/equals.rb +12 -0
  30. data/lib/inquirex/rules/greater_than.rb +12 -0
  31. data/lib/inquirex/rules/less_than.rb +12 -0
  32. data/lib/inquirex/rules/not_empty.rb +12 -0
  33. data/lib/inquirex/validation/adapter.rb +1 -0
  34. data/lib/inquirex/validation/null_adapter.rb +5 -0
  35. data/lib/inquirex/version.rb +1 -1
  36. data/lib/inquirex/widget_registry.rb +1 -0
  37. data/lib/inquirex.rb +13 -0
  38. metadata +30 -18
  39. data/.relaxed_rubocop.yml +0 -153
  40. data/.rub +0 -0
  41. data/.ruby-version +0 -1
  42. data/.secrets.baseline +0 -127
  43. data/Brewfile +0 -7
  44. data/Rakefile +0 -36
  45. data/docs/badges/coverage_badge.svg +0 -21
  46. data/examples/01_readme_example.rb +0 -55
  47. data/examples/02_readme_mermaid.png +0 -0
  48. data/examples/02_readme_mermaid.rb +0 -36
  49. data/examples/README.md +0 -33
  50. data/justfile +0 -70
  51. data/lefthook.yml +0 -35
  52. data/sig/inquirex.rbs +0 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ce3e2bcf0a91cdba8c83abd9af02807b25462ff878762f0a0114372554dfe84
4
- data.tar.gz: 017b1f51f0641f57aecd2a9c84fb3c4fb5e39b6252ac1a4de4f9735bd79d7837
3
+ metadata.gz: 3422d46566a55c973dc712fb60918cde34029d3dc739e36bb1eb2c40110f1f25
4
+ data.tar.gz: eb6e54e12a407b21547ac97ca7153b93af516713c0dbcaa83a2323f909f8f816
5
5
  SHA512:
6
- metadata.gz: a426a31f57e7c3ef4e7bdeb2cd288da6fb66717436c167e0f3a03e808706f9525ea3c687055da32b3413015a80882524f88b05c6d01ea06dc5875dfb96f93521
7
- data.tar.gz: 9be96813505bf0ce8a0d5eed1a3e775dd0a08b8b54a5484993abe9119663323dd8075aba8fb74d3aebeed63c82370ee04fff6488a2b72d13bfcda0a47d38b525
6
+ metadata.gz: 42b3c45e598ebadec67d48e251605e74022cfe1ba0923c21b5998b0c0c2ef8005f2895e43c501dfd9a8613d05821a95653330c5395ff7755fa1892a74761873c
7
+ data.tar.gz: c85a9f013fb304ddfc7cbb9742a24167c7da7cefe18fa83425cfc83f1ac5184eeb7960a9ee3592d95ebd82890f7b9568aaea2b1eb488d8840a17106cbe091f3f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2026-07-19
4
+
5
+ - Post-completion `action` DSL verb with an extensible effect registry
6
+ - `send_email` effect: builds `Mail::Message` objects (multipart text/HTML) from `{{field}}` templates into `Answers#outbox`; delivery is the host application's responsibility. The `mail` gem is a soft dependency.
7
+ - `run { |answers, outbox| ... }` escape-hatch effect (stripped from JSON)
8
+ - `webhook` effect: POSTs the answers envelope to a static https URL whose host must be covered by the new top-level `allowed_domains` declaration; enforcement runs in `Definition#validate!`, so tampered JSON definitions fail at rehydration
9
+ - `Inquirex::Actions::Runner` and `Inquirex::Actions.run(definition, answers)`
10
+ - Actions serialize to/from JSON with their rule gates
11
+ - `CompletionMetadata` (OpenStruct; `engine` and `engine_version` required) stamped by the engine at flow completion, enrichable by front-ends via the new `Engine#after_completion` hook, persisted in engine state, and merged into answers by `Engine#answers_with_metadata`
12
+
3
13
  ## [0.1.0] - 2026-04-13
4
14
 
5
15
  - Initial release
data/README.md CHANGED
@@ -31,7 +31,7 @@ It provides a rich DSL for creating dynamic user intake forms, with complexity r
31
31
  >
32
32
  > For technically inclined, Inquirex turns user forms into a directed graph, where nodes are either questions or statements (or UI transitions), while edges are AST-based logical conditions that can be stacked and joined in arbitrarily complex ways, allowing you to move from one question to any other based on the previous answer. See the details below.
33
33
 
34
- ***Don't want to deal with figuring it out? Head to [Qualified.At](https://qualified.at/onboarding) and walk through the demo onboarding form, that exists specifically to show you how quickly you can have the same conceptually on your site, tailored to YOUR users.***
34
+ ***Don't want to deal with figuring it out? Head to [Qualified.At](https://qualified.at) and walk through the demo onboarding form, that exists specifically to show you how quickly you can have the same conceptually on your site, tailored to YOUR users.***
35
35
 
36
36
  ## Summary
37
37
 
@@ -435,11 +435,7 @@ Behavior:
435
435
  - Use `finished?` to detect completion
436
436
  - Use `total(:price)` / `totals` to read running totals
437
437
  - Use `to_state` / `.from_state` for persistence/resume (totals included)
438
- - Use `prefill!(hash)` to merge externally-supplied answers into the state,
439
- e.g. fields extracted by an LLM from a free-text answer (see
440
- [inquirex-llm](#extension-gems)). Existing answers are preserved; `nil`
441
- and empty values are ignored so they don't spuriously satisfy
442
- `not_empty` rules. The engine auto-advances past any newly-skippable step.
438
+ - Use `prefill!(hash)` to merge externally-supplied answers into the state, e.g. fields extracted by an LLM from a free-text answer (see [inquirex-llm](#extension-gems)). Existing answers are preserved; `nil` and empty values are ignored so they don't spuriously satisfy `not_empty` rules. The engine auto-advances past any newly-skippable step.
443
439
 
444
440
  ```ruby
445
441
  engine = Inquirex::Engine.new(definition)
@@ -466,6 +462,90 @@ Pass a custom adapter to the engine:
466
462
  engine = Inquirex::Engine.new(definition, validator: my_validator)
467
463
  ```
468
464
 
465
+ ## Completion Metadata
466
+
467
+ When a flow finishes, the engine guarantees a `CompletionMetadata` — an OpenStruct describing how the answers were collected. Only `engine` and `engine_version` are required members; rendering front-ends attach richer environment details from an `after_completion` hook:
468
+
469
+ ```ruby
470
+ engine.after_completion do |eng|
471
+ eng.completion_metadata = Inquirex::CompletionMetadata.new(
472
+ engine: "inquirex-tty",
473
+ engine_version: Inquirex::TTY::VERSION,
474
+ uname: OpenStruct.new(Etc.uname),
475
+ user: Etc.getlogin,
476
+ terminal: ENV["LC_TERMINAL"] || ENV["TERM_PROGRAM"] || "Unknown"
477
+ )
478
+ end
479
+ ```
480
+
481
+ If no hook supplies one, the engine stamps the minimal core version (`engine: "inquirex"`, `engine_version: Inquirex::VERSION`). The metadata persists through `Engine#to_state` / `Engine.from_state`, and `engine.answers_with_metadata` merges it into the answers hash under `:completion_metadata` — which also makes it available to post-completion actions: webhook payloads carry it, and email templates can interpolate `{{completion_metadata.engine}}`.
482
+
483
+ ## Post-Completion Actions
484
+
485
+ After all questions are answered, `action` declarations run server-side with the collected answers. The flagship effect is `send_email`, which **builds** `Mail::Message` objects (the same object ActionMailer wraps) and attaches them to `answers.outbox` — **nothing is delivered**; the host application decides how and when to send.
486
+
487
+ ```ruby
488
+ Inquirex.define id: "tax-intake-2025" do
489
+ allowed_domains "*.agentica.group" # egress allowlist for webhook effects
490
+
491
+ # ... ask steps ...
492
+
493
+ action :client_receipt, if: not_empty(:email) do
494
+ send_email to: "{{email}}",
495
+ from: "forms@agentica.group",
496
+ subject: "Thanks {{name}} — we received your intake",
497
+ text: <<~TEXT,
498
+ Hi {{name}},
499
+ We received your answers:
500
+
501
+ {{answers_summary}}
502
+ TEXT
503
+ html: <<~HTML
504
+ <p>Hi {{name}},</p>
505
+ <img src="https://cdn.agentica.group/logo.png" alt="Logo">
506
+ {{answers_summary}}
507
+ HTML
508
+ end
509
+
510
+ action :admin_alert do
511
+ send_email to: "owner@agentica.group", from: "forms@agentica.group",
512
+ subject: "New lead: {{name}} <{{email}}>",
513
+ html: "{{answers_summary}}"
514
+ run { |answers, outbox| Metrics.count(:lead, answers.to_flat_h) }
515
+ end
516
+
517
+ action :crm_push do
518
+ webhook url: "https://hooks.agentica.group/inquirex",
519
+ headers: { "X-Api-Key" => "..." }
520
+ end
521
+ end
522
+ ```
523
+
524
+ Execution and delivery:
525
+
526
+ ```ruby
527
+ answers = Inquirex::Actions.run(definition, engine.answers)
528
+ answers.outbox.messages # => [Mail::Message, ...]
529
+ answers.outbox.results # => per-action :ok / :skipped / :failed trail
530
+
531
+ # In a Rails host:
532
+ answers.outbox.each do |message|
533
+ ActionMailer::Base.wrap_delivery_behavior(message) # adopt Rails delivery config
534
+ message.deliver
535
+ end
536
+ ```
537
+
538
+ Key semantics:
539
+
540
+ - **Templating is `{{field}}` interpolation only** — dot-notation keys resolved against `Answers#to_flat_h`, deliberately inert (no code execution), so definitions stored in a database render safely. Values interpolated into `html:` bodies are HTML-escaped automatically; `text:` bodies stay verbatim. The built-in `{{answers_summary}}` expands to all collected answers.
541
+ - **`if:` gates** reuse the serializable rule AST (`not_empty(:email)`, ...). A false rule records `:skipped` — declare no actions (or gate them) when a flow should only save answers.
542
+ - **`run { |answers, outbox| ... }`** is the full-Ruby escape hatch; like all lambdas it is stripped from JSON.
543
+ - **Failures are isolated**: a raising effect records `:failed` in `outbox.results` and never blocks other actions.
544
+ - **Images** in HTML bodies must be external URLs; attachments are unsupported.
545
+ - The `mail` gem is a soft dependency, needed only when a message is built (Rails hosts already have it via ActionMailer).
546
+ - **`webhook url:`** POSTs `{"answers": {...}}` as JSON to a static URL. The URL's host must be covered by `allowed_domains`, declared at the top of the definition so the flow's egress surface is auditable at a glance. The check runs inside `Definition.new` — a JSON definition whose webhook URL was tampered with fails at *rehydration*, before anything executes. Also enforced: https only (plain http just for localhost), no userinfo, no `{{field}}` templates in URLs (the destination must be static), redirects are not followed, and non-2xx responses record `:failed`.
547
+ - Effects are extensible: `Inquirex::Actions.register(:save_record, MyEffect)` gives a new verb both DSL and JSON wire support.
548
+
469
549
  ## Serialization
470
550
 
471
551
  Definitions support round-trip serialization:
@@ -483,6 +563,8 @@ Serialized structure includes:
483
563
  - Steps and transitions
484
564
  - Rule AST payloads
485
565
  - Widget hints
566
+ - Post-completion actions (`actions`) with their rules and effects; `run` blocks are stripped, and an action left with no serializable effects is omitted entirely
567
+ - The `allowed_domains` egress allowlist, re-enforced against webhook URLs every time a definition is rehydrated
486
568
 
487
569
  Important serialization details:
488
570
 
data/lib/.DS_Store ADDED
Binary file
@@ -8,9 +8,21 @@ module Inquirex
8
8
  # @attr_reader name [Symbol] accumulator identifier (e.g. :price)
9
9
  # @attr_reader type [Symbol] one of Node::TYPES (typically :currency, :integer, :decimal)
10
10
  # @attr_reader default [Numeric] starting value (default: 0)
11
+ #
12
+ # @example Declare a running price total and contribute to it from a step
13
+ # Inquirex.define do
14
+ # accumulator :price, type: :currency, default: 0
15
+ # ask :dependents do
16
+ # type :integer
17
+ # accumulate :price, per_unit: 50
18
+ # end
19
+ # end
11
20
  class Accumulator
12
21
  attr_reader :name, :type, :default
13
22
 
23
+ # @param name [Symbol, String] accumulator identifier
24
+ # @param type [Symbol, String] value type, one of Node::TYPES
25
+ # @param default [Numeric] starting value before any contributions
14
26
  def initialize(name:, type: :decimal, default: 0)
15
27
  @name = name.to_sym
16
28
  @type = type.to_sym
@@ -18,10 +30,19 @@ module Inquirex
18
30
  freeze
19
31
  end
20
32
 
33
+ # Serializes the accumulator to its wire format. The name is omitted —
34
+ # Definition#to_h keys the accumulators map by name.
35
+ #
36
+ # @return [Hash{String => Object}] e.g. { "type" => "currency", "default" => 0 }
21
37
  def to_h
22
38
  { "type" => @type.to_s, "default" => @default }
23
39
  end
24
40
 
41
+ # Deserializes an Accumulator from its wire format.
42
+ #
43
+ # @param name [Symbol, String] accumulator identifier (the map key)
44
+ # @param hash [Hash] type/default attributes (string or symbol keys)
45
+ # @return [Accumulator]
25
46
  def self.from_h(name, hash)
26
47
  new(
27
48
  name: name,
@@ -44,10 +65,15 @@ module Inquirex
44
65
  # @attr_reader shape [Symbol] one of :lookup, :per_selection, :per_unit, :flat
45
66
  # @attr_reader payload [Object] shape-specific data (Hash or Numeric)
46
67
  class Accumulation
68
+ # Valid accumulation shapes; exactly one must be declared per entry.
47
69
  SHAPES = %i[lookup per_selection per_unit flat].freeze
48
70
 
49
71
  attr_reader :target, :shape, :payload
50
72
 
73
+ # @param target [Symbol, String] accumulator name to contribute to
74
+ # @param shape [Symbol, String] one of SHAPES
75
+ # @param payload [Hash, Numeric] shape-specific data (Hash for :lookup/:per_selection)
76
+ # @raise [Errors::DefinitionError] when shape is not one of SHAPES
51
77
  def initialize(target:, shape:, payload:)
52
78
  @target = target.to_sym
53
79
  @shape = shape.to_sym
@@ -73,6 +99,9 @@ module Inquirex
73
99
  end
74
100
  end
75
101
 
102
+ # Serializes to the single-key shape Hash that .from_h accepts.
103
+ #
104
+ # @return [Hash{String => Object}] e.g. { "per_unit" => 50 }
76
105
  def to_h
77
106
  { @shape.to_s => serialize_payload }
78
107
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module Actions
5
+ # A named post-completion unit: an optional rule gating execution and an
6
+ # ordered list of effects. Declared with the DSL word `action`:
7
+ #
8
+ # action :client_receipt, if: not_empty(:email) do
9
+ # send_email to: "{{email}}", subject: "Thanks {{name}}!", text: "..."
10
+ # end
11
+ #
12
+ # Rules reuse the same serializable AST as transitions, so conditions
13
+ # survive the JSON round-trip. Non-serializable effects (run blocks) are
14
+ # stripped on serialization; an action left with no serializable effects
15
+ # is omitted from JSON entirely.
16
+ class Action
17
+ attr_reader :id, :rule, :effects
18
+
19
+ # @param id [Symbol] action identifier
20
+ # @param effects [Array<Actions::Base>] executed in declaration order
21
+ # @param rule [Rules::Base, nil] gate — action runs only when true
22
+ def initialize(id:, effects:, rule: nil)
23
+ @id = id.to_sym
24
+ @effects = effects.freeze
25
+ @rule = rule
26
+ freeze
27
+ end
28
+
29
+ # @param answers_hash [Hash] step_id => value context for rule evaluation
30
+ # @return [Boolean]
31
+ def applicable?(answers_hash)
32
+ @rule.nil? || @rule.evaluate(answers_hash)
33
+ end
34
+
35
+ # @return [Boolean] whether anything survives JSON serialization
36
+ def serializable?
37
+ @effects.any?(&:serializable?)
38
+ end
39
+
40
+ # @return [Hash] wire format; run blocks are stripped
41
+ def to_h
42
+ hash = { "id" => @id.to_s }
43
+ hash["if"] = @rule.to_h if @rule
44
+ hash["effects"] = @effects.select(&:serializable?).map(&:to_h)
45
+ hash
46
+ end
47
+
48
+ # @param hash [Hash] string or symbol keys
49
+ # @return [Action]
50
+ def self.from_h(hash)
51
+ id = hash["id"] || hash[:id]
52
+ rule_data = hash["if"] || hash[:if]
53
+ effects_data = hash["effects"] || hash[:effects] || []
54
+
55
+ effects = effects_data.map do |effect_hash|
56
+ type = effect_hash["type"] || effect_hash[:type]
57
+ Actions.lookup(type).from_h(effect_hash)
58
+ end
59
+
60
+ new(
61
+ id: id.to_sym,
62
+ effects: effects,
63
+ rule: rule_data ? Rules::Base.from_h(rule_data) : nil
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module Actions
5
+ # Abstract base for action effects — the executable units inside an
6
+ # `action` block. Subclasses implement #call and, when serializable,
7
+ # #to_h / .from_h following the same round-trip pattern as Rules::Base.
8
+ #
9
+ # Effects that wrap Ruby procs (Actions::Custom) return false from
10
+ # #serializable? and are stripped from JSON, consistent with how
11
+ # lambdas are handled everywhere else in Inquirex.
12
+ class Base
13
+ # Executes the effect. Email-building effects append Mail::Message
14
+ # objects to the outbox; custom effects may do anything server-side.
15
+ #
16
+ # @param answers [Answers] completed answers
17
+ # @param outbox [Outbox] collector for built messages
18
+ # @return [void]
19
+ def call(answers, outbox)
20
+ raise NotImplementedError, "#{self.class}#call must be implemented"
21
+ end
22
+
23
+ # @return [Boolean] whether this effect survives JSON serialization
24
+ def serializable? = true
25
+
26
+ # Hook for effects that must be checked against the definition carrying
27
+ # them (e.g. Webhook vs allowed_domains). Runs inside
28
+ # Definition#validate!, which both DSL-built and JSON-rehydrated
29
+ # definitions pass through — so violations fail at load time.
30
+ #
31
+ # @param _definition [Definition]
32
+ # @raise [Errors::DefinitionError] on violation
33
+ def validate_against(_definition); end
34
+
35
+ # @return [Hash]
36
+ def to_h
37
+ raise NotImplementedError, "#{self.class}#to_h must be implemented"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module Actions
5
+ # Escape-hatch effect wrapping an arbitrary Ruby block, declared in the
6
+ # DSL as `run { |answers, outbox| ... }`. Full language power — build a
7
+ # Mail::Message by hand with outbox.add_message, call a service, record
8
+ # metrics — at the cost of serialization: like every lambda in Inquirex,
9
+ # the block is stripped from JSON and exists only in Ruby-authored
10
+ # definitions.
11
+ class Custom < Base
12
+ # @param block [Proc] receives (answers, outbox)
13
+ def initialize(block)
14
+ super()
15
+ @block = block
16
+ freeze
17
+ end
18
+
19
+ # Invokes the wrapped block with the collected answers and the outbox.
20
+ #
21
+ # @param answers [Answers] completed answers
22
+ # @param outbox [Outbox] collector for messages the block may build
23
+ # @return [Object] whatever the block returns (recorded, not interpreted)
24
+ def call(answers, outbox)
25
+ @block.call(answers, outbox)
26
+ end
27
+
28
+ def serializable? = false
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module Actions
5
+ # Collected output of running a definition's actions: the Mail::Message
6
+ # objects built by send_email effects, plus a per-execution result trail.
7
+ #
8
+ # The outbox is attached to Answers#outbox and is intentionally excluded
9
+ # from Answers serialization — mail objects never leak into persisted
10
+ # answer data. Delivery is the host application's job:
11
+ #
12
+ # answers.outbox.each do |message|
13
+ # ActionMailer::Base.wrap_delivery_behavior(message)
14
+ # message.deliver
15
+ # end
16
+ class Outbox
17
+ include Enumerable
18
+
19
+ # One entry per effect execution (or per skipped action).
20
+ # status is :ok, :skipped (action rule was false), or :failed.
21
+ Result = Data.define(:action_id, :status, :error)
22
+
23
+ attr_reader :messages, :results
24
+
25
+ def initialize
26
+ @messages = []
27
+ @results = []
28
+ end
29
+
30
+ # @param mail [Mail::Message]
31
+ # @return [Mail::Message]
32
+ def add_message(mail)
33
+ @messages << mail
34
+ mail
35
+ end
36
+
37
+ # @param action_id [Symbol]
38
+ # @param status [Symbol] :ok, :skipped, or :failed
39
+ # @param error [StandardError, nil]
40
+ def record(action_id, status, error: nil)
41
+ @results << Result.new(action_id:, status:, error:)
42
+ end
43
+
44
+ # Iterates over built Mail::Message objects.
45
+ def each(&) = @messages.each(&)
46
+
47
+ # @return [Integer] number of built messages
48
+ def size = @messages.size
49
+
50
+ # @return [Boolean]
51
+ def empty? = @messages.empty?
52
+
53
+ # @return [Array<Result>] executions that raised
54
+ def failures = @results.select { |result| result.status == :failed }
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module Actions
5
+ # Executes a definition's actions against completed answers, in
6
+ # declaration order, populating Answers#outbox with built messages and a
7
+ # result trail.
8
+ #
9
+ # Deliberately separate from the Engine: in the cross-site architecture
10
+ # the frontend collects answers and a different server process
11
+ # post-processes them, so the runner is a pure function of
12
+ # (definition, answers) — which also makes rebuilding messages inside a
13
+ # background job trivial.
14
+ #
15
+ # A failing effect records a :failed result and never aborts the other
16
+ # actions; the host inspects outbox.failures and decides what to do.
17
+ class Runner
18
+ # @param definition [Definition]
19
+ def initialize(definition)
20
+ @definition = definition
21
+ end
22
+
23
+ # @param answers [Answers, Hash] completed answers (a Hash is wrapped)
24
+ # @return [Answers] the (possibly wrapped) answers with #outbox populated
25
+ def call(answers)
26
+ answers = Answers.new(answers) unless answers.is_a?(Answers)
27
+ context = answers.to_h
28
+
29
+ @definition.actions.each do |action|
30
+ unless action.applicable?(context)
31
+ answers.outbox.record(action.id, :skipped)
32
+ next
33
+ end
34
+ execute(action, answers)
35
+ end
36
+
37
+ answers
38
+ end
39
+
40
+ private
41
+
42
+ def execute(action, answers)
43
+ action.effects.each do |effect|
44
+ effect.call(answers, answers.outbox)
45
+ answers.outbox.record(action.id, :ok)
46
+ rescue StandardError => e
47
+ answers.outbox.record(action.id, :failed, error: e)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module Actions
5
+ # Declarative email effect. Builds a Mail::Message (the object ActionMailer
6
+ # itself wraps) from {{field}} templates and appends it to the outbox.
7
+ # Nothing is delivered — the host application sends the messages.
8
+ #
9
+ # Scalar fields (to, from, cc, bcc, reply_to, subject) and the text body
10
+ # render values verbatim; the html body HTML-escapes every interpolated
11
+ # value automatically. Both bodies given => multipart/alternative.
12
+ #
13
+ # Bodies accept an inline template String or { file: "path" }, which is
14
+ # read once at definition time and inlined — a definition rehydrated from
15
+ # JSON never touches the filesystem.
16
+ #
17
+ # Images: reference external URLs in the html body. Attachments are
18
+ # deliberately unsupported.
19
+ #
20
+ # The mail gem is a soft dependency, required only when a message is
21
+ # actually built. Rails hosts always have it (ActionMailer depends on it).
22
+ class SendEmail < Base
23
+ # Scalar header fields rendered verbatim via Template.render_text in #to_mail and #to_h.
24
+ SCALAR_FIELDS = %i[to from cc bcc reply_to subject].freeze
25
+
26
+ # @return [String] required recipient / subject templates ({{field}} placeholders allowed)
27
+ attr_reader :to, :subject
28
+
29
+ # @return [String, nil] optional address templates ({{field}} placeholders allowed)
30
+ attr_reader :from, :cc, :bcc, :reply_to
31
+
32
+ # @return [String, nil] body template, inlined at definition time when { file: } was given
33
+ attr_reader :text, :html
34
+
35
+ # @return [Hash{String => String}] extra headers (values support {{field}})
36
+ attr_reader :headers
37
+
38
+ # @param to [String] recipient template (required)
39
+ # @param subject [String] subject template (required)
40
+ # @param text [String, Hash, nil] plain-text body template or { file: }
41
+ # @param html [String, Hash, nil] HTML body template or { file: }
42
+ # @param headers [Hash] extra headers (values support {{field}})
43
+ # @raise [Errors::DefinitionError] when required fields are missing
44
+ def initialize(to:, subject:, from: nil, cc: nil, bcc: nil, reply_to: nil,
45
+ text: nil, html: nil, headers: {})
46
+ super()
47
+ @to = to
48
+ @from = from
49
+ @cc = cc
50
+ @bcc = bcc
51
+ @reply_to = reply_to
52
+ @subject = subject
53
+ @text = resolve_body(text)
54
+ @html = resolve_body(html)
55
+ @headers = headers.transform_keys(&:to_s).freeze
56
+ validate!
57
+ freeze
58
+ end
59
+
60
+ # Builds the message and appends it to the outbox.
61
+ #
62
+ # @param answers [Answers] completed answers
63
+ # @param outbox [Outbox] receives the built Mail::Message
64
+ # @return [void]
65
+ def call(answers, outbox)
66
+ outbox.add_message(to_mail(answers))
67
+ end
68
+
69
+ # Builds a Mail::Message from the templates and the given answers.
70
+ # Pure function — safe to call from a background job to rebuild
71
+ # messages from persisted answers.
72
+ #
73
+ # @param answers [Answers]
74
+ # @return [Mail::Message]
75
+ def to_mail(answers)
76
+ require_mail!
77
+ mail = ::Mail.new
78
+ SCALAR_FIELDS.each do |field|
79
+ value = public_send(field)
80
+ mail.public_send(:"#{field}=", Template.render_text(value, answers)) if value
81
+ end
82
+ @headers.each { |name, value| mail.header[name] = Template.render_text(value.to_s, answers) }
83
+ attach_bodies(mail, answers)
84
+ mail
85
+ end
86
+
87
+ # @return [Hash] wire format, same shape .from_h accepts
88
+ def to_h
89
+ hash = { "type" => "send_email" }
90
+ SCALAR_FIELDS.each do |field|
91
+ value = public_send(field)
92
+ hash[field.to_s] = value if value
93
+ end
94
+ hash["text"] = @text if @text
95
+ hash["html"] = @html if @html
96
+ hash["headers"] = @headers unless @headers.empty?
97
+ hash
98
+ end
99
+
100
+ # @param hash [Hash] string or symbol keys
101
+ # @return [SendEmail]
102
+ def self.from_h(hash)
103
+ fetch = ->(key) { hash[key.to_s] || hash[key.to_sym] }
104
+ new(
105
+ to: fetch.call(:to),
106
+ from: fetch.call(:from),
107
+ cc: fetch.call(:cc),
108
+ bcc: fetch.call(:bcc),
109
+ reply_to: fetch.call(:reply_to),
110
+ subject: fetch.call(:subject),
111
+ text: fetch.call(:text),
112
+ html: fetch.call(:html),
113
+ headers: fetch.call(:headers) || {}
114
+ )
115
+ end
116
+
117
+ private
118
+
119
+ def attach_bodies(mail, answers)
120
+ text = @text && Template.render_text(@text, answers)
121
+ html = @html && Template.render_html(@html, answers)
122
+ if text && html
123
+ mail.text_part = build_part("text/plain; charset=UTF-8", text)
124
+ mail.html_part = build_part("text/html; charset=UTF-8", html)
125
+ elsif html
126
+ mail.content_type = "text/html; charset=UTF-8"
127
+ mail.body = html
128
+ else
129
+ mail.body = text
130
+ end
131
+ end
132
+
133
+ def build_part(content_type, body)
134
+ part = ::Mail::Part.new
135
+ part.content_type = content_type
136
+ part.body = body
137
+ part
138
+ end
139
+
140
+ # Inline template string, or { file: "path" } read once at definition time.
141
+ def resolve_body(value)
142
+ return value if value.nil? || value.is_a?(String)
143
+
144
+ path = value.is_a?(Hash) && (value[:file] || value["file"])
145
+ return File.read(File.expand_path(path)) if path.is_a?(String)
146
+
147
+ raise Errors::DefinitionError,
148
+ "send_email body must be a template String or { file: \"path\" }, got #{value.inspect}"
149
+ end
150
+
151
+ def validate!
152
+ raise Errors::DefinitionError, "send_email requires to:" if blank?(@to)
153
+ raise Errors::DefinitionError, "send_email requires subject:" if blank?(@subject)
154
+ return unless @text.nil? && @html.nil?
155
+
156
+ raise Errors::DefinitionError, "send_email requires a text: or html: body"
157
+ end
158
+
159
+ def blank?(value) = value.nil? || value.to_s.strip.empty?
160
+
161
+ def require_mail!
162
+ return if defined?(::Mail)
163
+
164
+ require "mail"
165
+ rescue LoadError
166
+ raise Errors::ActionError,
167
+ "send_email requires the mail gem — add `gem \"mail\"` to your Gemfile " \
168
+ "(Rails applications already have it via ActionMailer)"
169
+ end
170
+ end
171
+
172
+ register(:send_email, SendEmail)
173
+ end
174
+ end