rails-profiler 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 80a34504e3cb67a077d5f46265cbc265cbf7704f89ad06f90e5ab5df22651a70
4
- data.tar.gz: d23a6d0d9f107611d66dfe34987447efde5f74f2562efd146d43d910fe431c6a
3
+ metadata.gz: b7bf5cab3285946189ba9adee630f79ad7303ae591aa4364e0fe4d7af320bab9
4
+ data.tar.gz: 4c2ad13691c8c6556355c2c4b2c5934ef2d6aab9d956e2f30d16d92098cd5997
5
5
  SHA512:
6
- metadata.gz: 668d228ede43471fc71a5d65d6498bfd00606865f7cad69dd23e03f79a4e84a2ba5f34dbc2d9e07809df4319946055464b7b8e615ee29e14c8cfafdef10ffc4a
7
- data.tar.gz: 16926d1a141c64cc72110698cd21e4d5c26d6e9022cd17ad6ba5ba3112aa98ce1917db9d005c112fac62190790433c06b24f1d9a85648edabf679664eb89b30d
6
+ metadata.gz: 1c846fa98d00dd5e6c5cc5ef8ca443b6f2b02664b9fdee77479bdbf6013d38cdf359e892ba6f08721b54fa5fb13d9b8663b909d7f17f97a849402654c4bf3821
7
+ data.tar.gz: 1697e1bfe8842df4d41aed3a1c5e84b8090d510fab6ee9622afc44e326553c13843a5252b74b34d5d6bb4195629f3d05330953b5527ef7a596aae89bf9a36b11
@@ -1010,6 +1010,60 @@
1010
1010
  ] });
1011
1011
  }
1012
1012
 
1013
+ // app/assets/typescript/profiler/components/toolbar/panels/MailerPanel.tsx
1014
+ function MailerPanel({ mailerData }) {
1015
+ const hasErrors = mailerData.failed > 0 || mailerData.loop_warnings.length > 0;
1016
+ return /* @__PURE__ */ u3(k, { children: [
1017
+ /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-header", children: [
1018
+ "Mailers",
1019
+ /* @__PURE__ */ u3("span", { class: "profiler-float-right", children: [
1020
+ mailerData.total,
1021
+ " email",
1022
+ mailerData.total !== 1 ? "s" : ""
1023
+ ] })
1024
+ ] }),
1025
+ /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-content", children: [
1026
+ mailerData.deliver_now > 0 && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", children: [
1027
+ /* @__PURE__ */ u3("span", { children: "deliver_now" }),
1028
+ /* @__PURE__ */ u3("strong", { children: mailerData.deliver_now })
1029
+ ] }),
1030
+ mailerData.deliver_later > 0 && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", children: [
1031
+ /* @__PURE__ */ u3("span", { children: "deliver_later" }),
1032
+ /* @__PURE__ */ u3("strong", { children: mailerData.deliver_later })
1033
+ ] }),
1034
+ (mailerData.queued_count ?? 0) > 0 && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", children: [
1035
+ /* @__PURE__ */ u3("span", { children: "Queued" }),
1036
+ /* @__PURE__ */ u3("strong", { children: mailerData.queued_count })
1037
+ ] }),
1038
+ mailerData.multi_part_count > 0 && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", children: [
1039
+ /* @__PURE__ */ u3("span", { children: "Multi-part" }),
1040
+ /* @__PURE__ */ u3("strong", { children: mailerData.multi_part_count })
1041
+ ] }),
1042
+ mailerData.failed > 0 && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", children: [
1043
+ /* @__PURE__ */ u3("span", { children: "Errors" }),
1044
+ /* @__PURE__ */ u3("strong", { class: "profiler-text--error", children: mailerData.failed })
1045
+ ] }),
1046
+ mailerData.loop_warnings.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row", children: [
1047
+ /* @__PURE__ */ u3("span", { class: "profiler-text--warning", children: "\u26A0\uFE0F Loop detected" }),
1048
+ /* @__PURE__ */ u3("strong", { class: "profiler-text--warning", children: mailerData.loop_warnings.length })
1049
+ ] }),
1050
+ [...mailerData.emails, ...mailerData.errors].slice(0, 3).map((email, i3) => /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row profiler-text--sm", children: [
1051
+ /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
1052
+ email.mailer_class,
1053
+ "#",
1054
+ email.action
1055
+ ] }),
1056
+ /* @__PURE__ */ u3("span", { class: email.error ? "profiler-text--error" : "profiler-text--success", children: email.error ? "\u274C" : "\u2705" })
1057
+ ] }, i3)),
1058
+ mailerData.total > 3 && /* @__PURE__ */ u3("div", { class: "profiler-toolbar-panel-row profiler-text--muted profiler-text--xs", children: [
1059
+ "+",
1060
+ mailerData.total - 3,
1061
+ " more\u2026"
1062
+ ] })
1063
+ ] })
1064
+ ] });
1065
+ }
1066
+
1013
1067
  // app/assets/typescript/profiler/components/toolbar/ToolbarApp.tsx
1014
1068
  function statusClass2(status) {
1015
1069
  if (status >= 200 && status < 300) return "profiler-text--success";
@@ -1045,6 +1099,7 @@
1045
1099
  const routesData = cd["routes"];
1046
1100
  const i18nData = cd["i18n"];
1047
1101
  const envData = cd["env"];
1102
+ const mailerData = cd["mailer"];
1048
1103
  const childJobs = profile.child_jobs ?? [];
1049
1104
  const reqClass = statusClass2(profile.status);
1050
1105
  const durClass = durationClass(profile.duration);
@@ -1283,6 +1338,32 @@
1283
1338
  ]
1284
1339
  }
1285
1340
  ),
1341
+ mailerData && (mailerData.total > 0 || (mailerData.queued_count ?? 0) > 0) && /* @__PURE__ */ u3(
1342
+ ToolbarItem,
1343
+ {
1344
+ href: `/_profiler/profiles/${token}?tab=mailer`,
1345
+ className: mailerData.failed > 0 ? "profiler-text--error" : "profiler-text--success",
1346
+ panelLarge: true,
1347
+ panel: /* @__PURE__ */ u3(MailerPanel, { mailerData }),
1348
+ children: [
1349
+ /* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: "MAIL" }),
1350
+ mailerData.total > 0 && /* @__PURE__ */ u3("span", { children: mailerData.total }),
1351
+ mailerData.total === 0 && (mailerData.queued_count ?? 0) > 0 && /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
1352
+ mailerData.queued_count,
1353
+ "q"
1354
+ ] }),
1355
+ mailerData.total > 0 && (mailerData.queued_count ?? 0) > 0 && /* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: [
1356
+ "+",
1357
+ mailerData.queued_count,
1358
+ "q"
1359
+ ] }),
1360
+ mailerData.failed > 0 && /* @__PURE__ */ u3("span", { class: "profiler-text--error profiler-text--xs", children: [
1361
+ "\u25B2 ",
1362
+ mailerData.failed
1363
+ ] })
1364
+ ]
1365
+ }
1366
+ ),
1286
1367
  envData && envData.total > 0 && /* @__PURE__ */ u3(
1287
1368
  ToolbarItem,
1288
1369
  {
@@ -4449,6 +4449,243 @@
4449
4449
  ] });
4450
4450
  }
4451
4451
 
4452
+ // app/assets/typescript/profiler/components/dashboard/tabs/MailerTab.tsx
4453
+ function BodyPreview({ email }) {
4454
+ const hasHtml = !!email.body_html;
4455
+ const hasText = !!email.body_text;
4456
+ if (!email.body_captured) {
4457
+ return /* @__PURE__ */ u3("div", { class: "profiler-mt-3 profiler-text--xs profiler-text--muted", children: [
4458
+ "Body not captured \u2014 enable ",
4459
+ /* @__PURE__ */ u3("code", { children: "config.capture_mail_body = true" }),
4460
+ " in your profiler initializer"
4461
+ ] });
4462
+ }
4463
+ if (!hasHtml && !hasText) return null;
4464
+ const [mode, setMode] = d2(hasHtml ? "preview" : "text");
4465
+ const switchMode = (m3) => (e3) => {
4466
+ e3.stopPropagation();
4467
+ setMode(m3);
4468
+ };
4469
+ const activeStyle = { borderColor: "var(--profiler-accent)", color: "var(--profiler-accent)" };
4470
+ return /* @__PURE__ */ u3("div", { class: "profiler-mt-3", children: [
4471
+ /* @__PURE__ */ u3("div", { class: "profiler-mb-2 profiler-flex profiler-flex--gap-2", style: { alignItems: "center" }, children: [
4472
+ /* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: "Body" }),
4473
+ hasHtml && /* @__PURE__ */ u3(k, { children: [
4474
+ /* @__PURE__ */ u3("button", { class: "profiler-btn profiler-btn--sm", style: mode === "preview" ? activeStyle : {}, onClick: switchMode("preview"), children: "HTML preview" }),
4475
+ /* @__PURE__ */ u3("button", { class: "profiler-btn profiler-btn--sm", style: mode === "source" ? activeStyle : {}, onClick: switchMode("source"), children: "HTML source" })
4476
+ ] }),
4477
+ hasText && /* @__PURE__ */ u3("button", { class: "profiler-btn profiler-btn--sm", style: mode === "text" ? activeStyle : {}, onClick: switchMode("text"), children: "Plain text" })
4478
+ ] }),
4479
+ mode === "preview" && hasHtml && /* @__PURE__ */ u3(
4480
+ "iframe",
4481
+ {
4482
+ srcdoc: email.body_html,
4483
+ sandbox: "allow-same-origin",
4484
+ style: { width: "100%", height: "300px", border: "1px solid var(--profiler-border)", borderRadius: "var(--profiler-radius-md)", background: "#fff", display: "block" }
4485
+ }
4486
+ ),
4487
+ (mode === "source" || mode === "text") && /* @__PURE__ */ u3("pre", { style: { maxHeight: "300px", overflow: "auto", background: "var(--profiler-bg-lighter, rgba(0,0,0,0.2))", padding: "12px", borderRadius: "var(--profiler-radius-md)", fontSize: "12px", margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-all", border: "1px solid var(--profiler-border)" }, children: mode === "source" ? email.body_html : email.body_text })
4488
+ ] });
4489
+ }
4490
+ function AssignsSection({ assigns }) {
4491
+ const entries = Object.entries(assigns);
4492
+ if (entries.length === 0) return null;
4493
+ return /* @__PURE__ */ u3("div", { class: "profiler-mt-3", children: [
4494
+ /* @__PURE__ */ u3("div", { class: "profiler-text--xs profiler-text--muted profiler-mb-1", style: { textTransform: "uppercase", letterSpacing: "0.05em" }, children: "Variables" }),
4495
+ entries.map(([key, value]) => /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
4496
+ /* @__PURE__ */ u3("span", { children: /* @__PURE__ */ u3("code", { children: [
4497
+ "@",
4498
+ key
4499
+ ] }) }),
4500
+ /* @__PURE__ */ u3("code", { style: { textAlign: "right", wordBreak: "break-all", maxWidth: "60%" }, children: value })
4501
+ ] }, key))
4502
+ ] });
4503
+ }
4504
+ function EmailDetail({ email }) {
4505
+ return /* @__PURE__ */ u3("div", { class: "profiler-mt-3", onClick: (e3) => e3.stopPropagation(), children: [
4506
+ email.subject && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
4507
+ /* @__PURE__ */ u3("span", { children: "Subject" }),
4508
+ /* @__PURE__ */ u3("span", { children: email.subject })
4509
+ ] }),
4510
+ email.to && email.to.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
4511
+ /* @__PURE__ */ u3("span", { children: "To" }),
4512
+ /* @__PURE__ */ u3("span", { style: { textAlign: "right", wordBreak: "break-all" }, children: email.to.join(", ") })
4513
+ ] }),
4514
+ email.from && email.from.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
4515
+ /* @__PURE__ */ u3("span", { children: "From" }),
4516
+ /* @__PURE__ */ u3("span", { style: { textAlign: "right" }, children: email.from.join(", ") })
4517
+ ] }),
4518
+ email.cc && email.cc.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
4519
+ /* @__PURE__ */ u3("span", { children: "CC" }),
4520
+ /* @__PURE__ */ u3("span", { style: { textAlign: "right" }, children: email.cc.join(", ") })
4521
+ ] }),
4522
+ email.bcc && email.bcc.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
4523
+ /* @__PURE__ */ u3("span", { children: "BCC" }),
4524
+ /* @__PURE__ */ u3("span", { style: { textAlign: "right" }, children: email.bcc.join(", ") })
4525
+ ] }),
4526
+ email.reply_to && email.reply_to.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
4527
+ /* @__PURE__ */ u3("span", { children: "Reply-To" }),
4528
+ /* @__PURE__ */ u3("span", { style: { textAlign: "right" }, children: email.reply_to.join(", ") })
4529
+ ] }),
4530
+ email.template && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
4531
+ /* @__PURE__ */ u3("span", { children: "Template" }),
4532
+ /* @__PURE__ */ u3("code", { children: email.template })
4533
+ ] }),
4534
+ email.parts && email.parts.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
4535
+ /* @__PURE__ */ u3("span", { children: "Parts" }),
4536
+ /* @__PURE__ */ u3("span", { style: { textAlign: "right" }, children: email.parts.join(", ") })
4537
+ ] }),
4538
+ email.attachments && email.attachments.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
4539
+ /* @__PURE__ */ u3("span", { children: "Attachments" }),
4540
+ /* @__PURE__ */ u3("span", { style: { textAlign: "right" }, children: email.attachments.map((a3) => `${a3.filename} (${(a3.size / 1024).toFixed(1)} KB)`).join(", ") })
4541
+ ] }),
4542
+ /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
4543
+ /* @__PURE__ */ u3("span", { children: "Render" }),
4544
+ /* @__PURE__ */ u3("span", { class: "profiler-query-card__duration", children: email.duration_ms != null ? `${email.duration_ms} ms` : "\u2014" })
4545
+ ] }),
4546
+ email.delivery_ms != null && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
4547
+ /* @__PURE__ */ u3("span", { children: "Delivery" }),
4548
+ /* @__PURE__ */ u3("span", { class: "profiler-query-card__duration", children: [
4549
+ email.delivery_ms,
4550
+ " ms"
4551
+ ] })
4552
+ ] }),
4553
+ email.message_id && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
4554
+ /* @__PURE__ */ u3("span", { children: "Message-ID" }),
4555
+ /* @__PURE__ */ u3("code", { style: { fontSize: "11px", wordBreak: "break-all", maxWidth: "70%", textAlign: "right" }, children: email.message_id })
4556
+ ] }),
4557
+ email.error && /* @__PURE__ */ u3("div", { class: "profiler-kv-row", children: [
4558
+ /* @__PURE__ */ u3("span", { children: "Error" }),
4559
+ /* @__PURE__ */ u3("span", { class: "profiler-text--error", style: { textAlign: "right" }, children: email.error })
4560
+ ] }),
4561
+ email.assigns && Object.keys(email.assigns).length > 0 && /* @__PURE__ */ u3(AssignsSection, { assigns: email.assigns }),
4562
+ /* @__PURE__ */ u3(BodyPreview, { email })
4563
+ ] });
4564
+ }
4565
+ function EmailRow({ email, onClick, isExpanded }) {
4566
+ const cardClass = ["profiler-query-card", email.error ? "profiler-query-card--slow" : ""].filter(Boolean).join(" ");
4567
+ return /* @__PURE__ */ u3("div", { class: cardClass, style: { cursor: "pointer", marginBottom: "6px" }, onClick, children: [
4568
+ /* @__PURE__ */ u3("div", { class: "profiler-query-card__header", children: [
4569
+ /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", style: { minWidth: 0, flex: 1 }, children: /* @__PURE__ */ u3("span", { class: "profiler-text--sm", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: [
4570
+ /* @__PURE__ */ u3("strong", { children: email.mailer_class }),
4571
+ /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
4572
+ "#",
4573
+ email.action
4574
+ ] }),
4575
+ email.subject && /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
4576
+ " \u2014 ",
4577
+ email.subject.slice(0, 60),
4578
+ email.subject.length > 60 ? "\u2026" : ""
4579
+ ] })
4580
+ ] }) }),
4581
+ /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", style: { flexShrink: 0 }, children: [
4582
+ email.delivery_mode && /* @__PURE__ */ u3("span", { class: "badge-info profiler-text--xs", children: email.delivery_mode }),
4583
+ email.duration_ms != null && /* @__PURE__ */ u3("span", { class: "profiler-query-card__duration profiler-text--xs", children: [
4584
+ email.duration_ms,
4585
+ " ms"
4586
+ ] }),
4587
+ email.error ? /* @__PURE__ */ u3("span", { class: "badge-error profiler-text--xs", children: "Error" }) : /* @__PURE__ */ u3("span", { class: "badge-success profiler-text--xs", children: "Sent" }),
4588
+ /* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: isExpanded ? "\u25B2" : "\u25BC" })
4589
+ ] })
4590
+ ] }),
4591
+ isExpanded && /* @__PURE__ */ u3(EmailDetail, { email })
4592
+ ] });
4593
+ }
4594
+ function QueuedRow({ email }) {
4595
+ return /* @__PURE__ */ u3("div", { class: "profiler-query-card", style: { marginBottom: "6px", borderLeft: "3px solid var(--profiler-info, #38bdf8)" }, children: [
4596
+ /* @__PURE__ */ u3("div", { class: "profiler-query-card__header", children: [
4597
+ /* @__PURE__ */ u3("span", { class: "profiler-text--sm", children: [
4598
+ /* @__PURE__ */ u3("strong", { children: email.mailer_class }),
4599
+ /* @__PURE__ */ u3("span", { class: "profiler-text--muted", children: [
4600
+ "#",
4601
+ email.action
4602
+ ] })
4603
+ ] }),
4604
+ /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-2", style: { flexShrink: 0 }, children: [
4605
+ /* @__PURE__ */ u3("span", { class: "badge-info profiler-text--xs", children: "queued" }),
4606
+ email.delivery_method && /* @__PURE__ */ u3("span", { class: "badge-default profiler-text--xs", children: email.delivery_method }),
4607
+ email.duration_ms != null && /* @__PURE__ */ u3("span", { class: "profiler-query-card__duration profiler-text--xs", children: [
4608
+ email.duration_ms,
4609
+ " ms"
4610
+ ] })
4611
+ ] })
4612
+ ] }),
4613
+ email.assigns && Object.keys(email.assigns).length > 0 && /* @__PURE__ */ u3(AssignsSection, { assigns: email.assigns })
4614
+ ] });
4615
+ }
4616
+ function MailerTab({ mailerData }) {
4617
+ const [expandedIndex, setExpandedIndex] = d2(null);
4618
+ const [expandedErrorIndex, setExpandedErrorIndex] = d2(null);
4619
+ const queuedCount = mailerData?.queued_count ?? 0;
4620
+ if (!mailerData || mailerData.total === 0 && queuedCount === 0) {
4621
+ return /* @__PURE__ */ u3("div", { class: "profiler-empty", children: /* @__PURE__ */ u3("p", { class: "profiler-empty__description", children: "No emails sent during this request" }) });
4622
+ }
4623
+ const toggleRow = (i3) => setExpandedIndex(expandedIndex === i3 ? null : i3);
4624
+ const toggleErrorRow = (i3) => setExpandedErrorIndex(expandedErrorIndex === i3 ? null : i3);
4625
+ return /* @__PURE__ */ u3(k, { children: [
4626
+ /* @__PURE__ */ u3("h2", { class: "profiler-section__header", children: "Mailer Deliveries" }),
4627
+ /* @__PURE__ */ u3("div", { class: "profiler-flex profiler-flex--gap-4 profiler-mb-4 profiler-text--sm", children: [
4628
+ /* @__PURE__ */ u3("span", { children: [
4629
+ "Total: ",
4630
+ /* @__PURE__ */ u3("strong", { children: mailerData.total })
4631
+ ] }),
4632
+ mailerData.deliver_now > 0 && /* @__PURE__ */ u3("span", { children: [
4633
+ "deliver_now: ",
4634
+ /* @__PURE__ */ u3("strong", { children: mailerData.deliver_now })
4635
+ ] }),
4636
+ mailerData.deliver_later > 0 && /* @__PURE__ */ u3("span", { children: [
4637
+ "deliver_later: ",
4638
+ /* @__PURE__ */ u3("strong", { children: mailerData.deliver_later })
4639
+ ] }),
4640
+ queuedCount > 0 && /* @__PURE__ */ u3("span", { children: [
4641
+ "queued: ",
4642
+ /* @__PURE__ */ u3("strong", { children: queuedCount })
4643
+ ] }),
4644
+ mailerData.multi_part_count > 0 && /* @__PURE__ */ u3("span", { children: [
4645
+ "Multi-part: ",
4646
+ /* @__PURE__ */ u3("strong", { children: mailerData.multi_part_count })
4647
+ ] }),
4648
+ mailerData.failed > 0 && /* @__PURE__ */ u3("span", { children: [
4649
+ "Errors: ",
4650
+ /* @__PURE__ */ u3("strong", { class: "profiler-text--error", children: mailerData.failed })
4651
+ ] })
4652
+ ] }),
4653
+ mailerData.loop_warnings.length > 0 && /* @__PURE__ */ u3("div", { class: "profiler-alert-banner profiler-alert-banner--warning profiler-mb-4", children: [
4654
+ /* @__PURE__ */ u3("span", { class: "profiler-alert-banner__icon", children: "\u26A0\uFE0F" }),
4655
+ /* @__PURE__ */ u3("div", { style: { flex: 1 }, children: [
4656
+ /* @__PURE__ */ u3("strong", { children: "Send loop detected" }),
4657
+ " \u2014 ",
4658
+ mailerData.loop_warnings.length,
4659
+ " pattern",
4660
+ mailerData.loop_warnings.length > 1 ? "s" : "",
4661
+ mailerData.loop_warnings.map((w3, i3) => /* @__PURE__ */ u3("div", { class: "profiler-text--xs profiler-text--muted profiler-mt-1", children: w3.message }, i3))
4662
+ ] })
4663
+ ] }),
4664
+ mailerData.truncated && /* @__PURE__ */ u3("div", { class: "profiler-alert-banner profiler-alert-banner--warning profiler-mb-4", children: [
4665
+ /* @__PURE__ */ u3("span", { class: "profiler-alert-banner__icon", children: "\u26A0\uFE0F" }),
4666
+ /* @__PURE__ */ u3("span", { children: "Showing first 50 emails only \u2014 additional emails were truncated" })
4667
+ ] }),
4668
+ mailerData.emails.length > 0 && /* @__PURE__ */ u3(k, { children: [
4669
+ /* @__PURE__ */ u3("h3", { class: "profiler-text--lg profiler-mt-4 profiler-mb-3", children: [
4670
+ "Emails",
4671
+ /* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--sm", style: { fontWeight: "normal", marginLeft: "8px" }, children: "click to expand" })
4672
+ ] }),
4673
+ mailerData.emails.map((email, i3) => /* @__PURE__ */ u3(EmailRow, { email, onClick: () => toggleRow(i3), isExpanded: expandedIndex === i3 }, i3))
4674
+ ] }),
4675
+ mailerData.queued && mailerData.queued.length > 0 && /* @__PURE__ */ u3(k, { children: [
4676
+ /* @__PURE__ */ u3("h3", { class: "profiler-text--lg profiler-mt-4 profiler-mb-2", children: [
4677
+ "Queued",
4678
+ /* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--sm", style: { fontWeight: "normal", marginLeft: "8px" }, children: "deliver_later \u2014 will be sent in a background job" })
4679
+ ] }),
4680
+ mailerData.queued.map((email, i3) => /* @__PURE__ */ u3(QueuedRow, { email }, `q-${i3}`))
4681
+ ] }),
4682
+ mailerData.errors.length > 0 && /* @__PURE__ */ u3(k, { children: [
4683
+ /* @__PURE__ */ u3("h3", { class: "profiler-text--lg profiler-mt-6 profiler-mb-3 profiler-text--error", children: "Delivery Errors" }),
4684
+ mailerData.errors.map((email, i3) => /* @__PURE__ */ u3(EmailRow, { email, onClick: () => toggleErrorRow(i3), isExpanded: expandedErrorIndex === i3 }, `err-${i3}`))
4685
+ ] })
4686
+ ] });
4687
+ }
4688
+
4452
4689
  // app/assets/typescript/profiler/components/dashboard/ProfileDashboard.tsx
4453
4690
  function ProfileDashboard({ profile, initialTab, embedded }) {
4454
4691
  const cd = profile.collectors_data || {};
@@ -4459,6 +4696,7 @@
4459
4696
  const hasRoutes = (cd["routes"]?.total ?? 0) > 0;
4460
4697
  const hasI18n = (cd["i18n"]?.total ?? 0) > 0;
4461
4698
  const hasJobs = (profile.child_jobs?.length ?? 0) > 0;
4699
+ const hasMailers = (cd["mailer"]?.total ?? 0) > 0;
4462
4700
  const [activeTab, setActiveTab] = d2(hasException ? "exception" : initialTab);
4463
4701
  const handleTabClick = (tab) => (e3) => {
4464
4702
  e3.preventDefault();
@@ -4530,6 +4768,7 @@
4530
4768
  profile.child_jobs.length,
4531
4769
  ")"
4532
4770
  ] }),
4771
+ hasMailers && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("mailer"), onClick: handleTabClick("mailer"), children: "Mailers" }),
4533
4772
  /* @__PURE__ */ u3("a", { href: "#", class: tabClass("env"), onClick: handleTabClick("env"), children: "Env" })
4534
4773
  ] }),
4535
4774
  /* @__PURE__ */ u3("div", { class: "profiler-p-4 tab-content active", children: [
@@ -4546,6 +4785,7 @@
4546
4785
  activeTab === "routes" && /* @__PURE__ */ u3(RoutesTab, { routesData: cd["routes"] }),
4547
4786
  activeTab === "i18n" && /* @__PURE__ */ u3(I18nTab, { i18nData: cd["i18n"] }),
4548
4787
  activeTab === "jobs" && /* @__PURE__ */ u3(JobsTab, { jobs: profile.child_jobs }),
4788
+ activeTab === "mailer" && /* @__PURE__ */ u3(MailerTab, { mailerData: cd["mailer"] }),
4549
4789
  activeTab === "env" && /* @__PURE__ */ u3(EnvTab, { envData: cd["env"], readOnly: true })
4550
4790
  ] })
4551
4791
  ] }),
@@ -4615,7 +4855,8 @@
4615
4855
  const hasDumps = (cd["dump"]?.count ?? 0) > 0;
4616
4856
  const hasLogs = (cd["logs"]?.total ?? 0) > 0;
4617
4857
  const hasException = !!cd["exception"]?.exception_class;
4618
- const validTabs = ["job", "database", "cache", "http", "jobs", "dump", "logs", "exception", "env", "timeline"];
4858
+ const hasMailers = (cd["mailer"]?.total ?? 0) > 0;
4859
+ const validTabs = ["job", "database", "cache", "http", "jobs", "dump", "logs", "exception", "env", "timeline", "mailer"];
4619
4860
  const defaultTab = validTabs.includes(initialTab) ? initialTab : "job";
4620
4861
  const [activeTab, setActiveTab] = d2(hasException ? "exception" : defaultTab);
4621
4862
  const jobData = cd["job"];
@@ -4706,6 +4947,7 @@
4706
4947
  ")"
4707
4948
  ] }),
4708
4949
  hasLogs && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("logs"), onClick: handleTabClick("logs"), children: "Logs" }),
4950
+ hasMailers && /* @__PURE__ */ u3("a", { href: "#", class: tabClass("mailer"), onClick: handleTabClick("mailer"), children: "Mailers" }),
4709
4951
  /* @__PURE__ */ u3("a", { href: "#", class: tabClass("env"), onClick: handleTabClick("env"), children: "Env" })
4710
4952
  ] }),
4711
4953
  /* @__PURE__ */ u3("div", { class: "profiler-p-4 tab-content active", children: [
@@ -4718,6 +4960,7 @@
4718
4960
  activeTab === "jobs" && /* @__PURE__ */ u3(JobsTab, { jobs: profile.child_jobs }),
4719
4961
  activeTab === "dump" && /* @__PURE__ */ u3(DumpsTab, { dumpData: cd["dump"] }),
4720
4962
  activeTab === "logs" && /* @__PURE__ */ u3(LogsTab, { logData: cd["logs"] }),
4963
+ activeTab === "mailer" && /* @__PURE__ */ u3(MailerTab, { mailerData: cd["mailer"] }),
4721
4964
  activeTab === "env" && /* @__PURE__ */ u3(EnvTab, { envData: cd["env"], readOnly: true })
4722
4965
  ] })
4723
4966
  ] }),
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_collector"
4
+
5
+ module Profiler
6
+ module Collectors
7
+ class MailerCollector < BaseCollector
8
+ MAX_EMAILS = 50
9
+ MAX_BODY_SIZE = 100 * 1024 # 100 KB
10
+
11
+ def initialize(profile)
12
+ super
13
+ @emails = []
14
+ @errors = []
15
+ @queued = []
16
+ @loop_warnings = []
17
+ @subscriptions = []
18
+ end
19
+
20
+ def icon
21
+ "✉️"
22
+ end
23
+
24
+ def priority
25
+ 45
26
+ end
27
+
28
+ def tab_config
29
+ {
30
+ key: "mailer",
31
+ label: "Mailers",
32
+ icon: icon,
33
+ priority: priority,
34
+ enabled: true,
35
+ default_active: false
36
+ }
37
+ end
38
+
39
+ def subscribe
40
+ return unless defined?(ActiveSupport::Notifications)
41
+ return unless Profiler.configuration.track_mailers
42
+
43
+ # Capture the subscriber thread so notifications from other threads (e.g. async
44
+ # job threads delivering mail enqueued via deliver_later) are ignored by this collector.
45
+ @subscriber_thread = Thread.current
46
+
47
+ # Use a stack so multiple deliver_later calls in the same request are all tracked.
48
+ Thread.current[:profiler_pending_processes] ||= []
49
+
50
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("process.action_mailer") do |_name, started, finished, _id, payload|
51
+ next unless Thread.current.equal?(@subscriber_thread)
52
+ next if rails_preview_request?
53
+
54
+ mailer_class = payload[:mailer].to_s
55
+ action = payload[:action].to_s
56
+ (Thread.current[:profiler_pending_processes] ||= []) << {
57
+ mailer_class: mailer_class,
58
+ action: action,
59
+ duration_ms: ((finished - started) * 1000).round(2),
60
+ assigns: extract_assigns(mailer_class, action, payload[:args])
61
+ }
62
+ end
63
+
64
+ @subscriptions << ActiveSupport::Notifications.monotonic_subscribe("deliver.action_mailer") do |_name, started, finished, _id, payload|
65
+ next unless Thread.current.equal?(@subscriber_thread)
66
+ next unless payload[:perform_deliveries]
67
+ next if rails_preview_request?
68
+
69
+ delivery_ms = ((finished - started) * 1000).round(2)
70
+ process_info = (Thread.current[:profiler_pending_processes] ||= []).pop || {}
71
+ mail = payload[:mail]
72
+
73
+ mailer_class = process_info[:mailer_class] || payload[:mailer_class].to_s
74
+ action = process_info[:action].to_s
75
+ config = Profiler.configuration
76
+ next if config.mailer_skip_actions.any? { |a| a == "#{mailer_class}##{action}" || a == mailer_class }
77
+
78
+ delivery_mode = mail_delivery_job? ? "deliver_later" : "deliver_now"
79
+ email = build_email_record(payload, mail, process_info, delivery_ms, delivery_mode)
80
+ email[:error] ? @errors << email : @emails << email
81
+ rescue StandardError => e
82
+ @errors << { error: e.message, triggered_at: Time.now.utc.iso8601(3) }
83
+ end
84
+ end
85
+
86
+ def collect
87
+ # Any remaining pending process entries had no matching deliver event:
88
+ # they were enqueued via deliver_later in the HTTP context.
89
+ pending = Thread.current[:profiler_pending_processes] || []
90
+ Thread.current[:profiler_pending_processes] = nil
91
+
92
+ pending.each do |info|
93
+ @queued << {
94
+ mailer_class: info[:mailer_class],
95
+ action: info[:action],
96
+ delivery_mode: "deliver_later",
97
+ delivery_method: extract_delivery_method,
98
+ duration_ms: info[:duration_ms],
99
+ assigns: info[:assigns],
100
+ body_captured: false,
101
+ triggered_at: Time.now.utc.iso8601(3)
102
+ }
103
+ end
104
+
105
+ @subscriptions.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
106
+
107
+ detect_loops
108
+
109
+ truncated = @emails.size > MAX_EMAILS
110
+ emails = @emails.first(MAX_EMAILS)
111
+
112
+ store_data(
113
+ emails: emails.map { |e| e.transform_keys(&:to_s) },
114
+ errors: @errors.map { |e| e.transform_keys(&:to_s) },
115
+ queued: @queued.map { |e| e.transform_keys(&:to_s) },
116
+ loop_warnings: @loop_warnings.map { |w| w.transform_keys(&:to_s) },
117
+ total: @emails.size + @errors.size,
118
+ queued_count: @queued.size,
119
+ deliver_now: @emails.count { |e| e[:delivery_mode] == "deliver_now" },
120
+ deliver_later: @emails.count { |e| e[:delivery_mode] == "deliver_later" },
121
+ multi_part_count: @emails.count { |e| e[:parts]&.size.to_i > 1 },
122
+ failed: @errors.size,
123
+ truncated: truncated
124
+ )
125
+ end
126
+
127
+ def has_data?
128
+ @emails.any? || @errors.any? || @queued.any?
129
+ end
130
+
131
+ def toolbar_summary
132
+ total = @emails.size + @errors.size
133
+ queued = @queued.size
134
+ return { text: "0 emails", color: "gray" } if total == 0 && queued == 0
135
+
136
+ now_count = @emails.count { |e| e[:delivery_mode] == "deliver_now" }
137
+ later_count = @emails.count { |e| e[:delivery_mode] == "deliver_later" }
138
+ has_errors = @errors.any? || @loop_warnings.any?
139
+
140
+ parts = []
141
+ parts << "#{now_count} now" if now_count > 0
142
+ parts << "#{later_count} later" if later_count > 0
143
+ parts << "#{queued} queued" if queued > 0
144
+ detail = parts.any? ? " (#{parts.join(" · ")})" : ""
145
+
146
+ text = "#{total + queued} email#{(total + queued) > 1 ? "s" : ""}#{detail}"
147
+ text += " ⚠️ #{@errors.size} error#{@errors.size > 1 ? "s" : ""}" if @errors.any?
148
+
149
+ { text: text, color: has_errors ? "red" : "green" }
150
+ end
151
+
152
+ private
153
+
154
+ def mail_delivery_job?
155
+ Thread.current[:profiler_current_job_class].to_s.end_with?("MailDeliveryJob")
156
+ end
157
+
158
+ def build_email_record(payload, mail, process_info, delivery_ms, delivery_mode = "deliver_now")
159
+ config = Profiler.configuration
160
+ mailer_class = process_info[:mailer_class] || payload[:mailer_class].to_s
161
+ action = process_info[:action]
162
+
163
+ # In Rails 7+, payload[:mail] is the encoded string (mail.encoded), not a Mail::Message.
164
+ # Parse it only for attributes not available in the payload (parts, attachments, reply_to, body).
165
+ mail_obj = parse_mail(mail)
166
+
167
+ to_list = sanitize_recipients(Array(payload[:to]), config)
168
+ body_html, body_text = config.capture_mail_body ? extract_body(mail_obj) : [nil, nil]
169
+
170
+ {
171
+ mailer_class: mailer_class,
172
+ action: action,
173
+ subject: payload[:subject],
174
+ to: to_list,
175
+ from: Array(payload[:from]),
176
+ cc: Array(payload[:cc]),
177
+ bcc: Array(payload[:bcc]),
178
+ reply_to: Array(mail_obj&.reply_to),
179
+ message_id: payload[:message_id],
180
+ delivery_method: extract_delivery_method,
181
+ delivery_mode: delivery_mode,
182
+ duration_ms: process_info[:duration_ms],
183
+ delivery_ms: delivery_ms,
184
+ parts: extract_parts(mail_obj),
185
+ attachments: extract_attachments(mail_obj),
186
+ template: action ? "#{to_path(mailer_class)}/#{action}" : nil,
187
+ assigns: process_info[:assigns] || {},
188
+ body_captured: !body_html.nil? || !body_text.nil?,
189
+ body_html: body_html,
190
+ body_text: body_text,
191
+ error: nil,
192
+ triggered_at: Time.now.utc.iso8601(3)
193
+ }
194
+ end
195
+
196
+ def extract_assigns(mailer_class, action, args)
197
+ return {} if action.nil? || action.empty? || args.nil?
198
+
199
+ klass = Object.const_get(mailer_class)
200
+ params = klass.instance_method(action).parameters
201
+ params.each_with_index.each_with_object({}) do |((_, name), i), h|
202
+ h[name.to_s] = serialize_assign(args[i])
203
+ end
204
+ rescue StandardError
205
+ {}
206
+ end
207
+
208
+ def extract_body(mail)
209
+ return [nil, nil] unless mail.respond_to?(:multipart?)
210
+
211
+ html = text = nil
212
+ if mail.multipart?
213
+ html_part = mail.parts.find { |p| p.content_type.to_s.start_with?("text/html") }
214
+ text_part = mail.parts.find { |p| p.content_type.to_s.start_with?("text/plain") }
215
+ html = html_part&.body&.decoded&.then { |b| b[0, MAX_BODY_SIZE] }
216
+ text = text_part&.body&.decoded&.then { |b| b[0, MAX_BODY_SIZE] }
217
+ elsif mail.content_type.to_s.start_with?("text/html")
218
+ html = mail.body.decoded[0, MAX_BODY_SIZE]
219
+ else
220
+ text = mail.body.decoded[0, MAX_BODY_SIZE]
221
+ end
222
+ [html.nil? || html.empty? ? nil : html, text.nil? || text.empty? ? nil : text]
223
+ rescue StandardError
224
+ [nil, nil]
225
+ end
226
+
227
+ def serialize_assign(value)
228
+ return "nil" if value.nil?
229
+
230
+ inspected = value.inspect
231
+ inspected.length > 300 ? "#{inspected[0, 300]}…" : inspected
232
+ rescue StandardError
233
+ value.to_s
234
+ end
235
+
236
+ def parse_mail(mail)
237
+ return mail if mail.respond_to?(:multipart?)
238
+ return nil unless mail.is_a?(String)
239
+
240
+ Mail.new(mail)
241
+ rescue StandardError
242
+ nil
243
+ end
244
+
245
+ def sanitize_recipients(recipients, config)
246
+ return recipients unless config.respond_to?(:sanitize_mailer_recipients) && config.sanitize_mailer_recipients
247
+
248
+ recipients.map do |addr|
249
+ local, domain = addr.to_s.split("@", 2)
250
+ domain ? "#{local[0]}***@#{domain.gsub(/[^.]+(?=\.)/, "***")}" : addr
251
+ end
252
+ end
253
+
254
+ def extract_delivery_method
255
+ return "unknown" unless defined?(ActionMailer::Base)
256
+
257
+ ActionMailer::Base.delivery_method.to_s
258
+ rescue StandardError
259
+ "unknown"
260
+ end
261
+
262
+ def extract_parts(mail)
263
+ return [] unless mail
264
+
265
+ if mail.multipart?
266
+ mail.parts.map { |p| p.content_type.to_s.split(";").first }
267
+ elsif mail.content_type
268
+ [mail.content_type.to_s.split(";").first]
269
+ else
270
+ []
271
+ end
272
+ end
273
+
274
+ def extract_attachments(mail)
275
+ return [] unless mail
276
+
277
+ (mail.attachments || []).map do |att|
278
+ {
279
+ "filename" => att.filename.to_s,
280
+ "size" => att.body.decoded.bytesize
281
+ }
282
+ rescue StandardError
283
+ { "filename" => att.filename.to_s, "size" => 0 }
284
+ end
285
+ end
286
+
287
+ def detect_loops
288
+ counts = Hash.new(0)
289
+ @emails.each { |e| counts["#{e[:mailer_class]}##{e[:action]}"] += 1 }
290
+
291
+ counts.each do |key, count|
292
+ next unless count > 3
293
+
294
+ @loop_warnings << {
295
+ key: key,
296
+ count: count,
297
+ message: "#{key} was called #{count} times in a single request — possible send loop"
298
+ }
299
+ end
300
+ end
301
+
302
+ def rails_preview_request?
303
+ path = Thread.current[:profiler_request_path]
304
+ path&.start_with?("/rails/mailers")
305
+ end
306
+
307
+ def to_path(class_name)
308
+ class_name.to_s
309
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
310
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
311
+ .downcase
312
+ end
313
+ end
314
+ end
315
+ end
@@ -11,6 +11,7 @@ module Profiler
11
11
  :track_ajax, :ajax_skip_paths,
12
12
  :track_http, :slow_http_threshold, :http_skip_hosts,
13
13
  :track_jobs,
14
+ :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
14
15
  :compress_bodies, :compress_body_threshold
15
16
 
16
17
  attr_writer :tmp_path
@@ -41,6 +42,10 @@ module Profiler
41
42
  @slow_http_threshold = 500 # milliseconds
42
43
  @http_skip_hosts = []
43
44
  @track_jobs = true
45
+ @track_mailers = true
46
+ @capture_mail_body = false
47
+ @sanitize_mailer_recipients = false
48
+ @mailer_skip_actions = []
44
49
  @compress_bodies = true
45
50
  @compress_body_threshold = 10 * 1024 # 10 KB
46
51
  @tmp_path = nil
@@ -11,6 +11,7 @@ require_relative "collectors/log_collector"
11
11
  require_relative "collectors/exception_collector"
12
12
  require_relative "collectors/env_collector"
13
13
  require_relative "collectors/flamegraph_collector"
14
+ require_relative "collectors/mailer_collector"
14
15
 
15
16
  module Profiler
16
17
  class JobProfiler
@@ -22,7 +23,8 @@ module Profiler
22
23
  Collectors::LogCollector,
23
24
  Collectors::ExceptionCollector,
24
25
  Collectors::EnvCollector,
25
- Collectors::FlameGraphCollector
26
+ Collectors::FlameGraphCollector,
27
+ Collectors::MailerCollector
26
28
  ].freeze
27
29
 
28
30
  def self.profile(job_class:, job_id:, queue:, arguments:, executions:, parent_token: nil, &block)
@@ -74,7 +76,9 @@ module Profiler
74
76
  error_message = nil
75
77
 
76
78
  previous_token = Profiler::CurrentContext.token
79
+ previous_job_class = Thread.current[:profiler_current_job_class]
77
80
  Profiler::CurrentContext.token = profile.token
81
+ Thread.current[:profiler_current_job_class] = @job_class
78
82
  begin
79
83
  result = block.call
80
84
  result
@@ -84,6 +88,7 @@ module Profiler
84
88
  exception_collector&.capture(e)
85
89
  raise
86
90
  ensure
91
+ Thread.current[:profiler_current_job_class] = previous_job_class
87
92
  Profiler::CurrentContext.token = previous_token
88
93
  if Profiler.configuration.track_memory
89
94
  profile.memory = current_memory - memory_before
@@ -70,6 +70,7 @@ module Profiler
70
70
  require_relative "tools/get_profile_dumps"
71
71
  require_relative "tools/get_profile_http"
72
72
  require_relative "tools/query_jobs"
73
+ require_relative "tools/query_mailers"
73
74
  require_relative "tools/clear_profiles"
74
75
 
75
76
  [
@@ -95,7 +96,7 @@ module Profiler
95
96
  input_schema: {
96
97
  properties: {
97
98
  token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
98
- sections: { type: "array", items: { type: "string" }, description: "Sections to include. Valid values: overview, exception, job, request, response, curl, database, performance, views, cache, ajax, http, routes, dumps. Omit for all." },
99
+ sections: { type: "array", items: { type: "string" }, description: "Sections to include. Valid values: overview, exception, job, request, response, curl, database, performance, views, cache, ajax, http, mailers, routes, dumps. Omit for all." },
99
100
  save_bodies: { type: "boolean", description: "Save request/response bodies to temp files and return paths instead of inlining content." },
100
101
  max_body_size: { type: "number", description: "Truncate inlined body content at N characters. Ignored when save_bodies is true." },
101
102
  json_path: { type: "string", description: "JSONPath expression to extract from response body (e.g. '$.data.items[0]'). Only applied when save_bodies is true." },
@@ -181,6 +182,22 @@ module Profiler
181
182
  },
182
183
  handler: Tools::QueryJobs
183
184
  ),
185
+ define_tool(
186
+ name: "query_mailers",
187
+ description: "Search and filter ActionMailer deliveries across profiles. Returns emails sent via deliver_now or deliver_later.",
188
+ input_schema: {
189
+ properties: {
190
+ mailer_class: { type: "string", description: "Filter by mailer class name (partial match, e.g. 'UserMailer')" },
191
+ action: { type: "string", description: "Filter by mailer action name (partial match, e.g. 'welcome_email')" },
192
+ delivery_mode: { type: "string", description: "Filter by delivery mode: 'deliver_now' or 'deliver_later'" },
193
+ has_error: { type: "boolean", description: "Filter to only emails with delivery errors" },
194
+ limit: { type: "number", description: "Maximum number of results (default 20)" },
195
+ fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, profile, mailer, action, subject, to, mode, duration, status, token. Omit for all." },
196
+ cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen." }
197
+ }
198
+ },
199
+ handler: Tools::QueryMailers
200
+ ),
184
201
  define_tool(
185
202
  name: "clear_profiles",
186
203
  description: "Clear profiler history. Omit type to clear everything, or pass 'http'/'job' to clear only requests or jobs.",
@@ -61,6 +61,7 @@ module Profiler
61
61
  lines += section_cache(profile) if want.("cache")
62
62
  lines += section_ajax(profile) if want.("ajax")
63
63
  lines += section_http(profile, params) if want.("http")
64
+ lines += section_mailers(profile) if want.("mailers")
64
65
  lines += section_routes(profile) if want.("routes")
65
66
  lines += section_dumps(profile) if want.("dumps")
66
67
  lines += section_related_jobs(profile) if want.("related_jobs")
@@ -385,6 +386,56 @@ module Profiler
385
386
  lines
386
387
  end
387
388
 
389
+ def self.section_mailers(profile)
390
+ lines = []
391
+ mailer_data = profile.collector_data("mailer")
392
+ return lines unless mailer_data && mailer_data["total"].to_i > 0
393
+
394
+ lines << "## Mailers"
395
+ lines << "- Total: #{mailer_data['total']}"
396
+ lines << "- deliver_now: #{mailer_data['deliver_now']}"
397
+ lines << "- deliver_later: #{mailer_data['deliver_later']}"
398
+ lines << "- Errors: #{mailer_data['failed']}"
399
+ lines << "- Multi-part: #{mailer_data['multi_part_count']}"
400
+ lines << "- Truncated: yes (showing first #{Profiler::Collectors::MailerCollector::MAX_EMAILS})" if mailer_data["truncated"]
401
+
402
+ warnings = mailer_data["loop_warnings"] || []
403
+ if warnings.any?
404
+ lines << ""
405
+ lines << "### ⚠️ Loop Warnings"
406
+ warnings.each { |w| lines << "- #{w['message']}" }
407
+ end
408
+
409
+ emails = mailer_data["emails"] || []
410
+ if emails.any?
411
+ lines << ""
412
+ lines << "### Emails"
413
+ lines << ""
414
+ lines << "| Mailer | Action | Subject | To | Mode | Duration | Status |"
415
+ lines << "|--------|--------|---------|-----|------|----------|--------|"
416
+ emails.each do |email|
417
+ to_str = Array(email["to"]).first(2).join(", ")
418
+ to_str += ", …" if Array(email["to"]).size > 2
419
+ mode = email["delivery_mode"] || "-"
420
+ duration = email["duration_ms"] ? "#{email["duration_ms"]}ms" : "-"
421
+ status = email["error"] ? "❌ #{email["error"]}" : "✅"
422
+ lines << "| #{email["mailer_class"]} | #{email["action"]} | #{(email["subject"].to_s)[0, 30]} | #{to_str} | #{mode} | #{duration} | #{status} |"
423
+ end
424
+ lines << ""
425
+ end
426
+
427
+ errors = mailer_data["errors"] || []
428
+ if errors.any?
429
+ lines << "### Delivery Errors"
430
+ errors.each do |err|
431
+ lines << "- **#{err["mailer_class"]}##{err["action"]}**: #{err["error"]}"
432
+ end
433
+ lines << ""
434
+ end
435
+
436
+ lines
437
+ end
438
+
388
439
  def self.section_routes(profile)
389
440
  lines = []
390
441
  routes_data = profile.collector_data("routes")
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module MCP
5
+ module Tools
6
+ class QueryMailers
7
+ ALL_FIELDS = %w[time mailer action subject to mode duration status token].freeze
8
+
9
+ class << self
10
+ def call(params)
11
+ limit = params["limit"]&.to_i || 20
12
+ fetch_size = [limit * 10, 1000].min
13
+ profiles = Profiler.storage.list(limit: fetch_size)
14
+
15
+ emails = []
16
+
17
+ profiles.each do |profile|
18
+ mailer_data = profile.collector_data("mailer")
19
+ next unless mailer_data
20
+
21
+ all_emails = Array(mailer_data["emails"]) + Array(mailer_data["errors"])
22
+ all_emails.each do |email|
23
+ entry = email.merge("profile_token" => profile.token, "profile_started_at" => profile.started_at)
24
+
25
+ if params["mailer_class"]
26
+ next unless entry["mailer_class"].to_s.downcase.include?(params["mailer_class"].downcase)
27
+ end
28
+
29
+ if params["action"]
30
+ next unless entry["action"].to_s.downcase.include?(params["action"].downcase)
31
+ end
32
+
33
+ if params["delivery_mode"]
34
+ next unless entry["delivery_mode"] == params["delivery_mode"]
35
+ end
36
+
37
+ if params["has_error"]
38
+ has_err = !entry["error"].nil?
39
+ next unless has_err == (params["has_error"] == true || params["has_error"] == "true")
40
+ end
41
+
42
+ emails << entry
43
+ end
44
+
45
+ break if emails.size >= limit * 2
46
+ end
47
+
48
+ if params["cursor"]
49
+ cutoff = Time.parse(params["cursor"]) rescue nil
50
+ if cutoff
51
+ emails = emails.select do |e|
52
+ started = e["profile_started_at"]
53
+ started && started < cutoff
54
+ end
55
+ end
56
+ end
57
+
58
+ emails = emails.first(limit)
59
+
60
+ [{ type: "text", text: format_mailers_table(emails, params["fields"]&.map(&:to_s), limit) }]
61
+ end
62
+
63
+ private
64
+
65
+ def format_mailers_table(emails, fields, limit)
66
+ return "No mailer deliveries found matching the criteria." if emails.empty?
67
+
68
+ fields ||= ALL_FIELDS
69
+ fields = fields & ALL_FIELDS
70
+
71
+ lines = []
72
+ lines << "# Mailer Deliveries\n"
73
+ lines << "Found #{emails.size} email#{emails.size > 1 ? "s" : ""}:\n"
74
+
75
+ header = fields.map { |f| f.split("_").map(&:capitalize).join(" ") }.join(" | ")
76
+ separator = fields.map { "------" }.join("|")
77
+ lines << "| #{header} |"
78
+ lines << "|#{separator}|"
79
+
80
+ emails.each do |email|
81
+ started = email["profile_started_at"]
82
+ to_list = Array(email["to"]).first(2).join(", ")
83
+ to_list += ", …" if Array(email["to"]).size > 2
84
+ status = email["error"] ? "❌ Error" : "✅"
85
+
86
+ row = fields.map do |f|
87
+ case f
88
+ when "time" then started ? started.strftime("%H:%M:%S") : "-"
89
+ when "mailer" then email["mailer_class"].to_s
90
+ when "action" then email["action"].to_s
91
+ when "subject" then (email["subject"].to_s)[0, 40]
92
+ when "to" then to_list
93
+ when "mode" then email["delivery_mode"].to_s
94
+ when "duration" then email["duration_ms"] ? "#{email["duration_ms"]}ms" : "-"
95
+ when "status" then status
96
+ when "token" then email["profile_token"].to_s
97
+ end
98
+ end
99
+ lines << "| #{row.join(" | ")} |"
100
+ end
101
+
102
+ if emails.size == limit
103
+ last_started = emails.last["profile_started_at"]
104
+ lines << ""
105
+ lines << "*Next cursor: #{last_started.iso8601}*" if last_started
106
+ end
107
+
108
+ lines.join("\n")
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -49,7 +49,8 @@ module Profiler
49
49
  Profiler::Collectors::LogCollector,
50
50
  Profiler::Collectors::RoutesCollector,
51
51
  Profiler::Collectors::I18nCollector,
52
- Profiler::Collectors::EnvCollector
52
+ Profiler::Collectors::EnvCollector,
53
+ Profiler::Collectors::MailerCollector
53
54
  ]
54
55
  end
55
56
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.21.0"
4
+ VERSION = "0.22.0"
5
5
  end
data/lib/profiler.rb CHANGED
@@ -103,6 +103,7 @@ require_relative "profiler/collectors/exception_collector"
103
103
  require_relative "profiler/collectors/routes_collector"
104
104
  require_relative "profiler/collectors/i18n_collector"
105
105
  require_relative "profiler/collectors/env_collector"
106
+ require_relative "profiler/collectors/mailer_collector"
106
107
 
107
108
  require_relative "profiler/env_override_store"
108
109
  require_relative "profiler/railtie" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.21.0
4
+ version: 0.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-02 00:00:00.000000000 Z
11
+ date: 2026-05-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -137,6 +137,7 @@ files:
137
137
  - lib/profiler/collectors/i18n_collector.rb
138
138
  - lib/profiler/collectors/job_collector.rb
139
139
  - lib/profiler/collectors/log_collector.rb
140
+ - lib/profiler/collectors/mailer_collector.rb
140
141
  - lib/profiler/collectors/request_collector.rb
141
142
  - lib/profiler/collectors/routes_collector.rb
142
143
  - lib/profiler/collectors/view_collector.rb
@@ -165,6 +166,7 @@ files:
165
166
  - lib/profiler/mcp/tools/get_profile_dumps.rb
166
167
  - lib/profiler/mcp/tools/get_profile_http.rb
167
168
  - lib/profiler/mcp/tools/query_jobs.rb
169
+ - lib/profiler/mcp/tools/query_mailers.rb
168
170
  - lib/profiler/mcp/tools/query_profiles.rb
169
171
  - lib/profiler/middleware/cors_middleware.rb
170
172
  - lib/profiler/middleware/profiler_middleware.rb