Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions tools/v2/team/mail-to-ticket-converter/docs/performance-notes.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"tool": "mail-to-ticket-converter",
"cases": [
{
"name": "script and secret-like body",
"input": {
"id": "hostile-001",
"subject": "Please convert this <script>alert(1)</script>",
"body": "password=cleartext token:abc123 <img width=\"1\" height=\"1\" src=\"https://tracker.example/pixel\">",
"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"]
}
]
}
Original file line number Diff line number Diff line change
@@ -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 = /<img\b[^>]*(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;
Loading