Initial commit with translated description

This commit is contained in:
2026-03-29 09:45:51 +08:00
commit ece0dd5579
7 changed files with 1698 additions and 0 deletions

141
scripts/config.js Normal file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env node
const path = require('path');
const os = require('os');
const fs = require('fs');
const dotenv = require('dotenv');
// Config file locations
const PRIMARY_ENV_PATH = path.join(os.homedir(), '.config', 'imap-smtp-email', '.env');
const FALLBACK_ENV_PATH = path.resolve(__dirname, '../.env');
// Find the .env file: primary location first, then fallback
function findEnvPath() {
if (fs.existsSync(PRIMARY_ENV_PATH)) return PRIMARY_ENV_PATH;
if (fs.existsSync(FALLBACK_ENV_PATH)) return FALLBACK_ENV_PATH;
return null;
}
// Parse and strip --account <name> from process.argv
// After this, process.argv[2] is always the command
function parseAccountFromArgv(argv) {
const args = argv.slice(2);
const idx = args.indexOf('--account');
if (idx !== -1 && idx + 1 < args.length) {
const name = args[idx + 1];
args.splice(idx, 2);
return { accountName: name, remainingArgs: args };
}
return { accountName: null, remainingArgs: args };
}
// Build config object from environment variables
// prefix: uppercase account name (e.g., 'WORK') or null for default
function buildConfig(env, prefix) {
const p = prefix ? `${prefix}_` : '';
// Account existence check for named accounts
if (prefix && !env[`${p}IMAP_HOST`]) {
console.error(`Error: Account "${prefix.toLowerCase()}" not found in config. Check ~/.config/imap-smtp-email/.env`);
process.exit(1);
}
return {
imap: {
host: env[`${p}IMAP_HOST`] || '127.0.0.1',
port: parseInt(env[`${p}IMAP_PORT`]) || 1143,
user: env[`${p}IMAP_USER`],
pass: env[`${p}IMAP_PASS`],
tls: env[`${p}IMAP_TLS`] === 'true',
rejectUnauthorized: env[`${p}IMAP_REJECT_UNAUTHORIZED`] !== 'false',
mailbox: env[`${p}IMAP_MAILBOX`] || 'INBOX',
},
smtp: {
host: env[`${p}SMTP_HOST`],
port: parseInt(env[`${p}SMTP_PORT`]) || 587,
user: env[`${p}SMTP_USER`],
pass: env[`${p}SMTP_PASS`],
secure: env[`${p}SMTP_SECURE`] === 'true',
from: env[`${p}SMTP_FROM`] || env[`${p}SMTP_USER`],
rejectUnauthorized: env[`${p}SMTP_REJECT_UNAUTHORIZED`] !== 'false',
},
allowedReadDirs: (env.ALLOWED_READ_DIRS || '').split(',').map(d => d.trim()).filter(Boolean),
allowedWriteDirs: (env.ALLOWED_WRITE_DIRS || '').split(',').map(d => d.trim()).filter(Boolean),
};
}
// List all configured accounts from .env file
// Returns { accounts: Array, configPath: String|null }
function listAccounts() {
const envPath = findEnvPath();
if (!envPath) {
return { accounts: [], configPath: null };
}
// Parse the env file fresh to get all account prefixes
const dotenvResult = dotenv.config({ path: envPath });
const env = dotenvResult.parsed || {};
const accounts = [];
const seen = new Set();
// Check for default account (no prefix)
if (env.IMAP_HOST) {
accounts.push(createAccountObject(env, '', 'default'));
seen.add('default');
}
// Scan for named accounts (pattern: XXX_IMAP_HOST)
for (const key of Object.keys(env)) {
const match = key.match(/^([A-Z0-9]+)_IMAP_HOST$/);
if (match) {
const prefix = match[1];
const name = prefix.toLowerCase();
if (!seen.has(name)) {
accounts.push(createAccountObject(env, prefix + '_', name));
seen.add(name);
}
}
}
return { accounts, configPath: envPath };
}
// Create an account object from env variables
function createAccountObject(env, prefix, name) {
const p = prefix;
return {
name,
email: env[`${p}IMAP_USER`] || env[`${p}SMTP_FROM`] || '-',
imapHost: env[`${p}IMAP_HOST`] || '-',
smtpHost: env[`${p}SMTP_HOST`] || '-',
isComplete: isAccountComplete(env, prefix)
};
}
// Check if an account has all required configuration
function isAccountComplete(env, prefix) {
const p = prefix;
return !!(
env[`${p}IMAP_HOST`] &&
env[`${p}IMAP_USER`] &&
env[`${p}IMAP_PASS`] &&
env[`${p}SMTP_HOST`]
);
}
// --- Module initialization ---
const envPath = findEnvPath();
if (envPath) {
dotenv.config({ path: envPath });
}
const { accountName, remainingArgs } = parseAccountFromArgv(process.argv);
const prefix = accountName ? accountName.toUpperCase() : null;
// Strip --account from process.argv so callers see command at argv[2]
process.argv = [process.argv[0], process.argv[1], ...remainingArgs];
const config = buildConfig(process.env, prefix);
module.exports = config;
module.exports.listAccounts = listAccounts;

644
scripts/imap.js Normal file
View File

@@ -0,0 +1,644 @@
#!/usr/bin/env node
/**
* IMAP Email CLI
* Works with any standard IMAP server (Gmail, ProtonMail Bridge, Fastmail, etc.)
* Supports IMAP ID extension (RFC 2971) for 163.com and other servers
*/
const Imap = require('imap');
const { simpleParser } = require('mailparser');
const path = require('path');
const fs = require('fs');
const os = require('os');
const config = require('./config');
function validateWritePath(dirPath) {
if (!config.allowedWriteDirs.length) {
throw new Error('ALLOWED_WRITE_DIRS not set in .env. Attachment download is disabled.');
}
const resolved = path.resolve(dirPath.replace(/^~/, os.homedir()));
const allowedDirs = config.allowedWriteDirs.map(d =>
path.resolve(d.replace(/^~/, os.homedir()))
);
const allowed = allowedDirs.some(dir =>
resolved === dir || resolved.startsWith(dir + path.sep)
);
if (!allowed) {
throw new Error(`Access denied: '${dirPath}' is outside allowed write directories`);
}
return resolved;
}
function sanitizeFilename(filename) {
return path.basename(filename).replace(/\.\./g, '').replace(/^[./\\]/, '') || 'attachment';
}
// IMAP ID information for 163.com compatibility
const IMAP_ID = {
name: 'openclaw',
version: '0.0.1',
vendor: 'netease',
'support-email': 'kefu@188.com'
};
const DEFAULT_MAILBOX = config.imap.mailbox;
// Parse command-line arguments
function parseArgs() {
const args = process.argv.slice(2);
const command = args[0];
const options = {};
const positional = [];
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--')) {
const key = arg.slice(2);
const value = args[i + 1];
options[key] = value || true;
if (value && !value.startsWith('--')) i++;
} else {
positional.push(arg);
}
}
return { command, options, positional };
}
// Create IMAP connection config
function createImapConfig() {
return {
user: config.imap.user,
password: config.imap.pass,
host: config.imap.host,
port: config.imap.port,
tls: config.imap.tls,
tlsOptions: {
rejectUnauthorized: config.imap.rejectUnauthorized,
},
connTimeout: 10000,
authTimeout: 10000,
};
}
// Connect to IMAP server with ID support
async function connect() {
const imapConfig = createImapConfig();
if (!imapConfig.user || !imapConfig.password) {
throw new Error('Missing IMAP user or password. Check your config at ~/.config/imap-smtp-email/.env');
}
return new Promise((resolve, reject) => {
const imap = new Imap(imapConfig);
imap.once('ready', () => {
// Send IMAP ID command for 163.com compatibility
if (typeof imap.id === 'function') {
imap.id(IMAP_ID, (err) => {
if (err) {
console.warn('Warning: IMAP ID command failed:', err.message);
}
resolve(imap);
});
} else {
// ID not supported, continue without it
resolve(imap);
}
});
imap.once('error', (err) => {
reject(new Error(`IMAP connection failed: ${err.message}`));
});
imap.connect();
});
}
// Open mailbox and return promise
function openBox(imap, mailbox, readOnly = false) {
return new Promise((resolve, reject) => {
imap.openBox(mailbox, readOnly, (err, box) => {
if (err) reject(err);
else resolve(box);
});
});
}
// Search for messages
function searchMessages(imap, criteria, fetchOptions) {
return new Promise((resolve, reject) => {
imap.search(criteria, (err, results) => {
if (err) {
reject(err);
return;
}
if (!results || results.length === 0) {
resolve([]);
return;
}
const fetch = imap.fetch(results, fetchOptions);
const messages = [];
fetch.on('message', (msg) => {
const parts = [];
msg.on('body', (stream, info) => {
let buffer = '';
stream.on('data', (chunk) => {
buffer += chunk.toString('utf8');
});
stream.once('end', () => {
parts.push({ which: info.which, body: buffer });
});
});
msg.once('attributes', (attrs) => {
parts.forEach((part) => {
part.attributes = attrs;
});
});
msg.once('end', () => {
if (parts.length > 0) {
messages.push(parts[0]);
}
});
});
fetch.once('error', (err) => {
reject(err);
});
fetch.once('end', () => {
resolve(messages);
});
});
});
}
// Parse email from raw buffer
async function parseEmail(bodyStr, includeAttachments = false) {
const parsed = await simpleParser(bodyStr);
return {
from: parsed.from?.text || 'Unknown',
to: parsed.to?.text,
subject: parsed.subject || '(no subject)',
date: parsed.date,
text: parsed.text,
html: parsed.html,
snippet: parsed.text
? parsed.text.slice(0, 200)
: (parsed.html ? parsed.html.slice(0, 200).replace(/<[^>]*>/g, '') : ''),
attachments: parsed.attachments?.map((a) => ({
filename: a.filename,
contentType: a.contentType,
size: a.size,
content: includeAttachments ? a.content : undefined,
cid: a.cid,
})),
};
}
// Check for new/unread emails
async function checkEmails(mailbox = DEFAULT_MAILBOX, limit = 10, recentTime = null, unreadOnly = false) {
const imap = await connect();
try {
await openBox(imap, mailbox);
// Build search criteria
const searchCriteria = unreadOnly ? ['UNSEEN'] : ['ALL'];
if (recentTime) {
const sinceDate = parseRelativeTime(recentTime);
searchCriteria.push(['SINCE', sinceDate]);
}
// Fetch messages sorted by date (newest first)
const fetchOptions = {
bodies: [''],
markSeen: false,
};
const messages = await searchMessages(imap, searchCriteria, fetchOptions);
// Sort by date (newest first) - parse from message attributes
const sortedMessages = messages.sort((a, b) => {
const dateA = a.attributes.date ? new Date(a.attributes.date) : new Date(0);
const dateB = b.attributes.date ? new Date(b.attributes.date) : new Date(0);
return dateB - dateA;
}).slice(0, limit);
const results = [];
for (const item of sortedMessages) {
const bodyStr = item.body;
const parsed = await parseEmail(bodyStr);
results.push({
uid: item.attributes.uid,
...parsed,
flags: item.attributes.flags,
});
}
return results;
} finally {
imap.end();
}
}
// Fetch full email by UID
async function fetchEmail(uid, mailbox = DEFAULT_MAILBOX) {
const imap = await connect();
try {
await openBox(imap, mailbox);
const searchCriteria = [['UID', uid]];
const fetchOptions = {
bodies: [''],
markSeen: false,
};
const messages = await searchMessages(imap, searchCriteria, fetchOptions);
if (messages.length === 0) {
throw new Error(`Message UID ${uid} not found`);
}
const item = messages[0];
const parsed = await parseEmail(item.body);
return {
uid: item.attributes.uid,
...parsed,
flags: item.attributes.flags,
};
} finally {
imap.end();
}
}
// Download attachments from email
async function downloadAttachments(uid, mailbox = DEFAULT_MAILBOX, outputDir = '.', specificFilename = null) {
const imap = await connect();
try {
await openBox(imap, mailbox);
const searchCriteria = [['UID', uid]];
const fetchOptions = {
bodies: [''],
markSeen: false,
};
const messages = await searchMessages(imap, searchCriteria, fetchOptions);
if (messages.length === 0) {
throw new Error(`Message UID ${uid} not found`);
}
const item = messages[0];
const parsed = await parseEmail(item.body, true);
if (!parsed.attachments || parsed.attachments.length === 0) {
return {
uid,
downloaded: [],
message: 'No attachments found',
};
}
// Create output directory if it doesn't exist
const resolvedDir = validateWritePath(outputDir);
if (!fs.existsSync(resolvedDir)) {
fs.mkdirSync(resolvedDir, { recursive: true });
}
const downloaded = [];
for (const attachment of parsed.attachments) {
// If specificFilename is provided, only download matching attachment
if (specificFilename && attachment.filename !== specificFilename) {
continue;
}
if (attachment.content) {
const filePath = path.join(resolvedDir, sanitizeFilename(attachment.filename));
fs.writeFileSync(filePath, attachment.content);
downloaded.push({
filename: attachment.filename,
path: filePath,
size: attachment.size,
});
}
}
// If specific file was requested but not found
if (specificFilename && downloaded.length === 0) {
const availableFiles = parsed.attachments.map(a => a.filename).join(', ');
return {
uid,
downloaded: [],
message: `File "${specificFilename}" not found. Available attachments: ${availableFiles}`,
};
}
return {
uid,
downloaded,
message: `Downloaded ${downloaded.length} attachment(s)`,
};
} finally {
imap.end();
}
}
// Parse relative time (e.g., "2h", "30m", "7d") to Date
function parseRelativeTime(timeStr) {
const match = timeStr.match(/^(\d+)(m|h|d)$/);
if (!match) {
throw new Error('Invalid time format. Use: 30m, 2h, 7d');
}
const value = parseInt(match[1]);
const unit = match[2];
const now = new Date();
switch (unit) {
case 'm': // minutes
return new Date(now.getTime() - value * 60 * 1000);
case 'h': // hours
return new Date(now.getTime() - value * 60 * 60 * 1000);
case 'd': // days
return new Date(now.getTime() - value * 24 * 60 * 60 * 1000);
default:
throw new Error('Unknown time unit');
}
}
// Search emails with criteria
async function searchEmails(options) {
const imap = await connect();
try {
const mailbox = options.mailbox || DEFAULT_MAILBOX;
await openBox(imap, mailbox);
const criteria = [];
if (options.unseen) criteria.push('UNSEEN');
if (options.seen) criteria.push('SEEN');
if (options.from) criteria.push(['FROM', options.from]);
if (options.subject) criteria.push(['SUBJECT', options.subject]);
// Handle relative time (--recent 2h)
if (options.recent) {
const sinceDate = parseRelativeTime(options.recent);
criteria.push(['SINCE', sinceDate]);
} else {
// Handle absolute dates
if (options.since) criteria.push(['SINCE', options.since]);
if (options.before) criteria.push(['BEFORE', options.before]);
}
// Default to all if no criteria
if (criteria.length === 0) criteria.push('ALL');
const fetchOptions = {
bodies: [''],
markSeen: false,
};
const messages = await searchMessages(imap, criteria, fetchOptions);
const limit = parseInt(options.limit) || 20;
const results = [];
// Sort by date (newest first)
const sortedMessages = messages.sort((a, b) => {
const dateA = a.attributes.date ? new Date(a.attributes.date) : new Date(0);
const dateB = b.attributes.date ? new Date(b.attributes.date) : new Date(0);
return dateB - dateA;
}).slice(0, limit);
for (const item of sortedMessages) {
const parsed = await parseEmail(item.body);
results.push({
uid: item.attributes.uid,
...parsed,
flags: item.attributes.flags,
});
}
return results;
} finally {
imap.end();
}
}
// Mark message(s) as read
async function markAsRead(uids, mailbox = DEFAULT_MAILBOX) {
const imap = await connect();
try {
await openBox(imap, mailbox);
return new Promise((resolve, reject) => {
imap.addFlags(uids, '\\Seen', (err) => {
if (err) reject(err);
else resolve({ success: true, uids, action: 'marked as read' });
});
});
} finally {
imap.end();
}
}
// Mark message(s) as unread
async function markAsUnread(uids, mailbox = DEFAULT_MAILBOX) {
const imap = await connect();
try {
await openBox(imap, mailbox);
return new Promise((resolve, reject) => {
imap.delFlags(uids, '\\Seen', (err) => {
if (err) reject(err);
else resolve({ success: true, uids, action: 'marked as unread' });
});
});
} finally {
imap.end();
}
}
// List all mailboxes
async function listMailboxes() {
const imap = await connect();
try {
return new Promise((resolve, reject) => {
imap.getBoxes((err, boxes) => {
if (err) reject(err);
else resolve(formatMailboxTree(boxes));
});
});
} finally {
imap.end();
}
}
// Format mailbox tree recursively
function formatMailboxTree(boxes, prefix = '') {
const result = [];
for (const [name, info] of Object.entries(boxes)) {
const fullName = prefix ? `${prefix}${info.delimiter}${name}` : name;
result.push({
name: fullName,
delimiter: info.delimiter,
attributes: info.attribs,
});
if (info.children) {
result.push(...formatMailboxTree(info.children, fullName));
}
}
return result;
}
// Display accounts in a formatted table
function displayAccounts(accounts, configPath) {
// Handle no config file case
if (!configPath) {
console.error('No configuration file found.');
console.error('Run "bash setup.sh" to configure your email account.');
process.exit(1);
}
// Handle no accounts case
if (accounts.length === 0) {
console.error(`No accounts configured in ${configPath}`);
process.exit(0);
}
// Display header with config path
console.log(`Configured accounts (from ${configPath}):\n`);
// Calculate column widths
const maxNameLen = Math.max(7, ...accounts.map(a => a.name.length)); // 7 = 'Account'.length
const maxEmailLen = Math.max(5, ...accounts.map(a => a.email.length)); // 5 = 'Email'.length
const maxImapLen = Math.max(4, ...accounts.map(a => a.imapHost.length)); // 4 = 'IMAP'.length
const maxSmtpLen = Math.max(4, ...accounts.map(a => a.smtpHost.length)); // 4 = 'SMTP'.length
// Table header
const header = ` ${padRight('Account', maxNameLen)} ${padRight('Email', maxEmailLen)} ${padRight('IMAP', maxImapLen)} ${padRight('SMTP', maxSmtpLen)} Status`;
console.log(header);
// Separator line
const separator = ' ' + '─'.repeat(maxNameLen) + ' ' + '─'.repeat(maxEmailLen) + ' ' + '─'.repeat(maxImapLen) + ' ' + '─'.repeat(maxSmtpLen) + ' ' + '────────────────';
console.log(separator);
// Table rows
for (const account of accounts) {
const statusIcon = account.isComplete ? '✓' : '⚠';
const statusText = account.isComplete ? 'Complete' : 'Incomplete';
const row = ` ${padRight(account.name, maxNameLen)} ${padRight(account.email, maxEmailLen)} ${padRight(account.imapHost, maxImapLen)} ${padRight(account.smtpHost, maxSmtpLen)} ${statusIcon} ${statusText}`;
console.log(row);
}
// Footer
console.log(`\n ${accounts.length} account${accounts.length > 1 ? 's' : ''} total`);
}
// Helper: right-pad a string to a fixed width
function padRight(str, len) {
return (str + ' '.repeat(len)).slice(0, len);
}
// Main CLI handler
async function main() {
const { command, options, positional } = parseArgs();
try {
let result;
switch (command) {
case 'check':
result = await checkEmails(
options.mailbox || DEFAULT_MAILBOX,
parseInt(options.limit) || 10,
options.recent || null,
options.unseen === 'true' // if --unseen is set, only get unread messages
);
break;
case 'fetch':
if (!positional[0]) {
throw new Error('UID required: node imap.js fetch <uid>');
}
result = await fetchEmail(positional[0], options.mailbox);
break;
case 'download':
if (!positional[0]) {
throw new Error('UID required: node imap.js download <uid>');
}
result = await downloadAttachments(positional[0], options.mailbox, options.dir || '.', options.file || null);
break;
case 'search':
result = await searchEmails(options);
break;
case 'mark-read':
if (positional.length === 0) {
throw new Error('UID(s) required: node imap.js mark-read <uid> [uid2...]');
}
result = await markAsRead(positional, options.mailbox);
break;
case 'mark-unread':
if (positional.length === 0) {
throw new Error('UID(s) required: node imap.js mark-unread <uid> [uid2...]');
}
result = await markAsUnread(positional, options.mailbox);
break;
case 'list-mailboxes':
result = await listMailboxes();
break;
case 'list-accounts':
{
const { listAccounts } = require('./config');
const { accounts, configPath } = listAccounts();
displayAccounts(accounts, configPath);
}
return; // Exit early, no JSON output
default:
console.error('Unknown command:', command);
console.error('Available commands: check, fetch, download, search, mark-read, mark-unread, list-mailboxes, list-accounts');
process.exit(1);
}
console.log(JSON.stringify(result, null, 2));
} catch (err) {
console.error('Error:', err.message);
process.exit(1);
}
}
main();

288
scripts/smtp.js Normal file
View File

@@ -0,0 +1,288 @@
#!/usr/bin/env node
/**
* SMTP Email CLI
* Send email via SMTP protocol. Works with Gmail, Outlook, 163.com, and any standard SMTP server.
* Supports attachments, HTML content, and multiple recipients.
*/
const nodemailer = require('nodemailer');
const path = require('path');
const os = require('os');
const fs = require('fs');
const config = require('./config');
function validateReadPath(inputPath) {
let realPath;
try {
realPath = fs.realpathSync(inputPath);
} catch {
realPath = path.resolve(inputPath);
}
if (!config.allowedReadDirs.length) {
throw new Error('ALLOWED_READ_DIRS not set in .env. File read operations are disabled.');
}
const allowedDirs = config.allowedReadDirs.map(d =>
path.resolve(d.replace(/^~/, os.homedir()))
);
const allowed = allowedDirs.some(dir =>
realPath === dir || realPath.startsWith(dir + path.sep)
);
if (!allowed) {
throw new Error(`Access denied: '${inputPath}' is outside allowed read directories`);
}
return realPath;
}
// Parse command-line arguments
function parseArgs() {
const args = process.argv.slice(2);
const command = args[0];
const options = {};
const positional = [];
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--')) {
const key = arg.slice(2);
const value = args[i + 1];
options[key] = value || true;
if (value && !value.startsWith('--')) i++;
} else {
positional.push(arg);
}
}
return { command, options, positional };
}
// Create SMTP transporter
function createTransporter() {
if (!config.smtp.host || !config.smtp.user || !config.smtp.pass) {
throw new Error('Missing SMTP configuration. Check your config at ~/.config/imap-smtp-email/.env');
}
return nodemailer.createTransport({
host: config.smtp.host,
port: config.smtp.port,
secure: config.smtp.secure,
auth: {
user: config.smtp.user,
pass: config.smtp.pass,
},
tls: {
rejectUnauthorized: config.smtp.rejectUnauthorized,
},
});
}
// Send email
async function sendEmail(options) {
const transporter = createTransporter();
// Verify connection
try {
await transporter.verify();
console.error('SMTP server is ready to send');
} catch (err) {
throw new Error(`SMTP connection failed: ${err.message}`);
}
const mailOptions = {
from: options.from || config.smtp.from,
to: options.to,
cc: options.cc || undefined,
bcc: options.bcc || undefined,
subject: options.subject || '(no subject)',
text: options.text || undefined,
html: options.html || undefined,
attachments: options.attachments || [],
};
// If neither text nor html provided, use default text
if (!mailOptions.text && !mailOptions.html) {
mailOptions.text = options.body || '';
}
const info = await transporter.sendMail(mailOptions);
return {
success: true,
messageId: info.messageId,
response: info.response,
to: mailOptions.to,
};
}
// Read file content for attachments
function readAttachment(filePath) {
validateReadPath(filePath);
if (!fs.existsSync(filePath)) {
throw new Error(`Attachment file not found: ${filePath}`);
}
return {
filename: path.basename(filePath),
path: path.resolve(filePath),
};
}
// Send email with file content
async function sendEmailWithContent(options) {
// Handle attachments
if (options.attach) {
const attachFiles = options.attach.split(',').map(f => f.trim());
options.attachments = attachFiles.map(f => readAttachment(f));
}
return await sendEmail(options);
}
// Test SMTP connection
async function testConnection() {
const transporter = createTransporter();
try {
await transporter.verify();
const info = await transporter.sendMail({
from: config.smtp.from || config.smtp.user,
to: config.smtp.user,
subject: 'SMTP Connection Test',
text: 'This is a test email from the IMAP/SMTP email skill.',
html: '<p>This is a <strong>test email</strong> from the IMAP/SMTP email skill.</p>',
});
return {
success: true,
message: 'SMTP connection successful',
messageId: info.messageId,
};
} catch (err) {
throw new Error(`SMTP test failed: ${err.message}`);
}
}
// Display accounts in a formatted table
function displayAccounts(accounts, configPath) {
// Handle no config file case
if (!configPath) {
console.error('No configuration file found.');
console.error('Run "bash setup.sh" to configure your email account.');
process.exit(1);
}
// Handle no accounts case
if (accounts.length === 0) {
console.error(`No accounts configured in ${configPath}`);
process.exit(0);
}
// Display header with config path
console.log(`Configured accounts (from ${configPath}):\n`);
// Calculate column widths
const maxNameLen = Math.max(7, ...accounts.map(a => a.name.length)); // 7 = 'Account'.length
const maxEmailLen = Math.max(5, ...accounts.map(a => a.email.length)); // 5 = 'Email'.length
const maxImapLen = Math.max(4, ...accounts.map(a => a.imapHost.length)); // 4 = 'IMAP'.length
const maxSmtpLen = Math.max(4, ...accounts.map(a => a.smtpHost.length)); // 4 = 'SMTP'.length
// Table header
const header = ` ${padRight('Account', maxNameLen)} ${padRight('Email', maxEmailLen)} ${padRight('IMAP', maxImapLen)} ${padRight('SMTP', maxSmtpLen)} Status`;
console.log(header);
// Separator line
const separator = ' ' + '─'.repeat(maxNameLen) + ' ' + '─'.repeat(maxEmailLen) + ' ' + '─'.repeat(maxImapLen) + ' ' + '─'.repeat(maxSmtpLen) + ' ' + '────────────────';
console.log(separator);
// Table rows
for (const account of accounts) {
const statusIcon = account.isComplete ? '✓' : '⚠';
const statusText = account.isComplete ? 'Complete' : 'Incomplete';
const row = ` ${padRight(account.name, maxNameLen)} ${padRight(account.email, maxEmailLen)} ${padRight(account.imapHost, maxImapLen)} ${padRight(account.smtpHost, maxSmtpLen)} ${statusIcon} ${statusText}`;
console.log(row);
}
// Footer
console.log(`\n ${accounts.length} account${accounts.length > 1 ? 's' : ''} total`);
}
// Helper: right-pad a string to a fixed width
function padRight(str, len) {
return (str + ' '.repeat(len)).slice(0, len);
}
// Main CLI handler
async function main() {
const { command, options, positional } = parseArgs();
try {
let result;
switch (command) {
case 'send':
if (!options.to) {
throw new Error('Missing required option: --to <email>');
}
if (!options.subject && !options['subject-file']) {
throw new Error('Missing required option: --subject <text> or --subject-file <file>');
}
// Read subject from file if specified
if (options['subject-file']) {
validateReadPath(options['subject-file']);
options.subject = fs.readFileSync(options['subject-file'], 'utf8').trim();
}
// Read body from file if specified
if (options['body-file']) {
validateReadPath(options['body-file']);
const content = fs.readFileSync(options['body-file'], 'utf8');
if (options['body-file'].endsWith('.html') || options.html) {
options.html = content;
} else {
options.text = content;
}
} else if (options['html-file']) {
validateReadPath(options['html-file']);
options.html = fs.readFileSync(options['html-file'], 'utf8');
} else if (options.body) {
options.text = options.body;
}
result = await sendEmailWithContent(options);
break;
case 'test':
result = await testConnection();
break;
case 'list-accounts':
{
const { listAccounts } = require('./config');
const { accounts, configPath } = listAccounts();
displayAccounts(accounts, configPath);
}
return; // Exit early, no JSON output
default:
console.error('Unknown command:', command);
console.error('Available commands: send, test, list-accounts');
console.error('\nUsage:');
console.error(' send --to <email> --subject <text> [--body <text>] [--html] [--cc <email>] [--bcc <email>] [--attach <file>]');
console.error(' send --to <email> --subject <text> --body-file <file> [--html-file <file>] [--attach <file>]');
console.error(' test Test SMTP connection');
process.exit(1);
}
console.log(JSON.stringify(result, null, 2));
} catch (err) {
console.error('Error:', err.message);
process.exit(1);
}
}
main();