@ulthon/ul-opencode-event 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 ./
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
- ### 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 |
131
+
132
+ ### IPv4/IPv6 Configuration
88
133
 
89
- ## Template Variables
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
- Use these variables in your subject and body templates:
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 plugin config file exists at `.opencode/ul-opencode-event.json`, `./ul-opencode-event.json`, or `~/.config/opencode/ul-opencode-event.json`
198
- 5. Enable debug logs with environment variable `UL_OPENCODE_EVENT_DEBUG=1`
199
-
200
- ### How to view logs
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
- ### SMTP authentication failed
273
+ # Test locally without publishing
274
+ node dist/cli.js test
207
275
 
208
- - For Gmail, use an App Password instead of your regular password
209
- - For QQ Mail, use the authorization code
210
- - Ensure `secure` matches your port (465 = true, 587 = false)
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
- return nodemailer.createTransport({
102
- host: config.host,
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
- return { success: false, error: message };
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
- try {
130
- const config = channel.config;
131
- const transporter = createTransporter(channel);
132
- const fromName = channel.name || 'OpenCode Notification Test';
133
- const timestamp = new Date().toISOString();
134
- const result = await transporter.sendMail({
135
- from: `"${fromName}" <${config.auth.user}>`,
136
- to: channel.recipients.join(', '),
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
- html: `
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
- return { success: false, error: message };
288
+ if (!shouldRetryWithLookup(message)) {
289
+ return { success: false, error: message };
290
+ }
291
+ const resolvedHost = await resolveHostViaLookup(config.host);
292
+ if (!resolvedHost) {
293
+ return { success: false, error: `${message}; lookup() fallback failed for ${config.host}` };
294
+ }
295
+ try {
296
+ const fallbackTransporter = createTransporter(channel, resolvedHost);
297
+ const result = await fallbackTransporter.sendMail(mailOptions);
298
+ return {
299
+ success: true,
300
+ messageId: result.messageId,
301
+ info: `DNS fallback used: ${config.host} -> ${resolvedHost}`,
302
+ };
303
+ }
304
+ catch (fallbackError) {
305
+ const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
306
+ return { success: false, error: `${message}; fallback send failed: ${fallbackMessage}` };
307
+ }
163
308
  }
164
309
  }
165
310
  /**
@@ -188,6 +333,9 @@ async function testChannel(channel, options) {
188
333
  return false;
189
334
  }
190
335
  console.log(` [OK] Connection successful`);
336
+ if (connectionResult.info) {
337
+ console.log(` [INFO] ${connectionResult.info}`);
338
+ }
191
339
  // Step 3: Send test email (if not skipped)
192
340
  if (options.noSend) {
193
341
  console.log(`\n [3/3] Skipping test email (--no-send flag)`);
@@ -201,6 +349,9 @@ async function testChannel(channel, options) {
201
349
  return false;
202
350
  }
203
351
  console.log(` [OK] Test email sent successfully`);
352
+ if (sendResult.info) {
353
+ console.log(` [INFO] ${sendResult.info}`);
354
+ }
204
355
  console.log(` Message ID: ${sendResult.messageId}`);
205
356
  console.log(`\n [SUCCESS] Channel "${channelName}" passed all tests`);
206
357
  console.log(` Please check the inbox of: ${channel.recipients?.join(', ')}`);
package/dist/email.js CHANGED
@@ -2,6 +2,44 @@
2
2
  * Email sender module for SMTP-based notifications
3
3
  */
4
4
  import * as nodemailer from 'nodemailer';
5
+ import { lookup } from 'node:dns/promises';
6
+ import { isIP } from 'node:net';
7
+ const LOOKUP_TIMEOUT_MS = 1200;
8
+ function shouldRetryWithLookup(message) {
9
+ const normalized = message.toLowerCase();
10
+ return (normalized.includes('querya etimeout') ||
11
+ normalized.includes('queryaaaa etimeout') ||
12
+ normalized.includes('enotfound') ||
13
+ normalized.includes('eai_again'));
14
+ }
15
+ async function resolveHostViaLookup(host) {
16
+ if (isIP(host) !== 0) {
17
+ return host;
18
+ }
19
+ const withTimeout = (task, timeoutMs) => {
20
+ return new Promise((resolve, reject) => {
21
+ const timer = setTimeout(() => reject(new Error(`lookup timeout after ${timeoutMs}ms`)), timeoutMs);
22
+ task.then(value => {
23
+ clearTimeout(timer);
24
+ resolve(value);
25
+ }, error => {
26
+ clearTimeout(timer);
27
+ reject(error);
28
+ });
29
+ });
30
+ };
31
+ try {
32
+ const addr = await withTimeout(lookup(host, { family: 4 }), LOOKUP_TIMEOUT_MS);
33
+ return addr.address;
34
+ }
35
+ catch { }
36
+ try {
37
+ const addr = await withTimeout(lookup(host, { family: 6 }), LOOKUP_TIMEOUT_MS);
38
+ return addr.address;
39
+ }
40
+ catch { }
41
+ return null;
42
+ }
5
43
  /**
6
44
  * Creates an email sender for the given SMTP channel
7
45
  * @param channel - The channel configuration (must be SMTP type)
@@ -22,31 +60,65 @@ export function createEmailSender(channel) {
22
60
  }
23
61
  const config = channel.config;
24
62
  const fromName = channel.name || 'Notification';
25
- // 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulthon/ul-opencode-event",
3
- "version": "0.1.3",
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",