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, lookup_campaign_emails (campaign email →
underlying template resolver), plus campaign management:
list_campaigns, create_campaign (INACTIVE — a human activates),
create_campaign_email, schedule_campaign_email
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.
Defined Under Namespace
Classes: BodyV4RuleError
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. ### HARD RULE — body_v4 IS CLEAN HTML, NEVER A TABLE NEVER put a <table> in body_v4 for layout, columns, or spacing. body_v4 is the EDITOR SOURCE; layout tables do NOT round-trip through the visual editor and corrupt it. ONLY Redactor's getEmail() may emit <table>s — into body_v4_email, which you never author. A write whose body_v4 contains a layout <table> is REJECTED server-side (you'll get a `body_v4 rejected` error) — resubmit clean HTML. - Multi-column / side-by-side → <div data-block="layout"><div data-block="column">…</div>…</div> - Vertical spacing between blocks → inline margin on the block, e.g. <p style="margin:0 0 24px;">…</p> - Do NOT emit <table width="100%" cellpadding="0" cellspacing="0"> spacing/row wrappers. The ONLY allowed <table> is genuine tabular DATA (a receipt / invoice / order summary). 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.
-
.enforce_body_v4_rules!(body_v4) ⇒ Object
Hard-block a write whose final
body_v4trips an :error-severity content rule. -
.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.
202 203 204 205 206 207 208 209 210 |
# File 'app/services/assistant/email_tool_builder.rb', line 202 def apply_body!(template, body_v4:) enforce_body_v4_rules!(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.
379 380 381 382 383 384 385 |
# File 'app/services/assistant/email_tool_builder.rb', line 379 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 |
.enforce_body_v4_rules!(body_v4) ⇒ Object
Hard-block a write whose final body_v4 trips an :error-severity content
rule. The editor source must be clean semantic HTML — only Redactor's
client-side getEmail() may emit s (into body_v4_email). The
:no_layout_tables rule catches hand-built presentational/layout tables,
which don't round-trip through the visual editor (the defect on #2466).
Raises BodyV4RuleError; the tools' rescue returns it to the model.
218 219 220 221 222 223 224 225 |
# File 'app/services/assistant/email_tool_builder.rb', line 218 def enforce_body_v4_rules!(body_v4) violations = Email::ContentRules.validate(scope: :body_v4, html: body_v4.to_s) blocking = violations.select { |v| v[:severity] == :error } return if blocking.empty? raise BodyV4RuleError, "body_v4 rejected — fix and resubmit clean editor HTML:\n#{Email::ContentRules.format_advisory(blocking)}" end |
.find_template_or_error(template_id) ⇒ Object
Look up a template by id, returning [template, nil] or [nil, error_json].
168 169 170 171 172 173 |
# File 'app/services/assistant/email_tool_builder.rb', line 168 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.
177 178 179 180 181 182 183 184 185 186 187 |
# File 'app/services/assistant/email_tool_builder.rb', line 177 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.
319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 |
# File 'app/services/assistant/email_tool_builder.rb', line 319 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.
301 302 303 304 305 306 307 308 309 |
# File 'app/services/assistant/email_tool_builder.rb', line 301 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.
240 241 242 |
# File 'app/services/assistant/email_tool_builder.rb', line 240 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.
230 231 232 |
# File 'app/services/assistant/email_tool_builder.rb', line 230 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.
412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 |
# File 'app/services/assistant/email_tool_builder.rb', line 412 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.
458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 |
# File 'app/services/assistant/email_tool_builder.rb', line 458 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.
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'app/services/assistant/email_tool_builder.rb', line 146 def tools(audit_context: {}) [ build_list_email_templates_tool, build_list_campaigns_tool, build_create_campaign_tool(audit_context), build_lookup_campaign_emails_tool, build_create_campaign_email_tool(audit_context), build_schedule_campaign_email_tool(audit_context), build_campaign_email_report_tool, build_campaign_report_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.
391 392 393 394 395 396 397 398 399 400 |
# File 'app/services/assistant/email_tool_builder.rb', line 391 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.
441 442 443 444 445 446 447 448 449 450 451 452 |
# File 'app/services/assistant/email_tool_builder.rb', line 441 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.
254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 |
# File 'app/services/assistant/email_tool_builder.rb', line 254 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 |