Class: Assistant::EmailToolBuilder
- Inherits:
-
Object
- Object
- Assistant::EmailToolBuilder
- 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
-
.apply_body!(template, body_v4:) ⇒ Object
Sunny writes ONLY
body_v4— the editor-semantic source Redactor loads, edits, and re-saves. -
.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.
-
.find_template_or_error(template_id) ⇒ Object
Look up a template by id, returning [template, nil] or [nil, error_json].
-
.guard_redactor_4!(template) ⇒ Object
Guard: refuse to author/edit legacy Redactor-3-only templates (decision #3).
-
.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.
-
.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.
-
.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.
-
.send_ready?(template) ⇒ Boolean
A template is send-ready only once getEmail() has populated body_v4_email.
-
.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).
-
.success_payload(template, message:, extra: {}) ⇒ Object
Compact success payload shared by create/update/edit.
-
.tools(audit_context: {}) ⇒ Array<RubyLLM::Tool>
Build all email-template tools.
-
.validate_liquid!(subject: nil, body_html: nil) ⇒ String?
Strict Liquid validation for subject + body.
-
.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.
-
.wrap_content_sections(content_html) ⇒ Object
Group flat authored content into heading-led sections, each wrapped in a padded
email-wrapperdiv.
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.
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.}") 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.
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.}") 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., Assistant::EmailBlocks.header, inner, Assistant::EmailBlocks. ].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.
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.}") 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: }.compact.merge(extra).to_json end |
.tools(audit_context: {}) ⇒ Array<RubyLLM::Tool>
Build all email-template tools.
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.
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.}" }.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.}") content_html.to_s end |