Class: Assistant::EmailComposeToolBuilder

Inherits:
Object
  • Object
show all
Defined in:
app/services/assistant/email_compose_tool_builder.rb

Overview

Builds RubyLLM::Tool subclasses for email_compose — Phase 5 of the
email_management plan (doc/tasks/202605301200_EMAIL_MANAGEMENT_TOOL_PLAN.md
§4.5). The sales-rep ad-hoc path: draft a ONE-OFF email to a customer the
rep owns, via CommunicationBuilder, never through the campaign stack.

Deliberately a separate service key from email_management so the heavy
campaign/audience tools never reach reps — role-gating is structural
(available_chat_services grants email_compose on the sales_rep role),
not a per-tool if.

Tools:
draft_customer_email — build a draft Communication to one owned party
schedule_customer_email — set transmit_at + queue (never immediate)
list_my_customer_emails — the rep's own recent non-campaign communications

Safety posture:

  • Ownership-scoped. Every recipient ref resolves through the rep's own
    books (Customer.by_rep, contacts of those customers,
    Opportunity.assigned_to_rep). Another rep's records are refused —
    except for sales managers and directors, who work the whole book
    (SALES_OVERRIDE_ROLES).
  • Draft/schedule only. draft_customer_email never queues; the
    scheduler requires a future send_at and even then the release goes
    through the normal worker + state machine. There is no send-now path.
  • Consent is the platform's job. Suppression (unsubscribes, bounced
    contact points, closed customers) is enforced by Communication#queue
    and again at send — these tools cannot bypass it.

Usage (via ChatToolBuilder's email-compose service):
tools = Assistant::EmailComposeToolBuilder.tools(audit_context: { user_id: 42 })

Constant Summary collapse

CRM_COMMUNICATION_URL =

URL for the CRM communication screens.

"#{CRM_URL}/communications".freeze
MAX_RECENT =

Row cap for list_my_customer_emails.

50
SALES_OVERRIDE_ROLES =

Roles that work the whole book rather than their own. Sales management
covers absent reps and runs follow-up batches across the team, so
per-record rep matching is the wrong gate for them. Mirrors
can :brief, Opportunity in CrmAbility, which is likewise unscoped by
rep. has_role? fails closed on an accountless employee.

%w[sales_manager sales_director].freeze
COVERAGE_REMEDY =

Refusal for a record the actor can't reach. A whole-book actor can only
miss on a bad id, so never hand them the coverage remedy — that advice
sends a manager off editing rep assignments to fix a typo.

For a rep, the message names the columns that were matched and the CRM
edit that clears it: covering for an absent rep is routine, and a bare
"not yours" reads as an immovable permissions wall — the assistant then
invents a reason and gives up instead of relaying the one-field fix.

'If you are covering for the assigned rep, put yourself on the record in the ' \
'CRM as local (or secondary) sales rep, then ask again.'

Class Method Summary collapse

Class Method Details

.actor(audit_context) ⇒ Object

The acting rep, resolved from the conversation's user (a Party id; CRM
users are Employee parties).



63
64
65
# File 'app/services/assistant/email_compose_tool_builder.rb', line 63

def actor(audit_context)
  Employee.find_by(id: audit_context[:user_id])
end

.build_draft_customer_email_tool(audit_context) ⇒ Object



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'app/services/assistant/email_compose_tool_builder.rb', line 155

def build_draft_customer_email_tool(audit_context)
  captured_ctx = audit_context
  crm_url = CRM_COMMUNICATION_URL

  klass = Class.new(RubyLLM::Tool) do
    description <<~DESC
      Draft a ONE-OFF email to one of YOUR customers as a CRM Communication
      in DRAFT state — for follow-ups, quote nudges, trade-show hellos.
      Nothing is sent: a human opens the draft in the CRM, reviews the
      recipients and body, and sends (or you schedule it with
      schedule_customer_email).

      Recipient — exactly one:
        customer_id    — your customer (their primary email on file)
        contact_id     — a contact of one of your customers
        opportunity_id — one of your opportunities (its primary party, plus
                         other parties on the opportunity as the builder adds
                         them — trim in the CRM if you want just one)
      Another rep's records are refused — unless the user is a sales
      manager or director, who may email any account.

      Content — exactly one:
        email_template_id — render an on-brand EmailTemplate (merge vars
                            like the customer's name and your signature fill
                            in automatically; `subject` overrides the
                            template's subject)
        body_html         — your own composed copy as simple HTML; REQUIRES
                            `subject`
    DESC

    parameters type: 'object',
           properties: {
             customer_id: { type: 'integer', description: 'Your customer id.' },
             contact_id: { type: 'integer', description: "A contact of one of your customers." },
             opportunity_id: { type: 'integer', description: 'Your opportunity id (recipient = its primary party).' },
             email_template_id: { type: 'integer', description: 'Render this EmailTemplate. Mutually exclusive with body_html.' },
             body_html: { type: 'string', description: 'Your composed email body (simple HTML). Requires subject.' },
             subject: { type: 'string', description: "Required with body_html; overrides the template's subject otherwise." }
           },
           required: []

    define_method(:name) { 'draft_customer_email' }

    define_method(:execute) do |customer_id: nil, contact_id: nil, opportunity_id: nil, email_template_id: nil, body_html: nil, subject: nil, **_|
      employee = Assistant::EmailComposeToolBuilder.actor(captured_ctx)
      return { error: 'Could not resolve the conversation user to an employee.' }.to_json unless employee

      refs = { customer_id: customer_id, contact_id: contact_id, opportunity_id: opportunity_id }.compact
      return { error: 'Pass exactly one recipient ref: customer_id, contact_id, or opportunity_id.' }.to_json if refs.size != 1
      return { error: 'Pass email_template_id OR body_html — not both.' }.to_json if email_template_id.present? && body_html.present?
      return { error: 'Pass email_template_id (render a template) or body_html + subject (your own copy).' }.to_json if email_template_id.blank? && body_html.blank?
      return { error: 'subject is required when composing body_html.' }.to_json if body_html.present? && subject.blank?

      resource, error = case refs.keys.first
                        when :customer_id then Assistant::EmailComposeToolBuilder.find_owned_customer(employee, customer_id)
                        when :contact_id then Assistant::EmailComposeToolBuilder.find_owned_contact(employee, contact_id)
                        else Assistant::EmailComposeToolBuilder.find_owned_opportunity(employee, opportunity_id)
                        end
      return error if error

      return { error: "Email template ##{email_template_id} not found." }.to_json if email_template_id.present? && !EmailTemplate.exists?(email_template_id)

      # build (unsaved) so composed body_html can replace the template render
      # before the draft persists; the record stays in its initial draft
      # state — no queue, no worker. template_system_code beats a resource
      # default (an opportunity's INSTANT_QUOTES) so composed copy rides the
      # plain BLANK wrapper, not a transactional category that would skip
      # the suppression check.
      communication = CommunicationBuilder.new(
        resource: resource,
        email_template_id: email_template_id,
        template_system_code: ('BLANK' if body_html.present?),
        sender_party: employee,
        current_user: employee,
        subject: subject
      ).build
      if communication.nil?
        return { error: 'Could not build the draft — no explicit template rendered and the default ' \
                        'BLANK template is missing.' }.to_json
      end
      # Resource initializers can claim the sender BEFORE the option chain
      # runs (initialize_for_opportunity sets the customer's primary rep and
      # the builder's sender_party ||= keeps it) — re-assert the author so
      # the draft is always attributable to, and schedulable by, the rep.
      communication.sender_party = employee
      communication.body = body_html if body_html.present?

      if communication.communication_recipients.empty?
        return { error: "#{resource.class.name} ##{resource.id} has no email address on file — " \
                        'add one in the CRM first. No draft was created.' }.to_json
      end
      communication.save!

      {
        success: true,
        communication_id: communication.id,
        state: communication.state,
        subject: communication.subject,
        sender: communication.sender_email,
        recipients: Assistant::EmailComposeToolBuilder.recipient_rows(communication),
        url: "#{crm_url}/#{communication.id}",
        message: "Communication ##{communication.id} is a DRAFT — nothing was sent. " \
                 'Open it in the CRM to review and send, or schedule it with schedule_customer_email.'
      }.to_json
    rescue StandardError => e
      Rails.logger.error("[EmailComposeToolBuilder] draft_customer_email failed: #{e.full_message(highlight: false)}")
      { error: e.message }.to_json
    end
  end

  klass.new
end

.build_list_my_customer_emails_tool(audit_context) ⇒ Object



371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
# File 'app/services/assistant/email_compose_tool_builder.rb', line 371

def build_list_my_customer_emails_tool(audit_context)
  captured_ctx = audit_context
  crm_url = CRM_COMMUNICATION_URL
  max_recent = MAX_RECENT

  klass = Class.new(RubyLLM::Tool) do
    description <<~DESC
      List YOUR recent one-off (non-campaign) customer emails — drafts,
      queued, sent — newest first. Use this to find a draft's
      communication_id for schedule_customer_email, or to answer "what did
      I send to customers lately". Campaign blasts are not included.
    DESC

    parameters type: 'object',
           properties: {
             limit: { type: 'integer', description: "Max rows (default 10, max #{max_recent})." }
           },
           required: []

    define_method(:name) { 'list_my_customer_emails' }

    define_method(:execute) do |limit: 10, **_|
      employee = Assistant::EmailComposeToolBuilder.actor(captured_ctx)
      return { error: 'Could not resolve the conversation user to an employee.' }.to_json unless employee

      limit = limit.to_i.clamp(1, max_recent)
      communications = Communication.non_campaign
                                    .includes(:communication_recipients)
                                    .where(sender_party_id: employee.id)
                                    .order(created_at: :desc, id: :desc)
                                    .limit(limit)

      rows = communications.map do |c|
        {
          communication_id: c.id,
          subject: c.subject,
          state: c.state,
          transmit_at: c.transmit_at,
          recipients: c.communication_recipients.map(&:detail),
          created_at: c.created_at,
          url: "#{crm_url}/#{c.id}"
        }
      end

      { communications: rows, count: rows.size }.to_json
    rescue StandardError => e
      Rails.logger.error("[EmailComposeToolBuilder] list_my_customer_emails failed: #{e.full_message(highlight: false)}")
      { error: e.message }.to_json
    end
  end

  klass.new
end

.build_schedule_customer_email_tool(audit_context) ⇒ Object



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# File 'app/services/assistant/email_compose_tool_builder.rb', line 268

def build_schedule_customer_email_tool(audit_context)
  captured_ctx = audit_context
  crm_url = CRM_COMMUNICATION_URL

  klass = Class.new(RubyLLM::Tool) do
    description <<~DESC
      Schedule one of YOUR draft communications for a future send time.
      The draft moves to queued with a transmit_at; the delivery worker
      releases it once the time passes, and unsubscribe/suppression checks
      apply automatically at queue and again at send.

      send_at must be in the FUTURE — there is no send-now path (open the
      draft in the CRM for that). Works on your own draft or already-queued
      communications only (re-scheduling a queued one just moves its time).
    DESC

    parameters type: 'object',
           properties: {
             communication_id: { type: 'integer', description: 'Communication id (from draft_customer_email or list_my_customer_emails). Required.' },
             send_at: { type: 'string', description: 'ISO 8601 future send time (America/Chicago when zoneless). Required.' }
           },
           required: %w[communication_id send_at]

    define_method(:name) { 'schedule_customer_email' }

    define_method(:execute) do |communication_id:, send_at:, **_|
      employee = Assistant::EmailComposeToolBuilder.actor(captured_ctx)
      return { error: 'Could not resolve the conversation user to an employee.' }.to_json unless employee

      communication = Communication.non_campaign.find_by(id: communication_id)
      return { error: "Communication ##{communication_id} not found." }.to_json unless communication
      unless communication.sender_party_id == employee.id
        return { error: "Communication ##{communication_id} is not yours — " \
                        'you can only schedule your own drafts.' }.to_json
      end
      unless communication.draft? || communication.queued?
        return { error: "Communication ##{communication_id} is #{communication.state}" \
                        'only draft or queued communications can be scheduled.' }.to_json
      end

      # The sender check alone isn't enough — a pre-existing draft could
      # address someone outside the rep's books. When the draft names a
      # recipient party, it must be one of theirs. (Raw-email drafts carry
      # no party and stay human-reviewed in the CRM.)
      recipient_party = communication.recipient_party
      if recipient_party.present? &&
         !Assistant::EmailComposeToolBuilder.owned_recipient?(employee, recipient_party)
        return { error: "Communication ##{communication_id} addresses #{recipient_party.class.name} " \
                        "##{recipient_party.id}, which is not on your books." }.to_json
      end

      parsed = Time.zone.parse(send_at.to_s)
      return { error: "Could not parse send_at: #{send_at.inspect}" }.to_json if parsed.nil?
      if parsed <= Time.current
        return { error: "send_at must be in the future (got #{parsed}) — to send now, " \
                        'open the draft in the CRM.' }.to_json
      end

      communication.update!(transmit_at: parsed)
      communication.queue if communication.draft?

      # queue is not a guarantee: the state machine can land the draft in
      # `suppressed` (unsubscribed/bounced/inactive recipient) or refuse to
      # transition at all (no recipients). Report the ACTUAL state — never
      # narrate a schedule that didn't happen.
      communication.reload
      base = {
        communication_id: communication.id,
        state: communication.state,
        transmit_at: communication.transmit_at,
        recipients: Assistant::EmailComposeToolBuilder.recipient_rows(communication),
        url: "#{crm_url}/#{communication.id}"
      }
      if communication.queued?
        base.merge(
          success: true,
          message: "Communication ##{communication.id} is queued for #{communication.transmit_at}. " \
                   'Suppression checks apply at release.'
        ).to_json
      elsif communication.suppressed?
        base.merge(
          success: false,
          message: "Communication ##{communication.id} was NOT queued — it was suppressed at queue time " \
                   '(recipient unsubscribed, bounced address, or inactive/closed record). ' \
                   'Open it in the CRM to see why.'
        ).to_json
      else
        base.merge(
          success: false,
          message: "Communication ##{communication.id} was NOT queued — it is still " \
                   "#{communication.state} (a draft with no recipients cannot queue). " \
                   'Review it in the CRM.'
        ).to_json
      end
    rescue StandardError => e
      Rails.logger.error("[EmailComposeToolBuilder] schedule_customer_email failed: #{e.full_message(highlight: false)}")
      { error: e.message }.to_json
    end
  end

  klass.new
end

.find_owned_contact(employee, contact_id) ⇒ Object



104
105
106
107
108
109
110
111
112
113
114
115
# File 'app/services/assistant/email_compose_tool_builder.rb', line 104

def find_owned_contact(employee, contact_id)
  scope = whole_book?(employee) ? Contact.all : Contact.where(customer_id: Customer.by_rep(employee).select(:id))
  contact = scope.find_by(id: contact_id)
  unless contact
    return not_on_books(employee, 'Contact', contact_id,
                        "this tool matches the sales reps on the contact's customer, and none of " \
                        'them is you. A contact is a NARROWER gate than opportunity_id, never a ' \
                        'way around one that was refused.')
  end

  [contact, nil]
end

.find_owned_customer(employee, customer_id) ⇒ Object

Ownership-gated lookups — each returns [record, nil] or [nil, error_json].
"Not found among YOUR ..." on purpose: it neither confirms nor denies the
record exists on someone else's books.



92
93
94
95
96
97
98
99
100
101
102
# File 'app/services/assistant/email_compose_tool_builder.rb', line 92

def find_owned_customer(employee, customer_id)
  scope = whole_book?(employee) ? Customer.all : Customer.by_rep(employee)
  customer = scope.find_by(id: customer_id)
  unless customer
    return not_on_books(employee, 'Customer', customer_id,
                        'this tool matches the primary, secondary, and local sales rep on the ' \
                        'customer, and none of them is you.')
  end

  [customer, nil]
end

.find_owned_opportunity(employee, opportunity_id) ⇒ Object



117
118
119
120
121
122
123
124
125
126
127
# File 'app/services/assistant/email_compose_tool_builder.rb', line 117

def find_owned_opportunity(employee, opportunity_id)
  scope = whole_book?(employee) ? Opportunity.all : Opportunity.assigned_to_rep(employee)
  opportunity = scope.find_by(id: opportunity_id)
  unless opportunity
    return not_on_books(employee, 'Opportunity', opportunity_id,
                        'this tool matches the primary, secondary, and local sales rep on the ' \
                        'opportunity, and none of them is you.')
  end

  [opportunity, nil]
end

.not_on_books(employee, label, id, gate) ⇒ Object



83
84
85
86
87
# File 'app/services/assistant/email_compose_tool_builder.rb', line 83

def not_on_books(employee, label, id, gate)
  return [nil, { error: "#{label} ##{id} was not found — check the id." }.to_json] if whole_book?(employee)

  [nil, { error: "#{label} ##{id} is not on your books — #{gate} #{COVERAGE_REMEDY}" }.to_json]
end

.owned_recipient?(employee, recipient_party) ⇒ Boolean

Whether a draft's recipient party sits on the rep's books — a Customer
they rep, or a Contact of one of their customers. Used by
schedule_customer_email to revalidate pre-existing drafts. Widened for
sales management in step with the finders above, or they could draft a
covered account and then be refused when scheduling it.

Returns:

  • (Boolean)


134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'app/services/assistant/email_compose_tool_builder.rb', line 134

def owned_recipient?(employee, recipient_party)
  return recipient_party.is_a?(Customer) || recipient_party.is_a?(Contact) if whole_book?(employee)

  case recipient_party
  when Customer
    Customer.by_rep(employee).where(id: recipient_party.id).exists?
  when Contact
    recipient_party.customer_id.present? &&
      Customer.by_rep(employee).where(id: recipient_party.customer_id).exists?
  else
    false
  end
end

.recipient_rows(communication) ⇒ Object

Flat recipient summary for JSON responses.



149
150
151
152
153
# File 'app/services/assistant/email_compose_tool_builder.rb', line 149

def recipient_rows(communication)
  communication.communication_recipients.map do |r|
    { email: r.detail, name: r.name, as: r.email_method.presence || 'to' }
  end
end

.tools(audit_context: {}) ⇒ Array<RubyLLM::Tool>

Build all email_compose tools.

Parameters:

  • audit_context (Hash) (defaults to: {})

    expects :user_id (the chatting rep's Employee id)

Returns:

  • (Array<RubyLLM::Tool>)


53
54
55
56
57
58
59
# File 'app/services/assistant/email_compose_tool_builder.rb', line 53

def tools(audit_context: {})
  [
    build_draft_customer_email_tool(audit_context),
    build_schedule_customer_email_tool(audit_context),
    build_list_my_customer_emails_tool(audit_context)
  ]
end

.whole_book?(employee) ⇒ Boolean

Whether this employee works the whole book instead of their own.

Returns:

  • (Boolean)


68
69
70
# File 'app/services/assistant/email_compose_tool_builder.rb', line 68

def whole_book?(employee)
  employee.has_role?(SALES_OVERRIDE_ROLES).present?
end