@ulthon/ul-opencode-event 0.1.4 → 0.1.5

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