@emailens/mcp 0.12.1 → 0.13.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.
package/dist/index.js CHANGED
@@ -123,7 +123,7 @@ import {
123
123
  toPlainText,
124
124
  checkDesignRetention,
125
125
  EMAIL_CLIENTS as EMAIL_CLIENTS2,
126
- MAX_HTML_SIZE
126
+ MAX_HTML_SIZE as MAX_HTML_SIZE2
127
127
  } from "@emailens/engine";
128
128
 
129
129
  // src/config.ts
@@ -167,6 +167,46 @@ function firstDraft(html) {
167
167
  return html;
168
168
  }
169
169
 
170
+ // src/source.ts
171
+ import { readFile, realpath, stat } from "fs/promises";
172
+ import { extname } from "path";
173
+ import { MAX_HTML_SIZE } from "@emailens/engine";
174
+ var EXTENSIONS = {
175
+ ".html": void 0,
176
+ ".htm": void 0,
177
+ ".mjml": "mjml",
178
+ ".jsx": "jsx",
179
+ ".tsx": "jsx",
180
+ ".vue": "maizzle"
181
+ };
182
+ async function readSource(html, path, format, names = { html: "html", path: "path" }) {
183
+ if (html === void 0 === (path === void 0)) {
184
+ return { ok: false, message: `Pass exactly one of \`${names.html}\` (the source) or \`${names.path}\` (a file holding it).` };
185
+ }
186
+ if (html !== void 0) return { ok: true, html, format };
187
+ let real;
188
+ try {
189
+ real = await realpath(path);
190
+ } catch {
191
+ return { ok: false, message: `No file at ${path}. Pass an absolute path, or the source itself as \`${names.html}\`.` };
192
+ }
193
+ const ext = extname(real).toLowerCase();
194
+ if (!(ext in EXTENSIONS)) {
195
+ return { ok: false, message: `${path} is not an email source. Accepted: ${Object.keys(EXTENSIONS).join(", ")}.` };
196
+ }
197
+ const info = await stat(real);
198
+ if (!info.isFile()) return { ok: false, message: `${path} is not a file.` };
199
+ if (info.size > MAX_HTML_SIZE) {
200
+ return { ok: false, message: `${path} exceeds the ${Math.round(MAX_HTML_SIZE / 1024)}KB limit.` };
201
+ }
202
+ return { ok: true, html: await readFile(real, "utf8"), format: format ?? EXTENSIONS[ext], path: real };
203
+ }
204
+ function withoutSource(prompt, html, format, path) {
205
+ return prompt.replace(`\`\`\`${format}
206
+ ${html}
207
+ \`\`\``, () => `The source is in \`${path}\`. Read and edit it there.`);
208
+ }
209
+
170
210
  // src/hosted.ts
171
211
  async function apiRequest(path, options) {
172
212
  const { apiKey, apiUrl, method = "GET", body, timeoutMs = 3e4 } = options;
@@ -255,8 +295,10 @@ function worstScore(scores) {
255
295
  }
256
296
  var formatEnum = z.enum(["html", "jsx", "mjml", "maizzle"]).optional();
257
297
  var detailEnum = z.enum(["summary", "full"]).optional().describe(
258
- "'summary' (default) returns one entry per problem, listing the clients it affects (roughly 10x smaller). 'full' returns one entry per client with fix snippets."
298
+ "'summary' (default) returns one entry per error or warning, listing the clients it affects, and only counts info findings (safe fallbacks). 'full' returns one entry per client with fix snippets, info included."
259
299
  );
300
+ var htmlParam = z.string().optional().describe("The email source: HTML, or an MJML / Maizzle / React Email template when `format` says so. Pass this or `path`.");
301
+ var pathParam = z.string().optional().describe("Absolute path to the email source file (.html, .htm, .mjml, .jsx, .tsx, .vue), instead of `html`. Prefer it when the email is on disk: the source stays out of your context. The extension sets `format` unless you pass one.");
260
302
  var targetingPolicyEnum = z.enum(["progressive", "strict", "lenient"]).optional().describe(
261
303
  "'progressive' (default) suppresses compatibility false-positives inside client-targeted scopes and only flags deprecated or dangerous hacks. 'strict' treats targeting as non-standard and reports every hack. 'lenient' suppresses false positives and ignores non-fatal targeting warnings."
262
304
  );
@@ -307,11 +349,14 @@ function compatibilityPayload(warnings, detail, clients) {
307
349
  }))
308
350
  };
309
351
  }
310
- const findings = collapseWarnings(scoped);
352
+ const collapsed = collapseWarnings(scoped);
353
+ const findings = collapsed.filter((f) => f.severity !== "info");
354
+ const info = collapsed.length - findings.length;
311
355
  return {
312
356
  warningCount: scoped.length,
313
357
  findingCount: findings.length,
314
- findings
358
+ findings,
359
+ ...info ? { infoFindings: info, infoNote: "Safe fallbacks, not problems. Pass detail:'full' to list them." } : {}
315
360
  };
316
361
  }
317
362
  function positionsApply(format) {
@@ -328,14 +373,14 @@ function withPositions(w) {
328
373
  };
329
374
  }
330
375
  function validateHtmlSize(html) {
331
- if (html.length > MAX_HTML_SIZE) {
332
- return mcpError(`HTML input exceeds ${Math.round(MAX_HTML_SIZE / 1024)}KB limit. Reduce the email size and try again.`);
376
+ if (html.length > MAX_HTML_SIZE2) {
377
+ return mcpError(`HTML input exceeds ${Math.round(MAX_HTML_SIZE2 / 1024)}KB limit. Reduce the email size and try again.`);
333
378
  }
334
379
  return null;
335
380
  }
336
381
  var server = new McpServer({
337
382
  name: "emailens",
338
- version: "0.12.1"
383
+ version: "0.13.0"
339
384
  });
340
385
  server.registerTool(
341
386
  "preview_email",
@@ -343,7 +388,8 @@ server.registerTool(
343
388
  title: "Preview Email",
344
389
  description: `Full email compatibility preview, transforms HTML for ${EMAIL_CLIENTS2.length} email clients (Gmail, Outlook, Apple Mail, Yahoo, Samsung, Thunderbird, HEY, Proton Mail, AOL, Fastmail, Superhuman), analyzes CSS, generates scores, simulates dark mode, checks inbox preview and email size.`,
345
390
  inputSchema: {
346
- html: z.string().describe("The email source: HTML, or an MJML / Maizzle / React Email template when `format` says so"),
391
+ html: htmlParam,
392
+ path: pathParam,
347
393
  clients: z.array(z.string()).optional().describe("Optional client ID filter (e.g. ['gmail-web', 'outlook-windows'])"),
348
394
  format: formatEnum.describe("Source format: 'html' (default), 'jsx' (React Email), 'mjml', or 'maizzle' (HTML template or Vue single-file component). Anything but 'html' is compiled before analysis and sets the syntax of the fix snippets. A pasted Vue file with no format is compiled as maizzle."),
349
395
  targetingPolicy: targetingPolicyEnum
@@ -356,7 +402,10 @@ server.registerTool(
356
402
  openWorldHint: false
357
403
  }
358
404
  },
359
- async ({ html, clients, format, targetingPolicy }) => {
405
+ async ({ html: inline, path, clients, format: given, targetingPolicy }) => {
406
+ const input = await readSource(inline, path, given);
407
+ if (!input.ok) return mcpError(input.message);
408
+ const { html, format } = input;
360
409
  const sizeError = validateHtmlSize(html);
361
410
  if (sizeError) return sizeError;
362
411
  const source = await toHtml(html, format);
@@ -369,7 +418,7 @@ server.registerTool(
369
418
  const filter = clients.filter((c) => validClientIds.has(c));
370
419
  if (filter.length === 0) {
371
420
  return mcpError(
372
- JSON.stringify({ error: "No valid client IDs provided", validClientIds: Array.from(validClientIds) }, null, 2)
421
+ JSON.stringify({ error: "No valid client IDs provided", validClientIds: Array.from(validClientIds) })
373
422
  );
374
423
  }
375
424
  transforms = filter.map((c) => transformForClient(source.html, c, framework));
@@ -413,7 +462,7 @@ server.registerTool(
413
462
  if (!config.isHosted) {
414
463
  result.tip = overallScore < 90 ? "Capture real screenshots to verify these issues across clients. Add EMAILENS_API_KEY to your MCP config \u2192 app.emailens.dev/dashboard/api?utm_source=mcp" : "Checked by Emailens against 21 email clients. Confirm on real Outlook, Gmail and Apple Mail \u2192 emailens.dev/real-devices?utm_source=mcp";
415
464
  }
416
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
465
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
417
466
  }
418
467
  );
419
468
  server.registerTool(
@@ -422,7 +471,8 @@ server.registerTool(
422
471
  title: "Analyze Email",
423
472
  description: "Fix error and warning findings. Info findings are not problems (the email falls back safely there): never remove responsive (@media) or dark-mode CSS to clear them. Quick CSS compatibility analysis, returns per-client scores and one finding per problem, listing the clients it affects. For HTML input each finding carries loc (line, column, offset) and alsoAtLines, so you can edit the exact source. Narrow with clients, or pass detail:'full' for the per-client breakdown with fix snippets. Use audit_email for a full quality report (spam, links, a11y, images, etc.).",
424
473
  inputSchema: {
425
- html: z.string().describe("The email source: HTML, or an MJML / Maizzle / React Email template when `format` says so"),
474
+ html: htmlParam,
475
+ path: pathParam,
426
476
  format: formatEnum.describe("Source format: 'html' (default), 'jsx' (React Email), 'mjml', or 'maizzle' (HTML template or Vue single-file component). Anything but 'html' is compiled before analysis and sets the syntax of the fix snippets. A pasted Vue file with no format is compiled as maizzle."),
427
477
  detail: detailEnum,
428
478
  clients: clientsParam,
@@ -436,7 +486,10 @@ server.registerTool(
436
486
  openWorldHint: false
437
487
  }
438
488
  },
439
- async ({ html, format, detail, clients, targetingPolicy }) => {
489
+ async ({ html: inline, path, format: given, detail, clients, targetingPolicy }) => {
490
+ const input = await readSource(inline, path, given);
491
+ if (!input.ok) return mcpError(input.message);
492
+ const { html, format } = input;
440
493
  const sizeError = validateHtmlSize(html);
441
494
  if (sizeError) return sizeError;
442
495
  const clientError = validateClients(clients);
@@ -458,9 +511,7 @@ server.registerTool(
458
511
  overallScore,
459
512
  scores,
460
513
  ...compatibilityPayload(warnings, detail, clients)
461
- },
462
- null,
463
- 2
514
+ }
464
515
  )
465
516
  }
466
517
  ]
@@ -473,7 +524,8 @@ server.registerTool(
473
524
  title: "Audit Email",
474
525
  description: "Fix error and warning findings, and restore anything listed in designRemovals. Info findings are not problems (the email falls back safely there): never remove responsive (@media), dark-mode CSS or rounded corners to clear them. designRemovals compares this version with the first one audited for the same email (or with previousHtml): design the edits deleted, such as @media rules, dark mode, corners, gradients, VML fallbacks or images. Comprehensive email quality audit: CSS compatibility, spam scoring, link validation, accessibility, images, inbox preview, size (Gmail clipping), template variables, content overflow, visual fallbacks, dark-mode and mobile text contrast, design consistency, structural faults in Outlook-only VML (which lives inside conditional comments and is invisible to every other check), style survival: CSS a client parses correctly and then discards, such as a stylesheet past Gmail's 16 KB ceiling or every rule after a `}}`, which no support matrix can express, and client targeting: HowToTarget hacks such as Gmail u+.body or MSO conditionals, which do not score as css-hack. For HTML input, findings tied to a specific element carry loc (line, column, offset) so you can edit the exact source. Compatibility is collapsed to one finding per problem listing the clients it affects; pass detail:'full' for the per-client breakdown with fix snippets. Use skip to omit checks, targetingPolicy to change how targeted scopes affect scores, and clients to narrow which clients are reported.",
475
526
  inputSchema: {
476
- html: z.string().describe("The email source: HTML, or an MJML / Maizzle / React Email template when `format` says so"),
527
+ html: htmlParam,
528
+ path: pathParam,
477
529
  format: formatEnum.describe("Source format: 'html' (default), 'jsx' (React Email), 'mjml', or 'maizzle' (HTML template or Vue single-file component). Anything but 'html' is compiled before analysis and sets the syntax of the fix snippets. A pasted Vue file with no format is compiled as maizzle."),
478
530
  skip: z.array(skipEnum).optional().describe("Checks to skip (e.g. ['spam', 'images'])"),
479
531
  detail: detailEnum,
@@ -490,7 +542,10 @@ server.registerTool(
490
542
  openWorldHint: false
491
543
  }
492
544
  },
493
- async ({ html, format, skip, detail, clients, targetingPolicy, emailType, previousHtml }) => {
545
+ async ({ html: inline, path, format: given, skip, detail, clients, targetingPolicy, emailType, previousHtml }) => {
546
+ const input = await readSource(inline, path, given);
547
+ if (!input.ok) return mcpError(input.message);
548
+ const { html, format } = input;
494
549
  const sizeError = validateHtmlSize(html);
495
550
  if (sizeError) return sizeError;
496
551
  const clientError = validateClients(clients);
@@ -533,9 +588,7 @@ server.registerTool(
533
588
  design: report.design,
534
589
  styleSurvival: report.styleSurvival,
535
590
  targeting: report.targeting
536
- },
537
- null,
538
- 2
591
+ }
539
592
  )
540
593
  }
541
594
  ]
@@ -548,7 +601,8 @@ server.registerTool(
548
601
  title: "Fix Email",
549
602
  description: "Generate a structured fix prompt for email compatibility issues. Returns markdown with the original code, detected issues (CSS or structural), fix snippets, and format-specific instructions. Use after preview_email or analyze_email.",
550
603
  inputSchema: {
551
- html: z.string().describe("The email source: HTML, or an MJML / Maizzle / React Email template when `format` says so: the source to fix"),
604
+ html: htmlParam,
605
+ path: pathParam,
552
606
  format: formatEnum.describe("Source format: 'html' (default), 'jsx' (React Email), 'mjml', or 'maizzle' (HTML template or Vue single-file component). Anything but 'html' is compiled before analysis and sets the syntax of the fix snippets. A pasted Vue file with no format is compiled as maizzle."),
553
607
  scope: z.enum(["all", "current"]).optional().describe("'all' (default) or 'current' (requires selectedClientId)"),
554
608
  selectedClientId: z.string().optional().describe("Client ID to scope fixes to (e.g. 'outlook-windows')")
@@ -561,7 +615,10 @@ server.registerTool(
561
615
  openWorldHint: false
562
616
  }
563
617
  },
564
- async ({ html, format, scope, selectedClientId }) => {
618
+ async ({ html: inline, path, format: given, scope, selectedClientId }) => {
619
+ const input = await readSource(inline, path, given);
620
+ if (!input.ok) return mcpError(input.message);
621
+ const { html, format } = input;
565
622
  const sizeError = validateHtmlSize(html);
566
623
  if (sizeError) return sizeError;
567
624
  const source = await toHtml(html, format);
@@ -577,7 +634,7 @@ server.registerTool(
577
634
  content: [
578
635
  {
579
636
  type: "text",
580
- text: JSON.stringify({ message: "No compatibility issues found \u2014 nothing to fix.", overallScore: 100 }, null, 2)
637
+ text: JSON.stringify({ message: "No compatibility issues found \u2014 nothing to fix.", overallScore: 100 })
581
638
  }
582
639
  ]
583
640
  };
@@ -614,12 +671,10 @@ server.registerTool(
614
671
  truncated: estimate.truncated
615
672
  },
616
673
  note: "Apply the fixes in the prompt below. Structural issues require HTML restructuring (tables, VML conditionals), not just CSS changes."
617
- },
618
- null,
619
- 2
674
+ }
620
675
  )
621
676
  },
622
- { type: "text", text: prompt }
677
+ { type: "text", text: input.path ? withoutSource(prompt, html, inputFormat, input.path) : prompt }
623
678
  ]
624
679
  };
625
680
  }
@@ -650,9 +705,7 @@ server.registerTool(
650
705
  engine: c.engine,
651
706
  darkModeSupport: c.darkModeSupport,
652
707
  ...c.deprecated ? { deprecated: c.deprecated } : {}
653
- })),
654
- null,
655
- 2
708
+ }))
656
709
  )
657
710
  }
658
711
  ]
@@ -665,8 +718,10 @@ server.registerTool(
665
718
  title: "Diff Emails",
666
719
  description: "Compare two email HTML versions, shows score changes, fixed issues, and newly introduced issues per client. Use after making fixes to verify improvements.",
667
720
  inputSchema: {
668
- before: z.string().describe("Original email source"),
669
- after: z.string().describe("Modified email source, in the same format"),
721
+ before: z.string().optional().describe("Original email source. Pass this or `beforePath`."),
722
+ after: z.string().optional().describe("Modified email source, in the same format. Pass this or `afterPath`."),
723
+ beforePath: z.string().optional().describe("Absolute path to the original source file, instead of `before`"),
724
+ afterPath: z.string().optional().describe("Absolute path to the modified source file, instead of `after`"),
670
725
  format: formatEnum.describe("Source format: 'html' (default), 'jsx' (React Email), 'mjml', or 'maizzle' (HTML template or Vue single-file component). Anything but 'html' is compiled before analysis and sets the syntax of the fix snippets. A pasted Vue file with no format is compiled as maizzle.")
671
726
  },
672
727
  annotations: {
@@ -677,7 +732,14 @@ server.registerTool(
677
732
  openWorldHint: false
678
733
  }
679
734
  },
680
- async ({ before, after, format }) => {
735
+ async ({ before: beforeInline, after: afterInline, beforePath, afterPath, format: given }) => {
736
+ const beforeInput = await readSource(beforeInline, beforePath, given, { html: "before", path: "beforePath" });
737
+ if (!beforeInput.ok) return mcpError(beforeInput.message);
738
+ const afterInput = await readSource(afterInline, afterPath, given, { html: "after", path: "afterPath" });
739
+ if (!afterInput.ok) return mcpError(afterInput.message);
740
+ const before = beforeInput.html;
741
+ const after = afterInput.html;
742
+ const format = afterInput.format ?? beforeInput.format;
681
743
  const beforeSizeError = validateHtmlSize(before);
682
744
  if (beforeSizeError) return beforeSizeError;
683
745
  const afterSizeError = validateHtmlSize(after);
@@ -729,9 +791,7 @@ server.registerTool(
729
791
  clientsUnchanged,
730
792
  avgScoreDelta: results.length > 0 ? Math.round(totalDelta / results.length * 10) / 10 : 0
731
793
  }
732
- },
733
- null,
734
- 2
794
+ }
735
795
  )
736
796
  }
737
797
  ]
@@ -762,7 +822,7 @@ server.registerTool(
762
822
  }
763
823
  const report = await _checkDeliverability(domain);
764
824
  return {
765
- content: [{ type: "text", text: JSON.stringify(report, null, 2) }]
825
+ content: [{ type: "text", text: JSON.stringify(report) }]
766
826
  };
767
827
  } catch (err) {
768
828
  const msg = err.message ?? String(err);
@@ -781,7 +841,8 @@ server.registerTool(
781
841
  title: "Capture Screenshots",
782
842
  description: "Capture real email screenshots across 21 clients (Gmail, Outlook, Apple Mail, etc.) with light and dark mode variants. Screenshots are rendered in real browsers and hosted on CDN. Requires EMAILENS_API_KEY env var \u2014 free plan at emailens.dev?utm_source=mcp.",
783
843
  inputSchema: {
784
- html: z.string().describe("The email source: HTML, or an MJML / Maizzle / React Email template when `format` says so"),
844
+ html: htmlParam,
845
+ path: pathParam,
785
846
  format: formatEnum.describe("Input format"),
786
847
  clients: z.array(z.string()).optional().describe("Filter to specific client IDs"),
787
848
  modes: z.array(z.enum(["light", "dark"])).optional().describe("Screenshot modes (default: ['light'])"),
@@ -795,7 +856,10 @@ server.registerTool(
795
856
  openWorldHint: true
796
857
  }
797
858
  },
798
- async ({ html, format, clients, modes, title: title2 }) => {
859
+ async ({ html: inline, path, format: given, clients, modes, title: title2 }) => {
860
+ const input = await readSource(inline, path, given);
861
+ if (!input.ok) return mcpError(input.message);
862
+ const { html, format } = input;
799
863
  if (!config.isHosted) {
800
864
  return noApiKeyError("capture_screenshots");
801
865
  }
@@ -836,9 +900,7 @@ server.registerTool(
836
900
  captured: [],
837
901
  failed: [],
838
902
  note: `Preview created but screenshot capture failed: ${screenshotResult.error}. View the full preview at the URL above.`
839
- },
840
- null,
841
- 2
903
+ }
842
904
  )
843
905
  }
844
906
  ]
@@ -857,7 +919,7 @@ server.registerTool(
857
919
  if (ss.failed?.length > 0) {
858
920
  result.note = `${ss.failed.length} of ${(ss.captured?.length ?? 0) + ss.failed.length} screenshots failed: ${ss.failed.join(", ")}`;
859
921
  }
860
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
922
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
861
923
  }
862
924
  );
863
925
  server.registerTool(
@@ -866,7 +928,8 @@ server.registerTool(
866
928
  title: "Share Preview",
867
929
  description: "Create a shareable link for an email preview. Recipients see the full analysis without needing an account. Requires EMAILENS_API_KEY env var and Dev plan ($9/mo).",
868
930
  inputSchema: {
869
- html: z.string().describe("The email source: HTML, or an MJML / Maizzle / React Email template when `format` says so"),
931
+ html: htmlParam,
932
+ path: pathParam,
870
933
  title: z.string().optional().describe("Display title for the share page"),
871
934
  format: formatEnum.describe("Input format")
872
935
  },
@@ -878,7 +941,10 @@ server.registerTool(
878
941
  openWorldHint: true
879
942
  }
880
943
  },
881
- async ({ html, title: title2, format }) => {
944
+ async ({ html: inline, path, title: title2, format: given }) => {
945
+ const input = await readSource(inline, path, given);
946
+ if (!input.ok) return mcpError(input.message);
947
+ const { html, format } = input;
882
948
  if (!config.isHosted) {
883
949
  return noApiKeyError("share_preview", "Shareable links require the Dev plan ($9/mo).");
884
950
  }
@@ -898,9 +964,7 @@ server.registerTool(
898
964
  {
899
965
  shareUrl: `${config.apiUrl}/share/${result.data.id}`,
900
966
  expiresAt: result.data.expiresAt
901
- },
902
- null,
903
- 2
967
+ }
904
968
  )
905
969
  }
906
970
  ]
@@ -938,7 +1002,7 @@ server.registerTool(
938
1002
  content: [
939
1003
  {
940
1004
  type: "text",
941
- text: JSON.stringify(result.data, null, 2)
1005
+ text: JSON.stringify(result.data)
942
1006
  }
943
1007
  ]
944
1008
  };
@@ -984,7 +1048,7 @@ server.registerTool(
984
1048
  content: [
985
1049
  {
986
1050
  type: "text",
987
- text: JSON.stringify(result.data?.message ?? result.data, null, 2)
1051
+ text: JSON.stringify(result.data?.message ?? result.data)
988
1052
  }
989
1053
  ]
990
1054
  };
@@ -1022,7 +1086,7 @@ server.registerTool(
1022
1086
  content: [
1023
1087
  {
1024
1088
  type: "text",
1025
- text: JSON.stringify(result.data, null, 2)
1089
+ text: JSON.stringify(result.data)
1026
1090
  }
1027
1091
  ]
1028
1092
  };
@@ -1059,7 +1123,7 @@ server.registerTool(
1059
1123
  content: [
1060
1124
  {
1061
1125
  type: "text",
1062
- text: JSON.stringify(result.data, null, 2)
1126
+ text: JSON.stringify(result.data)
1063
1127
  }
1064
1128
  ]
1065
1129
  };