Skip to content

Commit ae6ed04

Browse files
committed
feat(email): multipart HTML+plaintext for RI exchange pending approval (#296)
Mirror the purchaseApprovalRequestHTMLTemplate pattern for the SendRIExchangePendingApproval flow: - Add riExchangePendingApprovalHTMLTemplate: inline-styled table of exchanges with per-row Approve/Reject CTA buttons, summary block, optional requested-by line, configurable cancellation-window note, and skipped-exchanges section. - Extend riExchangePendingApprovalTemplate (plaintext): labelled Approve/Reject URLs (replacing bracket notation), optional requested-by block, configurable cancellation-window note. - Extend RIExchangeNotificationData with RequestedByName, RequestedByEmail, RequestedAt, CancellationWindowNote fields. - Add RenderRIExchangePendingApprovalEmailHTML renderer. - Add sendRIExchangePendingApprovalVia helper (shared by Sender and SMTPSender, non-fatal HTML degrade on render error). - SMTPSender.SendRIExchangePendingApproval: delegates to helper. - Sender.SendRIExchangePendingApproval: uses direct SES multipart path when emailAddress+fromEmail are configured; falls back to SNS broadcast for topic-only deployments. - 7 new test cases covering both halves, empty/custom cancellation notes, missing requested-by, and skipped-exchanges rendering.
1 parent 4956d66 commit ae6ed04

5 files changed

Lines changed: 297 additions & 15 deletions

File tree

‎internal/email/sender.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,18 @@ type RIExchangeNotificationData struct {
464464
Exchanges []RIExchangeItem
465465
Skipped []SkippedExchange
466466
TotalPayment string
467+
// RequestedByName is the human-readable display name of the user who
468+
// triggered the exchange run. Empty falls back to RequestedByEmail.
469+
RequestedByName string
470+
// RequestedByEmail is the requester's email address. Empty omits the
471+
// requested-by block from the approval email.
472+
RequestedByEmail string
473+
// RequestedAt is the ISO-8601 / RFC3339 timestamp the exchange was
474+
// submitted. Empty omits the timestamp from the summary.
475+
RequestedAt string
476+
// CancellationWindowNote is short text rendered below the approve/reject
477+
// buttons. Empty falls back to a generic 6-hour note.
478+
CancellationWindowNote string
467479
}
468480

469481
// RIExchangeItem represents a single exchange in an email notification

‎internal/email/smtp_sender.go‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -369,13 +369,10 @@ func (s *SMTPSender) SendPurchaseFailedNotification(ctx context.Context, data No
369369
}
370370

371371
// SendRIExchangePendingApproval sends an RI exchange approval email via SMTP
372+
// as multipart/alternative (plain-text + styled HTML). Issue #296.
372373
func (s *SMTPSender) SendRIExchangePendingApproval(ctx context.Context, data RIExchangeNotificationData) error {
373374
subject := fmt.Sprintf("CUDly - RI Exchange Approval Required (%d exchanges)", len(data.Exchanges))
374-
body, err := RenderRIExchangePendingApprovalEmail(data)
375-
if err != nil {
376-
return fmt.Errorf("failed to render ri exchange pending approval email: %w", err)
377-
}
378-
return s.SendToEmail(ctx, s.notifyEmail, subject, body)
375+
return sendRIExchangePendingApprovalVia(ctx, s, s.notifyEmail, subject, data)
379376
}
380377

381378
// SendRIExchangeCompleted sends an RI exchange completion email via SMTP

‎internal/email/template_renderers.go‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,22 @@ func RenderPurchaseFailedEmail(data NotificationData) (string, error) {
101101
return renderTemplate("failed", purchaseFailedTemplate, data)
102102
}
103103

104-
// RenderRIExchangePendingApprovalEmail renders the RI exchange pending approval email template
104+
// RenderRIExchangePendingApprovalEmail renders the plain-text RI exchange
105+
// pending approval email template. Pair with
106+
// RenderRIExchangePendingApprovalEmailHTML for multipart/alternative delivery.
105107
func RenderRIExchangePendingApprovalEmail(data RIExchangeNotificationData) (string, error) {
106108
return renderTemplate("ri-exchange-pending", riExchangePendingApprovalTemplate, data)
107109
}
108110

111+
// RenderRIExchangePendingApprovalEmailHTML renders the HTML half of the RI
112+
// exchange pending approval email -- inline-styled per email-client constraints.
113+
// The plain-text half (RenderRIExchangePendingApprovalEmail) carries the same
114+
// content; receiving clients pick whichever they support via the
115+
// multipart/alternative wrapper assembled by the sender. Issue #296.
116+
func RenderRIExchangePendingApprovalEmailHTML(data RIExchangeNotificationData) (string, error) {
117+
return renderTemplate("ri-exchange-pending-html", riExchangePendingApprovalHTMLTemplate, data)
118+
}
119+
109120
// RenderRIExchangeCompletedEmail renders the RI exchange completed email template
110121
func RenderRIExchangeCompletedEmail(data RIExchangeNotificationData) (string, error) {
111122
return renderTemplate("ri-exchange-completed", riExchangeCompletedTemplate, data)

‎internal/email/template_renderers_test.go‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,3 +496,167 @@ func TestRenderWelcomeEmailHTML(t *testing.T) {
496496
assert.Contains(t, html, "Hello carol@example.com,")
497497
assert.Contains(t, html, "<strong>admin</strong>")
498498
}
499+
500+
// Issue #296: plain-text RI exchange pending approval email carries the
501+
// enriched summary fields (requested-by, cancellation-window note) and uses
502+
// labelled Approve/Reject URLs instead of bracket-wrapped actions.
503+
func TestRenderRIExchangePendingApprovalEmail_Issue296(t *testing.T) {
504+
data := RIExchangeNotificationData{
505+
DashboardURL: "https://dashboard.example.com",
506+
TotalPayment: "125.50",
507+
RequestedByName: "Cristi M",
508+
RequestedByEmail: "cristi@acme.com",
509+
RequestedAt: "2026-05-22T10:00:00Z",
510+
CancellationWindowNote: "RI exchanges are irreversible once accepted by AWS.",
511+
Exchanges: []RIExchangeItem{
512+
{
513+
RecordID: "rec-001",
514+
ApprovalToken: "tok-abc",
515+
SourceRIID: "ri-0123456789abcdef0",
516+
SourceInstanceType: "m5.large",
517+
TargetInstanceType: "m6i.large",
518+
TargetCount: 1,
519+
PaymentDue: "125.50",
520+
UtilizationPct: 42.5,
521+
},
522+
},
523+
}
524+
525+
body, err := RenderRIExchangePendingApprovalEmail(data)
526+
require.NoError(t, err)
527+
528+
// Exchange details.
529+
assert.Contains(t, body, "ri-0123456789abcdef0")
530+
assert.Contains(t, body, "m5.large")
531+
assert.Contains(t, body, "m6i.large")
532+
assert.Contains(t, body, "42.5")
533+
assert.Contains(t, body, "125.50")
534+
535+
// Requested-by block.
536+
assert.Contains(t, body, "Cristi M")
537+
assert.Contains(t, body, "cristi@acme.com")
538+
assert.Contains(t, body, "2026-05-22T10:00:00Z")
539+
540+
// Labelled action URLs (not bracket notation).
541+
assert.Contains(t, body, "Approve: ")
542+
assert.Contains(t, body, "Reject: ")
543+
assert.Contains(t, body, "/api/ri-exchange/approve/rec-001")
544+
assert.Contains(t, body, "/api/ri-exchange/reject/rec-001")
545+
546+
// Custom cancellation-window note overrides the generic fallback.
547+
assert.Contains(t, body, "RI exchanges are irreversible once accepted by AWS.")
548+
assert.NotContains(t, body, "Please approve within 6 hours")
549+
}
550+
551+
// Issue #296: plain-text template omits requested-by block when email is empty.
552+
func TestRenderRIExchangePendingApprovalEmail_NoRequestedBy(t *testing.T) {
553+
data := RIExchangeNotificationData{
554+
DashboardURL: "https://dashboard.example.com",
555+
TotalPayment: "50.00",
556+
Exchanges: []RIExchangeItem{{
557+
RecordID: "rec-002", ApprovalToken: "tok-xyz",
558+
SourceRIID: "ri-aaa", SourceInstanceType: "c5.large",
559+
TargetInstanceType: "c6i.large", TargetCount: 1,
560+
PaymentDue: "50.00", UtilizationPct: 80.0,
561+
}},
562+
}
563+
564+
body, err := RenderRIExchangePendingApprovalEmail(data)
565+
require.NoError(t, err)
566+
567+
assert.NotContains(t, body, "Requested by:")
568+
// Generic note rendered when CancellationWindowNote is empty.
569+
assert.Contains(t, body, "Please approve within 6 hours")
570+
}
571+
572+
// Issue #296: HTML half of the RI exchange pending approval email carries
573+
// inline-styled approve/reject anchors with correct hrefs, the exchange
574+
// summary table, and the requested-by line.
575+
func TestRenderRIExchangePendingApprovalEmailHTML_Issue296(t *testing.T) {
576+
data := RIExchangeNotificationData{
577+
DashboardURL: "https://dashboard.example.com",
578+
TotalPayment: "125.50",
579+
RequestedByEmail: "cristi@acme.com",
580+
CancellationWindowNote: "RI exchanges are irreversible once accepted by AWS.",
581+
Exchanges: []RIExchangeItem{
582+
{
583+
RecordID: "rec-001",
584+
ApprovalToken: "tok-abc",
585+
SourceRIID: "ri-0123456789abcdef0",
586+
SourceInstanceType: "m5.large",
587+
TargetInstanceType: "m6i.large",
588+
TargetCount: 1,
589+
PaymentDue: "125.50",
590+
UtilizationPct: 42.5,
591+
},
592+
},
593+
}
594+
595+
html, err := RenderRIExchangePendingApprovalEmailHTML(data)
596+
require.NoError(t, err)
597+
assert.NotEmpty(t, html)
598+
599+
// Inline-styled approve anchor with correct href.
600+
assert.Contains(t, html, `href="https://dashboard.example.com/api/ri-exchange/approve/rec-001?token=tok-abc"`)
601+
assert.Contains(t, html, `href="https://dashboard.example.com/api/ri-exchange/reject/rec-001?token=tok-abc"`)
602+
assert.Contains(t, html, ">Approve<")
603+
assert.Contains(t, html, ">Reject<")
604+
605+
// Approve button has inline green background (prove CSS classes aren't relied on).
606+
assert.Regexp(t, `<a[^>]*style="[^"]*background:#16a34a[^"]*"[^>]*>Approve</a>`, html)
607+
608+
// Exchange table cells.
609+
assert.Contains(t, html, "m5.large")
610+
assert.Contains(t, html, "m6i.large")
611+
assert.Contains(t, html, "42.5%")
612+
assert.Contains(t, html, "125.50")
613+
614+
// Summary block + requested-by line.
615+
assert.Contains(t, html, "cristi@acme.com")
616+
assert.Contains(t, html, "RI Exchange Approval Required")
617+
618+
// Custom cancellation note.
619+
assert.Contains(t, html, "RI exchanges are irreversible once accepted by AWS.")
620+
assert.NotContains(t, html, "Please approve within 6 hours")
621+
}
622+
623+
// Issue #296: HTML template falls back to generic 6-hour note when
624+
// CancellationWindowNote is empty.
625+
func TestRenderRIExchangePendingApprovalEmailHTML_DefaultCancellationNote(t *testing.T) {
626+
data := RIExchangeNotificationData{
627+
DashboardURL: "https://dashboard.example.com",
628+
TotalPayment: "10.00",
629+
Exchanges: []RIExchangeItem{{
630+
RecordID: "rec-003", ApprovalToken: "tok-def",
631+
SourceRIID: "ri-bbb", SourceInstanceType: "t3.medium",
632+
TargetInstanceType: "t4g.medium", TargetCount: 1,
633+
PaymentDue: "10.00", UtilizationPct: 60.0,
634+
}},
635+
}
636+
637+
html, err := RenderRIExchangePendingApprovalEmailHTML(data)
638+
require.NoError(t, err)
639+
640+
assert.Contains(t, html, "Please approve within 6 hours")
641+
}
642+
643+
// Issue #296: skipped exchanges block appears in HTML when Skipped is non-empty.
644+
func TestRenderRIExchangePendingApprovalEmailHTML_SkippedBlock(t *testing.T) {
645+
data := RIExchangeNotificationData{
646+
DashboardURL: "https://dashboard.example.com",
647+
TotalPayment: "0.00",
648+
Exchanges: []RIExchangeItem{},
649+
Skipped: []SkippedExchange{{
650+
SourceRIID: "ri-skip-01",
651+
SourceInstanceType: "r5.large",
652+
Reason: "no matching target available",
653+
}},
654+
}
655+
656+
html, err := RenderRIExchangePendingApprovalEmailHTML(data)
657+
require.NoError(t, err)
658+
659+
assert.Contains(t, html, "ri-skip-01")
660+
assert.Contains(t, html, "no matching target available")
661+
assert.Contains(t, html, "Skipped")
662+
}

‎internal/email/templates.go‎

Lines changed: 107 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -293,29 +293,103 @@ const riExchangePendingApprovalTemplate = `CUDly - RI Exchange Approval Required
293293
======================================
294294
295295
CUDly has identified convertible RI exchanges that need your approval.
296-
296+
{{if .RequestedByEmail}}
297+
Requested by: {{if .RequestedByName}}{{.RequestedByName}} <{{.RequestedByEmail}}>{{else}}{{.RequestedByEmail}}{{end}}{{if .RequestedAt}} at {{.RequestedAt}}{{end}}
298+
{{end}}
297299
Proposed Exchanges:
298300
{{range .Exchanges}}
299301
- Source: {{.SourceRIID}} ({{.SourceInstanceType}}, {{printf "%.1f" .UtilizationPct}}% utilized)
300302
Target: {{.TargetInstanceType}} x{{.TargetCount}}
301303
Payment Due: ${{.PaymentDue}}
302-
[Approve] {{$.DashboardURL}}/api/ri-exchange/approve/{{.RecordID}}?token={{urlquery .ApprovalToken}}
303-
[Reject] {{$.DashboardURL}}/api/ri-exchange/reject/{{.RecordID}}?token={{urlquery .ApprovalToken}}
304+
Approve: {{$.DashboardURL}}/api/ri-exchange/approve/{{.RecordID}}?token={{urlquery .ApprovalToken}}
305+
Reject: {{$.DashboardURL}}/api/ri-exchange/reject/{{.RecordID}}?token={{urlquery .ApprovalToken}}
304306
{{end}}
305307
Total Payment: ${{.TotalPayment}}
306308
{{if .Skipped}}
307309
Skipped (could not process):
308310
{{range .Skipped}}
309311
- {{.SourceRIID}} ({{.SourceInstanceType}}): {{.Reason}}
310312
{{end}}{{end}}
311-
Please approve within 6 hours (before the next analysis run).
313+
{{if .CancellationWindowNote}}{{.CancellationWindowNote}}{{else}}Please approve within 6 hours (before the next analysis run).{{end}}
312314
313315
Review exchange history:
314316
{{.DashboardURL}}/#ri-exchange
315317
316-
This is an automated message from CUDly.
318+
This is an automated message from CUDly. Do not share these links.
317319
`
318320

321+
// riExchangePendingApprovalHTMLTemplate renders the same approval request as
322+
// the plain-text template above with inline-styled approve/reject CTAs and an
323+
// exchange summary table. CSS classes are NOT honoured by most email clients
324+
// (Outlook, mobile Gmail) — every visual rule lives in inline style=""
325+
// attributes. Mirrors purchaseApprovalRequestHTMLTemplate. Issue #296.
326+
const riExchangePendingApprovalHTMLTemplate = `<!DOCTYPE html>
327+
<html><head><meta charset="UTF-8"><title>CUDly - RI Exchange Approval Required</title></head>
328+
<body style="margin:0;padding:0;background:#f4f6f8;font-family:Arial,Helvetica,sans-serif;color:#1a202c;">
329+
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#f4f6f8;padding:24px 0;">
330+
<tr><td align="center">
331+
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="background:#ffffff;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
332+
<tr><td style="padding:32px 32px 16px 32px;">
333+
<h1 style="margin:0;font-size:22px;color:#0f172a;">RI Exchange Approval Required</h1>
334+
<p style="margin:8px 0 0 0;color:#475569;font-size:14px;">CUDly has identified <strong>{{len .Exchanges}}</strong> convertible RI exchange(s) that need your approval.</p>
335+
</td></tr>
336+
337+
<tr><td style="padding:0 32px 8px 32px;">
338+
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse;font-size:14px;">
339+
<tr><td style="padding:6px 0;color:#64748b;width:180px;">Total Payment</td><td style="padding:6px 0;color:#0f172a;font-weight:600;">${{.TotalPayment}}</td></tr>
340+
{{if .RequestedByEmail}}<tr><td style="padding:6px 0;color:#64748b;">Requested by</td><td style="padding:6px 0;color:#0f172a;">{{if .RequestedByName}}{{.RequestedByName}} &lt;{{.RequestedByEmail}}&gt;{{else}}{{.RequestedByEmail}}{{end}}{{if .RequestedAt}} <span style="color:#94a3b8;">at {{.RequestedAt}}</span>{{end}}</td></tr>
341+
{{end}}
342+
</table>
343+
</td></tr>
344+
345+
<tr><td style="padding:0 32px 16px 32px;">
346+
<h2 style="margin:16px 0 8px 0;font-size:15px;color:#334155;text-transform:uppercase;letter-spacing:0.04em;">Proposed Exchanges</h2>
347+
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse;font-size:13px;border:1px solid #e2e8f0;border-radius:6px;overflow:hidden;">
348+
<thead><tr style="background:#f1f5f9;">
349+
<th align="left" style="padding:10px 12px;color:#475569;font-weight:600;">Source RI</th>
350+
<th align="left" style="padding:10px 12px;color:#475569;font-weight:600;">Target</th>
351+
<th align="right" style="padding:10px 12px;color:#475569;font-weight:600;">Utilization</th>
352+
<th align="right" style="padding:10px 12px;color:#475569;font-weight:600;">Payment Due</th>
353+
<th align="left" style="padding:10px 12px;color:#475569;font-weight:600;">Actions</th>
354+
</tr></thead>
355+
<tbody>
356+
{{range .Exchanges}}<tr style="border-top:1px solid #e2e8f0;">
357+
<td style="padding:10px 12px;color:#0f172a;"><div>{{.SourceInstanceType}}</div><div style="color:#94a3b8;font-size:11px;margin-top:2px;">{{.SourceRIID}}</div></td>
358+
<td style="padding:10px 12px;color:#0f172a;">{{.TargetInstanceType}} x{{.TargetCount}}</td>
359+
<td align="right" style="padding:10px 12px;color:#475569;">{{printf "%.1f" .UtilizationPct}}%</td>
360+
<td align="right" style="padding:10px 12px;color:#0f172a;font-weight:600;">${{.PaymentDue}}</td>
361+
<td style="padding:10px 12px;">
362+
<a href="{{$.DashboardURL}}/api/ri-exchange/approve/{{.RecordID}}?token={{urlquery .ApprovalToken}}" style="display:inline-block;padding:6px 14px;background:#16a34a;color:#ffffff;text-decoration:none;font-weight:600;font-size:12px;border-radius:4px;margin-right:4px;">Approve</a>
363+
<a href="{{$.DashboardURL}}/api/ri-exchange/reject/{{.RecordID}}?token={{urlquery .ApprovalToken}}" style="display:inline-block;padding:6px 14px;background:#ffffff;color:#dc2626;text-decoration:none;font-weight:600;font-size:12px;border-radius:4px;border:1px solid #dc2626;">Reject</a>
364+
</td>
365+
</tr>
366+
{{end}}</tbody></table>
367+
</td></tr>
368+
369+
{{if .Skipped}}
370+
<tr><td style="padding:0 32px 16px 32px;">
371+
<h2 style="margin:8px 0 8px 0;font-size:13px;color:#94a3b8;text-transform:uppercase;letter-spacing:0.04em;">Skipped (could not process)</h2>
372+
<ul style="margin:0;padding-left:20px;color:#475569;font-size:13px;">
373+
{{range .Skipped}}<li style="margin-bottom:4px;">{{.SourceRIID}} ({{.SourceInstanceType}}): {{.Reason}}</li>
374+
{{end}}</ul>
375+
</td></tr>
376+
{{end}}
377+
378+
<tr><td style="padding:8px 32px 24px 32px;">
379+
<p style="margin:8px 0 0 0;color:#64748b;font-size:12px;line-height:1.5;">
380+
{{if .CancellationWindowNote}}{{.CancellationWindowNote}}{{else}}Please approve within 6 hours (before the next analysis run).{{end}}
381+
</p>
382+
<p style="margin:8px 0 0 0;color:#94a3b8;font-size:11px;">Clicking a link will require you to sign in if you aren't already; the action is then recorded against your logged-in account.</p>
383+
</td></tr>
384+
385+
<tr><td style="padding:16px 32px;background:#f8fafc;border-top:1px solid #e2e8f0;border-radius:0 0 8px 8px;">
386+
<p style="margin:0;color:#94a3b8;font-size:11px;">This is an automated message from CUDly. Do not share these links. <a href="{{.DashboardURL}}/#ri-exchange" style="color:#64748b;">View exchange history</a></p>
387+
</td></tr>
388+
389+
</table>
390+
</td></tr></table>
391+
</body></html>`
392+
319393
const riExchangeCompletedTemplate = `CUDly - RI Exchanges Completed
320394
==============================
321395
@@ -435,15 +509,39 @@ func (s *Sender) SendUserInviteEmail(ctx context.Context, email, setupURL string
435509
)
436510
}
437511

512+
// sendRIExchangePendingApprovalVia composes the plain-text + HTML approval
513+
// bodies and ships them through s.SendToEmailWithCCMultipart. HTML render
514+
// failures are non-fatal and degrade to single-part text so a template bug
515+
// never drops the approval email. Shared by Sender and SMTPSender -- issue #296.
516+
func sendRIExchangePendingApprovalVia(ctx context.Context, s SenderInterface, recipient, subject string, data RIExchangeNotificationData) error {
517+
textBody, err := RenderRIExchangePendingApprovalEmail(data)
518+
if err != nil {
519+
return fmt.Errorf("failed to render ri exchange pending approval email (text): %w", err)
520+
}
521+
htmlBody, htmlErr := RenderRIExchangePendingApprovalEmailHTML(data)
522+
if htmlErr != nil {
523+
logging.Warnf("email: HTML ri-exchange-pending render failed, falling back to text-only: %v", htmlErr)
524+
htmlBody = ""
525+
}
526+
return s.SendToEmailWithCCMultipart(ctx, recipient, nil, subject, textBody, htmlBody)
527+
}
528+
438529
// SendRIExchangePendingApproval sends an email with RI exchange approval links
530+
// as multipart/alternative (plain-text + styled HTML) when a direct recipient
531+
// address is configured, or falls back to SNS broadcast (text-only) for
532+
// deployments that use the topic-based notification path. Issue #296.
439533
func (s *Sender) SendRIExchangePendingApproval(ctx context.Context, data RIExchangeNotificationData) error {
440-
body, err := RenderRIExchangePendingApprovalEmail(data)
534+
subject := fmt.Sprintf("CUDly - RI Exchange Approval Required (%d exchanges)", len(data.Exchanges))
535+
// Use the direct SES multipart path when a notification address and a
536+
// FROM_EMAIL are both configured; otherwise fall back to SNS broadcast.
537+
if s.emailAddress != "" && isValidFromEmail(s.fromEmail) {
538+
return sendRIExchangePendingApprovalVia(ctx, s, s.emailAddress, subject, data)
539+
}
540+
textBody, err := RenderRIExchangePendingApprovalEmail(data)
441541
if err != nil {
442542
return fmt.Errorf("failed to render ri exchange pending approval email: %w", err)
443543
}
444-
445-
subject := fmt.Sprintf("CUDly - RI Exchange Approval Required (%d exchanges)", len(data.Exchanges))
446-
return s.SendNotification(ctx, subject, body)
544+
return s.SendNotification(ctx, subject, textBody)
447545
}
448546

449547
// SendRIExchangeCompleted sends a notification about completed RI exchanges

0 commit comments

Comments
 (0)