@ulthon/ul-opencode-event 0.1.3 → 0.1.5
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.d.ts +1 -0
- package/dist/email.js +114 -20
- package/dist/handler.d.ts +2 -0
- package/dist/handler.js +41 -23
- package/dist/index.d.ts +2 -2
- package/dist/index.js +15 -0
- 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.d.ts
CHANGED
package/dist/email.js
CHANGED
|
@@ -2,6 +2,68 @@
|
|
|
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
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Creates nodemailer transport options
|
|
45
|
+
*/
|
|
46
|
+
function buildTransportOptions(config, hostOverride) {
|
|
47
|
+
const host = hostOverride || config.host;
|
|
48
|
+
const transportOptions = {
|
|
49
|
+
host,
|
|
50
|
+
port: config.port,
|
|
51
|
+
secure: config.secure,
|
|
52
|
+
auth: {
|
|
53
|
+
user: config.auth.user,
|
|
54
|
+
pass: config.auth.pass,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
if (config.localAddress) {
|
|
58
|
+
transportOptions.localAddress = config.localAddress;
|
|
59
|
+
}
|
|
60
|
+
if (hostOverride && hostOverride !== config.host) {
|
|
61
|
+
transportOptions.tls = {
|
|
62
|
+
servername: config.host,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return transportOptions;
|
|
66
|
+
}
|
|
5
67
|
/**
|
|
6
68
|
* Creates an email sender for the given SMTP channel
|
|
7
69
|
* @param channel - The channel configuration (must be SMTP type)
|
|
@@ -22,35 +84,67 @@ export function createEmailSender(channel) {
|
|
|
22
84
|
}
|
|
23
85
|
const config = channel.config;
|
|
24
86
|
const fromName = channel.name || 'Notification';
|
|
25
|
-
// Create
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
secure: config.secure,
|
|
30
|
-
auth: {
|
|
31
|
-
user: config.auth.user,
|
|
32
|
-
pass: config.auth.pass,
|
|
33
|
-
},
|
|
34
|
-
});
|
|
35
|
-
// Set security options
|
|
36
|
-
transport.set('disableFileAccess', true);
|
|
37
|
-
transport.set('disableUrlAccess', true);
|
|
87
|
+
// Create and cache the primary transporter (connection reuse)
|
|
88
|
+
const primaryTransport = nodemailer.createTransport(buildTransportOptions(config));
|
|
89
|
+
primaryTransport.set('disableFileAccess', true);
|
|
90
|
+
primaryTransport.set('disableUrlAccess', true);
|
|
38
91
|
return {
|
|
39
92
|
async send(subject, body) {
|
|
93
|
+
const mailOptions = {
|
|
94
|
+
from: `"${fromName}" <${config.auth.user}>`,
|
|
95
|
+
to: channel.recipients.join(', '),
|
|
96
|
+
subject,
|
|
97
|
+
html: body,
|
|
98
|
+
text: body,
|
|
99
|
+
};
|
|
100
|
+
// Try pre-resolved host first if DNS resolution succeeded
|
|
101
|
+
const preResolvedHost = await resolveHostViaLookup(config.host);
|
|
102
|
+
if (preResolvedHost && preResolvedHost !== config.host) {
|
|
103
|
+
let preResolvedTransport = null;
|
|
104
|
+
try {
|
|
105
|
+
// Create temporary transport for resolved IP (can't reuse for different host)
|
|
106
|
+
preResolvedTransport = nodemailer.createTransport(buildTransportOptions(config, preResolvedHost));
|
|
107
|
+
preResolvedTransport.set('disableFileAccess', true);
|
|
108
|
+
preResolvedTransport.set('disableUrlAccess', true);
|
|
109
|
+
await preResolvedTransport.sendMail(mailOptions);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
catch { }
|
|
113
|
+
finally {
|
|
114
|
+
preResolvedTransport?.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
40
117
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
to: channel.recipients.join(', '),
|
|
44
|
-
subject,
|
|
45
|
-
html: body,
|
|
46
|
-
text: body,
|
|
47
|
-
});
|
|
118
|
+
// Use cached primary transporter (connection reuse)
|
|
119
|
+
await primaryTransport.sendMail(mailOptions);
|
|
48
120
|
}
|
|
49
121
|
catch (error) {
|
|
122
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
123
|
+
if (shouldRetryWithLookup(message)) {
|
|
124
|
+
const resolvedHost = await resolveHostViaLookup(config.host);
|
|
125
|
+
if (resolvedHost) {
|
|
126
|
+
let fallbackTransport = null;
|
|
127
|
+
try {
|
|
128
|
+
// Create temporary transport for fallback (different host)
|
|
129
|
+
fallbackTransport = nodemailer.createTransport(buildTransportOptions(config, resolvedHost));
|
|
130
|
+
fallbackTransport.set('disableFileAccess', true);
|
|
131
|
+
fallbackTransport.set('disableUrlAccess', true);
|
|
132
|
+
await fallbackTransport.sendMail(mailOptions);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
catch { }
|
|
136
|
+
finally {
|
|
137
|
+
fallbackTransport?.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
50
141
|
// Silent failure: log error but don't throw
|
|
51
142
|
const timestamp = new Date().toISOString();
|
|
52
143
|
console.error(`[${timestamp}] [${fromName}] Failed to send email:`, error);
|
|
53
144
|
}
|
|
54
145
|
},
|
|
146
|
+
async close() {
|
|
147
|
+
primaryTransport.close();
|
|
148
|
+
},
|
|
55
149
|
};
|
|
56
150
|
}
|
package/dist/handler.d.ts
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
import type { NotificationConfig, EventPayload, EventType } from './types.js';
|
|
5
5
|
export interface EventHandler {
|
|
6
6
|
handle: (eventType: EventType, payload: EventPayload) => Promise<void>;
|
|
7
|
+
close: () => Promise<void>;
|
|
7
8
|
}
|
|
8
9
|
/**
|
|
9
10
|
* Creates an event handler that dispatches notifications to all enabled channels
|
|
11
|
+
* Senders are cached at initialization for connection reuse)
|
|
10
12
|
*/
|
|
11
13
|
export declare function createEventHandler(config: NotificationConfig): EventHandler;
|
package/dist/handler.js
CHANGED
|
@@ -17,16 +17,27 @@ const defaultTemplates = {
|
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* Creates an event handler that dispatches notifications to all enabled channels
|
|
20
|
+
* Senders are cached at initialization for connection reuse)
|
|
20
21
|
*/
|
|
21
22
|
export function createEventHandler(config) {
|
|
23
|
+
// Create and cache all senders at initialization (connection reuse)
|
|
24
|
+
const senders = new Map();
|
|
25
|
+
// map channel index to sender key for same order
|
|
26
|
+
const channelIndexMap = new Map();
|
|
27
|
+
for (let i = 0; i < config.channels.length; i++) {
|
|
28
|
+
const channel = config.channels[i];
|
|
29
|
+
const sender = createChannelSender(channel);
|
|
30
|
+
if (sender) {
|
|
31
|
+
const key = channel.name || `channel-${i}`;
|
|
32
|
+
senders.set(key, sender);
|
|
33
|
+
channelIndexMap.set(i, key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
22
36
|
return {
|
|
23
37
|
handle: async (eventType, payload) => {
|
|
24
|
-
// Process each channel
|
|
25
|
-
for (
|
|
26
|
-
|
|
27
|
-
if (channel.enabled === false) {
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
38
|
+
// Process each channel using cached senders
|
|
39
|
+
for (let i = 0; i < config.channels.length; i++) {
|
|
40
|
+
const channel = config.channels[i];
|
|
30
41
|
// Check if this event type is enabled for the channel
|
|
31
42
|
const eventEnabled = channel.events[eventType];
|
|
32
43
|
if (eventEnabled === false) {
|
|
@@ -34,7 +45,7 @@ export function createEventHandler(config) {
|
|
|
34
45
|
}
|
|
35
46
|
// Get template (channel-specific or default)
|
|
36
47
|
const template = channel.templates?.[eventType] ?? defaultTemplates[eventType];
|
|
37
|
-
//
|
|
48
|
+
// prepare template variables from payload
|
|
38
49
|
const variables = {
|
|
39
50
|
eventType: payload.eventType,
|
|
40
51
|
timestamp: payload.timestamp,
|
|
@@ -44,35 +55,42 @@ export function createEventHandler(config) {
|
|
|
44
55
|
error: payload.error,
|
|
45
56
|
duration: payload.duration,
|
|
46
57
|
};
|
|
47
|
-
//
|
|
58
|
+
// render templates
|
|
48
59
|
const subject = template.subject ? renderTemplate(template.subject, variables) : '';
|
|
49
60
|
const body = template.body ? renderTemplate(template.body, variables) : '';
|
|
50
|
-
//
|
|
51
|
-
|
|
61
|
+
// dispatch using cached sender
|
|
62
|
+
const key = channel.name || `channel-${i}`;
|
|
63
|
+
const sender = senders.get(key);
|
|
64
|
+
if (sender) {
|
|
65
|
+
await sender.send(subject, body);
|
|
66
|
+
}
|
|
52
67
|
}
|
|
53
68
|
},
|
|
69
|
+
close: async () => {
|
|
70
|
+
// close all cached senders (cleanup SMTP connections)
|
|
71
|
+
const closePromises = Array.from(senders.values()).map(sender => sender.close());
|
|
72
|
+
await Promise.all(closePromises);
|
|
73
|
+
senders.clear();
|
|
74
|
+
channelIndexMap.clear();
|
|
75
|
+
},
|
|
54
76
|
};
|
|
55
77
|
}
|
|
56
78
|
/**
|
|
57
|
-
*
|
|
79
|
+
* create a sender for the given channel type
|
|
58
80
|
*/
|
|
59
|
-
|
|
81
|
+
function createChannelSender(channel) {
|
|
60
82
|
switch (channel.type) {
|
|
61
|
-
case 'smtp':
|
|
62
|
-
|
|
63
|
-
if (sender) {
|
|
64
|
-
await sender.send(subject, body);
|
|
65
|
-
}
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
83
|
+
case 'smtp':
|
|
84
|
+
return createEmailSender(channel);
|
|
68
85
|
case 'feishu':
|
|
69
86
|
case 'dingtalk':
|
|
70
|
-
//
|
|
71
|
-
|
|
87
|
+
// skip unsupported channel types silently
|
|
88
|
+
return null;
|
|
72
89
|
default: {
|
|
73
|
-
//
|
|
90
|
+
// log warning for unknown channel types
|
|
74
91
|
const _exhaustiveCheck = channel.type;
|
|
75
|
-
console.warn(`[EventHandler]
|
|
92
|
+
console.warn(`[EventHandler] unsupported channel type: ${_exhaustiveCheck}`);
|
|
93
|
+
return null;
|
|
76
94
|
}
|
|
77
95
|
}
|
|
78
96
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -88,6 +88,21 @@ export const NotificationPlugin = async (ctx) => {
|
|
|
88
88
|
if (DEBUG_ENABLED) {
|
|
89
89
|
console.info(`${LOG_PREFIX} Plugin initialized for ${ctx.directory} with ${config.channels.length} channels`);
|
|
90
90
|
}
|
|
91
|
+
// Register cleanup handlers for graceful shutdown
|
|
92
|
+
const cleanup = async () => {
|
|
93
|
+
await eventHandler.close();
|
|
94
|
+
if (DEBUG_ENABLED) {
|
|
95
|
+
console.info(`${LOG_PREFIX} Closed all SMTP connections`);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
// Handle process exit (sync handler, cannot be async)
|
|
99
|
+
process.on('exit', () => {
|
|
100
|
+
// Note: async cleanup won't work in 'exit' handler, but we try anyway
|
|
101
|
+
eventHandler.close().catch(() => { });
|
|
102
|
+
});
|
|
103
|
+
// Handle termination signals (async cleanup possible)
|
|
104
|
+
process.on('SIGINT', cleanup);
|
|
105
|
+
process.on('SIGTERM', cleanup);
|
|
91
106
|
return {
|
|
92
107
|
event: async ({ event }) => {
|
|
93
108
|
if (event.type === 'session.created') {
|
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;
|