diff --git a/tools/v2/team/mail-to-ticket-converter/docs/performance-notes.md b/tools/v2/team/mail-to-ticket-converter/docs/performance-notes.md
new file mode 100644
index 00000000..99b30c2d
--- /dev/null
+++ b/tools/v2/team/mail-to-ticket-converter/docs/performance-notes.md
@@ -0,0 +1,18 @@
+# Mail-to-Ticket Converter Performance Notes
+
+The helper favors predictable bounded work before any future integration with live mail or ticketing systems.
+
+## Bounded Work
+
+- Batch processing is capped with `maxBatchSize`.
+- Message body, subject, queue, assignee, and list item strings are capped before matching.
+- Recipient, attachment, history, and team-candidate collections are truncated.
+- Attachment contents are not read or decoded.
+
+## Large Mail Handling
+
+Future integrations should pass only mail metadata plus a bounded text excerpt into this helper. Large raw MIME payloads, inline attachments, and provider-specific envelope data should stay outside the UI/tool boundary until a dedicated integration issue defines streaming, scanning, and retention rules.
+
+## Team And History Handling
+
+Team candidate lists and ticket histories can grow quickly. Keep ranking and history display work on the capped arrays returned by the guard helper, and fetch deeper history only after a reviewer opens a specific draft.
diff --git a/tools/v2/team/mail-to-ticket-converter/docs/security-and-performance.md b/tools/v2/team/mail-to-ticket-converter/docs/security-and-performance.md
new file mode 100644
index 00000000..fceb2475
--- /dev/null
+++ b/tools/v2/team/mail-to-ticket-converter/docs/security-and-performance.md
@@ -0,0 +1,27 @@
+# Mail-to-Ticket Converter Security And Performance Notes
+
+This folder is still isolated from the main app. The guard helper documents and enforces constraints for future mail-to-ticket integration without touching inbox, routing, database, auth, wallet, Stellar, notification, or ticket-provider code.
+
+## Threat Assumptions
+
+- Incoming mail content is untrusted, including subject, body, sender, recipients, attachment metadata, history, and team candidate names.
+- Attachment contents are not parsed by this helper. Only metadata is normalized so future integrations can decide whether to fetch or scan files.
+- Ticket drafts should not preserve control characters, bidirectional override characters, script tags, or markup delimiters.
+- Secret-like text can appear in user mail and should be surfaced as a warning instead of silently promoted into a ticket.
+- Large messages, attachment lists, histories, and team candidate lists must be capped before any downstream matching or ticket creation.
+
+## Guarded Inputs
+
+- Sender must be a valid email address.
+- Subject and body are required and length-limited.
+- Recipients, attachment metadata, history events, and team candidates are truncated to bounded collections.
+- Risky attachment extensions and oversized attachments are flagged.
+- Script-like content, tracking-pixel hints, and secret-like key/value text are flagged.
+
+## Non-Goals
+
+- No live mail fetching.
+- No ticket creation or update.
+- No external malware scanning.
+- No database writes.
+- No main application integration.
diff --git a/tools/v2/team/mail-to-ticket-converter/fixtures/hostile-ticket-inputs.json b/tools/v2/team/mail-to-ticket-converter/fixtures/hostile-ticket-inputs.json
new file mode 100644
index 00000000..fe2167ea
--- /dev/null
+++ b/tools/v2/team/mail-to-ticket-converter/fixtures/hostile-ticket-inputs.json
@@ -0,0 +1,55 @@
+{
+ "tool": "mail-to-ticket-converter",
+ "cases": [
+ {
+ "name": "script and secret-like body",
+ "input": {
+ "id": "hostile-001",
+ "subject": "Please convert this ",
+ "body": "password=cleartext token:abc123
",
+ "sender": "Customer@Example.com",
+ "recipients": ["support@example.com"],
+ "queue": "support",
+ "attachments": []
+ },
+ "expectedWarnings": [
+ "active_content_removed",
+ "tracking_pixel_hint",
+ "secret_like_text_detected"
+ ]
+ },
+ {
+ "name": "invalid sender",
+ "input": {
+ "id": "hostile-002",
+ "subject": "Need help",
+ "body": "Create a support ticket from this mail.",
+ "sender": "not-an-email",
+ "recipients": ["support@example.com"]
+ },
+ "expectedErrors": ["invalid_sender"]
+ },
+ {
+ "name": "oversized and risky attachments",
+ "input": {
+ "id": "hostile-003",
+ "subject": "Attachment review",
+ "body": "This mail includes several attachments.",
+ "sender": "sender@example.com",
+ "attachments": [
+ {
+ "fileName": "invoice.pdf",
+ "contentType": "application/pdf",
+ "byteSize": 250000
+ },
+ {
+ "fileName": "run-me.exe",
+ "contentType": "application/octet-stream",
+ "byteSize": 52428800
+ }
+ ]
+ },
+ "expectedWarnings": ["risky_attachment_extension", "oversized_attachment"]
+ }
+ ]
+}
diff --git a/tools/v2/team/mail-to-ticket-converter/services/ticket-draft-guards.mjs b/tools/v2/team/mail-to-ticket-converter/services/ticket-draft-guards.mjs
new file mode 100644
index 00000000..d968bb2e
--- /dev/null
+++ b/tools/v2/team/mail-to-ticket-converter/services/ticket-draft-guards.mjs
@@ -0,0 +1,263 @@
+const DEFAULT_LIMITS = Object.freeze({
+ maxSubjectLength: 160,
+ maxBodyLength: 4000,
+ maxQueueLength: 80,
+ maxAssigneeLength: 80,
+ maxRecipientCount: 25,
+ maxAttachmentCount: 8,
+ maxAttachmentBytes: 10 * 1024 * 1024,
+ maxHistoryEvents: 40,
+ maxTeamCandidates: 25,
+ maxBatchSize: 50,
+});
+
+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/i;
+const RISKY_ATTACHMENT_EXTENSION = /\.(bat|cmd|com|exe|js|jse|msi|ps1|scr|vbs|wsf)$/i;
+const SCRIPT_PATTERN = /<\s*\/?\s*script\b|javascript\s*:/i;
+const TRACKING_PIXEL_PATTERN = /
]*(width|height)\s*=\s*["']?1["']?/i;
+const TOKEN_PATTERN = /\b(api[_-]?key|bearer|secret|token|password)\b\s*[:=]/i;
+const CONTROL_OR_BIDI_PATTERN = /[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g;
+
+function mergeLimits(options = {}) {
+ return {
+ ...DEFAULT_LIMITS,
+ ...(options.limits || {}),
+ };
+}
+
+function hasText(value) {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function sanitizeText(value, maxLength) {
+ if (typeof value !== "string") {
+ return "";
+ }
+
+ const normalized = value
+ .replace(CONTROL_OR_BIDI_PATTERN, " ")
+ .replace(/[<>]/g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ if (normalized.length > maxLength) {
+ return normalized.slice(0, maxLength).trimEnd();
+ }
+
+ return normalized;
+}
+
+function sanitizeEmail(value) {
+ if (typeof value !== "string") {
+ return "";
+ }
+
+ return value.trim().toLowerCase();
+}
+
+function normalizeStringList(values, maxCount, maxLength, warnings, warningPrefix) {
+ if (!Array.isArray(values)) {
+ return [];
+ }
+
+ if (values.length > maxCount) {
+ warnings.push(`${warningPrefix}_truncated`);
+ }
+
+ return values
+ .slice(0, maxCount)
+ .map((value) => sanitizeText(value, maxLength))
+ .filter(Boolean);
+}
+
+function detectContentWarnings(rawSubject, rawBody) {
+ const combined = `${typeof rawSubject === "string" ? rawSubject : ""}\n${
+ typeof rawBody === "string" ? rawBody : ""
+ }`;
+ const warnings = [];
+
+ if (SCRIPT_PATTERN.test(combined)) {
+ warnings.push("active_content_removed");
+ }
+
+ if (TRACKING_PIXEL_PATTERN.test(combined)) {
+ warnings.push("tracking_pixel_hint");
+ }
+
+ if (TOKEN_PATTERN.test(combined)) {
+ warnings.push("secret_like_text_detected");
+ }
+
+ return warnings;
+}
+
+function sanitizeAttachmentMetadata(attachments, limits, warnings) {
+ if (!Array.isArray(attachments)) {
+ return [];
+ }
+
+ if (attachments.length > limits.maxAttachmentCount) {
+ warnings.push("attachments_truncated");
+ }
+
+ return attachments.slice(0, limits.maxAttachmentCount).map((attachment, index) => {
+ const fileName = sanitizeText(attachment?.fileName, 160) || `attachment-${index + 1}`;
+ const contentType = sanitizeText(attachment?.contentType, 120) || "application/octet-stream";
+ const byteSize = Number.isFinite(attachment?.byteSize)
+ ? Math.max(0, Math.floor(attachment.byteSize))
+ : 0;
+ const flags = [];
+
+ if (RISKY_ATTACHMENT_EXTENSION.test(fileName)) {
+ flags.push("risky_extension");
+ warnings.push("risky_attachment_extension");
+ }
+
+ if (byteSize > limits.maxAttachmentBytes) {
+ flags.push("oversized_attachment");
+ warnings.push("oversized_attachment");
+ }
+
+ return {
+ fileName,
+ contentType,
+ byteSize: Math.min(byteSize, limits.maxAttachmentBytes),
+ flags,
+ };
+ });
+}
+
+function sanitizeHistoryEvents(events, limits, warnings) {
+ if (!Array.isArray(events)) {
+ return [];
+ }
+
+ if (events.length > limits.maxHistoryEvents) {
+ warnings.push("history_truncated");
+ }
+
+ return events.slice(0, limits.maxHistoryEvents).map((event) => ({
+ actor: sanitizeText(event?.actor, 80) || "unknown",
+ action: sanitizeText(event?.action, 80) || "noted",
+ at: sanitizeText(event?.at, 40),
+ }));
+}
+
+function rejectIfMissingRequiredFields(input, errors) {
+ if (!hasText(input?.subject)) {
+ errors.push("missing_subject");
+ }
+
+ if (!hasText(input?.body)) {
+ errors.push("missing_body");
+ }
+
+ if (!EMAIL_PATTERN.test(sanitizeEmail(input?.sender))) {
+ errors.push("invalid_sender");
+ }
+}
+
+export function sanitizeMailTicketDraft(input, options = {}) {
+ const limits = mergeLimits(options);
+ const errors = [];
+ const warnings = [];
+
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
+ return {
+ ok: false,
+ errors: ["invalid_input"],
+ warnings,
+ };
+ }
+
+ rejectIfMissingRequiredFields(input, errors);
+ warnings.push(...detectContentWarnings(input.subject, input.body));
+
+ const subjectWasLong =
+ typeof input.subject === "string" && input.subject.length > limits.maxSubjectLength;
+ const bodyWasLong = typeof input.body === "string" && input.body.length > limits.maxBodyLength;
+
+ if (subjectWasLong) {
+ warnings.push("subject_truncated");
+ }
+
+ if (bodyWasLong) {
+ warnings.push("body_truncated");
+ }
+
+ const sender = sanitizeEmail(input.sender);
+ const recipients = normalizeStringList(
+ input.recipients,
+ limits.maxRecipientCount,
+ 120,
+ warnings,
+ "recipients",
+ );
+ const attachments = sanitizeAttachmentMetadata(input.attachments, limits, warnings);
+ const history = sanitizeHistoryEvents(input.history, limits, warnings);
+ const teamCandidates = normalizeStringList(
+ input.teamCandidates,
+ limits.maxTeamCandidates,
+ limits.maxAssigneeLength,
+ warnings,
+ "team_candidates",
+ );
+
+ const value = {
+ id: sanitizeText(input.id, 80),
+ subject: sanitizeText(input.subject, limits.maxSubjectLength),
+ body: sanitizeText(input.body, limits.maxBodyLength),
+ sender,
+ recipients,
+ queue: sanitizeText(input.queue, limits.maxQueueLength) || "support",
+ requestedAssignee: sanitizeText(input.requestedAssignee, limits.maxAssigneeLength),
+ attachments,
+ history,
+ teamCandidates,
+ };
+
+ return {
+ ok: errors.length === 0,
+ errors,
+ warnings: [...new Set(warnings)],
+ value: errors.length === 0 ? value : undefined,
+ };
+}
+
+export function createMailTicketBatchPlan(items, options = {}) {
+ const limits = mergeLimits(options);
+ const source = Array.isArray(items) ? items : [];
+ const processLimit = Math.max(1, Math.floor(limits.maxBatchSize));
+ const selected = source.slice(0, processLimit);
+
+ return {
+ items: selected,
+ processLimit,
+ sourceCount: source.length,
+ skippedCount: Math.max(0, source.length - selected.length),
+ truncated: source.length > selected.length,
+ };
+}
+
+export function guardMailTicketBatch(items, options = {}) {
+ const plan = createMailTicketBatchPlan(items, options);
+ const accepted = [];
+ const rejected = [];
+
+ for (const item of plan.items) {
+ const result = sanitizeMailTicketDraft(item, options);
+ if (result.ok) {
+ accepted.push(result);
+ } else {
+ rejected.push(result);
+ }
+ }
+
+ return {
+ accepted,
+ rejected,
+ plan,
+ };
+}
+
+export const mailTicketGuardDefaults = DEFAULT_LIMITS;
diff --git a/tools/v2/team/mail-to-ticket-converter/tests/security-performance-guards.test.mjs b/tools/v2/team/mail-to-ticket-converter/tests/security-performance-guards.test.mjs
new file mode 100644
index 00000000..f817cf1e
--- /dev/null
+++ b/tools/v2/team/mail-to-ticket-converter/tests/security-performance-guards.test.mjs
@@ -0,0 +1,108 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import test from "node:test";
+import {
+ createMailTicketBatchPlan,
+ guardMailTicketBatch,
+ mailTicketGuardDefaults,
+ sanitizeMailTicketDraft,
+} from "../services/ticket-draft-guards.mjs";
+
+const currentDir = dirname(fileURLToPath(import.meta.url));
+const toolDir = join(currentDir, "..");
+const fixturePath = join(toolDir, "fixtures", "hostile-ticket-inputs.json");
+const securityDocPath = join(toolDir, "docs", "security-and-performance.md");
+const performanceDocPath = join(toolDir, "docs", "performance-notes.md");
+
+async function readJson(path) {
+ const raw = await readFile(path, "utf8");
+ return JSON.parse(raw);
+}
+
+test("fixture cases document hostile mail-to-ticket inputs", async () => {
+ const fixture = await readJson(fixturePath);
+
+ assert.equal(fixture.tool, "mail-to-ticket-converter");
+ assert.ok(fixture.cases.length >= 3);
+
+ for (const item of fixture.cases) {
+ assert.ok(item.name);
+ assert.ok(item.input);
+ assert.ok(item.expectedWarnings || item.expectedErrors);
+ }
+});
+
+test("sanitizes active content and flags secret-like or tracking content", async () => {
+ const fixture = await readJson(fixturePath);
+ const scriptCase = fixture.cases.find((item) => item.name === "script and secret-like body");
+ const result = sanitizeMailTicketDraft(scriptCase.input);
+
+ assert.equal(result.ok, true);
+ assert.equal(result.value.sender, "customer@example.com");
+ assert.ok(!result.value.subject.includes("<"));
+ assert.ok(!result.value.body.includes(">"));
+
+ for (const expected of scriptCase.expectedWarnings) {
+ assert.ok(result.warnings.includes(expected), `${expected} warning is missing`);
+ }
+});
+
+test("rejects malformed required fields before conversion", async () => {
+ const fixture = await readJson(fixturePath);
+ const invalidSenderCase = fixture.cases.find((item) => item.name === "invalid sender");
+ const result = sanitizeMailTicketDraft(invalidSenderCase.input);
+
+ assert.equal(result.ok, false);
+ assert.deepEqual(result.errors, ["invalid_sender"]);
+ assert.equal(result.value, undefined);
+});
+
+test("bounds attachment metadata and flags risky attachments", async () => {
+ const fixture = await readJson(fixturePath);
+ const attachmentCase = fixture.cases.find(
+ (item) => item.name === "oversized and risky attachments",
+ );
+ const result = sanitizeMailTicketDraft(attachmentCase.input);
+
+ assert.equal(result.ok, true);
+ assert.equal(result.value.attachments.length, 2);
+ assert.equal(result.value.attachments[1].byteSize, mailTicketGuardDefaults.maxAttachmentBytes);
+ assert.ok(result.value.attachments[1].flags.includes("risky_extension"));
+ assert.ok(result.value.attachments[1].flags.includes("oversized_attachment"));
+
+ for (const expected of attachmentCase.expectedWarnings) {
+ assert.ok(result.warnings.includes(expected), `${expected} warning is missing`);
+ }
+});
+
+test("caps large batches before validating drafts", () => {
+ const baseDraft = {
+ subject: "Need support",
+ body: "Please convert this message into a ticket.",
+ sender: "sender@example.com",
+ };
+ const drafts = Array.from({ length: 12 }, (_, index) => ({ ...baseDraft, id: `draft-${index}` }));
+ const plan = createMailTicketBatchPlan(drafts, { limits: { maxBatchSize: 5 } });
+ const result = guardMailTicketBatch(drafts, { limits: { maxBatchSize: 5 } });
+
+ assert.equal(plan.sourceCount, 12);
+ assert.equal(plan.items.length, 5);
+ assert.equal(plan.skippedCount, 7);
+ assert.equal(result.accepted.length, 5);
+ assert.equal(result.rejected.length, 0);
+});
+
+test("security and performance docs describe the isolated guard boundary", async () => {
+ const [securityDoc, performanceDoc] = await Promise.all([
+ readFile(securityDocPath, "utf8"),
+ readFile(performanceDocPath, "utf8"),
+ ]);
+
+ assert.ok(securityDoc.includes("Threat Assumptions"));
+ assert.ok(securityDoc.includes("No ticket creation or update"));
+ assert.ok(securityDoc.includes("No main application integration"));
+ assert.ok(performanceDoc.includes("Bounded Work"));
+ assert.ok(performanceDoc.includes("Attachment contents are not read or decoded"));
+});