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
This commit is contained in:
小橘 2026-03-31 17:19:56 +00:00
commit c12c1913bc
13 changed files with 1537 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
*.js.map
.env

161
README.md Normal file
View File

@ -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

609
package-lock.json generated Normal file
View File

@ -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"
}
}
}

28
package.json Normal file
View File

@ -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"
}
}

131
src/api.ts Normal file
View File

@ -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<Credentials> {
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<T>(
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<string, string> = {
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 };
}

20
src/cli.ts Normal file
View File

@ -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);

186
src/commands/accounts.ts Normal file
View File

@ -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<boolean> {
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 <n>', 'Max records', '50')
.option('--search <term>', '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<AccountsResponse>(
'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 <email>')
.description('Create a new MailUser account')
.requiredOption('--password <pwd>', 'Account password')
.option('--quota <mib>', 'Storage quota in MiB', '2048')
.option('--recovery-email <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<string, unknown> = {
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<Account>('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 <email>')
.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<null>('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 <email>')
.description('Show details for an email account')
.option('--json', 'Output as JSON')
.action(async (email: string, opts) => {
try {
const { data } = await apiRequest<Account>(
'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);
}
});
}

107
src/commands/aliases.ts Normal file
View File

@ -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<boolean> {
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 <n>', 'Max records', '50')
.option('--search <term>', '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<AliasesResponse>(
'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 <alias>')
.description('Create an alias')
.requiredOption('--to <recipients>', '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<Alias>('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 <alias>')
.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<null>('DELETE', '/api/v1/aliases/', { alias });
console.log(`✓ Alias deleted: ${alias}`);
} catch (err) {
console.error(`${(err as Error).message}`);
process.exit(1);
}
});
}

135
src/commands/auth.ts Normal file
View File

@ -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<string[]> {
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<string> {
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' },
]);
}
});
}

56
src/commands/domains.ts Normal file
View File

@ -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<DomainsResponse>('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);
}
});
}

41
src/credentials.ts Normal file
View File

@ -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<void> {
await mkdir(CONFIG_DIR, { recursive: true });
await writeFile(CREDS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
}
export async function loadCredentials(): Promise<Credentials | null> {
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<void> {
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
}

43
src/output.ts Normal file
View File

@ -0,0 +1,43 @@
/**
* Simple table printer for the terminal.
* Falls back to JSON if --json flag is set.
*/
export function printTable(rows: Record<string, unknown>[], 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();
}

16
tsconfig.json Normal file
View File

@ -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"]
}