commit c12c1913bc7616ddafff994b704b994ed02bc4cb Author: 小橘 Date: Tue Mar 31 17:19:56 2026 +0000 feat: initial implementation of mcmail CLI - Authentication: login / logout / whoami with token caching - Accounts: list / create / delete / info - Aliases: list / create / delete - Domains: list - TypeScript + ESM + commander.js - Node fetch (no axios) - Table and --json output modes - Auto token refresh on expiry - MCMAIL_HOST / MCMAIL_USER / MCMAIL_PASSWORD env var support diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8625bf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.js.map +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..4423e2a --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# mcmail + +Mailcheap CLI — manage your mail server from the terminal. + +## Features + +- 🔐 Secure authentication with token caching +- 📬 Manage email accounts (list / create / delete / info) +- 🔀 Manage aliases (list / create / delete) +- 🌐 List domains +- 📋 Table or JSON output (`--json` flag) +- 🔄 Auto token refresh (if password is cached) +- 🌍 Environment variable support + +## Installation + +### From npm (recommended) + +```bash +npm install -g mcmail +``` + +### From source + +```bash +git clone https://github.com/shazhou-ww/mcmail.git +cd mcmail +npm install +npm run build +npm link +``` + +## Configuration + +### Environment variables (highest priority) + +| Variable | Description | +|------------------|---------------------------------------------------| +| `MCMAIL_HOST` | API host (default: `https://mail8.mymailcheap.com`) | +| `MCMAIL_USER` | Username for stateless auth | +| `MCMAIL_PASSWORD`| Password for stateless auth | + +When `MCMAIL_USER` and `MCMAIL_PASSWORD` are set, `mcmail` authenticates fresh on every call and ignores the credentials file. + +### Credentials file + +After `mcmail login`, credentials are stored at: + +``` +~/.config/mcmail/credentials.json +``` + +The file is created with `0600` permissions. It stores: +- `username` +- `auth_token` (valid 24h) +- `valid_to` (UNIX timestamp) +- `password` (for auto-refresh) + +## Usage + +### Authentication + +```bash +# Log in interactively +mcmail login + +# Show current login status +mcmail whoami + +# Log out (clear cached token) +mcmail logout +``` + +### Account Management + +```bash +# List all accounts +mcmail accounts list + +# List with search +mcmail accounts list --search alice + +# Create a MailUser account +mcmail accounts create alice@example.com --password "SecurePass123!" + +# Create with custom quota +mcmail accounts create alice@example.com --password "SecurePass123!" --quota 4096 + +# Show account details +mcmail accounts info alice@example.com + +# Delete an account (prompts for confirmation) +mcmail accounts delete alice@example.com + +# Delete without confirmation prompt +mcmail accounts delete alice@example.com --yes +``` + +### Alias Management + +```bash +# List all aliases +mcmail aliases list + +# Create an alias +mcmail aliases create info@example.com --to alice@example.com + +# Create alias with multiple recipients +mcmail aliases create team@example.com --to "alice@example.com,bob@example.com" + +# Delete an alias +mcmail aliases delete info@example.com + +# Delete without confirmation +mcmail aliases delete info@example.com --yes +``` + +### Domain Management + +```bash +# List all domains +mcmail domains list +``` + +### JSON output + +Append `--json` to any command to get machine-readable JSON: + +```bash +mcmail accounts list --json +mcmail accounts info alice@example.com --json +mcmail whoami --json +``` + +## Password Requirements + +Account passwords must: +- Be at least 12 characters long +- Contain at least 1 uppercase and 1 lowercase letter +- Contain at least 1 digit + +## Examples + +```bash +# Stateless one-liner (useful in scripts) +MCMAIL_USER=admin MCMAIL_PASSWORD=secret mcmail accounts list --json + +# Login and use credentials file +mcmail login +mcmail accounts create deploy@myapp.com --password "Deploy-2026-Secure!" +mcmail aliases create noreply@myapp.com --to deploy@myapp.com +mcmail accounts info deploy@myapp.com +``` + +## Requirements + +- Node.js ≥ 18 + +## License + +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b9266ae --- /dev/null +++ b/package-lock.json @@ -0,0 +1,609 @@ +{ + "name": "mcmail", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mcmail", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "commander": "^12.1.0" + }, + "bin": { + "mcmail": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9779369 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "mcmail", + "version": "0.1.0", + "description": "Mailcheap CLI — manage your mail server from the terminal", + "type": "module", + "bin": { + "mcmail": "./dist/cli.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsx src/cli.ts", + "prepublishOnly": "npm run build" + }, + "keywords": ["mailcheap", "mail", "cli", "email"], + "author": "小橘", + "license": "MIT", + "dependencies": { + "commander": "^12.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..bd88aec --- /dev/null +++ b/src/api.ts @@ -0,0 +1,131 @@ +import { loadCredentials, saveCredentials, isTokenExpired, Credentials } from './credentials.js'; + +export function getHost(): string { + return process.env.MCMAIL_HOST || 'https://mail8.mymailcheap.com'; +} + +/** + * Perform POST /api/v1/auth/ and return credentials. + */ +export async function authenticate(username: string, password: string): Promise { + const host = getHost(); + const res = await fetch(`${host}/api/v1/auth/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Authentication failed (${res.status}): ${text}`); + } + + const data = (await res.json()) as { + auth: { + auth_token: string; + username: string; + valid_to: number; + status: string; + message: string; + }; + }; + + if (data.auth.status !== 'success') { + throw new Error(`Authentication failed: ${data.auth.message}`); + } + + return { + username: data.auth.username, + auth_token: data.auth.auth_token, + valid_to: data.auth.valid_to, + password, // cache for auto-refresh + }; +} + +/** + * Get valid credentials. Handles env vars, auto-refresh on expiry. + * Throws if not logged in. + */ +export async function getCredentials(): Promise<{ username: string; auth_token: string }> { + // Environment variables take highest priority + const envUser = process.env.MCMAIL_USER; + const envPass = process.env.MCMAIL_PASSWORD; + + if (envUser && envPass) { + // Always auth fresh when using env vars (stateless) + const creds = await authenticate(envUser, envPass); + return { username: creds.username, auth_token: creds.auth_token }; + } + + // Load from credentials file + const creds = await loadCredentials(); + + if (!creds) { + throw new Error('Not logged in. Run: mcmail login'); + } + + if (!isTokenExpired(creds)) { + return { username: creds.username, auth_token: creds.auth_token }; + } + + // Token expired — try to auto-refresh with cached password + if (creds.password) { + console.error('Token expired, refreshing...'); + const fresh = await authenticate(creds.username, creds.password); + await saveCredentials(fresh); + return { username: fresh.username, auth_token: fresh.auth_token }; + } + + throw new Error('Token expired. Run: mcmail login'); +} + +/** + * Build Authorization header value. + */ +export function buildAuthHeader(username: string, auth_token: string): string { + const encoded = Buffer.from(`${username}:${auth_token}`).toString('base64'); + return `Basic ${encoded}`; +} + +/** + * Perform an authenticated API request. + */ +export async function apiRequest( + method: string, + path: string, + body?: unknown +): Promise<{ data: T; status: number }> { + const { username, auth_token } = await getCredentials(); + const host = getHost(); + const url = `${host}${path}`; + + const headers: Record = { + Authorization: buildAuthHeader(username, auth_token), + 'Content-Type': 'application/json', + }; + + const res = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + if (res.status === 204) { + return { data: null as T, status: 204 }; + } + + if (!res.ok) { + const text = await res.text(); + let detail = text; + try { + const json = JSON.parse(text); + detail = json.detail || json.message || JSON.stringify(json); + } catch { + // keep raw text + } + throw new Error(`API error (${res.status}): ${detail}`); + } + + const data = (await res.json()) as T; + return { data, status: res.status }; +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..3ade1bb --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { Command } from 'commander'; +import { registerAuthCommands } from './commands/auth.js'; +import { registerAccountsCommands } from './commands/accounts.js'; +import { registerAliasesCommands } from './commands/aliases.js'; +import { registerDomainsCommands } from './commands/domains.js'; + +const program = new Command(); + +program + .name('mcmail') + .description('Mailcheap CLI — manage your mail server from the terminal') + .version('0.1.0'); + +registerAuthCommands(program); +registerAccountsCommands(program); +registerAliasesCommands(program); +registerDomainsCommands(program); + +program.parse(process.argv); diff --git a/src/commands/accounts.ts b/src/commands/accounts.ts new file mode 100644 index 0000000..23c1187 --- /dev/null +++ b/src/commands/accounts.ts @@ -0,0 +1,186 @@ +import { Command } from 'commander'; +import { apiRequest } from '../api.js'; +import { printTable, printJson, formatTimestamp } from '../output.js'; +import { createInterface } from 'readline'; + +interface Account { + username: string; + perm_level: string; + enabled: number; + api_access: number; + language: string; + recovery_email?: string; + last_login?: number; + domain?: string; + storagequota_total?: number; + storagequota_used?: number; + password_hash?: string; +} + +interface AccountsResponse { + accounts: Account[]; +} + +function askConfirm(question: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(question, (ans) => { + rl.close(); + resolve(ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes'); + }); + }); +} + +export function registerAccountsCommands(program: Command): void { + const accounts = program + .command('accounts') + .description('Manage email accounts'); + + accounts + .command('list') + .description('List all email accounts') + .option('--json', 'Output as JSON') + .option('--limit ', 'Max records', '50') + .option('--search ', 'Search term') + .action(async (opts) => { + try { + const params = new URLSearchParams({ limit: opts.limit }); + if (opts.search) params.set('search', opts.search); + + const { data } = await apiRequest( + 'GET', + `/api/v1/accounts/?${params}` + ); + + if (opts.json) { + printJson(data.accounts); + return; + } + + const rows = data.accounts.map((a) => ({ + Username: a.username, + 'Perm Level': a.perm_level, + Enabled: a.enabled ? 'yes' : 'no', + 'API Access': a.api_access ? 'yes' : 'no', + Language: a.language, + 'Last Login': a.last_login ? formatTimestamp(a.last_login) : '-', + 'Quota (MiB)': a.storagequota_total != null + ? `${a.storagequota_used ?? 0}/${a.storagequota_total}` + : '-', + })); + + printTable(rows); + console.log(`Total: ${data.accounts.length}`); + } catch (err) { + console.error(`✗ ${(err as Error).message}`); + process.exit(1); + } + }); + + accounts + .command('create ') + .description('Create a new MailUser account') + .requiredOption('--password ', 'Account password') + .option('--quota ', 'Storage quota in MiB', '2048') + .option('--recovery-email ', 'Recovery email') + .option('--json', 'Output as JSON') + .action(async (email: string, opts) => { + try { + // Extract domain from email for MailUser creation + const atIndex = email.indexOf('@'); + const domain = atIndex >= 0 ? email.slice(atIndex + 1) : ''; + + const body: Record = { + username: email, + password: opts.password, + perm_level: 'MailUser', + enabled: 1, + api_access: 1, + language: 'zh', + storagequota_total: parseInt(opts.quota, 10), + recovery_email: opts.recoveryEmail || email, + domain, + }; + + const { data } = await apiRequest('POST', '/api/v1/accounts/', body); if (opts.json) { + printJson(data); + return; + } + + console.log(`✓ Account created: ${data.username}`); + printTable([ + { Field: 'Username', Value: data.username }, + { Field: 'Perm Level', Value: data.perm_level }, + { Field: 'Enabled', Value: data.enabled ? 'yes' : 'no' }, + { Field: 'API Access', Value: data.api_access ? 'yes' : 'no' }, + { Field: 'Language', Value: data.language }, + ]); + } catch (err) { + console.error(`✗ ${(err as Error).message}`); + process.exit(1); + } + }); + + accounts + .command('delete ') + .description('Delete an email account') + .option('-y, --yes', 'Skip confirmation') + .action(async (email: string, opts) => { + try { + if (!opts.yes) { + const ok = await askConfirm(`Delete account "${email}"? [y/N] `); + if (!ok) { + console.log('Aborted.'); + return; + } + } + + await apiRequest('DELETE', `/api/v1/accounts/${encodeURIComponent(email)}/`); + console.log(`✓ Account deleted: ${email}`); + } catch (err) { + console.error(`✗ ${(err as Error).message}`); + process.exit(1); + } + }); + + accounts + .command('info ') + .description('Show details for an email account') + .option('--json', 'Output as JSON') + .action(async (email: string, opts) => { + try { + const { data } = await apiRequest( + 'GET', + `/api/v1/accounts/${encodeURIComponent(email)}/` + ); + + if (opts.json) { + printJson(data); + return; + } + + const rows = [ + { Field: 'Username', Value: data.username }, + { Field: 'Perm Level', Value: data.perm_level }, + { Field: 'Enabled', Value: data.enabled ? 'yes' : 'no' }, + { Field: 'API Access', Value: data.api_access ? 'yes' : 'no' }, + { Field: 'Language', Value: data.language }, + { Field: 'Recovery Email', Value: data.recovery_email || '-' }, + { Field: 'Last Login', Value: data.last_login ? formatTimestamp(data.last_login) : '-' }, + { Field: 'Domain', Value: data.domain || '-' }, + { + Field: 'Storage (MiB)', + Value: + data.storagequota_total != null + ? `${data.storagequota_used ?? 0} used / ${data.storagequota_total} total` + : '-', + }, + ]; + + printTable(rows); + } catch (err) { + console.error(`✗ ${(err as Error).message}`); + process.exit(1); + } + }); +} diff --git a/src/commands/aliases.ts b/src/commands/aliases.ts new file mode 100644 index 0000000..11a4cd0 --- /dev/null +++ b/src/commands/aliases.ts @@ -0,0 +1,107 @@ +import { Command } from 'commander'; +import { apiRequest } from '../api.js'; +import { printTable, printJson } from '../output.js'; +import { createInterface } from 'readline'; + +interface Alias { + alias: string; + recipients: string; +} + +interface AliasesResponse { + aliases: Alias[]; +} + +function askConfirm(question: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(question, (ans) => { + rl.close(); + resolve(ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes'); + }); + }); +} + +export function registerAliasesCommands(program: Command): void { + const aliases = program + .command('aliases') + .description('Manage email aliases'); + + aliases + .command('list') + .description('List all aliases') + .option('--json', 'Output as JSON') + .option('--limit ', 'Max records', '50') + .option('--search ', 'Search term') + .action(async (opts) => { + try { + const params = new URLSearchParams({ limit: opts.limit }); + if (opts.search) params.set('search', opts.search); + + const { data } = await apiRequest( + 'GET', + `/api/v1/aliases/?${params}` + ); + + if (opts.json) { + printJson(data.aliases); + return; + } + + const rows = data.aliases.map((a) => ({ + Alias: a.alias, + Recipients: a.recipients, + })); + + printTable(rows); + console.log(`Total: ${data.aliases.length}`); + } catch (err) { + console.error(`✗ ${(err as Error).message}`); + process.exit(1); + } + }); + + aliases + .command('create ') + .description('Create an alias') + .requiredOption('--to ', 'Recipient(s), comma-separated') + .option('--json', 'Output as JSON') + .action(async (alias: string, opts) => { + try { + const body = { alias, recipients: opts.to }; + const { data } = await apiRequest('POST', '/api/v1/aliases/', body); + + if (opts.json) { + printJson(data); + return; + } + + console.log(`✓ Alias created: ${data.alias} → ${data.recipients}`); + } catch (err) { + console.error(`✗ ${(err as Error).message}`); + process.exit(1); + } + }); + + aliases + .command('delete ') + .description('Delete an alias') + .option('-y, --yes', 'Skip confirmation') + .action(async (alias: string, opts) => { + try { + if (!opts.yes) { + const ok = await askConfirm(`Delete alias "${alias}"? [y/N] `); + if (!ok) { + console.log('Aborted.'); + return; + } + } + + await apiRequest('DELETE', '/api/v1/aliases/', { alias }); + console.log(`✓ Alias deleted: ${alias}`); + } catch (err) { + console.error(`✗ ${(err as Error).message}`); + process.exit(1); + } + }); +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..a152753 --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,135 @@ +import { Command } from 'commander'; +import { createInterface } from 'readline'; +import { authenticate } from '../api.js'; +import { saveCredentials, loadCredentials, clearCredentials } from '../credentials.js'; +import { printJson, printTable, formatTimestamp } from '../output.js'; + +/** + * Read all stdin lines upfront when not a TTY (piped input). + */ +async function readStdinLines(): Promise { + if (process.stdin.isTTY) return []; + return new Promise((resolve) => { + const lines: string[] = []; + const rl = createInterface({ input: process.stdin, terminal: false }); + rl.on('line', (line) => lines.push(line.trim())); + rl.on('close', () => resolve(lines)); + }); +} + +function promptTTY(question: string, hidden: boolean): Promise { + return new Promise((resolve) => { + if (hidden) { + // Disable echo for password + process.stdout.write(question); + process.stdin.setRawMode?.(true); + process.stdin.resume(); + process.stdin.setEncoding('utf8'); + let input = ''; + const onData = (ch: string) => { + if (ch === '\n' || ch === '\r' || ch === '\u0003') { + process.stdin.setRawMode?.(false); + process.stdin.pause(); + process.stdin.removeListener('data', onData); + process.stdout.write('\n'); + resolve(input); + } else if (ch === '\u007F') { + input = input.slice(0, -1); + } else { + input += ch; + } + }; + process.stdin.on('data', onData); + } else { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + } + }); +} + +export function registerAuthCommands(program: Command): void { + program + .command('login') + .description('Log in to Mailcheap and cache credentials') + .option('--json', 'Output as JSON') + .action(async (opts) => { + try { + let username: string; + let password: string; + + if (process.stdin.isTTY) { + username = await promptTTY('Username: ', false); + password = await promptTTY('Password: ', true); + } else { + // Piped/non-interactive: read both lines upfront + const lines = await readStdinLines(); + username = lines[0] || ''; + password = lines[1] || ''; + } + + if (!username || !password) { + console.error('Username and password are required.'); + process.exit(1); + } + + const creds = await authenticate(username, password); + await saveCredentials(creds); + + if (opts.json) { + printJson({ + username: creds.username, + valid_to: creds.valid_to, + valid_to_human: formatTimestamp(creds.valid_to), + }); + } else { + console.log(`✓ Logged in as ${creds.username}`); + console.log(` Token valid until: ${formatTimestamp(creds.valid_to)}`); + } + } catch (err) { + console.error(`✗ Login failed: ${(err as Error).message}`); + process.exit(1); + } + }); + + program + .command('logout') + .description('Clear cached credentials') + .action(async () => { + await clearCredentials(); + console.log('✓ Logged out.'); + }); + + program + .command('whoami') + .description('Show current login status') + .option('--json', 'Output as JSON') + .action(async (opts) => { + const creds = await loadCredentials(); + + if (!creds) { + console.log('Not logged in.'); + process.exit(1); + } + + const now = Date.now() / 1000; + const expired = now > creds.valid_to - 60; + + if (opts.json) { + printJson({ + username: creds.username, + valid_to: creds.valid_to, + valid_to_human: formatTimestamp(creds.valid_to), + expired, + }); + } else { + printTable([ + { Field: 'Username', Value: creds.username }, + { Field: 'Token valid until', Value: formatTimestamp(creds.valid_to) }, + { Field: 'Status', Value: expired ? '⚠ Expired' : '✓ Active' }, + ]); + } + }); +} diff --git a/src/commands/domains.ts b/src/commands/domains.ts new file mode 100644 index 0000000..a0210c6 --- /dev/null +++ b/src/commands/domains.ts @@ -0,0 +1,56 @@ +import { Command } from 'commander'; +import { apiRequest } from '../api.js'; +import { printTable, printJson, formatTimestamp } from '../output.js'; + +interface DomainsResponse { + domains: string[]; + domains_pending_verification: Array<{ + domain: string; + expire_time: number; + verification_code: string; + }>; +} + +export function registerDomainsCommands(program: Command): void { + const domains = program + .command('domains') + .description('Manage domains'); + + domains + .command('list') + .description('List all domains') + .option('--json', 'Output as JSON') + .action(async (opts) => { + try { + const { data } = await apiRequest('GET', '/api/v1/domains/'); + + if (opts.json) { + printJson(data); + return; + } + + // Verified domains + if (data.domains.length > 0) { + console.log('Verified domains:'); + printTable(data.domains.map((d) => ({ Domain: d, Status: '✓ verified' }))); + } else { + console.log('No verified domains.'); + } + + // Pending domains + if (data.domains_pending_verification.length > 0) { + console.log('\nPending verification:'); + printTable( + data.domains_pending_verification.map((d) => ({ + Domain: d.domain, + 'Expires At': formatTimestamp(d.expire_time), + 'Verification Code': d.verification_code, + })) + ); + } + } catch (err) { + console.error(`✗ ${(err as Error).message}`); + process.exit(1); + } + }); +} diff --git a/src/credentials.ts b/src/credentials.ts new file mode 100644 index 0000000..6d765f0 --- /dev/null +++ b/src/credentials.ts @@ -0,0 +1,41 @@ +import { homedir } from 'os'; +import { join } from 'path'; +import { readFile, writeFile, mkdir, unlink } from 'fs/promises'; +import { existsSync } from 'fs'; + +export interface Credentials { + username: string; + auth_token: string; + valid_to: number; // UNIX timestamp + password?: string; // optionally cached for auto-refresh +} + +const CONFIG_DIR = join(homedir(), '.config', 'mcmail'); +const CREDS_FILE = join(CONFIG_DIR, 'credentials.json'); + +export async function saveCredentials(creds: Credentials): Promise { + await mkdir(CONFIG_DIR, { recursive: true }); + await writeFile(CREDS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 }); +} + +export async function loadCredentials(): Promise { + try { + if (!existsSync(CREDS_FILE)) return null; + const raw = await readFile(CREDS_FILE, 'utf-8'); + return JSON.parse(raw) as Credentials; + } catch { + return null; + } +} + +export async function clearCredentials(): Promise { + try { + await unlink(CREDS_FILE); + } catch { + // ignore if not found + } +} + +export function isTokenExpired(creds: Credentials): boolean { + return Date.now() / 1000 > creds.valid_to - 60; // 60s buffer +} diff --git a/src/output.ts b/src/output.ts new file mode 100644 index 0000000..d8a4b83 --- /dev/null +++ b/src/output.ts @@ -0,0 +1,43 @@ +/** + * Simple table printer for the terminal. + * Falls back to JSON if --json flag is set. + */ + +export function printTable(rows: Record[], headers?: string[]): void { + if (!rows.length) { + console.log('(no results)'); + return; + } + + const cols = headers || Object.keys(rows[0]); + + // Compute column widths + const widths: number[] = cols.map((c) => c.length); + for (const row of rows) { + cols.forEach((col, i) => { + const val = String(row[col] ?? ''); + if (val.length > widths[i]) widths[i] = val.length; + }); + } + + const sep = widths.map((w) => '-'.repeat(w + 2)).join('+'); + const fmt = (cells: string[]) => + cells.map((c, i) => ' ' + c.padEnd(widths[i]) + ' ').join('|'); + + console.log(sep); + console.log(fmt(cols)); + console.log(sep); + for (const row of rows) { + console.log(fmt(cols.map((c) => String(row[c] ?? '')))); + } + console.log(sep); +} + +export function printJson(data: unknown): void { + console.log(JSON.stringify(data, null, 2)); +} + +export function formatTimestamp(ts: number | undefined | null): string { + if (!ts) return '-'; + return new Date(ts * 1000).toLocaleString(); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fd67e83 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}