Class: Assistant::EmailMediaToolBuilder
- Inherits:
-
Object
- Object
- Assistant::EmailMediaToolBuilder
- Defined in:
- app/services/assistant/email_media_tool_builder.rb
Overview
Builds RubyLLM::Tool subclasses that render media as editor-semantic
Redactor 4 source for EmailTemplate bodies. Phase 2 of the email_management
plan (doc/tasks/202605301200_EMAIL_MANAGEMENT_TOOL_PLAN.md).
Unlike BlogMediaToolBuilder — which emits JS-hydrated oEmbed blocks
and creates EmbeddedAsset rows — these tools emit simple inline-styled snippets
the model pastes into the semantic body_v4 (NOT body_v4_email). That matters:
body_v4 is what Redactor loads, so media authored here stays editable in the
visual editor, and the email plugin's getEmail() (on a human save) — plus the
interim EmailV4Compiler — produce the final email-safe HTML. So:
- images are centered inline-styled in a
- buttons are (the plugin's button marker)
- product cards are content s, not pre-built nested tables
- image/link URLs are absolute https (ImageKit CDN / WEB_URL)
No post_id / parent_type / EmbeddedAsset hydration: the snippet is plain source.
Tools: insert_email_image, insert_email_button, insert_email_product
Constant Summary collapse
- BRAND_BURGUNDY =
WarmlyYours brand burgundy — matches .email-button in
public/stylesheets/emails/default.css so inline and class-based buttons
render identically. '#83393b'- DEFAULT_IMAGE_WIDTH =
Default content width for email images, matching the ~600px template
container. Images render fluid (max-width:100%) but we request this CDN
width so the asset isn't oversized. 600
Class Method Summary collapse
-
.h(value) ⇒ Object
HTML-escape helper (CGI is always loaded; avoids view-context dependency).
-
.https_url?(value) ⇒ Boolean
Email links must be absolute https — relative/http URLs break in the inbox and undermine the email-safe contract.
-
.normalize_hex_color(value) ⇒ Object
Accept a valid 3- or 6-digit hex color (with or without a leading #) and normalize to a #-prefixed value; nil for anything else (caller falls back).
-
.render_button_html(text:, href:, align: 'center', background: BRAND_BURGUNDY) ⇒ String
Render a CTA button as editor-semantic source: an (the Redactor email plugin's button marker) inside a centered paragraph.
-
.render_email_product_card(sku:, locale: 'en-US') ⇒ Hash{Symbol=>String}, String
Build an email-safe product card (image + name + price + Shop Now button).
-
.render_image_html(src:, alt:, href: nil, width: DEFAULT_IMAGE_WIDTH, caption: nil) ⇒ String
Render an email image as editor-semantic source: a centered with inline styles (optionally linked), plus an optional caption paragraph.
-
.tools ⇒ Array<RubyLLM::Tool>
Accepts (and ignores) audit_context for a uniform builder signature with the other media builders — these tools render static HTML and make no DB writes, so there is no updater attribution to capture.
Class Method Details
.h(value) ⇒ Object
HTML-escape helper (CGI is always loaded; avoids view-context dependency).
85 86 87 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 85 def h(value) CGI.escapeHTML(value.to_s) end |
.https_url?(value) ⇒ Boolean
Email links must be absolute https — relative/http URLs break in the inbox
and undermine the email-safe contract.
91 92 93 94 95 96 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 91 def https_url?(value) uri = URI.parse(value.to_s) uri.is_a?(URI::HTTPS) && uri.host.present? rescue URI::InvalidURIError false end |
.normalize_hex_color(value) ⇒ Object
Accept a valid 3- or 6-digit hex color (with or without a leading #) and
normalize to a #-prefixed value; nil for anything else (caller falls back).
100 101 102 103 104 105 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 100 def normalize_hex_color(value) s = value.to_s return nil unless s.match?(/\A#?(?:\h{3}|\h{6})\z/) s.start_with?('#') ? s : "##{s}" end |
.render_button_html(text:, href:, align: 'center', background: BRAND_BURGUNDY) ⇒ String
Render a CTA button as editor-semantic source: an
(the Redactor email plugin's button marker) inside a centered paragraph.
Inline styles make it render correctly in the interim (compiler) and in the
inbox; the class lets the email plugin re-style it on a human edit/save so
the button stays editable. Absolute https href required (validated by caller).
76 77 78 79 80 81 82 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 76 def (text:, href:, align: 'center', background: BRAND_BURGUNDY) style = "display:inline-block;padding:14px 28px;font-family:Arial,Helvetica,sans-serif;" \ "font-size:16px;font-weight:bold;color:#ffffff;text-decoration:none;" \ "border-radius:6px;background-color:#{background};" %(<p style="text-align:#{h(align)};margin:20px 0;">) + %(<a class="email-button" href="#{h(href)}" target="_blank" style="#{style}">#{h(text)}</a></p>) end |
.render_email_product_card(sku:, locale: 'en-US') ⇒ Hash{Symbol=>String}, String
Build an email-safe product card (image + name + price + Shop Now button).
Reuses CatalogConstants locale→catalog mapping and the same availability
rules as Oembed::ProductProvider, but renders static inline HTML instead of
the JS-hydrated blog embed component.
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 114 def render_email_product_card(sku:, locale: 'en-US') sku = sku.to_s.strip return { error: 'No SKU provided.' }.to_json if sku.blank? item = Item.find_by(sku: sku) || Item.where('UPPER(sku) = ?', sku.upcase).first return { error: "Product not found: #{sku}" }.to_json if item.nil? catalog_id = CatalogConstants::LOCALE_TO_CATALOG[locale.to_sym] || CatalogConstants::US_CATALOG_ID catalog_item = item.catalog_items.find_by(catalog_id: catalog_id) if item.is_discontinued? || catalog_item.nil? || catalog_item.is_discontinued? || CatalogItem::HIDDEN_STATES.include?(catalog_item.state) return { error: "Product #{sku} is not available for #{locale} (discontinued or hidden)." }.to_json end title = item.public_name.presence || item.name product_url = "#{WEB_URL}#{item.canonical_url(locale: canada?(locale) ? 'en-CA' : 'en-US')}" image_src = begin item.primary_image&.image_url(width: 240) rescue StandardError item.primary_image&.ik_raw_url end price_html = product_price_html(catalog_item, locale) { html: product_card_html(title:, product_url:, image_src:, price_html:), sku:, title:, price: price_html.gsub(/<[^>]+>/, ' ').squish, product_url: } end |
.render_image_html(src:, alt:, href: nil, width: DEFAULT_IMAGE_WIDTH, caption: nil) ⇒ String
Render an email image as editor-semantic source: a centered with
inline styles (optionally linked), plus an optional caption paragraph.
This is the shape a human gets from Redactor's image tool, so it round-trips
in the editor; getEmail()/the compiler keep it email-safe.
55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 55 def render_image_html(src:, alt:, href: nil, width: DEFAULT_IMAGE_WIDTH, caption: nil) img = %(<img src="#{h(src)}" alt="#{h(alt)}" width="#{width.to_i}" ) + %(style="display:block;width:100%;max-width:#{width.to_i}px;height:auto;border:0;margin:0 auto;" />) img = %(<a href="#{h(href)}" target="_blank">#{img}</a>) if href.present? caption_p = if caption.present? %(\n<p style="font-size:13px;line-height:1.4;color:#777777;text-align:center;margin:8px 0 0;">#{h(caption)}</p>) else '' end %(<p style="text-align:center;margin:16px 0;">#{img}</p>#{caption_p}) end |
.tools ⇒ Array<RubyLLM::Tool>
Accepts (and ignores) audit_context for a uniform builder signature with
the other media builders — these tools render static HTML and make no DB
writes, so there is no updater attribution to capture.
42 43 44 45 46 47 48 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 42 def tools(**) [ build_insert_email_image_tool, , build_insert_email_product_tool ] end |