@ulthon/ul-opencode-event 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -95
- package/dist/cli.js +169 -18
- package/dist/email.js +92 -20
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,33 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
OpenCode notification plugin that sends email notifications when session events occur.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Quick Start
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
+
# 1. 安装
|
|
8
9
|
npm install @ulthon/ul-opencode-event
|
|
9
|
-
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# 2. 创建配置文件(项目根目录)
|
|
12
|
+
cp node_modules/@ulthon/ul-opencode-event/examples/ul-opencode-event.json ./
|
|
12
13
|
|
|
14
|
+
# 3. 编辑配置,填入你的 SMTP 信息
|
|
15
|
+
|
|
16
|
+
# 4. 测试连接
|
|
17
|
+
npx @ulthon/ul-opencode-event test
|
|
18
|
+
|
|
19
|
+
# 5. 添加到 OpenCode 配置
|
|
20
|
+
# 在 .opencode/settings.json 中添加:
|
|
21
|
+
# { "plugin": ["@ulthon/ul-opencode-event"] }
|
|
22
|
+
```
|
|
23
|
+
## Installation
|
|
24
|
+
```bash
|
|
25
|
+
npm install @ulthon/ul-opencode-event
|
|
26
|
+
```
|
|
27
|
+
Then add to your OpenCode configuration (`.opencode/settings.json`):
|
|
13
28
|
```json
|
|
14
29
|
{
|
|
15
30
|
"plugin": ["@ulthon/ul-opencode-event"]
|
|
16
31
|
}
|
|
17
32
|
```
|
|
18
|
-
|
|
19
33
|
## Configuration
|
|
20
|
-
|
|
21
34
|
### Configuration File Locations
|
|
22
|
-
|
|
23
35
|
The plugin looks for configuration in these locations (in order of priority):
|
|
24
|
-
|
|
25
36
|
1. **Project-level**: `.opencode/ul-opencode-event.json` or `./ul-opencode-event.json`
|
|
26
37
|
2. **Global**: `~/.config/opencode/ul-opencode-event.json`
|
|
27
|
-
|
|
28
38
|
Project-level configuration overrides global configuration (channels are merged by name).
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
### Minimal Configuration
|
|
40
|
+
Create `ul-opencode-event.json` in your project root:
|
|
32
41
|
```json
|
|
33
42
|
{
|
|
34
43
|
"channels": [
|
|
@@ -47,49 +56,143 @@ Project-level configuration overrides global configuration (channels are merged
|
|
|
47
56
|
},
|
|
48
57
|
"recipients": ["receiver@example.com"],
|
|
49
58
|
"events": {
|
|
50
|
-
"
|
|
59
|
+
"idle": true,
|
|
60
|
+
"error": true
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
### Common SMTP Servers
|
|
67
|
+
| Provider | Host | Port | Secure | Auth |
|
|
68
|
+
|----------|------|------|--------|------|
|
|
69
|
+
| QQ Mail | smtp.qq.com | 465 | true | Authorization code |
|
|
70
|
+
| 163 Mail | smtp.163.com | 465 | true | Authorization code |
|
|
71
|
+
| Gmail | smtp.gmail.com | 587 | false | App Password |
|
|
72
|
+
| Outlook | smtp.office365.com | 587 | false | Password |
|
|
73
|
+
> **Note**: For QQ/163 Mail, use the authorization code (授权码), not your login password.
|
|
74
|
+
> For Gmail, use an App Password with 2FA enabled.
|
|
75
|
+
### Full Configuration Example
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"channels": [
|
|
79
|
+
{
|
|
80
|
+
"type": "smtp",
|
|
81
|
+
"enabled": true,
|
|
82
|
+
"name": "QQ邮箱",
|
|
83
|
+
"config": {
|
|
84
|
+
"host": "smtp.qq.com",
|
|
85
|
+
"port": 465,
|
|
86
|
+
"secure": true,
|
|
87
|
+
"auth": {
|
|
88
|
+
"user": "your@qq.com",
|
|
89
|
+
"pass": "your-authorization-code"
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"recipients": ["admin@example.com", "dev-team@example.com"],
|
|
93
|
+
"events": {
|
|
94
|
+
"created": false,
|
|
51
95
|
"idle": true,
|
|
52
96
|
"error": true
|
|
53
97
|
},
|
|
54
98
|
"templates": {
|
|
55
|
-
"
|
|
56
|
-
"subject": "[OpenCode]
|
|
57
|
-
"body": "
|
|
99
|
+
"idle": {
|
|
100
|
+
"subject": "[OpenCode] 任务完成 - {{projectName}}",
|
|
101
|
+
"body": "任务已完成\n\n项目: {{projectName}}\n时间: {{timestamp}}\n耗时: {{duration}}\n消息: {{message}}"
|
|
102
|
+
},
|
|
103
|
+
"error": {
|
|
104
|
+
"subject": "[OpenCode] 任务错误 - {{projectName}}",
|
|
105
|
+
"body": "任务发生错误\n\n项目: {{projectName}}\n时间: {{timestamp}}\n错误: {{error}}"
|
|
58
106
|
}
|
|
59
107
|
}
|
|
60
108
|
}
|
|
61
109
|
]
|
|
62
110
|
}
|
|
63
111
|
```
|
|
64
|
-
|
|
65
|
-
See `examples/ul-opencode-event.json` for a complete example.
|
|
66
|
-
|
|
67
112
|
### Channel Configuration
|
|
68
|
-
|
|
69
113
|
| Field | Type | Required | Description |
|
|
70
114
|
|-------|------|----------|-------------|
|
|
71
|
-
| `type` | string | Yes | Channel type: `smtp`
|
|
115
|
+
| `type` | string | Yes | Channel type: `smtp` |
|
|
72
116
|
| `enabled` | boolean | No | Whether this channel is active (default: true) |
|
|
73
117
|
| `name` | string | No | Channel name for identification |
|
|
74
|
-
| `config` | object | Yes |
|
|
75
|
-
| `recipients` | string[] | Yes
|
|
118
|
+
| `config` | object | Yes | SMTP configuration |
|
|
119
|
+
| `recipients` | string[] | Yes | Email recipients |
|
|
76
120
|
| `events` | object | No | Which events to notify |
|
|
77
121
|
| `templates` | object | No | Custom templates for each event |
|
|
78
|
-
|
|
79
122
|
### SMTP Configuration
|
|
80
|
-
|
|
81
123
|
| Field | Type | Required | Description |
|
|
82
124
|
|-------|------|----------|-------------|
|
|
83
|
-
| `host` | string | Yes | SMTP server hostname |
|
|
125
|
+
| `host` | string | Yes | SMTP server hostname or IPv4/IPv6 address |
|
|
84
126
|
| `port` | number | Yes | SMTP port (465 for SSL, 587 for STARTTLS) |
|
|
85
127
|
| `secure` | boolean | No | Use SSL (default: true for port 465) |
|
|
86
|
-
| `
|
|
87
|
-
| `auth.
|
|
128
|
+
| `localAddress` | string | No | Local interface to bind for IPv4/IPv6 control |
|
|
129
|
+
| `auth.user` | string | Yes | SMTP username (usually your email) |
|
|
130
|
+
| `auth.pass` | string | Yes | SMTP password or authorization code |
|
|
131
|
+
|
|
132
|
+
### IPv4/IPv6 Configuration
|
|
88
133
|
|
|
89
|
-
|
|
134
|
+
By default, the plugin automatically supports both IPv4 and IPv6. Nodemailer will:
|
|
135
|
+
- Resolve DNS to both IPv4 (A) and IPv6 (AAAA) records
|
|
136
|
+
- Try both address families in parallel
|
|
137
|
+
- Use whichever connects successfully
|
|
90
138
|
|
|
91
|
-
|
|
139
|
+
#### Force IPv4
|
|
140
|
+
If you want to force IPv4 connection (e.g., IPv6 is unavailable or unstable):
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"config": {
|
|
144
|
+
"host": "smtp.qq.com",
|
|
145
|
+
"port": 465,
|
|
146
|
+
"secure": true,
|
|
147
|
+
"localAddress": "0.0.0.0",
|
|
148
|
+
"auth": {
|
|
149
|
+
"user": "your@qq.com",
|
|
150
|
+
"pass": "authorization-code"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
92
155
|
|
|
156
|
+
#### Force IPv6
|
|
157
|
+
If you want to force IPv6 connection:
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"config": {
|
|
161
|
+
"host": "smtp.example.com",
|
|
162
|
+
"port": 465,
|
|
163
|
+
"secure": true,
|
|
164
|
+
"localAddress": "::",
|
|
165
|
+
"auth": {
|
|
166
|
+
"user": "your@example.com",
|
|
167
|
+
"pass": "your-password"
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
#### Use IPv6 Address as Host
|
|
174
|
+
You can also use an IPv6 address directly as the host:
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"config": {
|
|
178
|
+
"host": "2001:db8::1",
|
|
179
|
+
"port": 465,
|
|
180
|
+
"secure": true,
|
|
181
|
+
"auth": {
|
|
182
|
+
"user": "your@example.com",
|
|
183
|
+
"pass": "your-password"
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Events Configuration
|
|
190
|
+
| Event | Default | Description |
|
|
191
|
+
|-------|---------|-------------|
|
|
192
|
+
| `created` | false | When a new session starts |
|
|
193
|
+
| `idle` | true | When a session completes (AI finishes responding) |
|
|
194
|
+
| `error` | true | When a session encounters an error |
|
|
195
|
+
### Template Variables
|
|
93
196
|
| Variable | Description | Available Events |
|
|
94
197
|
|----------|-------------|------------------|
|
|
95
198
|
| `{{eventType}}` | Event type (created, idle, error) | All |
|
|
@@ -99,48 +202,9 @@ Use these variables in your subject and body templates:
|
|
|
99
202
|
| `{{message}}` | Completion message | idle |
|
|
100
203
|
| `{{duration}}` | Task duration | idle |
|
|
101
204
|
| `{{error}}` | Error message | error |
|
|
102
|
-
|
|
103
|
-
## Events
|
|
104
|
-
|
|
105
|
-
| Event | When it triggers |
|
|
106
|
-
|-------|------------------|
|
|
107
|
-
| `created` | When a new session starts |
|
|
108
|
-
| `idle` | When a session completes (AI finishes responding) |
|
|
109
|
-
| `error` | When a session encounters an error |
|
|
110
|
-
|
|
111
|
-
## Multiple Channels
|
|
112
|
-
|
|
113
|
-
You can configure multiple channels, and all enabled channels will receive notifications:
|
|
114
|
-
|
|
115
|
-
```json
|
|
116
|
-
{
|
|
117
|
-
"channels": [
|
|
118
|
-
{
|
|
119
|
-
"type": "smtp",
|
|
120
|
-
"enabled": true,
|
|
121
|
-
"name": "Primary Email",
|
|
122
|
-
"config": { "host": "smtp.primary.com", ... },
|
|
123
|
-
"recipients": ["primary@example.com"],
|
|
124
|
-
"events": { "created": true, "idle": true, "error": true }
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
"type": "smtp",
|
|
128
|
-
"enabled": true,
|
|
129
|
-
"name": "Secondary Email",
|
|
130
|
-
"config": { "host": "smtp.secondary.com", ... },
|
|
131
|
-
"recipients": ["secondary@example.com"],
|
|
132
|
-
"events": { "idle": true, "error": true }
|
|
133
|
-
}
|
|
134
|
-
]
|
|
135
|
-
}
|
|
136
|
-
```
|
|
137
|
-
|
|
138
205
|
## CLI Tool
|
|
139
|
-
|
|
140
|
-
This package includes a CLI tool to test your notification channel configuration before running OpenCode.
|
|
141
|
-
|
|
206
|
+
This package includes a CLI tool to test your notification channel configuration.
|
|
142
207
|
### Test Command
|
|
143
|
-
|
|
144
208
|
```bash
|
|
145
209
|
# Test all channels (validates config, tests connection, sends test email)
|
|
146
210
|
npx @ulthon/ul-opencode-event test
|
|
@@ -151,15 +215,7 @@ npx @ulthon/ul-opencode-event test --channel "My Email"
|
|
|
151
215
|
# Only verify connection without sending email
|
|
152
216
|
npx @ulthon/ul-opencode-event test --no-send
|
|
153
217
|
```
|
|
154
|
-
|
|
155
|
-
### What the test command does:
|
|
156
|
-
|
|
157
|
-
1. **Configuration validation**: Checks if the config file exists and has valid format
|
|
158
|
-
2. **SMTP connection test**: Verifies that the SMTP server is reachable and credentials work
|
|
159
|
-
3. **Test email**: Sends a test email to configured recipients (unless `--no-send` is used)
|
|
160
|
-
|
|
161
|
-
### Test output example:
|
|
162
|
-
|
|
218
|
+
### Test Output Example
|
|
163
219
|
```
|
|
164
220
|
[ul-opencode-event-cli] Starting channel test...
|
|
165
221
|
[ul-opencode-event-cli] Loaded project config: ./ul-opencode-event.json
|
|
@@ -180,35 +236,49 @@ npx @ulthon/ul-opencode-event test --no-send
|
|
|
180
236
|
Message ID: <xxx>
|
|
181
237
|
|
|
182
238
|
[SUCCESS] Channel "My Email" passed all tests
|
|
183
|
-
Please check the inbox of: user@example.com
|
|
184
239
|
|
|
185
240
|
[ul-opencode-event-cli] Test Summary:
|
|
186
241
|
Passed: 1
|
|
187
242
|
Failed: 0
|
|
188
243
|
```
|
|
189
|
-
|
|
190
244
|
## Troubleshooting
|
|
191
|
-
|
|
245
|
+
### SMTP connection failed
|
|
246
|
+
**Q: Does this plugin support IPv4? IPv6?**
|
|
247
|
+
**A: Yes, both are supported.** The plugin uses nodemailer which automatically:
|
|
248
|
+
- Resolves DNS to both IPv4 (A) and IPv6 (AAAA) records
|
|
249
|
+
- Tries both address families in parallel
|
|
250
|
+
- Falls back if one fails
|
|
251
|
+
**Common issues:**
|
|
252
|
+
1. **Wrong password**: Use authorization code for QQ/163, App Password for Gmail
|
|
253
|
+
2. **Wrong port**: Use 465 for SSL, 587 for STARTTLS
|
|
254
|
+
3. **Firewall**: Ensure outbound connections to SMTP port are allowed
|
|
255
|
+
4. **IPv6 issues**: If your network has incomplete IPv6, connections may timeout
|
|
256
|
+
- Check your network's IPv6 configuration
|
|
257
|
+
- Contact your network administrator if needed
|
|
258
|
+
### Debug mode
|
|
259
|
+
```bash
|
|
260
|
+
# Enable debug logging
|
|
261
|
+
UL_OPENCODE_EVENT_DEBUG=1 npx @ulthon/ul-opencode-event test
|
|
262
|
+
```
|
|
192
263
|
### No notifications sent
|
|
193
|
-
|
|
194
264
|
1. Check if `enabled: true` is set
|
|
195
265
|
2. Check if the event type is enabled in `events`
|
|
196
266
|
3. Check SMTP credentials are correct
|
|
197
|
-
4. Ensure
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
- Start OpenCode from terminal to view stdout/stderr logs
|
|
203
|
-
- Plugin logs use prefix `[ul-opencode-event]`
|
|
204
|
-
- You will see errors for JSON parse failure, invalid config shape, and event handling failures
|
|
267
|
+
4. Ensure config file exists at correct location
|
|
268
|
+
## Local Development
|
|
269
|
+
```bash
|
|
270
|
+
# Build
|
|
271
|
+
npm run build
|
|
205
272
|
|
|
206
|
-
|
|
273
|
+
# Test locally without publishing
|
|
274
|
+
node dist/cli.js test
|
|
207
275
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
-
|
|
276
|
+
# Or use npm link
|
|
277
|
+
npm link
|
|
278
|
+
npx @ulthon/ul-opencode-event test
|
|
211
279
|
|
|
280
|
+
# Unlink when done
|
|
281
|
+
npm unlink -g @ulthon/ul-opencode-event
|
|
282
|
+
```
|
|
212
283
|
## License
|
|
213
|
-
|
|
214
284
|
MIT
|
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,10 @@ import * as nodemailer from 'nodemailer';
|
|
|
11
11
|
import * as fs from 'fs';
|
|
12
12
|
import * as path from 'path';
|
|
13
13
|
import * as os from 'os';
|
|
14
|
+
import { lookup } from 'node:dns/promises';
|
|
15
|
+
import { isIP } from 'node:net';
|
|
14
16
|
const CLI_PREFIX = '[ul-opencode-event-cli]';
|
|
17
|
+
const LOOKUP_TIMEOUT_MS = 1200;
|
|
15
18
|
/**
|
|
16
19
|
* Print usage information
|
|
17
20
|
*/
|
|
@@ -95,23 +98,99 @@ function validateChannel(channel) {
|
|
|
95
98
|
}
|
|
96
99
|
/**
|
|
97
100
|
* Create SMTP transporter
|
|
101
|
+
* Supports IPv4/IPv6 via localAddress option
|
|
98
102
|
*/
|
|
99
|
-
function createTransporter(channel) {
|
|
103
|
+
function createTransporter(channel, hostOverride) {
|
|
100
104
|
const config = channel.config;
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
const host = hostOverride || config.host;
|
|
106
|
+
const transportOptions = {
|
|
107
|
+
host,
|
|
103
108
|
port: config.port,
|
|
104
109
|
secure: config.secure ?? (config.port === 465),
|
|
105
110
|
auth: {
|
|
106
111
|
user: config.auth.user,
|
|
107
112
|
pass: config.auth.pass,
|
|
108
113
|
},
|
|
109
|
-
}
|
|
114
|
+
};
|
|
115
|
+
// Add localAddress for IPv4/IPv6 control
|
|
116
|
+
// - "0.0.0.0" or specific IPv4 to force IPv4
|
|
117
|
+
// - "::" or specific IPv6 to force IPv6
|
|
118
|
+
if (config.localAddress) {
|
|
119
|
+
transportOptions.localAddress = config.localAddress;
|
|
120
|
+
}
|
|
121
|
+
if (hostOverride && hostOverride !== config.host) {
|
|
122
|
+
transportOptions.tls = {
|
|
123
|
+
servername: config.host,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return nodemailer.createTransport(transportOptions);
|
|
127
|
+
}
|
|
128
|
+
function shouldRetryWithLookup(message) {
|
|
129
|
+
const normalized = message.toLowerCase();
|
|
130
|
+
return (normalized.includes('querya etimeout') ||
|
|
131
|
+
normalized.includes('queryaaaa etimeout') ||
|
|
132
|
+
normalized.includes('enotfound') ||
|
|
133
|
+
normalized.includes('eai_again'));
|
|
134
|
+
}
|
|
135
|
+
async function resolveHostViaLookup(host) {
|
|
136
|
+
if (isIP(host) !== 0) {
|
|
137
|
+
return host;
|
|
138
|
+
}
|
|
139
|
+
const withTimeout = (task, timeoutMs) => {
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const timer = setTimeout(() => reject(new Error(`lookup timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
142
|
+
task.then(value => {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
resolve(value);
|
|
145
|
+
}, error => {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
reject(error);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
try {
|
|
152
|
+
const addr = await withTimeout(lookup(host, { family: 4 }), LOOKUP_TIMEOUT_MS);
|
|
153
|
+
return addr.address;
|
|
154
|
+
}
|
|
155
|
+
catch { }
|
|
156
|
+
try {
|
|
157
|
+
const addr = await withTimeout(lookup(host, { family: 6 }), LOOKUP_TIMEOUT_MS);
|
|
158
|
+
return addr.address;
|
|
159
|
+
}
|
|
160
|
+
catch { }
|
|
161
|
+
return null;
|
|
110
162
|
}
|
|
111
163
|
/**
|
|
112
164
|
* Test SMTP connection
|
|
113
165
|
*/
|
|
114
166
|
async function testConnection(channel) {
|
|
167
|
+
const config = channel.config;
|
|
168
|
+
const preResolvedHost = await resolveHostViaLookup(config.host);
|
|
169
|
+
if (preResolvedHost && preResolvedHost !== config.host) {
|
|
170
|
+
try {
|
|
171
|
+
const preResolvedTransporter = createTransporter(channel, preResolvedHost);
|
|
172
|
+
await preResolvedTransporter.verify();
|
|
173
|
+
return {
|
|
174
|
+
success: true,
|
|
175
|
+
info: `DNS pre-resolved: ${config.host} -> ${preResolvedHost}`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
catch (preResolveError) {
|
|
179
|
+
const preResolveMessage = preResolveError instanceof Error ? preResolveError.message : String(preResolveError);
|
|
180
|
+
try {
|
|
181
|
+
const transporter = createTransporter(channel);
|
|
182
|
+
await transporter.verify();
|
|
183
|
+
return {
|
|
184
|
+
success: true,
|
|
185
|
+
info: `DNS pre-resolve verify failed (${preResolveMessage}), hostname verify succeeded`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
catch (hostError) {
|
|
189
|
+
const hostMessage = hostError instanceof Error ? hostError.message : String(hostError);
|
|
190
|
+
return { success: false, error: `${preResolveMessage}; hostname verify failed: ${hostMessage}` };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
115
194
|
try {
|
|
116
195
|
const transporter = createTransporter(channel);
|
|
117
196
|
await transporter.verify();
|
|
@@ -119,30 +198,46 @@ async function testConnection(channel) {
|
|
|
119
198
|
}
|
|
120
199
|
catch (error) {
|
|
121
200
|
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
-
|
|
201
|
+
if (!shouldRetryWithLookup(message)) {
|
|
202
|
+
return { success: false, error: message };
|
|
203
|
+
}
|
|
204
|
+
const resolvedHost = await resolveHostViaLookup(config.host);
|
|
205
|
+
if (!resolvedHost) {
|
|
206
|
+
return { success: false, error: `${message}; lookup() fallback failed for ${config.host}` };
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const fallbackTransporter = createTransporter(channel, resolvedHost);
|
|
210
|
+
await fallbackTransporter.verify();
|
|
211
|
+
return {
|
|
212
|
+
success: true,
|
|
213
|
+
info: `DNS fallback used: ${config.host} -> ${resolvedHost}`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
catch (fallbackError) {
|
|
217
|
+
const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
|
218
|
+
return { success: false, error: `${message}; fallback verify failed: ${fallbackMessage}` };
|
|
219
|
+
}
|
|
123
220
|
}
|
|
124
221
|
}
|
|
125
222
|
/**
|
|
126
223
|
* Send test email
|
|
127
224
|
*/
|
|
128
225
|
async function sendTestEmail(channel) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
subject: `[Test] OpenCode Notification Channel Test - ${timestamp}`,
|
|
138
|
-
text: `This is a test email from OpenCode notification plugin.
|
|
226
|
+
const config = channel.config;
|
|
227
|
+
const fromName = channel.name || 'OpenCode Notification Test';
|
|
228
|
+
const timestamp = new Date().toISOString();
|
|
229
|
+
const mailOptions = {
|
|
230
|
+
from: `"${fromName}" <${config.auth.user}>`,
|
|
231
|
+
to: channel.recipients.join(', '),
|
|
232
|
+
subject: `[Test] OpenCode Notification Channel Test - ${timestamp}`,
|
|
233
|
+
text: `This is a test email from OpenCode notification plugin.
|
|
139
234
|
|
|
140
235
|
Channel: ${channel.name || 'unnamed'}
|
|
141
236
|
Type: ${channel.type}
|
|
142
237
|
Time: ${timestamp}
|
|
143
238
|
|
|
144
239
|
If you received this email, your notification channel is configured correctly.`,
|
|
145
|
-
|
|
240
|
+
html: `
|
|
146
241
|
<h2>OpenCode Notification Channel Test</h2>
|
|
147
242
|
<p>This is a test email from OpenCode notification plugin.</p>
|
|
148
243
|
<hr>
|
|
@@ -154,12 +249,62 @@ If you received this email, your notification channel is configured correctly.`,
|
|
|
154
249
|
<hr>
|
|
155
250
|
<p style="color: green;">If you received this email, your notification channel is configured correctly.</p>
|
|
156
251
|
`,
|
|
157
|
-
|
|
252
|
+
};
|
|
253
|
+
const preResolvedHost = await resolveHostViaLookup(config.host);
|
|
254
|
+
if (preResolvedHost && preResolvedHost !== config.host) {
|
|
255
|
+
try {
|
|
256
|
+
const preResolvedTransporter = createTransporter(channel, preResolvedHost);
|
|
257
|
+
const result = await preResolvedTransporter.sendMail(mailOptions);
|
|
258
|
+
return {
|
|
259
|
+
success: true,
|
|
260
|
+
messageId: result.messageId,
|
|
261
|
+
info: `DNS pre-resolved: ${config.host} -> ${preResolvedHost}`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
catch (preResolveError) {
|
|
265
|
+
const preResolveMessage = preResolveError instanceof Error ? preResolveError.message : String(preResolveError);
|
|
266
|
+
try {
|
|
267
|
+
const transporter = createTransporter(channel);
|
|
268
|
+
const result = await transporter.sendMail(mailOptions);
|
|
269
|
+
return {
|
|
270
|
+
success: true,
|
|
271
|
+
messageId: result.messageId,
|
|
272
|
+
info: `DNS pre-resolve send failed (${preResolveMessage}), hostname send succeeded`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
catch (hostError) {
|
|
276
|
+
const hostMessage = hostError instanceof Error ? hostError.message : String(hostError);
|
|
277
|
+
return { success: false, error: `${preResolveMessage}; hostname send failed: ${hostMessage}` };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const transporter = createTransporter(channel);
|
|
283
|
+
const result = await transporter.sendMail(mailOptions);
|
|
158
284
|
return { success: true, messageId: result.messageId };
|
|
159
285
|
}
|
|
160
286
|
catch (error) {
|
|
161
287
|
const message = error instanceof Error ? error.message : String(error);
|
|
162
|
-
|
|
288
|
+
if (!shouldRetryWithLookup(message)) {
|
|
289
|
+
return { success: false, error: message };
|
|
290
|
+
}
|
|
291
|
+
const resolvedHost = await resolveHostViaLookup(config.host);
|
|
292
|
+
if (!resolvedHost) {
|
|
293
|
+
return { success: false, error: `${message}; lookup() fallback failed for ${config.host}` };
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const fallbackTransporter = createTransporter(channel, resolvedHost);
|
|
297
|
+
const result = await fallbackTransporter.sendMail(mailOptions);
|
|
298
|
+
return {
|
|
299
|
+
success: true,
|
|
300
|
+
messageId: result.messageId,
|
|
301
|
+
info: `DNS fallback used: ${config.host} -> ${resolvedHost}`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
catch (fallbackError) {
|
|
305
|
+
const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
|
306
|
+
return { success: false, error: `${message}; fallback send failed: ${fallbackMessage}` };
|
|
307
|
+
}
|
|
163
308
|
}
|
|
164
309
|
}
|
|
165
310
|
/**
|
|
@@ -188,6 +333,9 @@ async function testChannel(channel, options) {
|
|
|
188
333
|
return false;
|
|
189
334
|
}
|
|
190
335
|
console.log(` [OK] Connection successful`);
|
|
336
|
+
if (connectionResult.info) {
|
|
337
|
+
console.log(` [INFO] ${connectionResult.info}`);
|
|
338
|
+
}
|
|
191
339
|
// Step 3: Send test email (if not skipped)
|
|
192
340
|
if (options.noSend) {
|
|
193
341
|
console.log(`\n [3/3] Skipping test email (--no-send flag)`);
|
|
@@ -201,6 +349,9 @@ async function testChannel(channel, options) {
|
|
|
201
349
|
return false;
|
|
202
350
|
}
|
|
203
351
|
console.log(` [OK] Test email sent successfully`);
|
|
352
|
+
if (sendResult.info) {
|
|
353
|
+
console.log(` [INFO] ${sendResult.info}`);
|
|
354
|
+
}
|
|
204
355
|
console.log(` Message ID: ${sendResult.messageId}`);
|
|
205
356
|
console.log(`\n [SUCCESS] Channel "${channelName}" passed all tests`);
|
|
206
357
|
console.log(` Please check the inbox of: ${channel.recipients?.join(', ')}`);
|
package/dist/email.js
CHANGED
|
@@ -2,6 +2,44 @@
|
|
|
2
2
|
* Email sender module for SMTP-based notifications
|
|
3
3
|
*/
|
|
4
4
|
import * as nodemailer from 'nodemailer';
|
|
5
|
+
import { lookup } from 'node:dns/promises';
|
|
6
|
+
import { isIP } from 'node:net';
|
|
7
|
+
const LOOKUP_TIMEOUT_MS = 1200;
|
|
8
|
+
function shouldRetryWithLookup(message) {
|
|
9
|
+
const normalized = message.toLowerCase();
|
|
10
|
+
return (normalized.includes('querya etimeout') ||
|
|
11
|
+
normalized.includes('queryaaaa etimeout') ||
|
|
12
|
+
normalized.includes('enotfound') ||
|
|
13
|
+
normalized.includes('eai_again'));
|
|
14
|
+
}
|
|
15
|
+
async function resolveHostViaLookup(host) {
|
|
16
|
+
if (isIP(host) !== 0) {
|
|
17
|
+
return host;
|
|
18
|
+
}
|
|
19
|
+
const withTimeout = (task, timeoutMs) => {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const timer = setTimeout(() => reject(new Error(`lookup timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
22
|
+
task.then(value => {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
resolve(value);
|
|
25
|
+
}, error => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
reject(error);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
try {
|
|
32
|
+
const addr = await withTimeout(lookup(host, { family: 4 }), LOOKUP_TIMEOUT_MS);
|
|
33
|
+
return addr.address;
|
|
34
|
+
}
|
|
35
|
+
catch { }
|
|
36
|
+
try {
|
|
37
|
+
const addr = await withTimeout(lookup(host, { family: 6 }), LOOKUP_TIMEOUT_MS);
|
|
38
|
+
return addr.address;
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
5
43
|
/**
|
|
6
44
|
* Creates an email sender for the given SMTP channel
|
|
7
45
|
* @param channel - The channel configuration (must be SMTP type)
|
|
@@ -22,31 +60,65 @@ export function createEmailSender(channel) {
|
|
|
22
60
|
}
|
|
23
61
|
const config = channel.config;
|
|
24
62
|
const fromName = channel.name || 'Notification';
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
63
|
+
const createTransport = (hostOverride) => {
|
|
64
|
+
const host = hostOverride || config.host;
|
|
65
|
+
const transportOptions = {
|
|
66
|
+
host,
|
|
67
|
+
port: config.port,
|
|
68
|
+
secure: config.secure,
|
|
69
|
+
auth: {
|
|
70
|
+
user: config.auth.user,
|
|
71
|
+
pass: config.auth.pass,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
if (config.localAddress) {
|
|
75
|
+
transportOptions.localAddress = config.localAddress;
|
|
76
|
+
}
|
|
77
|
+
if (hostOverride && hostOverride !== config.host) {
|
|
78
|
+
transportOptions.tls = {
|
|
79
|
+
servername: config.host,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const transport = nodemailer.createTransport(transportOptions);
|
|
83
|
+
transport.set('disableFileAccess', true);
|
|
84
|
+
transport.set('disableUrlAccess', true);
|
|
85
|
+
return transport;
|
|
86
|
+
};
|
|
38
87
|
return {
|
|
39
88
|
async send(subject, body) {
|
|
89
|
+
const mailOptions = {
|
|
90
|
+
from: `"${fromName}" <${config.auth.user}>`,
|
|
91
|
+
to: channel.recipients.join(', '),
|
|
92
|
+
subject,
|
|
93
|
+
html: body,
|
|
94
|
+
text: body,
|
|
95
|
+
};
|
|
96
|
+
const preResolvedHost = await resolveHostViaLookup(config.host);
|
|
97
|
+
if (preResolvedHost && preResolvedHost !== config.host) {
|
|
98
|
+
try {
|
|
99
|
+
const preResolvedTransport = createTransport(preResolvedHost);
|
|
100
|
+
await preResolvedTransport.sendMail(mailOptions);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
104
|
+
}
|
|
40
105
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
to: channel.recipients.join(', '),
|
|
44
|
-
subject,
|
|
45
|
-
html: body,
|
|
46
|
-
text: body,
|
|
47
|
-
});
|
|
106
|
+
const transport = createTransport();
|
|
107
|
+
await transport.sendMail(mailOptions);
|
|
48
108
|
}
|
|
49
109
|
catch (error) {
|
|
110
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
111
|
+
if (shouldRetryWithLookup(message)) {
|
|
112
|
+
const resolvedHost = await resolveHostViaLookup(config.host);
|
|
113
|
+
if (resolvedHost) {
|
|
114
|
+
try {
|
|
115
|
+
const fallbackTransport = createTransport(resolvedHost);
|
|
116
|
+
await fallbackTransport.sendMail(mailOptions);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
catch { }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
50
122
|
// Silent failure: log error but don't throw
|
|
51
123
|
const timestamp = new Date().toISOString();
|
|
52
124
|
console.error(`[${timestamp}] [${fromName}] Failed to send email:`, error);
|
package/dist/types.d.ts
CHANGED
|
@@ -36,6 +36,13 @@ export interface SMTPConfig {
|
|
|
36
36
|
host: string;
|
|
37
37
|
port: number;
|
|
38
38
|
secure?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Local interface to bind for outgoing connections
|
|
41
|
+
* - Use IPv4 address (e.g., "0.0.0.0", "192.168.1.1") to force IPv4
|
|
42
|
+
* - Use IPv6 address (e.g., "::", "::1", "2001:db8::1") to force IPv6
|
|
43
|
+
* - If not specified, both IPv4 and IPv6 are tried automatically
|
|
44
|
+
*/
|
|
45
|
+
localAddress?: string;
|
|
39
46
|
auth: {
|
|
40
47
|
user: string;
|
|
41
48
|
pass: string;
|