inquirex 0.7.0 → 0.9.4

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.
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module SafeSource
5
+ # The registry of DSL words {Validator} accepts, and of the words it
6
+ # deliberately refuses.
7
+ #
8
+ # Every entry is *bound to the builder that implements it*: the six flow
9
+ # verbs are derived from {Node::VERBS}, step types from {Node::TYPES}, the
10
+ # rule vocabulary from {DSL::RuleHelpers}, and each scope declares the
11
+ # method list it governs (`vocabulary:`) so that {.undeclared_names} can
12
+ # name any builder method nobody has made an allowlist decision about.
13
+ # `spec/inquirex/safe_source/vocabulary_spec.rb` fails the build when that
14
+ # list is non-empty — which is what stops this table drifting away from the
15
+ # DSL it is supposed to describe.
16
+ #
17
+ # Downstream gems extend the vocabulary at boot rather than forking it.
18
+ #
19
+ # @example Teaching the allowlist about inquirex-llm's verbs
20
+ # V = Inquirex::SafeSource::Vocabulary
21
+ # V.register_scope(:llm_step, label: "an LLM step",
22
+ # vocabulary: -> { Inquirex::LLM::DSL::StepBuilder.public_instance_methods(false) })
23
+ # V.allow(:flow, :clarify, positional: %i[symbol], block: :llm_step)
24
+ # V.allow(:llm_step, :prompt, positional: %i[literal])
25
+ # V.exclude(:llm_step, :fallback, "a Ruby block cannot be validated")
26
+ module Vocabulary
27
+ # The only constant a flow definition may name.
28
+ ENTRY_CONSTANT = :Inquirex
29
+
30
+ # The only method that constant may receive.
31
+ ENTRY_METHOD = :define
32
+
33
+ # Keyword-descriptor key standing for "every keyword not named
34
+ # explicitly". Legitimate only where the real method takes `**rest`.
35
+ ANY_OTHER = :*
36
+
37
+ # Value kinds a `transition` accepts. Shared by every step-like scope.
38
+ TRANSITION_KEYWORDS = { to: :symbol, if_rule: :rule, requires_server: :literal }.freeze
39
+
40
+ # The `send_email` fields, and the shape each accepts. One definition
41
+ # serves both DSL forms — the flow-level keywords (`send_email to: ...`)
42
+ # and the block setters (`send_email do to "..." end`) — so the two can
43
+ # never drift apart. `headers` is `:literal` because it is a Hash; every
44
+ # other field is one static string.
45
+ EMAIL_FIELD_KEYWORDS = {
46
+ to: :string,
47
+ from: :string,
48
+ cc: :string,
49
+ bcc: :string,
50
+ reply_to: :string,
51
+ subject: :string,
52
+ text: :string,
53
+ markdown_text: :string,
54
+ html: :string,
55
+ headers: :literal
56
+ }.freeze
57
+
58
+ # Builder methods that exist for the builder's own sake and are not DSL
59
+ # words, so they need no allowlist decision. `params` is
60
+ # SendEmailBuilder's accumulator, read by FlowBuilder once the block has
61
+ # run — nothing a flow author writes.
62
+ PLUMBING = %i[build params method_missing respond_to_missing?].freeze
63
+
64
+ class << self
65
+ # Declares a nested scope (idempotent): a block whose statements are
66
+ # validated against their own table of calls.
67
+ #
68
+ # @param name [Symbol] scope name, e.g. :step
69
+ # @param label [String] how to name the scope in a violation message,
70
+ # article included ("a step")
71
+ # @param vocabulary [Proc, nil] returns the DSL words the scope's
72
+ # builder really implements; used by {.undeclared_names} to detect
73
+ # drift. Lazy, so load order does not matter.
74
+ # @return [void]
75
+ def register_scope(name, label:, vocabulary: nil)
76
+ sym = name.to_sym
77
+ @labels[sym] = label
78
+ @vocabularies[sym] = vocabulary
79
+ @calls[sym] ||= {}
80
+ @exclusions[sym] ||= {}
81
+ end
82
+
83
+ # Allowlists one call inside one scope.
84
+ #
85
+ # @param scope [Symbol] scope name, e.g. :step
86
+ # @param name [Symbol] DSL word, e.g. :question
87
+ # @param positional [Array<Symbol>, Hash] see {CallSpec#positional}
88
+ # @param keywords [nil, Hash] see {CallSpec#keywords}
89
+ # @param block [Symbol] :forbidden, or the scope its block opens
90
+ # @return [CallSpec] the registered spec
91
+ # @raise [ArgumentError] when the scope was never registered
92
+ def allow(scope, name, positional: [], keywords: nil, block: :forbidden)
93
+ table = @calls.fetch(scope.to_sym) { raise ArgumentError, "Unknown SafeSource scope: #{scope.inspect}" }
94
+ table[name.to_sym] = CallSpec.new(positional:, keywords:, block:)
95
+ end
96
+
97
+ # Records a DSL word that is knowingly *not* allowlisted, with the
98
+ # reason — which {Validator} quotes back to the author, so a rejection
99
+ # reads as a decision rather than an oversight.
100
+ #
101
+ # @param scope [Symbol] scope name
102
+ # @param name [Symbol] DSL word, e.g. :compute
103
+ # @param reason [String] why safe mode cannot accept it
104
+ # @return [String] the reason
105
+ # @raise [ArgumentError] when the scope was never registered
106
+ def exclude(scope, name, reason)
107
+ table = @exclusions.fetch(scope.to_sym) { raise ArgumentError, "Unknown SafeSource scope: #{scope.inspect}" }
108
+ table[name.to_sym] = reason
109
+ end
110
+
111
+ # @param scope [Symbol]
112
+ # @param name [Symbol]
113
+ # @return [CallSpec, nil] nil when the call is not allowlisted
114
+ def spec_for(scope, name)
115
+ @calls.fetch(scope.to_sym, {})[name.to_sym]
116
+ end
117
+
118
+ # @param scope [Symbol]
119
+ # @param name [Symbol]
120
+ # @return [String, nil] why the call is excluded, nil when it was never
121
+ # part of the scope's vocabulary at all
122
+ def exclusion_for(scope, name)
123
+ @exclusions.fetch(scope.to_sym, {})[name.to_sym]
124
+ end
125
+
126
+ # @param scope [Symbol]
127
+ # @return [String] the scope's human-readable label, article included
128
+ def label_for(scope)
129
+ @labels.fetch(scope.to_sym, "an unknown")
130
+ end
131
+
132
+ # @param name [Symbol]
133
+ # @return [Boolean] whether the scope has been registered
134
+ def scope?(name)
135
+ @labels.key?(name.to_sym)
136
+ end
137
+
138
+ # @return [Array<Symbol>] every registered scope
139
+ def scopes
140
+ @labels.keys
141
+ end
142
+
143
+ # @param scope [Symbol]
144
+ # @return [Array<Symbol>] every allowlisted call in the scope
145
+ def allowed_names(scope)
146
+ @calls.fetch(scope.to_sym, {}).keys
147
+ end
148
+
149
+ # @param scope [Symbol]
150
+ # @return [Array<Symbol>] every knowingly excluded call in the scope
151
+ def excluded_names(scope)
152
+ @exclusions.fetch(scope.to_sym, {}).keys
153
+ end
154
+
155
+ # The DSL words the scope's builder implements, per the `vocabulary:`
156
+ # binding given to {.register_scope}, minus builder plumbing.
157
+ #
158
+ # @param scope [Symbol]
159
+ # @return [Array<Symbol>] empty when the scope declared no binding
160
+ def governed_names(scope)
161
+ @vocabularies.fetch(scope.to_sym, nil)&.call.to_a.map(&:to_sym) - PLUMBING
162
+ end
163
+
164
+ # Builder methods with no allowlist decision: neither allowed nor
165
+ # knowingly excluded. Anything here is a new DSL word that silently
166
+ # became unusable in safe mode (or, worse, an old one that quietly
167
+ # gained a new meaning).
168
+ #
169
+ # @example Fail fast at boot
170
+ # raise "unreviewed DSL words" if V.scopes.any? { |s| V.undeclared_names(s).any? }
171
+ #
172
+ # @param scope [Symbol]
173
+ # @return [Array<Symbol>]
174
+ def undeclared_names(scope)
175
+ governed_names(scope) - allowed_names(scope) - excluded_names(scope)
176
+ end
177
+
178
+ # Allowlist entries that no longer correspond to a real builder method
179
+ # — a typo, or a DSL word that has since been removed or renamed.
180
+ #
181
+ # @param scope [Symbol]
182
+ # @return [Array<Symbol>]
183
+ def stale_names(scope)
184
+ return [] if @vocabularies.fetch(scope.to_sym, nil).nil?
185
+
186
+ (allowed_names(scope) + excluded_names(scope)) - governed_names(scope)
187
+ end
188
+
189
+ # The spec for `Inquirex.define` itself.
190
+ #
191
+ # @return [CallSpec]
192
+ def entry_spec
193
+ CallSpec.new(positional: [], keywords: { id: :string, version: :string }, block: :flow)
194
+ end
195
+
196
+ # Discards every registration and reinstalls the core vocabulary.
197
+ # Hosts call this to undo an extension; the specs call it to isolate.
198
+ #
199
+ # @return [void]
200
+ def reset!
201
+ @labels = {}
202
+ @calls = {}
203
+ @exclusions = {}
204
+ @vocabularies = {}
205
+ install_core!
206
+ end
207
+
208
+ private
209
+
210
+ # @return [void]
211
+ def install_core!
212
+ register_scope(:rule,
213
+ label: "a rule",
214
+ vocabulary: -> { DSL::RuleHelpers.public_instance_methods(false) })
215
+ register_scope(:flow,
216
+ label: "a flow",
217
+ vocabulary: -> { DSL::FlowBuilder.public_instance_methods(false) })
218
+ register_scope(:step,
219
+ label: "a step",
220
+ vocabulary: -> { DSL::StepBuilder.public_instance_methods(false) })
221
+ register_scope(:email,
222
+ label: "a send_email",
223
+ vocabulary: -> { DSL::SendEmailBuilder.public_instance_methods(false) })
224
+
225
+ install_rules!
226
+ install_flow!
227
+ install_step!
228
+ install_email!
229
+ end
230
+
231
+ # Rules are serializable AST objects, so their arguments are always a
232
+ # field name plus a literal — never an expression, which is what makes
233
+ # `equals(:a, ENV["X"])` a violation.
234
+ #
235
+ # @return [void]
236
+ def install_rules!
237
+ %i[equals contains greater_than less_than].each do |name|
238
+ allow :rule, name, positional: %i[symbol literal]
239
+ end
240
+ allow :rule, :not_empty, positional: %i[symbol]
241
+ %i[all any].each { |name| allow :rule, name, positional: { repeat: :rule, min: 1 } }
242
+ end
243
+
244
+ # @return [void]
245
+ def install_flow!
246
+ allow :flow, :start, positional: %i[symbol]
247
+ allow :flow,
248
+ :meta,
249
+ keywords: { title: :literal, subtitle: :literal, brand: :literal, theme: :literal }
250
+ allow :flow,
251
+ :accumulator,
252
+ positional: %i[symbol],
253
+ keywords: { type: :type_name, default: :literal }
254
+ Node::VERBS.each { |verb| allow :flow, verb, positional: %i[symbol], block: :step }
255
+ # `send_email` takes an optional `if:` rule gate plus, in its inline
256
+ # form, the same field keywords the block setters cover. Both shapes
257
+ # are real DSL (`send_email to: "...", subject: "..."` and
258
+ # `send_email do to "..." end`), so both are described here; block
259
+ # values win at build time either way.
260
+ allow :flow,
261
+ :send_email,
262
+ keywords: EMAIL_FIELD_KEYWORDS.merge(if: :rule),
263
+ block: { optional: :email }
264
+ end
265
+
266
+ # @return [void]
267
+ def install_step!
268
+ allow :step, :type, positional: %i[type_name]
269
+ allow :step, :question, positional: %i[string]
270
+ allow :step, :text, positional: %i[string]
271
+ allow :step, :options, positional: %i[literal]
272
+ allow :step, :default, positional: %i[literal]
273
+ # `required` is inert metadata: Node coerces it with `value ? true :
274
+ # false` and serializes it as `"required": false`. It reaches nothing
275
+ # outside the definition, and an author who can write the DSL text can
276
+ # already delete the question outright — strictly more powerful than
277
+ # marking it skippable — so it is allowed rather than excluded.
278
+ # `{ optional: :literal }` mirrors `required(value = true)`: both bare
279
+ # `required` and `required false` are real DSL.
280
+ allow :step, :required, positional: { optional: :literal }
281
+ allow :step, :skip_if, positional: %i[rule]
282
+ allow :step, :transition, keywords: TRANSITION_KEYWORDS
283
+ allow :step,
284
+ :accumulate,
285
+ positional: %i[symbol],
286
+ keywords: { lookup: :literal, per_selection: :literal, per_unit: :literal, flat: :literal }
287
+ # widget(type:, target:, **opts) and price(**kwargs) really do take
288
+ # open keyword lists — a widget's options and a price lookup's option
289
+ # keys are arbitrary — so ANY_OTHER is faithful rather than lax.
290
+ allow :step, :widget, keywords: { type: :symbol, target: :symbol, ANY_OTHER => :literal }
291
+ allow :step,
292
+ :price,
293
+ keywords: { lookup: :literal, per_selection: :literal, per_unit: :literal,
294
+ flat: :literal, ANY_OTHER => :literal }
295
+ exclude :step, :compute, "a compute block is arbitrary Ruby, indistinguishable from a payload"
296
+ end
297
+
298
+ # The `send_email` setters take one static string each (`headers` a
299
+ # literal Hash). {SendEmail} never delivers anything and the gem never
300
+ # renders a body, so what a stored definition can express here is a
301
+ # template the *host* chooses to act on — which is why the verb needs
302
+ # no exclusions. `{{field}}` placeholders are ordinary text to the
303
+ # parser; the shape enforced is "a string literal with no Ruby in it",
304
+ # so `#{}`, a constant and a method call are rejected exactly as
305
+ # everywhere else.
306
+ #
307
+ # The `{ file: "path" }` body form is not expressible: bodies are
308
+ # `:string`, so a stored flow cannot make the gem `File.read` an
309
+ # attacker-chosen path at definition time.
310
+ #
311
+ # @return [void]
312
+ def install_email!
313
+ EMAIL_FIELD_KEYWORDS.each do |setter, shape|
314
+ allow :email, setter, positional: [shape]
315
+ end
316
+ end
317
+ end
318
+
319
+ reset!
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "safe_source/call_spec"
6
+ require_relative "safe_source/vocabulary"
7
+ require_relative "safe_source/validator"
8
+
9
+ module Inquirex
10
+ # Decides whether a string of flow DSL is safe to evaluate — *without*
11
+ # evaluating it.
12
+ #
13
+ # {Inquirex.load_dsl} is an `eval`. Whenever the text comes from somewhere
14
+ # other than your own repository — a database column a customer edits, an
15
+ # upload, an LLM, a visual builder's "sync" button — evaluating it unguarded
16
+ # is arbitrary code execution in the process that loads the flow. As of 0.9.2
17
+ # `load_dsl` therefore runs {SafeSource.validate!} first, and only source that
18
+ # matches the real DSL vocabulary ({Vocabulary}) with literal arguments gets
19
+ # past it.
20
+ #
21
+ # Use {.validate} or {.safe?} directly to audit stored definitions without
22
+ # loading them — the answer is "would `load_dsl` accept this?", which is
23
+ # exactly what you want before a deploy tightens the allowlist.
24
+ #
25
+ # @example Reject before evaluating
26
+ # Inquirex.load_dsl(customer.flow_dsl) # validates, then evals
27
+ # Inquirex.load_dsl(File.read("flow.rb"), unsafe: true) # your own file: skip validation
28
+ #
29
+ # @example Audit a table of stored definitions
30
+ # Qualifier.find_each do |q|
31
+ # violations = Inquirex::SafeSource.validate(q.flow_dsl)
32
+ # puts "REJECT #{q.id}: #{violations.join("; ")}" if violations.any?
33
+ # end
34
+ #
35
+ # @example Raise the ceilings for an unusually large questionnaire
36
+ # Inquirex::SafeSource.max_source_bytes = 256 * 1024
37
+ # Inquirex::SafeSource.max_depth = 32
38
+ module SafeSource
39
+ # Default ceiling on DSL source size, in bytes.
40
+ #
41
+ # Real flows are tiny: the largest definition anywhere in this project or
42
+ # its downstream app — a 47-step loan application with three levels of
43
+ # composed rules — is under 6 KB. 64 KiB is an order of magnitude above
44
+ # that, comfortably fits a several-hundred-step questionnaire, and still
45
+ # bounds the work handed to Prism (and to any formatter the host runs on
46
+ # the same text), neither of which should be fed megabytes of hostile input.
47
+ DEFAULT_MAX_SOURCE_BYTES = 64 * 1024
48
+
49
+ # Default ceiling on AST nesting depth.
50
+ #
51
+ # Measured against every flow in this gem's specs and examples and the
52
+ # downstream app's fixtures, the deepest legitimate construct needs 9 levels
53
+ # (`define` → block → `ask` → block → `transition` → `all` → `any` →
54
+ # `equals` → literal). 24 leaves better than 2.5x headroom while keeping
55
+ # the validator's own recursion clear of a stack overflow triggered by
56
+ # `all(all(all(...)))` nested a million deep.
57
+ DEFAULT_MAX_DEPTH = 24
58
+
59
+ @max_source_bytes = DEFAULT_MAX_SOURCE_BYTES
60
+ @max_depth = DEFAULT_MAX_DEPTH
61
+
62
+ class << self
63
+ # Ceiling on DSL source size in bytes, applied by {.validate} and
64
+ # therefore by {Inquirex.load_dsl}.
65
+ #
66
+ # @return [Integer]
67
+ attr_accessor :max_source_bytes
68
+
69
+ # Ceiling on AST nesting depth, applied by {.validate} and therefore by
70
+ # {Inquirex.load_dsl}.
71
+ #
72
+ # @return [Integer]
73
+ attr_accessor :max_depth
74
+
75
+ # Every reason the source would be rejected. An empty array means the
76
+ # source is inside the allowlist — it says nothing about whether the flow
77
+ # is semantically valid (unknown step reference, missing `start`), which
78
+ # only evaluation can decide.
79
+ #
80
+ # @param source [String, nil] Inquirex DSL source
81
+ # @param max_bytes [Integer] override the size ceiling for this call
82
+ # @param max_depth [Integer] override the depth ceiling for this call
83
+ # @return [Array<String>] `"line N: reason"` messages, empty when safe
84
+ def validate(source, max_bytes: max_source_bytes, max_depth: self.max_depth)
85
+ Validator.new(source, max_bytes:, max_depth:).violations
86
+ end
87
+
88
+ # @param source [String, nil] Inquirex DSL source
89
+ # @param max_bytes [Integer] override the size ceiling for this call
90
+ # @param max_depth [Integer] override the depth ceiling for this call
91
+ # @return [Boolean] true when the source is inside the allowlist
92
+ def safe?(source, max_bytes: max_source_bytes, max_depth: self.max_depth)
93
+ validate(source, max_bytes:, max_depth:).empty?
94
+ end
95
+
96
+ # Validates, and raises unless the source is inside the allowlist.
97
+ #
98
+ # @param source [String, nil] Inquirex DSL source
99
+ # @param max_bytes [Integer] override the size ceiling for this call
100
+ # @param max_depth [Integer] override the depth ceiling for this call
101
+ # @return [String] the source, unchanged, when it is safe
102
+ # @raise [Errors::UnsafeSourceError] listing every violation found
103
+ def validate!(source, max_bytes: max_source_bytes, max_depth: self.max_depth)
104
+ violations = validate(source, max_bytes:, max_depth:)
105
+ raise Errors::UnsafeSourceError, violations unless violations.empty?
106
+
107
+ source
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ # A declarative email built from the collected answers, declared at flow
5
+ # level with the DSL verb `send_email`. This is the only server-side
6
+ # completion declaration the core gem carries — anything richer (webhooks,
7
+ # CRM pushes, custom code) belongs to the host application.
8
+ #
9
+ # send_email if: not_empty(:email) do
10
+ # to "{{email}}"
11
+ # from "forms@agentica.group"
12
+ # subject "Thanks {{name}} — we got your inquiry"
13
+ # markdown_text <<~TEXT
14
+ # Hi {{name}},
15
+ #
16
+ # We received your answers and will reply within one business day.
17
+ #
18
+ # {{answers_summary}}
19
+ # TEXT
20
+ # end
21
+ #
22
+ # Nothing is ever delivered by this gem. A SendEmail is data: templated
23
+ # header fields plus one or more body templates, serialized into the
24
+ # definition JSON under "send_emails". The host application decides when
25
+ # (and whether) to render and deliver — either from the serialized fields
26
+ # directly, or via #to_mail, which builds a Mail::Message (the object
27
+ # ActionMailer wraps).
28
+ #
29
+ # Scalar fields (to, from, cc, bcc, reply_to, subject) and the text /
30
+ # markdown_text bodies render {{field}} values verbatim; the html body
31
+ # HTML-escapes every interpolated value automatically. markdown_text is
32
+ # carried on the wire as markdown — the core gem never renders Markdown
33
+ # to HTML (no dependencies); hosts that want an HTML part render it
34
+ # themselves.
35
+ #
36
+ # Bodies accept an inline template String or { file: "path" }, which is
37
+ # read once at definition time and inlined — a definition rehydrated from
38
+ # JSON never touches the filesystem.
39
+ #
40
+ # The mail gem is a soft dependency, required only when #to_mail is
41
+ # called. Rails hosts always have it (ActionMailer depends on it).
42
+ class SendEmail
43
+ # Scalar header fields rendered verbatim via Template.render_text in #to_mail and #to_h.
44
+ SCALAR_FIELDS = %i[to from cc bcc reply_to subject].freeze
45
+
46
+ # @return [String] required recipient / subject templates ({{field}} placeholders allowed)
47
+ attr_reader :to, :subject
48
+
49
+ # @return [String, nil] optional address templates ({{field}} placeholders allowed)
50
+ attr_reader :from, :cc, :bcc, :reply_to
51
+
52
+ # @return [String, nil] body template, inlined at definition time when { file: } was given
53
+ attr_reader :text, :markdown_text, :html
54
+
55
+ # @return [Hash{String => String}] extra headers (values support {{field}})
56
+ attr_reader :headers
57
+
58
+ # @return [Rules::Base, nil] gate — the email applies only when the rule is true
59
+ attr_reader :rule
60
+
61
+ # @param to [String] recipient template (required; keyword defaults to nil
62
+ # so a missing field raises the friendly DefinitionError from #validate!)
63
+ # @param subject [String] subject template (required)
64
+ # @param text [String, Hash, nil] plain-text body template or { file: }
65
+ # @param markdown_text [String, Hash, nil] Markdown body template or { file: }
66
+ # @param html [String, Hash, nil] HTML body template or { file: }
67
+ # @param headers [Hash] extra headers (values support {{field}})
68
+ # @param rule [Rules::Base, nil] serializable gate (the DSL's if: option)
69
+ # @raise [Errors::DefinitionError] when required fields are missing
70
+ def initialize(to: nil, subject: nil, from: nil, cc: nil, bcc: nil, reply_to: nil,
71
+ text: nil, markdown_text: nil, html: nil, headers: {}, rule: nil)
72
+ @to = to
73
+ @from = from
74
+ @cc = cc
75
+ @bcc = bcc
76
+ @reply_to = reply_to
77
+ @subject = subject
78
+ @text = resolve_body(text)
79
+ @markdown_text = resolve_body(markdown_text)
80
+ @html = resolve_body(html)
81
+ @headers = headers.transform_keys(&:to_s).freeze
82
+ @rule = rule
83
+ validate!
84
+ freeze
85
+ end
86
+
87
+ # Whether this email should be built for the given answers — true when
88
+ # no gate was declared or the gate rule evaluates to true.
89
+ #
90
+ # @param answers_hash [Hash] step_id => value context for rule evaluation
91
+ # @return [Boolean]
92
+ def applicable?(answers_hash)
93
+ @rule.nil? || @rule.evaluate(answers_hash)
94
+ end
95
+
96
+ # Builds a Mail::Message from the templates and the given answers.
97
+ # Pure function — safe to call from a background job to rebuild
98
+ # messages from persisted answers. The text part is @text, falling back
99
+ # to @markdown_text rendered verbatim (Markdown reads fine as plain
100
+ # text); the html part is @html when present.
101
+ #
102
+ # @param answers [Answers]
103
+ # @return [Mail::Message]
104
+ def to_mail(answers)
105
+ require_mail!
106
+ mail = ::Mail.new
107
+ SCALAR_FIELDS.each do |field|
108
+ value = public_send(field)
109
+ mail.public_send(:"#{field}=", Template.render_text(value, answers)) if value
110
+ end
111
+ @headers.each { |name, value| mail.header[name] = Template.render_text(value.to_s, answers) }
112
+ attach_bodies(mail, answers)
113
+ mail
114
+ end
115
+
116
+ # @return [Hash] wire format, same shape .from_h accepts
117
+ def to_h
118
+ hash = {}
119
+ hash["if"] = @rule.to_h if @rule
120
+ SCALAR_FIELDS.each do |field|
121
+ value = public_send(field)
122
+ hash[field.to_s] = value if value
123
+ end
124
+ hash["text"] = @text if @text
125
+ hash["markdown_text"] = @markdown_text if @markdown_text
126
+ hash["html"] = @html if @html
127
+ hash["headers"] = @headers unless @headers.empty?
128
+ hash
129
+ end
130
+
131
+ # @param hash [Hash] string or symbol keys
132
+ # @return [SendEmail]
133
+ def self.from_h(hash)
134
+ fetch = ->(key) { hash[key.to_s] || hash[key.to_sym] }
135
+ rule_data = fetch.call(:if)
136
+ new(
137
+ to: fetch.call(:to),
138
+ from: fetch.call(:from),
139
+ cc: fetch.call(:cc),
140
+ bcc: fetch.call(:bcc),
141
+ reply_to: fetch.call(:reply_to),
142
+ subject: fetch.call(:subject),
143
+ text: fetch.call(:text),
144
+ markdown_text: fetch.call(:markdown_text),
145
+ html: fetch.call(:html),
146
+ headers: fetch.call(:headers) || {},
147
+ rule: rule_data ? Rules::Base.from_h(rule_data) : nil
148
+ )
149
+ end
150
+
151
+ private
152
+
153
+ def attach_bodies(mail, answers)
154
+ text_source = @text || @markdown_text
155
+ text = text_source && Template.render_text(text_source, answers)
156
+ html = @html && Template.render_html(@html, answers)
157
+ if text && html
158
+ mail.text_part = build_part("text/plain; charset=UTF-8", text)
159
+ mail.html_part = build_part("text/html; charset=UTF-8", html)
160
+ elsif html
161
+ mail.content_type = "text/html; charset=UTF-8"
162
+ mail.body = html
163
+ else
164
+ mail.body = text
165
+ end
166
+ end
167
+
168
+ def build_part(content_type, body)
169
+ part = ::Mail::Part.new
170
+ part.content_type = content_type
171
+ part.body = body
172
+ part
173
+ end
174
+
175
+ # Inline template string, or { file: "path" } read once at definition time.
176
+ def resolve_body(value)
177
+ return value if value.nil? || value.is_a?(String)
178
+
179
+ path = value.is_a?(Hash) && (value[:file] || value["file"])
180
+ return File.read(File.expand_path(path)) if path.is_a?(String)
181
+
182
+ raise Errors::DefinitionError,
183
+ "send_email body must be a template String or { file: \"path\" }, got #{value.inspect}"
184
+ end
185
+
186
+ def validate!
187
+ raise Errors::DefinitionError, "send_email requires to:" if blank?(@to)
188
+ raise Errors::DefinitionError, "send_email requires subject:" if blank?(@subject)
189
+ return unless @text.nil? && @markdown_text.nil? && @html.nil?
190
+
191
+ raise Errors::DefinitionError, "send_email requires a text:, markdown_text: or html: body"
192
+ end
193
+
194
+ def blank?(value) = value.nil? || value.to_s.strip.empty?
195
+
196
+ def require_mail!
197
+ return if defined?(::Mail)
198
+
199
+ require "mail"
200
+ rescue LoadError
201
+ raise Errors::SendEmailError,
202
+ "send_email requires the mail gem — add `gem \"mail\"` to your Gemfile " \
203
+ "(Rails applications already have it via ActionMailer)"
204
+ end
205
+ end
206
+ end