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")); +});