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_video, 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.
-
.resolve_video(video_id: nil, video_url: nil) ⇒ Video?
Resolve a Video from an explicit id, a page URL, or a bare slug — the three shapes a person pastes.
-
.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.
-
.video_poster_url(video, width:) ⇒ String?
The email-safe poster for a video: its own poster image, else the Cloudflare Stream still.
Class Method Details
.h(value) ⇒ Object
HTML-escape helper (CGI is always loaded; avoids view-context dependency).
123 124 125 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 123 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.
129 130 131 132 133 134 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 129 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).
138 139 140 141 142 143 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 138 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).
114 115 116 117 118 119 120 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 114 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.
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 152 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.
93 94 95 96 97 98 99 100 101 102 103 104 105 106 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 93 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 |
.resolve_video(video_id: nil, video_url: nil) ⇒ Video?
Resolve a Video from an explicit id, a page URL, or a bare slug — the
three shapes a person pastes. URL parsing ignores the locale prefix, so
/en-US/video-media/ and /video-media/ both resolve.
Scoped to active: a campaign email goes to tens of thousands of
recipients, so linking a deactivated video ships a dead link at scale.
Deliberately NOT the full linkable_videos scope — that also requires
cloudflare_videos, which would refuse a self-hosted video that has a
perfectly good poster image, and the poster is all an email needs.
65 66 67 68 69 70 71 72 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 65 def resolve_video(video_id: nil, video_url: nil) return Video.active.find_by(id: video_id) if video_id.present? return nil if video_url.blank? slug = video_url.to_s[%r{/video-media/([^/?#]+)}, 1] || video_url.to_s.strip.split('?').first.to_s.split('/').last Video.active.find_by(slug: slug) 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.
43 44 45 46 47 48 49 50 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 43 def tools(**) [ build_insert_email_image_tool, build_insert_email_video_tool, , build_insert_email_product_tool ] end |
.video_poster_url(video, width:) ⇒ String?
The email-safe poster for a video: its own poster image, else the
Cloudflare Stream still. Nil when the video has neither, which is a
refusal rather than an excuse to go looking for an unrelated image.
81 82 83 84 85 86 |
# File 'app/services/assistant/email_media_tool_builder.rb', line 81 def video_poster_url(video, width:) return video.thumbnail_url(width: width) if video.poster_image return video.cf_poster_url if video.is_cloudflare? nil end |