import nodemailer from "nodemailer";
import type { Logger } from "../logger.js";
import { loadServerConfig, resolveAccountConfig } from "../config.js";
import { ToolError } from "../errors.js";
import type { SendMessageInput } from "../schemas.js";
import { successResponse, errorResponse, type ToolCallResult } from "./response.js";
import { decodeBase64Strict } from "../utils/base64.js";
import { validateEmailAddress } from "../utils/email.js";
import { isSafeFilename } from "../utils/strings.js";
import { byteLength, estimateMessageBytes } from "../utils/sizes.js";
import { enforceRecipientPolicy, normalizeRecipients } from "../policy.js";
interface SendMailInfo {
readonly messageId?: string;
readonly accepted?: string[];
readonly rejected?: string[];
}
/**
* Send or validate an outbound message via SMTP.
*/
export async function handleSendMessage(
input: SendMessageInput,
env: NodeJS.ProcessEnv,
_logger: Logger,
): Promise<ToolCallResult> {
try {
const accountId = input.account_id.toLowerCase();
const { policy } = loadServerConfig(env);
const { config, missing } = resolveAccountConfig(env, accountId);
if (missing.length > 0 || !config) {
throw new ToolError(
"CONFIG_MISSING",
`Account not configured. Missing: ${missing.join(", ")}`,
);
}
const from = input.from ?? config.defaultFrom;
if (!from) {
throw new ToolError("VALIDATION_ERROR", "Sender address is required.");
}
if (!validateEmailAddress(from)) {
throw new ToolError("VALIDATION_ERROR", `Invalid sender address: ${from}`);
}
if (input.reply_to && !validateEmailAddress(input.reply_to)) {
throw new ToolError("VALIDATION_ERROR", `Invalid reply-to address: ${input.reply_to}`);
}
const recipientInput: {
to: string | readonly string[];
cc?: string | readonly string[];
bcc?: string | readonly string[];
} = {
to: input.to,
...(input.cc ? { cc: input.cc } : {}),
...(input.bcc ? { bcc: input.bcc } : {}),
};
const recipients = normalizeRecipients(recipientInput);
enforceRecipientPolicy(policy, recipients);
const textBody = input.text_body ?? "";
const htmlBody = input.html_body ?? "";
if (textBody.length > policy.maxTextChars) {
throw new ToolError(
"POLICY_VIOLATION",
`text_body exceeds ${policy.maxTextChars} characters.`,
);
}
if (htmlBody.length > policy.maxHtmlChars) {
throw new ToolError(
"POLICY_VIOLATION",
`html_body exceeds ${policy.maxHtmlChars} characters.`,
);
}
const attachmentsInput = input.attachments ?? [];
if (attachmentsInput.length > policy.maxAttachments) {
throw new ToolError(
"ATTACHMENT_ERROR",
`Too many attachments (max ${policy.maxAttachments}).`,
);
}
const attachments = attachmentsInput.map((attachment) => {
if (!isSafeFilename(attachment.filename)) {
throw new ToolError(
"ATTACHMENT_ERROR",
`Invalid attachment filename: ${attachment.filename}`,
);
}
let content: Buffer;
try {
content = decodeBase64Strict(attachment.content_base64);
} catch {
throw new ToolError(
"ATTACHMENT_ERROR",
`Invalid base64 content for attachment: ${attachment.filename}`,
);
}
if (content.byteLength > policy.maxAttachmentBytes) {
throw new ToolError(
"ATTACHMENT_ERROR",
`Attachment exceeds ${policy.maxAttachmentBytes} bytes: ${attachment.filename}`,
);
}
return {
filename: attachment.filename,
content,
contentType: attachment.content_type,
};
});
const attachmentBytes = attachments.reduce(
(total, attachment) => total + attachment.content.length,
0,
);
const sizeEstimate = estimateMessageBytes({
subjectBytes: byteLength(input.subject),
textBytes: byteLength(textBody),
htmlBytes: byteLength(htmlBody),
attachmentBytes,
});
if (sizeEstimate > policy.maxMessageBytes) {
throw new ToolError(
"POLICY_VIOLATION",
`Message size exceeds ${policy.maxMessageBytes} bytes.`,
);
}
const dryRun = input.dry_run ?? false;
if (!dryRun && !policy.sendEnabled) {
throw new ToolError("SEND_DISABLED", "Sending is disabled by server policy.");
}
const envelope = {
from,
to: recipients.to,
cc: recipients.cc.length > 0 ? recipients.cc : undefined,
bcc: recipients.bcc.length > 0 ? recipients.bcc : undefined,
};
if (dryRun) {
return successResponse({
summary: `Validated message for ${recipients.to.length + recipients.cc.length + recipients.bcc.length} recipient(s).`,
data: {
account_id: accountId,
dry_run: true,
envelope,
size_bytes_estimate: sizeEstimate,
},
});
}
const transport = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: {
user: config.user,
pass: config.pass,
},
connectionTimeout: policy.connectTimeoutMs,
socketTimeout: policy.socketTimeoutMs,
});
const info = (await transport.sendMail({
from,
to: Array.from(recipients.to),
cc: recipients.cc.length > 0 ? Array.from(recipients.cc) : undefined,
bcc: recipients.bcc.length > 0 ? Array.from(recipients.bcc) : undefined,
replyTo: input.reply_to,
subject: input.subject,
text: textBody || undefined,
html: htmlBody || undefined,
attachments,
})) as SendMailInfo;
return successResponse({
summary: `Sent message to ${recipients.to.length + recipients.cc.length + recipients.bcc.length} recipient(s).`,
data: {
account_id: accountId,
dry_run: false,
envelope,
message_id: info.messageId ?? undefined,
accepted: info.accepted ?? [],
rejected: info.rejected ?? [],
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to send message.";
return errorResponse(message);
}
}