@ulthon/ul-opencode-event 0.1.1 → 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 +196 -74
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +473 -0
- package/dist/email.js +92 -20
- package/dist/types.d.ts +7 -0
- package/package.json +6 -2
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 ./
|
|
13
|
+
|
|
14
|
+
# 3. 编辑配置,填入你的 SMTP 信息
|
|
12
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 |
|
|
88
131
|
|
|
89
|
-
|
|
132
|
+
### IPv4/IPv6 Configuration
|
|
90
133
|
|
|
91
|
-
|
|
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
|
|
92
138
|
|
|
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
|
+
```
|
|
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,64 +202,83 @@ 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 |
|
|
205
|
+
## CLI Tool
|
|
206
|
+
This package includes a CLI tool to test your notification channel configuration.
|
|
207
|
+
### Test Command
|
|
208
|
+
```bash
|
|
209
|
+
# Test all channels (validates config, tests connection, sends test email)
|
|
210
|
+
npx @ulthon/ul-opencode-event test
|
|
102
211
|
|
|
103
|
-
|
|
212
|
+
# Test a specific channel by name
|
|
213
|
+
npx @ulthon/ul-opencode-event test --channel "My Email"
|
|
104
214
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
215
|
+
# Only verify connection without sending email
|
|
216
|
+
npx @ulthon/ul-opencode-event test --no-send
|
|
217
|
+
```
|
|
218
|
+
### Test Output Example
|
|
219
|
+
```
|
|
220
|
+
[ul-opencode-event-cli] Starting channel test...
|
|
221
|
+
[ul-opencode-event-cli] Loaded project config: ./ul-opencode-event.json
|
|
222
|
+
[ul-opencode-event-cli] Found 1 channel(s) to test
|
|
110
223
|
|
|
111
|
-
|
|
224
|
+
[ul-opencode-event-cli] Testing channel: "My Email"
|
|
225
|
+
Type: smtp
|
|
226
|
+
Recipients: user@example.com
|
|
112
227
|
|
|
113
|
-
|
|
228
|
+
[1/3] Validating configuration...
|
|
229
|
+
[OK] Configuration is valid
|
|
114
230
|
|
|
115
|
-
|
|
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
|
-
```
|
|
231
|
+
[2/3] Testing SMTP connection to smtp.example.com:465...
|
|
232
|
+
[OK] Connection successful
|
|
137
233
|
|
|
138
|
-
|
|
234
|
+
[3/3] Sending test email...
|
|
235
|
+
[OK] Test email sent successfully
|
|
236
|
+
Message ID: <xxx>
|
|
139
237
|
|
|
140
|
-
|
|
238
|
+
[SUCCESS] Channel "My Email" passed all tests
|
|
141
239
|
|
|
240
|
+
[ul-opencode-event-cli] Test Summary:
|
|
241
|
+
Passed: 1
|
|
242
|
+
Failed: 0
|
|
243
|
+
```
|
|
244
|
+
## Troubleshooting
|
|
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
|
+
```
|
|
263
|
+
### No notifications sent
|
|
142
264
|
1. Check if `enabled: true` is set
|
|
143
265
|
2. Check if the event type is enabled in `events`
|
|
144
266
|
3. Check SMTP credentials are correct
|
|
145
|
-
4. Ensure
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
- Start OpenCode from terminal to view stdout/stderr logs
|
|
151
|
-
- Plugin logs use prefix `[ul-opencode-event]`
|
|
152
|
-
- 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
|
|
153
272
|
|
|
154
|
-
|
|
273
|
+
# Test locally without publishing
|
|
274
|
+
node dist/cli.js test
|
|
155
275
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
-
|
|
276
|
+
# Or use npm link
|
|
277
|
+
npm link
|
|
278
|
+
npx @ulthon/ul-opencode-event test
|
|
159
279
|
|
|
280
|
+
# Unlink when done
|
|
281
|
+
npm unlink -g @ulthon/ul-opencode-event
|
|
282
|
+
```
|
|
160
283
|
## License
|
|
161
|
-
|
|
162
284
|
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI tool for testing notification channel configuration
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx @ulthon/ul-opencode-event test
|
|
7
|
+
* npx @ulthon/ul-opencode-event test --channel "My Channel"
|
|
8
|
+
* npx @ulthon/ul-opencode-event test --no-send # Only verify connection, don't send email
|
|
9
|
+
*/
|
|
10
|
+
import * as nodemailer from 'nodemailer';
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import { lookup } from 'node:dns/promises';
|
|
15
|
+
import { isIP } from 'node:net';
|
|
16
|
+
const CLI_PREFIX = '[ul-opencode-event-cli]';
|
|
17
|
+
const LOOKUP_TIMEOUT_MS = 1200;
|
|
18
|
+
/**
|
|
19
|
+
* Print usage information
|
|
20
|
+
*/
|
|
21
|
+
function printUsage() {
|
|
22
|
+
console.log(`
|
|
23
|
+
Usage: npx @ulthon/ul-opencode-event test [options]
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--channel <name> Test only the channel with this name
|
|
27
|
+
--no-send Only verify connection, don't send test email
|
|
28
|
+
--help Show this help message
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
npx @ulthon/ul-opencode-event test
|
|
32
|
+
npx @ulthon/ul-opencode-event test --channel "Primary Email"
|
|
33
|
+
npx @ulthon/ul-opencode-event test --no-send
|
|
34
|
+
`);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parse command line arguments
|
|
38
|
+
*/
|
|
39
|
+
function parseArgs() {
|
|
40
|
+
const args = process.argv.slice(2);
|
|
41
|
+
const options = {
|
|
42
|
+
noSend: false,
|
|
43
|
+
};
|
|
44
|
+
let command = null;
|
|
45
|
+
for (let i = 0; i < args.length; i++) {
|
|
46
|
+
const arg = args[i];
|
|
47
|
+
if (arg === '--help' || arg === '-h') {
|
|
48
|
+
printUsage();
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
else if (arg === '--channel') {
|
|
52
|
+
options.channelName = args[++i];
|
|
53
|
+
}
|
|
54
|
+
else if (arg === '--no-send') {
|
|
55
|
+
options.noSend = true;
|
|
56
|
+
}
|
|
57
|
+
else if (!arg.startsWith('-')) {
|
|
58
|
+
command = arg;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { command, options };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Validate channel configuration
|
|
65
|
+
*/
|
|
66
|
+
function validateChannel(channel) {
|
|
67
|
+
const errors = [];
|
|
68
|
+
// Check type
|
|
69
|
+
if (channel.type !== 'smtp') {
|
|
70
|
+
errors.push(`Unsupported channel type: "${channel.type}". Only "smtp" is supported.`);
|
|
71
|
+
return { valid: false, errors };
|
|
72
|
+
}
|
|
73
|
+
// Check enabled
|
|
74
|
+
if (channel.enabled === false) {
|
|
75
|
+
errors.push('Channel is disabled (enabled: false)');
|
|
76
|
+
return { valid: false, errors };
|
|
77
|
+
}
|
|
78
|
+
// Check recipients
|
|
79
|
+
if (!channel.recipients || channel.recipients.length === 0) {
|
|
80
|
+
errors.push('No recipients configured');
|
|
81
|
+
return { valid: false, errors };
|
|
82
|
+
}
|
|
83
|
+
// Check SMTP config
|
|
84
|
+
const config = channel.config;
|
|
85
|
+
if (!config.host) {
|
|
86
|
+
errors.push('SMTP host is missing');
|
|
87
|
+
}
|
|
88
|
+
if (!config.port) {
|
|
89
|
+
errors.push('SMTP port is missing');
|
|
90
|
+
}
|
|
91
|
+
if (!config.auth?.user) {
|
|
92
|
+
errors.push('SMTP auth.user is missing');
|
|
93
|
+
}
|
|
94
|
+
if (!config.auth?.pass) {
|
|
95
|
+
errors.push('SMTP auth.pass is missing');
|
|
96
|
+
}
|
|
97
|
+
return { valid: errors.length === 0, errors };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Create SMTP transporter
|
|
101
|
+
* Supports IPv4/IPv6 via localAddress option
|
|
102
|
+
*/
|
|
103
|
+
function createTransporter(channel, hostOverride) {
|
|
104
|
+
const config = channel.config;
|
|
105
|
+
const host = hostOverride || config.host;
|
|
106
|
+
const transportOptions = {
|
|
107
|
+
host,
|
|
108
|
+
port: config.port,
|
|
109
|
+
secure: config.secure ?? (config.port === 465),
|
|
110
|
+
auth: {
|
|
111
|
+
user: config.auth.user,
|
|
112
|
+
pass: config.auth.pass,
|
|
113
|
+
},
|
|
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;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Test SMTP connection
|
|
165
|
+
*/
|
|
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
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const transporter = createTransporter(channel);
|
|
196
|
+
await transporter.verify();
|
|
197
|
+
return { success: true };
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
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
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Send test email
|
|
224
|
+
*/
|
|
225
|
+
async function sendTestEmail(channel) {
|
|
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.
|
|
234
|
+
|
|
235
|
+
Channel: ${channel.name || 'unnamed'}
|
|
236
|
+
Type: ${channel.type}
|
|
237
|
+
Time: ${timestamp}
|
|
238
|
+
|
|
239
|
+
If you received this email, your notification channel is configured correctly.`,
|
|
240
|
+
html: `
|
|
241
|
+
<h2>OpenCode Notification Channel Test</h2>
|
|
242
|
+
<p>This is a test email from OpenCode notification plugin.</p>
|
|
243
|
+
<hr>
|
|
244
|
+
<ul>
|
|
245
|
+
<li><strong>Channel:</strong> ${channel.name || 'unnamed'}</li>
|
|
246
|
+
<li><strong>Type:</strong> ${channel.type}</li>
|
|
247
|
+
<li><strong>Time:</strong> ${timestamp}</li>
|
|
248
|
+
</ul>
|
|
249
|
+
<hr>
|
|
250
|
+
<p style="color: green;">If you received this email, your notification channel is configured correctly.</p>
|
|
251
|
+
`,
|
|
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);
|
|
284
|
+
return { success: true, messageId: result.messageId };
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
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
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Test a single channel
|
|
312
|
+
*/
|
|
313
|
+
async function testChannel(channel, options) {
|
|
314
|
+
const channelName = channel.name || 'unnamed';
|
|
315
|
+
console.log(`\n${CLI_PREFIX} Testing channel: "${channelName}"`);
|
|
316
|
+
console.log(` Type: ${channel.type}`);
|
|
317
|
+
console.log(` Recipients: ${channel.recipients?.join(', ') || 'none'}`);
|
|
318
|
+
// Step 1: Validate configuration
|
|
319
|
+
console.log(`\n [1/3] Validating configuration...`);
|
|
320
|
+
const validation = validateChannel(channel);
|
|
321
|
+
if (!validation.valid) {
|
|
322
|
+
console.log(` [FAIL] Configuration validation failed:`);
|
|
323
|
+
validation.errors.forEach(err => console.log(` - ${err}`));
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
console.log(` [OK] Configuration is valid`);
|
|
327
|
+
// Step 2: Test connection
|
|
328
|
+
const smtpConfig = channel.config;
|
|
329
|
+
console.log(`\n [2/3] Testing SMTP connection to ${smtpConfig.host}:${smtpConfig.port}...`);
|
|
330
|
+
const connectionResult = await testConnection(channel);
|
|
331
|
+
if (!connectionResult.success) {
|
|
332
|
+
console.log(` [FAIL] Connection failed: ${connectionResult.error}`);
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
console.log(` [OK] Connection successful`);
|
|
336
|
+
if (connectionResult.info) {
|
|
337
|
+
console.log(` [INFO] ${connectionResult.info}`);
|
|
338
|
+
}
|
|
339
|
+
// Step 3: Send test email (if not skipped)
|
|
340
|
+
if (options.noSend) {
|
|
341
|
+
console.log(`\n [3/3] Skipping test email (--no-send flag)`);
|
|
342
|
+
console.log(`\n [SUCCESS] Channel "${channelName}" passed all tests (connection only)`);
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
console.log(`\n [3/3] Sending test email...`);
|
|
346
|
+
const sendResult = await sendTestEmail(channel);
|
|
347
|
+
if (!sendResult.success) {
|
|
348
|
+
console.log(` [FAIL] Failed to send test email: ${sendResult.error}`);
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
console.log(` [OK] Test email sent successfully`);
|
|
352
|
+
if (sendResult.info) {
|
|
353
|
+
console.log(` [INFO] ${sendResult.info}`);
|
|
354
|
+
}
|
|
355
|
+
console.log(` Message ID: ${sendResult.messageId}`);
|
|
356
|
+
console.log(`\n [SUCCESS] Channel "${channelName}" passed all tests`);
|
|
357
|
+
console.log(` Please check the inbox of: ${channel.recipients?.join(', ')}`);
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Load configuration (simplified version that loads from file)
|
|
362
|
+
* This is similar to config.ts but returns all channels including disabled ones
|
|
363
|
+
*/
|
|
364
|
+
function loadConfigForTesting() {
|
|
365
|
+
// Config file paths
|
|
366
|
+
const globalPath = path.join(os.homedir(), '.config', 'opencode', 'ul-opencode-event.json');
|
|
367
|
+
const projectOpencodePath = path.join(process.cwd(), '.opencode', 'ul-opencode-event.json');
|
|
368
|
+
const projectLocalPath = path.join(process.cwd(), 'ul-opencode-event.json');
|
|
369
|
+
let globalConfig = null;
|
|
370
|
+
let projectConfig = null;
|
|
371
|
+
// Load global config
|
|
372
|
+
if (fs.existsSync(globalPath)) {
|
|
373
|
+
try {
|
|
374
|
+
const content = fs.readFileSync(globalPath, 'utf-8');
|
|
375
|
+
globalConfig = JSON.parse(content);
|
|
376
|
+
console.log(`${CLI_PREFIX} Loaded global config: ${globalPath}`);
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
console.error(`${CLI_PREFIX} Failed to parse global config: ${globalPath}`, error);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Load project config
|
|
383
|
+
const projectPath = fs.existsSync(projectOpencodePath) ? projectOpencodePath :
|
|
384
|
+
fs.existsSync(projectLocalPath) ? projectLocalPath : null;
|
|
385
|
+
if (projectPath) {
|
|
386
|
+
try {
|
|
387
|
+
const content = fs.readFileSync(projectPath, 'utf-8');
|
|
388
|
+
projectConfig = JSON.parse(content);
|
|
389
|
+
console.log(`${CLI_PREFIX} Loaded project config: ${projectPath}`);
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
console.error(`${CLI_PREFIX} Failed to parse project config: ${projectPath}`, error);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// If no configs found
|
|
396
|
+
if (!globalConfig && !projectConfig) {
|
|
397
|
+
console.error(`${CLI_PREFIX} No configuration file found.`);
|
|
398
|
+
console.error(`${CLI_PREFIX} Checked locations:`);
|
|
399
|
+
console.error(` - ${globalPath}`);
|
|
400
|
+
console.error(` - ${projectOpencodePath}`);
|
|
401
|
+
console.error(` - ${projectLocalPath}`);
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
// Merge channels (project overrides global by name)
|
|
405
|
+
const globalChannels = globalConfig?.channels || [];
|
|
406
|
+
const projectChannels = projectConfig?.channels || [];
|
|
407
|
+
const mergedChannels = [...globalChannels];
|
|
408
|
+
for (const projectChannel of projectChannels) {
|
|
409
|
+
if (projectChannel.name) {
|
|
410
|
+
const existingIndex = mergedChannels.findIndex(c => c.name === projectChannel.name);
|
|
411
|
+
if (existingIndex >= 0) {
|
|
412
|
+
mergedChannels[existingIndex] = projectChannel;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
mergedChannels.push(projectChannel);
|
|
417
|
+
}
|
|
418
|
+
return { channels: mergedChannels };
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Main CLI entry point
|
|
422
|
+
*/
|
|
423
|
+
async function main() {
|
|
424
|
+
const { command, options } = parseArgs();
|
|
425
|
+
if (command !== 'test') {
|
|
426
|
+
console.error(`${CLI_PREFIX} Unknown command: "${command || '(none)'}"`);
|
|
427
|
+
printUsage();
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
console.log(`${CLI_PREFIX} Starting channel test...`);
|
|
431
|
+
// Load configuration
|
|
432
|
+
const config = loadConfigForTesting();
|
|
433
|
+
if (config.channels.length === 0) {
|
|
434
|
+
console.error(`${CLI_PREFIX} No channels configured.`);
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
// Filter channels by name if specified
|
|
438
|
+
let channelsToTest = config.channels;
|
|
439
|
+
if (options.channelName) {
|
|
440
|
+
channelsToTest = config.channels.filter(c => c.name === options.channelName);
|
|
441
|
+
if (channelsToTest.length === 0) {
|
|
442
|
+
console.error(`${CLI_PREFIX} No channel found with name: "${options.channelName}"`);
|
|
443
|
+
console.error(`${CLI_PREFIX} Available channels:`);
|
|
444
|
+
config.channels.forEach(c => console.error(` - ${c.name || 'unnamed'}`));
|
|
445
|
+
process.exit(1);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
console.log(`${CLI_PREFIX} Found ${channelsToTest.length} channel(s) to test`);
|
|
449
|
+
// Test each channel
|
|
450
|
+
let successCount = 0;
|
|
451
|
+
let failCount = 0;
|
|
452
|
+
for (const channel of channelsToTest) {
|
|
453
|
+
const success = await testChannel(channel, options);
|
|
454
|
+
if (success) {
|
|
455
|
+
successCount++;
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
failCount++;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Summary
|
|
462
|
+
console.log(`\n${CLI_PREFIX} Test Summary:`);
|
|
463
|
+
console.log(` Passed: ${successCount}`);
|
|
464
|
+
console.log(` Failed: ${failCount}`);
|
|
465
|
+
if (failCount > 0) {
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Run CLI
|
|
470
|
+
main().catch(error => {
|
|
471
|
+
console.error(`${CLI_PREFIX} Unexpected error:`, error);
|
|
472
|
+
process.exit(1);
|
|
473
|
+
});
|
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;
|
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ulthon/ul-opencode-event",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "OpenCode notification plugin - sends email when session events occur",
|
|
5
5
|
"author": "augushong",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "dist/index.js",
|
|
9
9
|
"types": "dist/index.d.ts",
|
|
10
|
+
"bin": {
|
|
11
|
+
"ul-opencode-event": "dist/cli.js"
|
|
12
|
+
},
|
|
10
13
|
"exports": {
|
|
11
14
|
".": {
|
|
12
15
|
"import": "./dist/index.js",
|
|
@@ -26,7 +29,8 @@
|
|
|
26
29
|
"plugin",
|
|
27
30
|
"notification",
|
|
28
31
|
"email",
|
|
29
|
-
"smtp"
|
|
32
|
+
"smtp",
|
|
33
|
+
"cli"
|
|
30
34
|
],
|
|
31
35
|
"files": [
|
|
32
36
|
"dist",
|