Initial commit with translated description
This commit is contained in:
274
SKILL.md
Normal file
274
SKILL.md
Normal file
@@ -0,0 +1,274 @@
|
||||
---
|
||||
name: imap-smtp-email
|
||||
description: "通过IMAP/SMTP阅读和发送邮件。检查新/未读消息、获取内容、搜索邮箱、标记已读/未读,并发送带附件的邮件。"
|
||||
metadata:
|
||||
openclaw:
|
||||
emoji: "📧"
|
||||
requires:
|
||||
bins:
|
||||
- node
|
||||
- npm
|
||||
---
|
||||
|
||||
# IMAP/SMTP Email Tool
|
||||
|
||||
Read, search, and manage email via IMAP protocol. Send email via SMTP. Supports Gmail, Outlook, 163.com, vip.163.com, 126.com, vip.126.com, 188.com, vip.188.com, and any standard IMAP/SMTP server.
|
||||
|
||||
## Configuration
|
||||
|
||||
Run the setup script to configure your email account:
|
||||
|
||||
```bash
|
||||
bash setup.sh
|
||||
```
|
||||
|
||||
Configuration is stored at `~/.config/imap-smtp-email/.env` (survives skill updates). If no config is found there, the skill falls back to a `.env` file in the skill directory (for backward compatibility).
|
||||
|
||||
### Config file format
|
||||
|
||||
```bash
|
||||
# Default account (no prefix)
|
||||
IMAP_HOST=imap.gmail.com
|
||||
IMAP_PORT=993
|
||||
IMAP_USER=your@email.com
|
||||
IMAP_PASS=your_password
|
||||
IMAP_TLS=true
|
||||
IMAP_REJECT_UNAUTHORIZED=true
|
||||
IMAP_MAILBOX=INBOX
|
||||
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your@email.com
|
||||
SMTP_PASS=your_password
|
||||
SMTP_FROM=your@email.com
|
||||
SMTP_REJECT_UNAUTHORIZED=true
|
||||
|
||||
# File access whitelist (security)
|
||||
ALLOWED_READ_DIRS=~/Downloads,~/Documents
|
||||
ALLOWED_WRITE_DIRS=~/Downloads
|
||||
```
|
||||
|
||||
## Multi-Account
|
||||
|
||||
You can configure additional email accounts in the same config file. Each account uses a name prefix (uppercase) on all variables.
|
||||
|
||||
### Adding an account
|
||||
|
||||
Run the setup script and choose "Add a new account":
|
||||
|
||||
```bash
|
||||
bash setup.sh
|
||||
```
|
||||
|
||||
Or manually add prefixed variables to `~/.config/imap-smtp-email/.env`:
|
||||
|
||||
```bash
|
||||
# Work account (WORK_ prefix)
|
||||
WORK_IMAP_HOST=imap.company.com
|
||||
WORK_IMAP_PORT=993
|
||||
WORK_IMAP_USER=me@company.com
|
||||
WORK_IMAP_PASS=password
|
||||
WORK_IMAP_TLS=true
|
||||
WORK_IMAP_REJECT_UNAUTHORIZED=true
|
||||
WORK_IMAP_MAILBOX=INBOX
|
||||
WORK_SMTP_HOST=smtp.company.com
|
||||
WORK_SMTP_PORT=587
|
||||
WORK_SMTP_SECURE=false
|
||||
WORK_SMTP_USER=me@company.com
|
||||
WORK_SMTP_PASS=password
|
||||
WORK_SMTP_FROM=me@company.com
|
||||
WORK_SMTP_REJECT_UNAUTHORIZED=true
|
||||
```
|
||||
|
||||
### Using a named account
|
||||
|
||||
Add `--account <name>` before the command:
|
||||
|
||||
```bash
|
||||
node scripts/imap.js --account work check
|
||||
node scripts/smtp.js --account work send --to foo@bar.com --subject Hi --body Hello
|
||||
```
|
||||
|
||||
Without `--account`, the default (unprefixed) account is used.
|
||||
|
||||
### Account name rules
|
||||
|
||||
- Letters and digits only (e.g., `work`, `163`, `personal2`)
|
||||
- Case-insensitive: `work` and `WORK` refer to the same account
|
||||
- The prefix in `.env` is always uppercase (e.g., `WORK_IMAP_HOST`)
|
||||
- `ALLOWED_READ_DIRS` and `ALLOWED_WRITE_DIRS` are shared across all accounts (always unprefixed)
|
||||
|
||||
## Common Email Servers
|
||||
|
||||
| Provider | IMAP Host | IMAP Port | SMTP Host | SMTP Port |
|
||||
|----------|-----------|-----------|-----------|-----------|
|
||||
| 163.com | imap.163.com | 993 | smtp.163.com | 465 |
|
||||
| vip.163.com | imap.vip.163.com | 993 | smtp.vip.163.com | 465 |
|
||||
| 126.com | imap.126.com | 993 | smtp.126.com | 465 |
|
||||
| vip.126.com | imap.vip.126.com | 993 | smtp.vip.126.com | 465 |
|
||||
| 188.com | imap.188.com | 993 | smtp.188.com | 465 |
|
||||
| vip.188.com | imap.vip.188.com | 993 | smtp.vip.188.com | 465 |
|
||||
| yeah.net | imap.yeah.net | 993 | smtp.yeah.net | 465 |
|
||||
| Gmail | imap.gmail.com | 993 | smtp.gmail.com | 587 |
|
||||
| Outlook | outlook.office365.com | 993 | smtp.office365.com | 587 |
|
||||
| QQ Mail | imap.qq.com | 993 | smtp.qq.com | 587 |
|
||||
|
||||
**Important for Gmail:**
|
||||
- Gmail does **not** accept your regular account password
|
||||
- You must generate an **App Password**: https://myaccount.google.com/apppasswords
|
||||
- Use the generated 16-character App Password as `IMAP_PASS` / `SMTP_PASS`
|
||||
- Requires Google Account with 2-Step Verification enabled
|
||||
|
||||
**Important for 163.com:**
|
||||
- Use **authorization code** (授权码), not account password
|
||||
- Enable IMAP/SMTP in web settings first
|
||||
|
||||
## IMAP Commands (Receiving Email)
|
||||
|
||||
### check
|
||||
Check for new/unread emails.
|
||||
|
||||
```bash
|
||||
node scripts/imap.js [--account <name>] check [--limit 10] [--mailbox INBOX] [--recent 2h]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--limit <n>`: Max results (default: 10)
|
||||
- `--mailbox <name>`: Mailbox to check (default: INBOX)
|
||||
- `--recent <time>`: Only show emails from last X time (e.g., 30m, 2h, 7d)
|
||||
|
||||
### fetch
|
||||
Fetch full email content by UID.
|
||||
|
||||
```bash
|
||||
node scripts/imap.js [--account <name>] fetch <uid> [--mailbox INBOX]
|
||||
```
|
||||
|
||||
### download
|
||||
Download all attachments from an email, or a specific attachment.
|
||||
|
||||
```bash
|
||||
node scripts/imap.js [--account <name>] download <uid> [--mailbox INBOX] [--dir <path>] [--file <filename>]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--mailbox <name>`: Mailbox (default: INBOX)
|
||||
- `--dir <path>`: Output directory (default: current directory)
|
||||
- `--file <filename>`: Download only the specified attachment (default: download all)
|
||||
|
||||
### search
|
||||
Search emails with filters.
|
||||
|
||||
```bash
|
||||
node scripts/imap.js [--account <name>] search [options]
|
||||
|
||||
Options:
|
||||
--unseen Only unread messages
|
||||
--seen Only read messages
|
||||
--from <email> From address contains
|
||||
--subject <text> Subject contains
|
||||
--recent <time> From last X time (e.g., 30m, 2h, 7d)
|
||||
--since <date> After date (YYYY-MM-DD)
|
||||
--before <date> Before date (YYYY-MM-DD)
|
||||
--limit <n> Max results (default: 20)
|
||||
--mailbox <name> Mailbox to search (default: INBOX)
|
||||
```
|
||||
|
||||
### mark-read / mark-unread
|
||||
Mark message(s) as read or unread.
|
||||
|
||||
```bash
|
||||
node scripts/imap.js [--account <name>] mark-read <uid> [uid2 uid3...]
|
||||
node scripts/imap.js [--account <name>] mark-unread <uid> [uid2 uid3...]
|
||||
```
|
||||
|
||||
### list-mailboxes
|
||||
List all available mailboxes/folders.
|
||||
|
||||
```bash
|
||||
node scripts/imap.js [--account <name>] list-mailboxes
|
||||
```
|
||||
|
||||
### list-accounts
|
||||
List all configured email accounts.
|
||||
|
||||
```bash
|
||||
node scripts/imap.js list-accounts
|
||||
node scripts/smtp.js list-accounts
|
||||
```
|
||||
|
||||
Shows account name, email address, server addresses, and configuration status.
|
||||
|
||||
## SMTP Commands (Sending Email)
|
||||
|
||||
### send
|
||||
Send email via SMTP.
|
||||
|
||||
```bash
|
||||
node scripts/smtp.js [--account <name>] send --to <email> --subject <text> [options]
|
||||
```
|
||||
|
||||
**Required:**
|
||||
- `--to <email>`: Recipient (comma-separated for multiple)
|
||||
- `--subject <text>`: Email subject, or `--subject-file <file>`
|
||||
|
||||
**Optional:**
|
||||
- `--body <text>`: Plain text body
|
||||
- `--html`: Send body as HTML
|
||||
- `--body-file <file>`: Read body from file
|
||||
- `--html-file <file>`: Read HTML from file
|
||||
- `--cc <email>`: CC recipients
|
||||
- `--bcc <email>`: BCC recipients
|
||||
- `--attach <file>`: Attachments (comma-separated)
|
||||
- `--from <email>`: Override default sender
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Simple text email
|
||||
node scripts/smtp.js send --to recipient@example.com --subject "Hello" --body "World"
|
||||
|
||||
# HTML email
|
||||
node scripts/smtp.js send --to recipient@example.com --subject "Newsletter" --html --body "<h1>Welcome</h1>"
|
||||
|
||||
# Email with attachment
|
||||
node scripts/smtp.js send --to recipient@example.com --subject "Report" --body "Please find attached" --attach report.pdf
|
||||
|
||||
# Multiple recipients
|
||||
node scripts/smtp.js send --to "a@example.com,b@example.com" --cc "c@example.com" --subject "Update" --body "Team update"
|
||||
```
|
||||
|
||||
### test
|
||||
Test SMTP connection by sending a test email to yourself.
|
||||
|
||||
```bash
|
||||
node scripts/smtp.js [--account <name>] test
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Configuration is stored at `~/.config/imap-smtp-email/.env` with `600` permissions (owner read/write only)
|
||||
- **Gmail**: regular password is rejected — generate an App Password at https://myaccount.google.com/apppasswords
|
||||
- For 163.com: use authorization code (授权码), not account password
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Connection timeout:**
|
||||
- Verify server is running and accessible
|
||||
- Check host/port configuration
|
||||
|
||||
**Authentication failed:**
|
||||
- Verify username (usually full email address)
|
||||
- Check password is correct
|
||||
- For 163.com: use authorization code, not account password
|
||||
- For Gmail: regular password won't work — generate an App Password at https://myaccount.google.com/apppasswords
|
||||
|
||||
**TLS/SSL errors:**
|
||||
- Match `IMAP_TLS`/`SMTP_SECURE` setting to server requirements
|
||||
- For self-signed certs: set `IMAP_REJECT_UNAUTHORIZED=false` or `SMTP_REJECT_UNAUTHORIZED=false`
|
||||
6
_meta.json
Normal file
6
_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn70j4ejnwqjpykvwwvgymmdcd8055qp",
|
||||
"slug": "imap-smtp-email",
|
||||
"version": "0.0.10",
|
||||
"publishedAt": 1773646380130
|
||||
}
|
||||
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "imap-smtp-email-skill",
|
||||
"version": "1.0.0",
|
||||
"description": "IMAP/SMTP email tool for Claude. Works with Gmail, Outlook, 163.com, vip.163.com, 126.com, vip.126.com, 188.com, vip.188.com, and any standard IMAP/SMTP server.",
|
||||
"main": "scripts/imap.js",
|
||||
"scripts": {
|
||||
"check": "node scripts/imap.js check",
|
||||
"fetch": "node scripts/imap.js fetch",
|
||||
"search": "node scripts/imap.js search"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.6.1",
|
||||
"imap": "^0.8.19",
|
||||
"imap-simple": "^5.1.0",
|
||||
"mailparser": "^3.9.3",
|
||||
"nodemailer": "^7.0.13"
|
||||
},
|
||||
"keywords": [
|
||||
"imap",
|
||||
"smtp",
|
||||
"email",
|
||||
"163.com",
|
||||
"126.com",
|
||||
"188.com",
|
||||
"gmail",
|
||||
"outlook",
|
||||
"skill"
|
||||
],
|
||||
"author": "NetEase",
|
||||
"license": "MIT"
|
||||
}
|
||||
141
scripts/config.js
Normal file
141
scripts/config.js
Normal 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
644
scripts/imap.js
Normal 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
288
scripts/smtp.js
Normal 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();
|
||||
314
setup.sh
Normal file
314
setup.sh
Normal file
@@ -0,0 +1,314 @@
|
||||
#!/bin/bash
|
||||
|
||||
# IMAP/SMTP Email Skill Setup Helper
|
||||
|
||||
CONFIG_DIR="$HOME/.config/imap-smtp-email"
|
||||
CONFIG_FILE="$CONFIG_DIR/.env"
|
||||
|
||||
echo "================================"
|
||||
echo " IMAP/SMTP Email Skill Setup"
|
||||
echo "================================"
|
||||
echo ""
|
||||
|
||||
# Determine setup mode
|
||||
SETUP_MODE="default"
|
||||
ACCOUNT_PREFIX=""
|
||||
ACCOUNT_NAME=""
|
||||
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
echo "Existing configuration found at $CONFIG_FILE"
|
||||
echo ""
|
||||
echo "What would you like to do?"
|
||||
echo " 1) Reconfigure default account"
|
||||
echo " 2) Add a new account"
|
||||
echo ""
|
||||
read -p "Enter choice (1-2): " SETUP_CHOICE
|
||||
|
||||
case $SETUP_CHOICE in
|
||||
1)
|
||||
SETUP_MODE="reconfigure"
|
||||
;;
|
||||
2)
|
||||
SETUP_MODE="add"
|
||||
while true; do
|
||||
read -p "Account name (letters/digits only, e.g. work): " ACCOUNT_NAME
|
||||
if [[ "$ACCOUNT_NAME" =~ ^[a-zA-Z0-9]+$ ]]; then
|
||||
ACCOUNT_PREFIX="$(echo "$ACCOUNT_NAME" | tr '[:lower:]' '[:upper:]')_"
|
||||
# Check if account already exists
|
||||
if grep -q "^${ACCOUNT_PREFIX}IMAP_HOST=" "$CONFIG_FILE" 2>/dev/null; then
|
||||
read -p "Account \"$ACCOUNT_NAME\" already exists. Overwrite? (y/n): " OVERWRITE
|
||||
if [ "$OVERWRITE" != "y" ]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
SETUP_MODE="overwrite"
|
||||
fi
|
||||
break
|
||||
else
|
||||
echo "Invalid name. Use only letters and digits."
|
||||
fi
|
||||
done
|
||||
;;
|
||||
*)
|
||||
echo "Invalid choice"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "This script will help you configure email credentials."
|
||||
echo ""
|
||||
|
||||
# Prompt for email provider
|
||||
echo "Select your email provider:"
|
||||
echo " 1) Gmail"
|
||||
echo " 2) Outlook"
|
||||
echo " 3) 163.com"
|
||||
echo " 4) vip.163.com"
|
||||
echo " 5) 126.com"
|
||||
echo " 6) vip.126.com"
|
||||
echo " 7) 188.com"
|
||||
echo " 8) vip.188.com"
|
||||
echo " 9) yeah.net"
|
||||
echo " 10) QQ Mail"
|
||||
echo " 11) Custom"
|
||||
echo ""
|
||||
read -p "Enter choice (1-11): " PROVIDER_CHOICE
|
||||
|
||||
case $PROVIDER_CHOICE in
|
||||
1)
|
||||
IMAP_HOST="imap.gmail.com"
|
||||
IMAP_PORT="993"
|
||||
SMTP_HOST="smtp.gmail.com"
|
||||
SMTP_PORT="587"
|
||||
SMTP_SECURE="false"
|
||||
IMAP_TLS="true"
|
||||
echo ""
|
||||
echo "⚠️ Gmail requires an App Password — your regular Google password will NOT work."
|
||||
echo " 1. Go to: https://myaccount.google.com/apppasswords"
|
||||
echo " 2. Generate an App Password (requires 2-Step Verification enabled)"
|
||||
echo " 3. Use the generated 16-character password below"
|
||||
echo ""
|
||||
;;
|
||||
2)
|
||||
IMAP_HOST="outlook.office365.com"
|
||||
IMAP_PORT="993"
|
||||
SMTP_HOST="smtp.office365.com"
|
||||
SMTP_PORT="587"
|
||||
SMTP_SECURE="false"
|
||||
IMAP_TLS="true"
|
||||
;;
|
||||
3)
|
||||
IMAP_HOST="imap.163.com"
|
||||
IMAP_PORT="993"
|
||||
SMTP_HOST="smtp.163.com"
|
||||
SMTP_PORT="465"
|
||||
SMTP_SECURE="true"
|
||||
IMAP_TLS="true"
|
||||
;;
|
||||
4)
|
||||
IMAP_HOST="imap.vip.163.com"
|
||||
IMAP_PORT="993"
|
||||
SMTP_HOST="smtp.vip.163.com"
|
||||
SMTP_PORT="465"
|
||||
SMTP_SECURE="true"
|
||||
IMAP_TLS="true"
|
||||
;;
|
||||
5)
|
||||
IMAP_HOST="imap.126.com"
|
||||
IMAP_PORT="993"
|
||||
SMTP_HOST="smtp.126.com"
|
||||
SMTP_PORT="465"
|
||||
SMTP_SECURE="true"
|
||||
IMAP_TLS="true"
|
||||
;;
|
||||
6)
|
||||
IMAP_HOST="imap.vip.126.com"
|
||||
IMAP_PORT="993"
|
||||
SMTP_HOST="smtp.vip.126.com"
|
||||
SMTP_PORT="465"
|
||||
SMTP_SECURE="true"
|
||||
IMAP_TLS="true"
|
||||
;;
|
||||
7)
|
||||
IMAP_HOST="imap.188.com"
|
||||
IMAP_PORT="993"
|
||||
SMTP_HOST="smtp.188.com"
|
||||
SMTP_PORT="465"
|
||||
SMTP_SECURE="true"
|
||||
IMAP_TLS="true"
|
||||
;;
|
||||
8)
|
||||
IMAP_HOST="imap.vip.188.com"
|
||||
IMAP_PORT="993"
|
||||
SMTP_HOST="smtp.vip.188.com"
|
||||
SMTP_PORT="465"
|
||||
SMTP_SECURE="true"
|
||||
IMAP_TLS="true"
|
||||
;;
|
||||
9)
|
||||
IMAP_HOST="imap.yeah.net"
|
||||
IMAP_PORT="993"
|
||||
SMTP_HOST="smtp.yeah.net"
|
||||
SMTP_PORT="465"
|
||||
SMTP_SECURE="true"
|
||||
IMAP_TLS="true"
|
||||
;;
|
||||
10)
|
||||
IMAP_HOST="imap.qq.com"
|
||||
IMAP_PORT="993"
|
||||
SMTP_HOST="smtp.qq.com"
|
||||
SMTP_PORT="587"
|
||||
SMTP_SECURE="false"
|
||||
IMAP_TLS="true"
|
||||
;;
|
||||
11)
|
||||
read -p "IMAP Host: " IMAP_HOST
|
||||
read -p "IMAP Port: " IMAP_PORT
|
||||
read -p "SMTP Host: " SMTP_HOST
|
||||
read -p "SMTP Port: " SMTP_PORT
|
||||
read -p "Use TLS for IMAP? (true/false): " IMAP_TLS
|
||||
read -p "Use SSL for SMTP? (true/false): " SMTP_SECURE
|
||||
;;
|
||||
*)
|
||||
echo "Invalid choice"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
read -p "Email address: " EMAIL
|
||||
read -s -p "Password / App Password / Authorization Code: " PASSWORD
|
||||
echo ""
|
||||
read -p "Accept self-signed certificates? (y/n): " ACCEPT_CERT
|
||||
if [ "$ACCEPT_CERT" = "y" ]; then
|
||||
REJECT_UNAUTHORIZED="false"
|
||||
else
|
||||
REJECT_UNAUTHORIZED="true"
|
||||
fi
|
||||
|
||||
# Only ask for shared settings on first-time or reconfigure
|
||||
ASK_SHARED=false
|
||||
if [ "$SETUP_MODE" = "default" ] || [ "$SETUP_MODE" = "reconfigure" ]; then
|
||||
ASK_SHARED=true
|
||||
fi
|
||||
|
||||
if [ "$ASK_SHARED" = true ]; then
|
||||
read -p "Allowed directories for reading files (comma-separated, e.g. ~/Downloads,~/Documents): " ALLOWED_READ_DIRS
|
||||
read -p "Allowed directories for saving attachments (comma-separated, e.g. ~/Downloads): " ALLOWED_WRITE_DIRS
|
||||
fi
|
||||
|
||||
# Create config directory
|
||||
mkdir -p -m 700 "$CONFIG_DIR"
|
||||
|
||||
# Build account variables block
|
||||
ACCOUNT_VARS="# ${ACCOUNT_NAME:-Default} account
|
||||
${ACCOUNT_PREFIX}IMAP_HOST=$IMAP_HOST
|
||||
${ACCOUNT_PREFIX}IMAP_PORT=$IMAP_PORT
|
||||
${ACCOUNT_PREFIX}IMAP_USER=$EMAIL
|
||||
${ACCOUNT_PREFIX}IMAP_PASS=$PASSWORD
|
||||
${ACCOUNT_PREFIX}IMAP_TLS=$IMAP_TLS
|
||||
${ACCOUNT_PREFIX}IMAP_REJECT_UNAUTHORIZED=$REJECT_UNAUTHORIZED
|
||||
${ACCOUNT_PREFIX}IMAP_MAILBOX=INBOX
|
||||
${ACCOUNT_PREFIX}SMTP_HOST=$SMTP_HOST
|
||||
${ACCOUNT_PREFIX}SMTP_PORT=$SMTP_PORT
|
||||
${ACCOUNT_PREFIX}SMTP_SECURE=$SMTP_SECURE
|
||||
${ACCOUNT_PREFIX}SMTP_USER=$EMAIL
|
||||
${ACCOUNT_PREFIX}SMTP_PASS=$PASSWORD
|
||||
${ACCOUNT_PREFIX}SMTP_FROM=$EMAIL
|
||||
${ACCOUNT_PREFIX}SMTP_REJECT_UNAUTHORIZED=$REJECT_UNAUTHORIZED"
|
||||
|
||||
case $SETUP_MODE in
|
||||
"default")
|
||||
# First-time setup: write entire file
|
||||
cat > "$CONFIG_FILE" << EOF
|
||||
$ACCOUNT_VARS
|
||||
|
||||
# File access whitelist (security)
|
||||
ALLOWED_READ_DIRS=${ALLOWED_READ_DIRS:-$HOME/Downloads,$HOME/Documents}
|
||||
ALLOWED_WRITE_DIRS=${ALLOWED_WRITE_DIRS:-$HOME/Downloads}
|
||||
EOF
|
||||
;;
|
||||
"reconfigure")
|
||||
# Keep only named-account lines (pattern: NAME_IMAP_* or NAME_SMTP_*)
|
||||
TEMP_FILE=$(mktemp)
|
||||
grep -E '^[A-Z0-9]+_(IMAP_|SMTP_)' "$CONFIG_FILE" > "$TEMP_FILE.named" 2>/dev/null || true
|
||||
|
||||
cat > "$TEMP_FILE" << EOF
|
||||
$ACCOUNT_VARS
|
||||
|
||||
# File access whitelist (security)
|
||||
ALLOWED_READ_DIRS=${ALLOWED_READ_DIRS:-$HOME/Downloads,$HOME/Documents}
|
||||
ALLOWED_WRITE_DIRS=${ALLOWED_WRITE_DIRS:-$HOME/Downloads}
|
||||
EOF
|
||||
|
||||
# Append retained named-account lines if any
|
||||
if [ -s "$TEMP_FILE.named" ]; then
|
||||
echo "" >> "$TEMP_FILE"
|
||||
echo "# Named accounts" >> "$TEMP_FILE"
|
||||
cat "$TEMP_FILE.named" >> "$TEMP_FILE"
|
||||
fi
|
||||
mv "$TEMP_FILE" "$CONFIG_FILE"
|
||||
rm -f "$TEMP_FILE.named"
|
||||
;;
|
||||
"add")
|
||||
# Append named account to existing file
|
||||
echo "" >> "$CONFIG_FILE"
|
||||
echo "$ACCOUNT_VARS" >> "$CONFIG_FILE"
|
||||
;;
|
||||
"overwrite")
|
||||
# Strip existing lines with this account prefix, then append new ones
|
||||
TEMP_FILE=$(mktemp)
|
||||
grep -v "^${ACCOUNT_PREFIX}\(IMAP_\|SMTP_\)" "$CONFIG_FILE" | grep -vi "^# ${ACCOUNT_NAME} account" > "$TEMP_FILE" 2>/dev/null || true
|
||||
# Remove trailing blank lines (portable: command substitution strips trailing newlines)
|
||||
content=$(cat "$TEMP_FILE") && printf '%s\n' "$content" > "$TEMP_FILE"
|
||||
echo "" >> "$TEMP_FILE"
|
||||
echo "$ACCOUNT_VARS" >> "$TEMP_FILE"
|
||||
mv "$TEMP_FILE" "$CONFIG_FILE"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "✅ Configuration saved to $CONFIG_FILE"
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
echo "✅ Set file permissions to 600 (owner read/write only)"
|
||||
echo ""
|
||||
echo "Testing connections..."
|
||||
echo ""
|
||||
|
||||
# Build test command with account flag if applicable
|
||||
ACCOUNT_FLAG=""
|
||||
if [ -n "$ACCOUNT_NAME" ]; then
|
||||
ACCOUNT_FLAG="--account $ACCOUNT_NAME"
|
||||
fi
|
||||
|
||||
# Test IMAP connection
|
||||
echo "Testing IMAP..."
|
||||
if node scripts/imap.js $ACCOUNT_FLAG list-mailboxes >/dev/null 2>&1; then
|
||||
echo "✅ IMAP connection successful!"
|
||||
else
|
||||
echo "❌ IMAP connection test failed"
|
||||
echo " Please check your credentials and settings"
|
||||
fi
|
||||
|
||||
# Test SMTP connection
|
||||
echo ""
|
||||
echo "Testing SMTP..."
|
||||
echo " (This will send a test email to your own address: $EMAIL)"
|
||||
if node scripts/smtp.js $ACCOUNT_FLAG test >/dev/null 2>&1; then
|
||||
echo "✅ SMTP connection successful!"
|
||||
else
|
||||
echo "❌ SMTP connection test failed"
|
||||
echo " Please check your credentials and settings"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Setup complete! Try:"
|
||||
if [ -n "$ACCOUNT_NAME" ]; then
|
||||
echo " node scripts/imap.js --account $ACCOUNT_NAME check"
|
||||
echo " node scripts/smtp.js --account $ACCOUNT_NAME send --to recipient@example.com --subject Test --body 'Hello World'"
|
||||
else
|
||||
echo " node scripts/imap.js check"
|
||||
echo " node scripts/smtp.js send --to recipient@example.com --subject Test --body 'Hello World'"
|
||||
fi
|
||||
Reference in New Issue
Block a user