@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 CHANGED
@@ -2,33 +2,42 @@
2
2
 
3
3
  OpenCode notification plugin that sends email notifications when session events occur.
4
4
 
5
- ## Installation
5
+ ## Quick Start
6
6
 
7
7
  ```bash
8
+ # 1. 安装
8
9
  npm install @ulthon/ul-opencode-event
9
- ```
10
10
 
11
- Then add to your OpenCode configuration:
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
- ### Configuration Format
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
- "created": true,
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
- "created": {
56
- "subject": "[OpenCode] Task Started - {{projectName}}",
57
- "body": "Task started at {{timestamp}}"
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` (more coming soon) |
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 | Channel-specific configuration |
75
- | `recipients` | string[] | Yes* | Email recipients (*required for smtp*) |
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
- | `auth.user` | string | Yes | SMTP username |
87
- | `auth.pass` | string | Yes | SMTP password or app password |
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
- ## Template Variables
132
+ ### IPv4/IPv6 Configuration
90
133
 
91
- Use these variables in your subject and body templates:
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
- ## Events
212
+ # Test a specific channel by name
213
+ npx @ulthon/ul-opencode-event test --channel "My Email"
104
214
 
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 |
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
- ## Multiple Channels
224
+ [ul-opencode-event-cli] Testing channel: "My Email"
225
+ Type: smtp
226
+ Recipients: user@example.com
112
227
 
113
- You can configure multiple channels, and all enabled channels will receive notifications:
228
+ [1/3] Validating configuration...
229
+ [OK] Configuration is valid
114
230
 
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
- ```
231
+ [2/3] Testing SMTP connection to smtp.example.com:465...
232
+ [OK] Connection successful
137
233
 
138
- ## Troubleshooting
234
+ [3/3] Sending test email...
235
+ [OK] Test email sent successfully
236
+ Message ID: <xxx>
139
237
 
140
- ### No notifications sent
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 plugin config file exists at `.opencode/ul-opencode-event.json`, `./ul-opencode-event.json`, or `~/.config/opencode/ul-opencode-event.json`
146
- 5. Enable debug logs with environment variable `UL_OPENCODE_EVENT_DEBUG=1`
147
-
148
- ### How to view logs
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
- ### SMTP authentication failed
273
+ # Test locally without publishing
274
+ node dist/cli.js test
155
275
 
156
- - For Gmail, use an App Password instead of your regular password
157
- - For QQ Mail, use the authorization code
158
- - Ensure `secure` matches your port (465 = true, 587 = false)
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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
- // Create nodemailer transport with SMTP config
26
- const transport = nodemailer.createTransport({
27
- host: config.host,
28
- port: config.port,
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);
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
- await transport.sendMail({
42
- from: `"${fromName}" <${config.auth.user}>`,
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.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",