Class: Assistant::EmailToolBuilder

Inherits:
Object
  • Object
show all
Defined in:
app/services/assistant/email_tool_builder.rb

Overview

Builds RubyLLM::Tool subclasses for email-template management — the
email-marketing analogue of BlogToolBuilder. Phase 1 of the
email_management plan (doc/tasks/202605301200_EMAIL_MANAGEMENT_TOOL_PLAN.md).

Provides template authoring/editing tools:
list_email_templates, get_email_template, create_email_template,
update_email_template, edit_email_template, clone_email_template,
preview_email_template

All template tools are Redactor-4-only (decision #3): create always
stamps redactor_4_ready: true, and update/edit refuse legacy R3-only
templates with a "migrate in the CRM first" message. Sunny authors ONLY the
editor-semantic body_v4; the email-ready body_v4_email is produced solely
by Redactor's client-side getEmail() on a human editor save (so apply_body!
nulls it and send_ready? is false until that save).

New templates are created in DRAFT state — Sunny never activates a
template; a human reviews and flips state in the CRM (decision #1).

Usage (via ChatToolBuilder):
tools = Assistant::EmailToolBuilder.tools(audit_context: { user_id: 42 })

rubocop:disable Metrics/ClassLength -- builder defines 7 tools, each with a
long LLM-facing heredoc description; splitting would hurt readability. Mirrors
the same disable on the sibling BlogToolBuilder.

Constant Summary collapse

CRM_EMAIL_TEMPLATE_URL =

URL for the CRM email-template editor.

"#{CRM_URL}/email_templates".freeze
EMAIL_STYLE_GUIDE =

Condensed email-design guidance injected into tool descriptions and the
email_editor system prompt so the model follows house email conventions
without rediscovering them. Email HTML is NOT blog HTML — it must survive
Outlook/Gmail and Premailer inlining.

<<~GUIDE
  ## Email Authoring Guide (Redactor 4 semantic source — MUST follow)

  You author the **editor-semantic `body_v4`** — the same simple HTML a human
  types into the Redactor 4 email editor. The final email-ready HTML is produced
  ONLY by the browser's email plugin (getEmail()) when a human opens the template
  in the CRM editor and saves; nothing renders it server-side. So a fresh draft is
  not send-ready until that human save — which is also where the user reviews and
  can refine the layout visually. Author SOURCE, never pre-built tables.

  AUTHOR LIKE THIS (semantic source):
  - Use simple block HTML: <p>, <h1>–<h4>, <ul>/<ol>, <hr>, and content <div>s.
  - ALWAYS inline-style your headings — bare <h1>/<h2> get the email plugin's flat
    defaults (normal weight, oversized, no color) and look raw/unstyled in the
    inbox. Use the house heading style, e.g.:
      <h1 style="font-size:24px;color:#83393b;font-weight:bold;margin:0 0 20px;text-align:center;">…</h1>
      <h2 style="font-size:18px;color:#83393b;font-weight:bold;margin:30px 0 15px;border-bottom:1px solid #eeeeee;padding-bottom:8px;">…</h2>
    Brand burgundy is #83393b; body copy is #333333 at 16px/1.6.
  - ALWAYS give body paragraphs bottom spacing — getEmail() zeroes paragraph
    margins, so unspaced <p>s collapse together and the email reads cramped (it
    looks fine in the editor but ships tight). Style body copy like:
      <p style="margin:0 0 16px;color:#333333;font-size:16px;line-height:1.6;">…</p>
  - Inline styles ARE fine and encouraged for emphasis/spacing — Redactor keeps
    them. But do NOT hand-build <table> layouts; let the editor generate those.
  - For multi-column layout use Redactor layout blocks (they become responsive
    columns; do NOT fake columns with your own <div> grid):
    <div data-block="layout"><div data-block="column">…</div><div data-block="column">…</div></div>
  - Buttons: an <a class="email-button" href="https://…">Label</a> (the email
    plugin styles it). Absolute https URLs only.
  - Images/buttons/products: use the insert_email_* tools and paste their output.

  PRESERVE THE SCAFFOLD (required for valid, sendable, editable email):
  - NEVER remove the `email-options` config <div> at the top — it carries the
    email's font/colors/width and the editor needs it.
  - NEVER remove the footer's {{unsubscribe_url}} link — it is legally required.
  - Keep the `email-wrapper` / `email-footer` structural divs intact.
  New templates start from the WarmlyYours base scaffold (header + content +
  footer); you fill the MAIN CONTENT region. Prefer clone_email_template to start
  from an on-brand email.

  PRESERVE CONTENT (the user did not ask you to redesign their email):
  - Only change what the user explicitly asked you to change. Every paragraph,
    image, button, list, layout block, link, and heading from the prior body
    must appear in your output unchanged.
  - Do NOT condense, summarize, simplify, reorder, paraphrase, or "tidy up"
    content that was not part of the request.
  - If you cannot make the requested change without removing or rewording other
    content, STOP and ask the user. Do not guess at what they would have
    wanted dropped.

  LIQUID MERGE VARS (rendered per recipient at send time — keep them intact):
    {{ first_name }}, {{ recipient }}, {{ unsubscribe_url }}, {{ signature }},
    {{ sender.full_name }}, {{ sender.job_title }}, {{ customer.full_name }}
  Use {% if %}/{% endif %} for conditional blocks. Liquid is validated on save —
  a syntax error blocks the write and is returned to you to fix.

  EDITING:
  - Style / attribute tweak (margin, padding, color, font size, alignment,
    button text) → edit_email_template with update_attr or replace_block ops.
    Re-emitting the whole body to change a margin is the #1 cause of accidental
    content loss; don't do it.
  - Surgical content change in one block → edit_email_template with block_id ops.
  - Full content rewrite the user explicitly asked for → update_email_template
    (and still preserve everything else they didn't ask to change).
  After any write, the human can still open the template in the visual editor
  and refine it.

  DESIGN RULES (enforced): write/update responses include `design_warnings` when
  your content breaks a house convention derived from our real templates (unstyled
  headings, off-brand/heavy weights, faked columns, non-https assets, buttons
  missing the email-button class). Treat any design_warnings as a must-fix and
  re-edit until they're gone before finalizing.

  SCOPE: You author and edit DRAFT templates and return a CRM preview link.
  You never activate a template or send a blast — a human reviews and ships it.
GUIDE

Class Method Summary collapse

Class Method Details

.apply_body!(template, body_v4:) ⇒ Object

Sunny writes ONLY body_v4 — the editor-semantic source Redactor loads,
edits, and re-saves. body_v4_email (the email-ready HTML) is produced
EXCLUSIVELY by Redactor's client-side getEmail(); the server never
reconstructs it. We NULL body_v4_email on every assistant write so a stale
email version can't be sent after the source changed.

The getEmail render then runs AUTOMATICALLY: EmailTemplate publishes
Events::EmailTemplateBodyV4Changed on after_commit (body_v4 changed +
body_v4_email blank), and EmailTemplateGetEmailRenderHandler drives a headless
Playwright browser through the real email plugin to repopulate body_v4_email.
The user does NOT open the CRM editor to finalize — send_ready? flips to true
a few seconds after the write. We don't enqueue anything here; publishing is
the model's responsibility so EVERY writer (assistant, CRM form, …) is covered.



175
176
177
178
179
180
181
182
# File 'app/services/assistant/email_tool_builder.rb', line 175

def apply_body!(template, body_v4:)
  template.body_v4 = body_v4
  template.body_v4_email = nil
  # body is a NOT NULL column; set a placeholder so a brand-new R4 record
  # passes validation deterministically (the model also guards this).
  template.body = '<p>This template uses Redactor 4. Edit the content in the editor.</p>' if template.body.blank?
  template.redactor_4_ready = true
end

.content_advisory(body_v4) ⇒ String?

Run the server-enforced email design rules (Email::ContentRules, derived from
the real template corpus) against a body_v4 and return a single advisory
string for the tool response, or nil if it's clean. Advisory by default so
the model self-corrects; never raises.

Returns:

  • (String, nil)


336
337
338
339
340
341
342
# File 'app/services/assistant/email_tool_builder.rb', line 336

def content_advisory(body_v4)
  violations = Email::ContentRules.validate(scope: :body_v4, html: body_v4.to_s)
  Email::ContentRules.format_advisory(violations)
rescue StandardError => e
  Rails.logger&.warn("[EmailToolBuilder] content_advisory failed: #{e.message}")
  nil
end

.find_template_or_error(template_id) ⇒ Object

Look up a template by id, returning [template, nil] or [nil, error_json].



141
142
143
144
145
146
# File 'app/services/assistant/email_tool_builder.rb', line 141

def find_template_or_error(template_id)
  template = EmailTemplate.find_by(id: template_id)
  return [nil, { error: "Email template ##{template_id} not found" }.to_json] unless template

  [template, nil]
end

.guard_redactor_4!(template) ⇒ Object

Guard: refuse to author/edit legacy Redactor-3-only templates (decision #3).
Returns an error JSON string when the template is not R4-ready, nil otherwise.



150
151
152
153
154
155
156
157
158
159
160
# File 'app/services/assistant/email_tool_builder.rb', line 150

def guard_redactor_4!(template)
  return nil if template.redactor_4_ready?

  {
    error: "Email template ##{template.id} (#{template.description}) is a legacy " \
           'Redactor 3 template and cannot be edited by the assistant. ' \
           'Migrate it to Redactor 4 in the CRM first ' \
           "(#{CRM_EMAIL_TEMPLATE_URL}/#{template.id}), then ask again.",
    redactor_4_ready: false
  }.to_json
end

.normalize_chrome(content_html) ⇒ String

Strip the scaffold chrome the model may have invented, keeping ONLY the real
authored content — at any nesting depth:

  • email-options / email-style config blocks
  • any block whose subtree contains a WarmlyYours brand logo (the header)
  • any email-footer block (and the leftover comments)
    then unwrap any outer container divs the model wrapped everything in, so the
    caller can splice clean content between the canonical header and footer.

Returns:

  • (String)

    inner content HTML (or the raw input if it had no chrome / on error)



276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'app/services/assistant/email_tool_builder.rb', line 276

def normalize_chrome(content_html)
  html = content_html.to_s
  return html unless html.match?(/email-options|email-style|email-footer|warmlyyours-logo|<!--\s*(HEADER|FOOTER)/i)

  fragment = Nokogiri::HTML5.fragment(html)
  fragment.css('.email-options, .email-style, .email-footer').each(&:unlink)
  strip_brand_logo_headers!(fragment)
  strip_chrome_comments!(fragment)
  fragment = unwrap_redundant_containers(fragment)

  inner = fragment.to_html.strip
  inner.presence || html
rescue StandardError => e
  Rails.logger&.warn("[EmailToolBuilder] normalize_chrome failed: #{e.message}")
  html
end

.reframe_body(content_html) ⇒ Object

Force a body_v4 to use the canonical header/footer/config the Redactor toolbar
provides, regardless of what the model produced. Strips any invented chrome at
ANY depth (config blocks, any brand-logo header, any footer) and rebuilds:
canonical email-options + canonical header + + canonical footer
Used by update_email_template so edits also normalize to the toolbar blocks.
Falls back to scaffold-wrapping the raw content if parsing fails.



258
259
260
261
262
263
264
265
266
# File 'app/services/assistant/email_tool_builder.rb', line 258

def reframe_body(content_html)
  inner = wrap_content_sections(normalize_chrome(content_html))
  [
    Assistant::EmailBlocks.email_options,
    Assistant::EmailBlocks.header,
    inner,
    Assistant::EmailBlocks.footer
  ].compact_blank.join("\n")
end

.scaffold_body(content_html) ⇒ Object

Build a NEW template's body_v4: the exact canonical Redactor 4 starter the
"Full Email Template" toolbar button inserts (email-options + branded header

  • hero + footer-with-unsubscribe), with the model's authored content spliced
    into the MAIN CONTENT region. Any chrome the model invented is stripped first
    (see normalize_chrome). This is the user's hard requirement: new templates
    ALWAYS look like the canonical starter.


197
198
199
# File 'app/services/assistant/email_tool_builder.rb', line 197

def scaffold_body(content_html)
  Assistant::EmailBlocks.scaffold_with(wrap_content_sections(normalize_chrome(content_html)))
end

.send_ready?(template) ⇒ Boolean

A template is send-ready only once getEmail() has populated body_v4_email.
After an assistant write this flips to true automatically once the background
render finishes (a few seconds); no human editor save required.

Returns:

  • (Boolean)


187
188
189
# File 'app/services/assistant/email_tool_builder.rb', line 187

def send_ready?(template)
  template.redactor_4_ready? && template.body_v4_email.present?
end

.stabilize_body_ids!(template) ⇒ Object

Persist stable data-block-id markers onto body_v4 (the semantic body of
record that block-ID edits target) so the block_index from get_email_template
matches what edit_email_template later reads (without this, assign_ids! mints
fresh IDs each call). Mirrors BlogToolBuilder's ensure_block_ids!.

Lock-safe: this runs in the READ path (get_email_template), so it must not
raise on a concurrent write. We do an atomic compare-and-swap on lock_version
(UPDATE ... WHERE id = ? AND lock_version = ?), which both respects and bumps
the optimistic-lock version. If another writer won the race, the CAS matches
zero rows and we simply return the freshest body_v4 instead of clobbering it.



369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
# File 'app/services/assistant/email_tool_builder.rb', line 369

def stabilize_body_ids!(template)
  original = template.body_v4.to_s
  return original if original.empty?

  with_ids = Assistant::BlockAddressedEditor.assign_ids!(original)
  return original if with_ids == original

  current_version = template.lock_version
  updated = EmailTemplate.where(id: template.id, lock_version: current_version)
                         .update_all(body_v4: with_ids, lock_version: current_version + 1)
  if updated.positive?
    template.body_v4 = with_ids
    template.lock_version = current_version + 1
    with_ids
  else
    # Lost the race — reload and hand back whatever the winning write stored.
    template.reload.body_v4.to_s
  end
rescue StandardError => e
  Rails.logger&.warn("[EmailToolBuilder] stabilize_body_ids! failed for ##{template.id}: #{e.message}")
  template.body_v4.to_s
end

.success_payload(template, message:, extra: {}) ⇒ Object

Compact success payload shared by create/update/edit. Carries send_ready +
finalize_note. Finalization is AUTOMATIC: the write publishes an event and a
background getEmail() render flips send_ready to true in a few seconds — the
note tells the model to simply re-check, NOT to ask the user to do anything.



415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# File 'app/services/assistant/email_tool_builder.rb', line 415

def success_payload(template, message:, extra: {})
  ready = send_ready?(template)
  advisory = content_advisory(template.body_v4)
  {
    success: true,
    email_template_id: template.id,
    description: template.description,
    subject: template.subject,
    category: template.category,
    state: template.state,
    redactor_4_ready: template.redactor_4_ready,
    send_ready: ready,
    finalize_note: if ready
                     nil
                   else
                     'Finalizing automatically in the background (getEmail() render, a few ' \
                       'seconds). Do NOT tell the user to open the CRM editor — just re-call ' \
                       'get_email_template to confirm send_ready: true before sending.'
                   end,
    # House design-rule feedback (Email::ContentRules). Present only when the
    # content violates a convention — the model should fix these before finalizing.
    design_warnings: advisory,
    url: "#{CRM_EMAIL_TEMPLATE_URL}/#{template.id}",
    message: message
  }.compact.merge(extra).to_json
end

.tools(audit_context: {}) ⇒ Array<RubyLLM::Tool>

Build all email-template tools.

Parameters:

  • audit_context (Hash) (defaults to: {})

    expects :user_id (Employee id for updater attribution)
    and :conversation_id.

Returns:

  • (Array<RubyLLM::Tool>)


126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'app/services/assistant/email_tool_builder.rb', line 126

def tools(audit_context: {})
  [
    build_list_email_templates_tool,
    build_get_email_template_tool,
    build_get_email_block_html_tool,
    build_create_email_template_tool(audit_context),
    build_update_email_template_tool(audit_context),
    build_edit_email_template_tool(audit_context),
    build_clone_email_template_tool(audit_context),
    build_preview_email_template_tool,
    build_generate_final_html_tool
  ]
end

.validate_liquid!(subject: nil, body_html: nil) ⇒ String?

Strict Liquid validation for subject + body. The model's serialize_*_template
callbacks parse with a non-raising parser, so invalid merge tags slip through
save; we use the raising strict parser here for immediate feedback.

Returns:

  • (String, nil)

    error JSON string when invalid, nil when OK.



348
349
350
351
352
353
354
355
356
357
# File 'app/services/assistant/email_tool_builder.rb', line 348

def validate_liquid!(subject: nil, body_html: nil)
  { 'subject' => subject, 'body' => body_html }.each do |field, value|
    next if value.nil?

    Liquid::Template.parse(value.to_s, error_mode: :strict)
  rescue Liquid::Error => e
    return { error: "Validation failed: Liquid syntax error in #{field}: #{e.message}" }.to_json
  end
  nil
end

.with_optimistic_retry(template, attempts: 3) ⇒ Object

Run a save under optimistic locking, reloading + replaying the block on a
StaleObjectError so a concurrent write (another assistant call, a human
editor save, or the render worker) is retried cleanly rather than 500ing.
The block receives the freshly-reloaded template and must re-apply its
changes; it returns truthy on success. Bounded retries; re-raises if the
race persists.



398
399
400
401
402
403
404
405
406
407
408
409
# File 'app/services/assistant/email_tool_builder.rb', line 398

def with_optimistic_retry(template, attempts: 3)
  tries = 0
  begin
    tries += 1
    yield template
  rescue ActiveRecord::StaleObjectError
    raise if tries >= attempts

    template.reload
    retry
  end
end

.wrap_content_sections(content_html) ⇒ Object

Group flat authored content into heading-led sections, each wrapped in a
padded email-wrapper div. This is the dominant corpus pattern (118/120
real templates) and the ONLY spacing that survives getEmail: the plugin
zeroes heading margin/padding, but preserves padding on wrapper divs. Without
this, headings butt against the preceding paragraph ("tight" — template 2411).

A "section" = an plus all following blocks up to the next heading.
Leading content before the first heading becomes its own section. If the
content is already mostly wrapped (the model nested sections itself, or it's
a single block), we leave it alone.



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'app/services/assistant/email_tool_builder.rb', line 211

def wrap_content_sections(content_html)
  html = content_html.to_s
  fragment = Nokogiri::HTML5.fragment(html)
  blocks = fragment.children.select { |n| n.element? || (n.text? && n.text.strip.present?) }
  headings = blocks.count { |n| n.element? && n.name.match?(/\Ah[1-4]\z/) }
  # Nothing to gain if there are no headings to separate, or it's already wrapped
  # as section divs (model did it / reframe of an existing sectioned template).
  return html if headings.zero?
  return html if blocks.all? { |n| n.element? && n.name == 'div' }

  sections = group_into_sections(blocks)
  sections.map { |nodes| wrap_section(nodes) }.join("\n")
rescue StandardError => e
  Rails.logger&.warn("[EmailToolBuilder] wrap_content_sections failed: #{e.message}")
  content_html.to_s
end