@leviyuan/lodestar 0.1.0 โ†’ 2.0.14

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.
@@ -0,0 +1,534 @@
1
+ /**
2
+ * Email worker โ€” IMAP listener + SMTP sender + task queue.
3
+ *
4
+ * Watches a mailbox via IMAP, filters by whitelist, parses
5
+ * project + request from subject/body, creates a DeepSeek thread+turn
6
+ * via Runtime API, and replies with the result via SMTP.
7
+ *
8
+ * Feishu notifications are sent at three points:
9
+ * ๐Ÿ“ฉ received ๐Ÿš€ started โœ… completed (or โฐ timeout / โŒ error)
10
+ *
11
+ * DeepSeek TUI migration: spawn claude -p โ†’ Runtime API createThread + createTurn.
12
+ *
13
+ * Imported and started by daemon.ts.
14
+ */
15
+
16
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
17
+ import { join } from 'path'
18
+ import { homedir } from 'os'
19
+ import { RuntimeApiClient } from './runtime-api'
20
+
21
+ // โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
22
+
23
+ export interface EmailConfig {
24
+ imap: { host: string; port?: number; user: string; pass: string }
25
+ smtp: { host: string; port?: number; user: string; pass: string }
26
+ projects: Record<string, ProjectConfig>
27
+ defaults?: {
28
+ timeout_minutes?: number
29
+ permission_mode?: string
30
+ max_budget_usd?: number
31
+ }
32
+ }
33
+
34
+ export interface ProjectConfig {
35
+ whitelist: string[]
36
+ allowed_intents?: string
37
+ system_prompt?: string
38
+ timeout_minutes?: number
39
+ permission_mode?: string
40
+ max_budget_usd?: number
41
+ }
42
+
43
+ interface EmailTask {
44
+ from: string
45
+ subject: string
46
+ project: string
47
+ request: string
48
+ messageId: string
49
+ inReplyTo?: string
50
+ receivedAt: Date
51
+ }
52
+
53
+ // โ”€โ”€ State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
54
+
55
+ const STATE_DIR = join(homedir(), '.deepseek', 'lodestar')
56
+ const PROJECTS_ROOT = process.env.FEISHU_PROJECTS_ROOT ?? homedir()
57
+ const CONFIG_FILE = join(STATE_DIR, 'email-config.json')
58
+ const API_URL = process.env.DEEPSEEK_API_URL ?? 'http://localhost:7878'
59
+ const API_TOKEN = process.env.DEEPSEEK_API_TOKEN ?? ''
60
+
61
+ let config: EmailConfig | null = null
62
+ let imapClient: any = null
63
+ let smtpTransport: any = null
64
+ let logFn: (msg: string) => void = console.log
65
+ let sendFeishuFn: ((chatId: string, text: string) => Promise<void>) | null = null
66
+ let chatNameCacheRef: Map<string, string> | null = null
67
+ let api: RuntimeApiClient
68
+
69
+ // Task queue: project โ†’ pending tasks
70
+ const taskQueues = new Map<string, EmailTask[]>()
71
+ const activeJobs = new Map<string, boolean>()
72
+
73
+ // Dedup: track processed message IDs to avoid re-processing after IMAP reconnect
74
+ const processedMessageIds = new Set<string>()
75
+
76
+ // โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
77
+
78
+ /**
79
+ * Initialize the email worker. Called from daemon.ts boot().
80
+ *
81
+ * @param log daemon's log function
82
+ * @param sendText daemon's sendTextMessage function
83
+ * @param chatNames daemon's chatNameCache (to find chat_id for a project)
84
+ */
85
+ export async function startEmailWorker(
86
+ log: (msg: string) => void,
87
+ sendText: (chatId: string, text: string) => Promise<void>,
88
+ chatNames: Map<string, string>,
89
+ ): Promise<void> {
90
+ logFn = log
91
+ sendFeishuFn = sendText
92
+ chatNameCacheRef = chatNames
93
+
94
+ api = new RuntimeApiClient({ baseUrl: API_URL, authToken: API_TOKEN })
95
+
96
+ config = loadConfig()
97
+ if (!config) {
98
+ logFn('email-worker: no email-config.json found, email channel disabled')
99
+ return
100
+ }
101
+
102
+ logFn(`email-worker: projects: ${Object.keys(config.projects).join(', ')}`)
103
+ for (const [name, proj] of Object.entries(config.projects)) {
104
+ logFn(`email-worker: ${name} whitelist: [${proj.whitelist.join(', ')}]`)
105
+ }
106
+
107
+ try {
108
+ await setupSMTP()
109
+ await setupIMAP()
110
+ logFn('email-worker: ready')
111
+ } catch (err) {
112
+ logFn(`email-worker: startup failed: ${err}`)
113
+ }
114
+ }
115
+
116
+ export function stopEmailWorker(): void {
117
+ try { imapClient?.close() } catch {}
118
+ try { imapClient?.logout() } catch {}
119
+ try { smtpTransport?.close() } catch {}
120
+ logFn('email-worker: stopped')
121
+ }
122
+
123
+ // โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
124
+
125
+ function loadConfig(): EmailConfig | null {
126
+ try {
127
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
128
+ } catch {
129
+ return null
130
+ }
131
+ }
132
+
133
+ // โ”€โ”€ IMAP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
134
+
135
+ let ImapFlowClass: any = null
136
+
137
+ async function createImapClient(): Promise<any> {
138
+ if (!ImapFlowClass) {
139
+ const mod = await import('imapflow')
140
+ ImapFlowClass = mod.ImapFlow
141
+ }
142
+ const imap = config!.imap
143
+ const client = new ImapFlowClass({
144
+ host: imap.host,
145
+ port: imap.port ?? 993,
146
+ secure: true,
147
+ auth: { user: imap.user, pass: imap.pass },
148
+ logger: false,
149
+ })
150
+ client.on('error', (err: any) => {
151
+ logFn(`email-worker: IMAP error: ${err}`)
152
+ })
153
+ return client
154
+ }
155
+
156
+ async function setupIMAP(): Promise<void> {
157
+ imapClient = await createImapClient()
158
+ await imapClient.connect()
159
+ logFn('email-worker: IMAP connected')
160
+
161
+ listenForMail().catch(err => logFn(`email-worker: listen loop crashed: ${err}`))
162
+ }
163
+
164
+ async function reconnectIMAP(): Promise<void> {
165
+ try { imapClient?.logout() } catch {}
166
+ imapClient = await createImapClient()
167
+ await imapClient.connect()
168
+ logFn('email-worker: IMAP reconnected')
169
+ }
170
+
171
+ async function listenForMail(): Promise<void> {
172
+ try {
173
+ const lock = await imapClient.getMailboxLock('INBOX')
174
+ try {
175
+ await processUnseen()
176
+ } finally {
177
+ lock.release()
178
+ }
179
+ } catch (err) {
180
+ logFn(`email-worker: initial processUnseen failed: ${err}`)
181
+ }
182
+
183
+ setInterval(async () => {
184
+ try {
185
+ const lock = await imapClient.getMailboxLock('INBOX')
186
+ try {
187
+ await processUnseen()
188
+ } finally {
189
+ lock.release()
190
+ }
191
+ } catch (err) {
192
+ logFn(`email-worker: poll error: ${err}`)
193
+ try {
194
+ await reconnectIMAP()
195
+ } catch (reconErr) {
196
+ logFn(`email-worker: reconnect failed: ${reconErr}`)
197
+ }
198
+ }
199
+ }, 30_000)
200
+
201
+ logFn('email-worker: polling every 30s for new mail')
202
+ }
203
+
204
+ async function processUnseen(): Promise<void> {
205
+ const messages = imapClient.fetch({ seen: false }, {
206
+ envelope: true,
207
+ source: true,
208
+ uid: true,
209
+ })
210
+
211
+ for await (const msg of messages) {
212
+ try {
213
+ await handleEmail(msg)
214
+ await imapClient.messageFlagsAdd({ uid: msg.uid }, ['\\Seen'], { uid: true })
215
+ } catch (err) {
216
+ logFn(`email-worker: handle email error: ${err}`)
217
+ }
218
+ }
219
+ }
220
+
221
+ // โ”€โ”€ Email parsing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
222
+
223
+ async function handleEmail(msg: any): Promise<void> {
224
+ const envelope = msg.envelope
225
+ const from = envelope?.from?.[0]?.address?.toLowerCase() ?? ''
226
+ const subject = envelope?.subject ?? ''
227
+ const messageId = envelope?.messageId ?? ''
228
+ const inReplyTo = envelope?.inReplyTo ?? undefined
229
+
230
+ logFn(`email-worker: received from=${from} subject="${subject}" msgId=${messageId}`)
231
+
232
+ if (processedMessageIds.has(messageId)) {
233
+ logFn(`email-worker: skipping duplicate messageId=${messageId}`)
234
+ return
235
+ }
236
+ processedMessageIds.add(messageId)
237
+ if (processedMessageIds.size > 500) {
238
+ const arr = [...processedMessageIds]
239
+ for (let i = 0; i < 250; i++) processedMessageIds.delete(arr[i])
240
+ }
241
+
242
+ const projectMatch = subject.match(/^\[([^\]]+)\]\s*(.*)$/)
243
+ if (!projectMatch) {
244
+ logFn(`email-worker: no [project] in subject, ignoring`)
245
+ return
246
+ }
247
+
248
+ const project = projectMatch[1].trim()
249
+ const requestTitle = projectMatch[2].trim()
250
+
251
+ if (!config!.projects[project]) {
252
+ logFn(`email-worker: project "${project}" not in config, ignoring`)
253
+ return
254
+ }
255
+
256
+ const projConfig = config!.projects[project]
257
+ if (!projConfig.whitelist.some(w => w.toLowerCase() === from)) {
258
+ logFn(`email-worker: ${from} not in whitelist for project "${project}", ignoring`)
259
+ return
260
+ }
261
+
262
+ const workDir = join(PROJECTS_ROOT, project)
263
+ if (!existsSync(workDir)) {
264
+ logFn(`email-worker: directory ~/${project} does not exist`)
265
+ await sendReply(from, subject, messageId,
266
+ `้กน็›ฎ็›ฎๅฝ• ~/${project} ไธๅญ˜ๅœจ`)
267
+ return
268
+ }
269
+
270
+ const body = await extractPlainText(msg.source?.toString() ?? '')
271
+
272
+ const task: EmailTask = {
273
+ from,
274
+ subject,
275
+ project,
276
+ request: body || requestTitle,
277
+ messageId,
278
+ inReplyTo,
279
+ receivedAt: new Date(),
280
+ }
281
+
282
+ enqueue(task)
283
+ }
284
+
285
+ async function extractPlainText(raw: string): Promise<string> {
286
+ try {
287
+ const { simpleParser } = await import('mailparser')
288
+ const parsed = await simpleParser(raw)
289
+ let body = parsed.text ?? ''
290
+ if (!body && parsed.html) {
291
+ body = parsed.html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ')
292
+ }
293
+ body = body.trim()
294
+ if (body.length > 10000) body = body.slice(0, 10000)
295
+ return body
296
+ } catch (err) {
297
+ logFn(`email-worker: mailparser failed: ${err}`)
298
+ return '(้‚ฎไปถๆญฃๆ–‡่งฃๆžๅคฑ่ดฅ๏ผŒ่ฏทไฝฟ็”จ็บฏๆ–‡ๆœฌๆ ผๅผๅ‘้€)'
299
+ }
300
+ }
301
+
302
+ // โ”€โ”€ Task queue & execution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
303
+
304
+ function mirrorQueueState(): void {
305
+ try {
306
+ const projects: Record<string, any> = {}
307
+ for (const [name, q] of taskQueues) {
308
+ const running = activeJobs.get(name) && q.length >= 0
309
+ const current = running ? (taskQueues.get(name)?.[0]?.subject ?? null) : null
310
+ projects[name] = { queued: q.length, running: current, total: 0 }
311
+ }
312
+ const path = join(STATE_DIR, 'email-queue.json')
313
+ writeFileSync(path, JSON.stringify({ projects }), 'utf-8')
314
+ } catch {}
315
+ }
316
+
317
+ function enqueue(task: EmailTask): void {
318
+ const q = taskQueues.get(task.project) ?? []
319
+ q.push(task)
320
+ taskQueues.set(task.project, q)
321
+ mirrorQueueState()
322
+ if (!activeJobs.get(task.project)) {
323
+ activeJobs.set(task.project, true)
324
+ processQueue(task.project)
325
+ }
326
+ }
327
+
328
+ async function processQueue(project: string): Promise<void> {
329
+ const q = taskQueues.get(project)
330
+
331
+ while (q?.length) {
332
+ const task = q.shift()!
333
+ mirrorQueueState()
334
+ await executeTask(task)
335
+ }
336
+
337
+ activeJobs.set(project, false)
338
+ mirrorQueueState()
339
+ }
340
+
341
+ async function executeTask(task: EmailTask): Promise<void> {
342
+ const { project, from, subject, request, messageId } = task
343
+
344
+ const projConfig = config!.projects[project]
345
+ const defaults = config!.defaults ?? {}
346
+ const timeoutMin = projConfig.timeout_minutes ?? defaults.timeout_minutes ?? 30
347
+
348
+ const systemPrompt = buildSystemPrompt(project, projConfig, from)
349
+
350
+ // Notify Feishu
351
+ await notifyFeishu(project,
352
+ `๐Ÿ“ฉ๐Ÿš€ ๆ”ถๅˆฐ้‚ฎไปถไปปๅŠก๏ผŒๅผ€ๅง‹ๆ‰ง่กŒ\nๅ‘ไปถไบบ: ${from}\nไธป้ข˜: ${subject}\nๆญฃๆ–‡: ${request}`)
353
+ logFn(`email-worker: executing task for "${project}": ${subject}`)
354
+
355
+ try {
356
+ const result = await runDeepseek(project, systemPrompt, request, timeoutMin)
357
+
358
+ await sendReply(from, subject, messageId, result)
359
+
360
+ const preview = result.length > 200 ? result.slice(0, 200) + '...' : result
361
+ await notifyFeishu(project, `โœ… ้‚ฎไปถไปปๅŠกๅฎŒๆˆ\nไธป้ข˜: ${subject}\n็ป“ๆžœ้ข„่งˆ: ${preview}`)
362
+ logFn(`email-worker: task completed for "${project}": ${subject}`)
363
+ } catch (err) {
364
+ const errMsg = err instanceof Error ? err.message : String(err)
365
+ await sendReply(from, subject, messageId,
366
+ `ไปปๅŠกๆ‰ง่กŒๅคฑ่ดฅ: ${errMsg}\n\n่ฏท่”็ณป็ฎก็†ๅ‘˜ๆฃ€ๆŸฅๆ—ฅๅฟ—ใ€‚`)
367
+ await notifyFeishu(project, `โŒ ้‚ฎไปถไปปๅŠกๅคฑ่ดฅ\nไธป้ข˜: ${subject}\n้”™่ฏฏ: ${errMsg}`)
368
+ logFn(`email-worker: task failed for "${project}": ${errMsg}`)
369
+ }
370
+ }
371
+
372
+ function buildSystemPrompt(project: string, projConfig: ProjectConfig, sender: string): string {
373
+ const parts: string[] = []
374
+
375
+ if (projConfig.system_prompt) {
376
+ parts.push(projConfig.system_prompt)
377
+ } else {
378
+ parts.push(`ไฝ ๆ˜ฏ ${project} ้กน็›ฎ็š„ๅผ€ๅ‘ๅŠฉๆ‰‹ใ€‚`)
379
+ }
380
+
381
+ if (projConfig.allowed_intents) {
382
+ parts.push(`ไฝ ๅช่ขซๅ…่ฎธ่ฟ›่กŒไปฅไธ‹็ฑปๅž‹็š„ๅทฅไฝœ๏ผš${projConfig.allowed_intents}ใ€‚`)
383
+ parts.push('ๅฆ‚ๆžœ้œ€ๆฑ‚่ถ…ๅ‡บ่Œƒๅ›ด๏ผŒ็›ดๆŽฅๆ‹’็ปๅนถ่ฏดๆ˜ŽๅŽŸๅ› ใ€‚')
384
+ }
385
+
386
+ parts.push(`ๅฝ“ๅ‰ไปปๅŠกๆฅ่‡ช้‚ฎไปถ็”จๆˆท ${sender}ใ€‚`)
387
+ parts.push('่ฏทๅฎŒๆˆไปปๅŠกๅŽ็ป™ๅ‡บๆธ…ๆ™ฐ็š„็ป“ๆžœๆ‘˜่ฆใ€‚')
388
+ parts.push('ๅฎŒๆˆๅŽ๏ผŒๅœจๆœ€ๅŽไธ€ๆฌกๅ›žๅคไธญ่ฏท็”จ markdown ๆ ผๅผๆธ…ๆ™ฐๆ€ป็ป“ไฝ ็š„ๅทฅไฝœใ€‚')
389
+
390
+ return parts.join('\n')
391
+ }
392
+
393
+ // โ”€โ”€ DeepSeek execution (replaces spawn claude -p) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
394
+
395
+ async function runDeepseek(
396
+ project: string,
397
+ systemPrompt: string,
398
+ request: string,
399
+ timeoutMin: number,
400
+ ): Promise<string> {
401
+ const workDir = join(PROJECTS_ROOT, project)
402
+ const threadTitle = `email-${project}-${Date.now()}`
403
+
404
+ // Create a one-shot thread for this email task
405
+ const thread = await api.createThread({
406
+ title: threadTitle,
407
+ workspace: workDir,
408
+ mode: 'yolo',
409
+ auto_approve: true,
410
+ system_prompt: systemPrompt,
411
+ })
412
+
413
+ logFn(`email-worker: created thread ${thread.id} for ${project}`)
414
+
415
+ try {
416
+ // Inject the turn
417
+ const turnRes = await api.createTurn(thread.id, {
418
+ prompt: request,
419
+ auto_approve: true,
420
+ mode: 'yolo',
421
+ })
422
+
423
+ const turnId = turnRes.turn.id
424
+ logFn(`email-worker: turn ${turnId} started for ${project}`)
425
+
426
+ // Poll for completion via thread detail (turns embedded in thread response)
427
+ const deadline = Date.now() + timeoutMin * 60 * 1000
428
+
429
+ while (Date.now() < deadline) {
430
+ await new Promise(r => setTimeout(r, 2000))
431
+
432
+ try {
433
+ const detail = await api.getThread(thread.id)
434
+ const turn = detail.turns.find(t => t.id === turnId)
435
+ if (!turn) continue
436
+
437
+ if (turn.status === 'completed') {
438
+ const agentMessages = detail.items
439
+ .filter(item => item.kind === 'agent_message')
440
+ .map(item => item.detail || item.summary)
441
+ return agentMessages.join('\n').trim() || '(ไปปๅŠกๅฎŒๆˆ๏ผŒๆ— ๆ–‡ๆœฌ่พ“ๅ‡บ)'
442
+ }
443
+ if (turn.status === 'error') {
444
+ throw new Error('turn ended with error status')
445
+ }
446
+ } catch (err: any) {
447
+ if (err.message?.includes('not found')) continue
448
+ throw err
449
+ }
450
+ }
451
+
452
+ // Timeout โ€” try to interrupt
453
+ try { await api.interruptTurn(thread.id, turnId) } catch {}
454
+ throw new Error(`่ถ…ๆ—ถ (${timeoutMin} ๅˆ†้’Ÿ)`)
455
+ } finally {
456
+ // Cleanup: archive the thread
457
+ try { await api.archiveThread(thread.id) } catch {}
458
+ }
459
+ }
460
+
461
+ // โ”€โ”€ SMTP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
462
+
463
+ async function setupSMTP(): Promise<void> {
464
+ const nodemailer = await import('nodemailer')
465
+ const smtp = config!.smtp
466
+ smtpTransport = nodemailer.default.createTransport({
467
+ host: smtp.host,
468
+ port: smtp.port ?? 465,
469
+ secure: true,
470
+ auth: { user: smtp.user, pass: smtp.pass },
471
+ })
472
+
473
+ await smtpTransport.verify()
474
+ logFn('email-worker: SMTP connected')
475
+ }
476
+
477
+ async function sendReply(
478
+ to: string,
479
+ originalSubject: string,
480
+ inReplyTo: string,
481
+ body: string,
482
+ ): Promise<void> {
483
+ if (!smtpTransport || !config) return
484
+
485
+ const subject = originalSubject.startsWith('Re:')
486
+ ? originalSubject
487
+ : `Re: ${originalSubject}`
488
+
489
+ const attachments: { filename: string; path: string }[] = []
490
+ const cleanBody = body.replace(/\[ATTACH:([^\]]+)\]/g, (_match, filePath: string) => {
491
+ const p = filePath.trim()
492
+ if (existsSync(p)) {
493
+ attachments.push({ filename: p.split('/').pop() ?? 'attachment', path: p })
494
+ return `(้™„ไปถ: ${p.split('/').pop()})`
495
+ }
496
+ return `(้™„ไปถๆœชๆ‰พๅˆฐ: ${p})`
497
+ })
498
+
499
+ try {
500
+ await smtpTransport.sendMail({
501
+ from: config.smtp.user,
502
+ to,
503
+ subject,
504
+ text: cleanBody,
505
+ inReplyTo,
506
+ references: inReplyTo,
507
+ ...(attachments.length ? { attachments } : {}),
508
+ })
509
+ logFn(`email-worker: replied to ${to}: ${subject} (${attachments.length} attachments)`)
510
+ } catch (err) {
511
+ logFn(`email-worker: SMTP send failed: ${err}`)
512
+ }
513
+ }
514
+
515
+ // โ”€โ”€ Feishu notifications โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
516
+
517
+ async function notifyFeishu(project: string, text: string): Promise<void> {
518
+ if (!sendFeishuFn || !chatNameCacheRef) return
519
+
520
+ let chatId: string | undefined
521
+ for (const [id, name] of chatNameCacheRef) {
522
+ if (name === project) {
523
+ chatId = id
524
+ break
525
+ }
526
+ }
527
+
528
+ if (chatId) {
529
+ await sendFeishuFn(chatId, text).catch(err =>
530
+ logFn(`email-worker: feishu notify failed: ${err}`))
531
+ } else {
532
+ logFn(`email-worker: no feishu group found for project "${project}", skipping notification`)
533
+ }
534
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Env bootstrap โ€” populate process.env from ~/.deepseek/lodestar.toml
3
+ * before any other module reads it. Must be the FIRST import in daemon.ts.
4
+ */
5
+
6
+ import { populateEnv } from './config'
7
+ populateEnv()