Class: Assistant::EmailMediaToolBuilder

Inherits:
Object
  • Object
show all
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

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.

Returns:

  • (Boolean)


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).

Returns:

  • (String)

    semantic HTML



76
77
78
79
80
81
82
# File 'app/services/assistant/email_media_tool_builder.rb', line 76

def render_button_html(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.

Returns:

  • (Hash{Symbol=>String})

    on success: sku:, title:, price:, product_url:

  • (String)

    error JSON string on failure (unknown/unavailable SKU)



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.

Returns:

  • (String)

    semantic HTML



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

.toolsArray<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.

Returns:

  • (Array<RubyLLM::Tool>)


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_button_tool,
    build_insert_email_product_tool
  ]
end