@zaclinley/dsh-mailharbor 0.0.0-stage → 0.2.0

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/package.json CHANGED
@@ -1,6 +1,115 @@
1
1
  {
2
2
  "name": "@zaclinley/dsh-mailharbor",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "MailHarbor: a multi-account IMAP/SMTP mail workspace for DeepSeek Harness with local backup, AI search and compose, CalDAV tasks and reports.",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "cordis-plugin",
10
+ "email",
11
+ "imap",
12
+ "smtp",
13
+ "mail",
14
+ "backup",
15
+ "caldav"
16
+ ],
17
+ "license": "MIT",
18
+ "author": {
19
+ "name": "Zac Linley",
20
+ "url": "https://github.com/Zac-Linley"
21
+ },
22
+ "homepage": "https://github.com/Zac-Linley/dsh-plugin-mailharbor#readme",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/Zac-Linley/dsh-plugin-mailharbor.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/Zac-Linley/dsh-plugin-mailharbor/issues"
29
+ },
30
+ "type": "module",
31
+ "engines": {
32
+ "node": ">=24.9.0",
33
+ "dsh": ">=0.2.2-0 <0.3.0"
34
+ },
35
+ "packageManager": "npm@10.9.9",
36
+ "main": "./lib/index.js",
37
+ "exports": {
38
+ ".": "./lib/index.js",
39
+ "./client": "./lib/client.js",
40
+ "./package.json": "./package.json",
41
+ "./cordis.patch.yml": "./cordis.patch.yml"
42
+ },
43
+ "files": [
44
+ "lib",
45
+ "cordis.patch.yml",
46
+ "scripts/migrate-legacy.mjs",
47
+ "README.md",
48
+ "README.zh.md",
49
+ "CHANGELOG.md",
50
+ "LICENSE"
51
+ ],
52
+ "dsh": {
53
+ "manifestVersion": 1,
54
+ "bundle": {
55
+ "patch": "./cordis.patch.yml"
56
+ },
57
+ "client": {
58
+ "platform": "web",
59
+ "immediately": true,
60
+ "inject": [
61
+ "@deepseek-ai/dsh-client-ui-layout",
62
+ "@deepseek-ai/dsh-client-ui-settings",
63
+ "@deepseek-ai/dsh-client-ui-sidebar"
64
+ ]
65
+ }
66
+ },
67
+ "publishConfig": {
68
+ "access": "public",
69
+ "registry": "https://registry.npmjs.org/"
70
+ },
71
+ "scripts": {
72
+ "build": "node scripts/build.mjs",
73
+ "dev": "node scripts/build.mjs --watch",
74
+ "typecheck": "tsc --noEmit",
75
+ "lint": "eslint .",
76
+ "lint:fix": "eslint . --fix",
77
+ "format": "prettier --write .",
78
+ "format:check": "prettier --check .",
79
+ "test": "vitest run",
80
+ "test:watch": "vitest",
81
+ "coverage": "vitest run --coverage",
82
+ "pack:check": "npm pack --dry-run",
83
+ "check": "npm run format:check && npm run typecheck && npm run lint && npm run build && npm run test && npm run pack:check",
84
+ "changeset": "changeset",
85
+ "version": "changeset version && node scripts/sync-version.mjs",
86
+ "release": "npm run check && npm publish",
87
+ "release:ci": "npm publish --provenance --access public"
88
+ },
89
+ "dependencies": {
90
+ "imapflow": "^2.11.0",
91
+ "mailparser": "^3.14.2",
92
+ "nodemailer": "^10.3.1"
93
+ },
94
+ "devDependencies": {
95
+ "@changesets/cli": "^3.0.3",
96
+ "@eslint/js": "^9.39.5",
97
+ "@types/mailparser": "^3.9.0",
98
+ "@types/node": "^24.10.1",
99
+ "@types/nodemailer": "^8.0.2",
100
+ "@types/react": "^19.3.0",
101
+ "@types/react-dom": "^19.3.0",
102
+ "@vitest/coverage-v8": "^5.1.0",
103
+ "esbuild": "^0.28.2",
104
+ "eslint": "^9.39.5",
105
+ "eslint-plugin-react-hooks": "^7.1.1",
106
+ "jsdom": "^31.0.0",
107
+ "prettier": "^3.9.10",
108
+ "react": "^19.3.1",
109
+ "react-dom": "^19.3.1",
110
+ "smtp-server": "^3.21.3",
111
+ "typescript": "^5.9.3",
112
+ "typescript-eslint": "^8.72.1",
113
+ "vitest": "^5.1.0"
114
+ }
115
+ }
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Migrate the retired `dsh-plugin-mail` (MailHarbor 0.6.x) data into
4
+ * `@zaclinley/dsh-mailharbor`.
5
+ *
6
+ * The old backup database can be several gigabytes, so the default mode
7
+ * *points* the new account at the existing data directory instead of copying
8
+ * it. `--copy` makes a self-contained copy under the new data root.
9
+ *
10
+ * node scripts/migrate-legacy.mjs --dry-run
11
+ * node scripts/migrate-legacy.mjs
12
+ * node scripts/migrate-legacy.mjs --copy --account me@example.com
13
+ *
14
+ * What it does per account:
15
+ * - finds (or creates) the account in the new config by email;
16
+ * - repoints `dataDir` at the old directory (or copies db + attachments);
17
+ * - converts `read-state.json` keys to the new `mid:` / `local:` form;
18
+ * - converts old drafts (body/bodyHtml -> html, dataB64 -> data);
19
+ * - copies the macOS Keychain password to the new service name.
20
+ *
21
+ * The old files are never deleted. A `*.pre-mailharbor` backup is written next
22
+ * to any file it rewrites.
23
+ */
24
+ import { execFileSync } from 'node:child_process'
25
+ import { createHash } from 'node:crypto'
26
+ import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
27
+ import { homedir } from 'node:os'
28
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
29
+
30
+ const args = process.argv.slice(2)
31
+ const dryRun = args.includes('--dry-run')
32
+ const copy = args.includes('--copy')
33
+ const accountFilter = valueOf('--account')?.toLowerCase() ?? ''
34
+ const fromRoot = resolve(
35
+ valueOf('--from') ?? process.env.DSH_MAIL_LEGACY_DIR ?? join(homedir(), '.dsh', 'mail-plugin'),
36
+ )
37
+ const toRoot = resolve(
38
+ valueOf('--to') ?? process.env.DSH_MAILHARBOR_DATA_DIR ?? join(homedir(), '.dsh', 'mailharbor'),
39
+ )
40
+ const toConfig = process.env.DSH_MAILHARBOR_CONFIG ?? join(toRoot, 'config.json')
41
+
42
+ function valueOf(flag) {
43
+ const index = args.indexOf(flag)
44
+ if (index < 0) return undefined
45
+ return args[index + 1]
46
+ }
47
+
48
+ function log(message) {
49
+ console.log(`${dryRun ? '[dry-run] ' : ''}${message}`)
50
+ }
51
+
52
+ function readJson(file, fallback) {
53
+ try {
54
+ return JSON.parse(readFileSync(file, 'utf8'))
55
+ } catch {
56
+ return fallback
57
+ }
58
+ }
59
+
60
+ function backupFile(file) {
61
+ if (!existsSync(file)) return
62
+ const target = `${file}.pre-mailharbor`
63
+ if (existsSync(target)) return
64
+ if (!dryRun) renameSync(file, target)
65
+ log(` 备份 ${file} -> ${target}`)
66
+ }
67
+
68
+ function atomicWrite(file, data) {
69
+ mkdirSync(dirname(file), { recursive: true })
70
+ const temp = `${file}.${String(process.pid)}.tmp`
71
+ writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 })
72
+ if (!dryRun) renameSync(temp, file)
73
+ }
74
+
75
+ function accountId(email, taken) {
76
+ const base = `acc_${createHash('sha1').update(email.trim().toLowerCase()).digest('hex').slice(0, 8)}`
77
+ if (!taken.has(base)) return base
78
+ for (let attempt = 0; attempt < 50; attempt += 1) {
79
+ const candidate = `acc_${createHash('sha1').update(`${email}:${attempt}`).digest('hex').slice(0, 8)}`
80
+ if (!taken.has(candidate)) return candidate
81
+ }
82
+ return `acc_${Date.now().toString(16).slice(-8)}`
83
+ }
84
+
85
+ /** Old read-state keys were bare Message-IDs or `local:<id>`. */
86
+ function convertReadKey(key) {
87
+ const text = String(key ?? '').trim()
88
+ if (text === '') return ''
89
+ if (text.startsWith('mid:') || text.startsWith('local:')) return text
90
+ return text.includes('@') ? `mid:${text}` : `local:${text}`
91
+ }
92
+
93
+ function convertDraft(draft, newAccountId) {
94
+ return {
95
+ id: String(draft.id),
96
+ accountId: newAccountId,
97
+ to: String(draft.to ?? ''),
98
+ cc: String(draft.cc ?? ''),
99
+ bcc: String(draft.bcc ?? ''),
100
+ subject: String(draft.subject ?? ''),
101
+ html: String(draft.bodyHtml ?? draft.html ?? draft.body ?? ''),
102
+ attachments: Array.isArray(draft.attachments)
103
+ ? draft.attachments
104
+ .filter((file) => file !== null && typeof file === 'object')
105
+ .map((file) => ({
106
+ filename: String(file.filename ?? 'attachment'),
107
+ contentType: String(file.contentType ?? 'application/octet-stream'),
108
+ data: String(file.data ?? file.dataB64 ?? ''),
109
+ }))
110
+ : [],
111
+ inReplyTo: String(draft.inReplyTo ?? ''),
112
+ references: String(draft.references ?? ''),
113
+ updatedAt: String(draft.updatedAt ?? draft.createdAt ?? new Date().toISOString()),
114
+ }
115
+ }
116
+
117
+ function keychainRead(service, account) {
118
+ try {
119
+ const value = execFileSync('security', ['find-generic-password', '-s', service, '-a', account, '-w'], {
120
+ encoding: 'utf8',
121
+ timeout: 5000,
122
+ stdio: ['ignore', 'pipe', 'ignore'],
123
+ })
124
+ return value.replace(/\r?\n$/, '')
125
+ } catch {
126
+ return undefined
127
+ }
128
+ }
129
+
130
+ function keychainWrite(service, account, secret) {
131
+ execFileSync('security', ['add-generic-password', '-U', '-s', service, '-a', account, '-w', secret], {
132
+ timeout: 5000,
133
+ })
134
+ }
135
+
136
+ function oldAccountDir(oldConfig, account) {
137
+ const dataDir = typeof account.dataDir === 'string' ? account.dataDir.trim() : ''
138
+ if (dataDir !== '') return isAbsolute(dataDir) ? dataDir : join(fromRoot, dataDir)
139
+ if (typeof oldConfig.workspace === 'string' && oldConfig.workspace !== '') return oldConfig.workspace
140
+ return join(fromRoot, 'accounts', String(account.id ?? 'acc_default'))
141
+ }
142
+
143
+ /** Legendary single-account configs stored the account fields at the top level. */
144
+ function oldAccounts(oldConfig) {
145
+ if (Array.isArray(oldConfig.accounts) && oldConfig.accounts.length > 0) return oldConfig.accounts
146
+ if (typeof oldConfig.account === 'string' && oldConfig.account !== '') {
147
+ return [
148
+ {
149
+ id: 'acc_default',
150
+ email: oldConfig.account,
151
+ label: oldConfig.account,
152
+ imap: oldConfig.imap,
153
+ smtp: oldConfig.smtp,
154
+ keychainService: oldConfig.keychain?.service ?? 'dsh-plugin-mail',
155
+ dataDir: null,
156
+ },
157
+ ]
158
+ }
159
+ return []
160
+ }
161
+
162
+ function run() {
163
+ log(`旧数据: ${fromRoot}`)
164
+ log(`新数据: ${toRoot}`)
165
+ if (!existsSync(fromRoot)) {
166
+ console.error(`找不到旧插件目录: ${fromRoot}`)
167
+ process.exitCode = 1
168
+ return
169
+ }
170
+ const oldConfig = readJson(join(fromRoot, 'config.json'), {})
171
+ const newConfig = readJson(toConfig, {})
172
+ const accounts = Array.isArray(newConfig.accounts) ? [...newConfig.accounts] : []
173
+ const taken = new Set(accounts.map((account) => account.id))
174
+ const legacy = oldAccounts(oldConfig)
175
+ if (legacy.length === 0) {
176
+ console.error('旧配置里没有账号记录。')
177
+ process.exitCode = 1
178
+ return
179
+ }
180
+ let changed = false
181
+
182
+ for (const old of legacy) {
183
+ const email = String(old.email ?? '').trim()
184
+ if (email === '') continue
185
+ if (accountFilter !== '' && email.toLowerCase() !== accountFilter) continue
186
+ if (old.auth?.type === 'oauth2') {
187
+ log(`\n账号 ${email}`)
188
+ log(' OAuth2 账号暂不支持(v0.2 提供),跳过')
189
+ continue
190
+ }
191
+ const sourceDir = oldAccountDir(oldConfig, old)
192
+ const sourceDb = join(sourceDir, 'mail.db')
193
+ log(`\n账号 ${email}`)
194
+ log(` 旧目录 ${sourceDir}`)
195
+ if (!existsSync(sourceDb)) {
196
+ log(' ⚠ 没有 mail.db,跳过(只迁移设置也可以,但没有本地备份可读)')
197
+ continue
198
+ }
199
+
200
+ const existing = accounts.find((account) => String(account.email).toLowerCase() === email.toLowerCase())
201
+ const newId = existing?.id ?? accountId(email, taken)
202
+ taken.add(newId)
203
+ const newService = existing?.keychainService ?? `dsh-mailharbor:${newId}`
204
+ let targetDir
205
+ if (copy) {
206
+ targetDir = join(toRoot, 'accounts', newId)
207
+ log(` 复制到 ${targetDir}`)
208
+ if (!dryRun) {
209
+ mkdirSync(targetDir, { recursive: true })
210
+ cpSync(sourceDb, join(targetDir, 'mail.db'))
211
+ for (const suffix of ['-wal', '-shm']) {
212
+ if (existsSync(`${sourceDb}${suffix}`))
213
+ cpSync(`${sourceDb}${suffix}`, join(targetDir, `mail.db${suffix}`))
214
+ }
215
+ if (existsSync(join(sourceDir, 'attachments'))) {
216
+ cpSync(join(sourceDir, 'attachments'), join(targetDir, 'attachments'), { recursive: true })
217
+ }
218
+ }
219
+ } else {
220
+ targetDir = sourceDir
221
+ log(` 直接沿用旧目录(不复制,节省空间)`)
222
+ }
223
+
224
+ if (existing === undefined) {
225
+ accounts.push({
226
+ id: newId,
227
+ label: String(old.label ?? email),
228
+ email,
229
+ username: String(old.username ?? email),
230
+ imap: {
231
+ host: String(old.imap?.host ?? ''),
232
+ port: Number(old.imap?.port) || 993,
233
+ secure: old.imap?.secure !== false,
234
+ },
235
+ smtp: {
236
+ host: String(old.smtp?.host ?? ''),
237
+ port: Number(old.smtp?.port) || 465,
238
+ secure: old.smtp?.secure !== false,
239
+ fromName: String(old.smtp?.fromName ?? ''),
240
+ },
241
+ keychainService: newService,
242
+ dataDir: targetDir,
243
+ auth: { type: 'password' },
244
+ caldav: null,
245
+ enabled: true,
246
+ createdAt: new Date().toISOString(),
247
+ })
248
+ log(' 新建账号记录')
249
+ } else {
250
+ existing.dataDir = targetDir
251
+ log(' 更新账号的数据目录')
252
+ }
253
+ changed = true
254
+
255
+ // Read state: convert bare Message-IDs to the new key form.
256
+ const readFile = join(sourceDir, 'read-state.json')
257
+ if (existsSync(readFile)) {
258
+ const current = readJson(readFile, {})
259
+ const keys = Array.isArray(current) ? current : Array.isArray(current.unread) ? current.unread : []
260
+ const converted = [...new Set(keys.map(convertReadKey).filter((key) => key !== ''))]
261
+ if (converted.length !== keys.length || converted.some((key, index) => key !== keys[index])) {
262
+ backupFile(readFile)
263
+ atomicWrite(readFile, { unread: converted })
264
+ log(` 已读状态: ${String(keys.length)} -> ${String(converted.length)} 个键`)
265
+ }
266
+ }
267
+
268
+ // Drafts: old body/bodyHtml -> html, dataB64 -> data.
269
+ const draftsFile = join(sourceDir, 'drafts.json')
270
+ if (existsSync(draftsFile)) {
271
+ const drafts = readJson(draftsFile, [])
272
+ if (Array.isArray(drafts) && drafts.length > 0) {
273
+ backupFile(draftsFile)
274
+ atomicWrite(
275
+ draftsFile,
276
+ drafts.map((draft) => convertDraft(draft, newId)),
277
+ )
278
+ log(` 草稿: ${String(drafts.length)} 封`)
279
+ }
280
+ }
281
+
282
+ // Password: copy the old keychain item when the new one is missing.
283
+ if (process.platform === 'darwin') {
284
+ const oldService = String(old.keychainService ?? oldConfig.keychain?.service ?? 'dsh-plugin-mail')
285
+ const existingPassword = keychainRead(newService, email)
286
+ if (existingPassword === undefined) {
287
+ const oldPassword = keychainRead(oldService, email)
288
+ if (oldPassword !== undefined) {
289
+ if (!dryRun) keychainWrite(newService, email, oldPassword)
290
+ log(` 钥匙串: 从 ${oldService} 复制到 ${newService}`)
291
+ } else {
292
+ log(` 钥匙串: 旧条目 ${oldService} 不存在,需要重新登录`)
293
+ }
294
+ } else {
295
+ log(' 钥匙串: 已有新密码,保持不动')
296
+ }
297
+ } else {
298
+ log(' 钥匙串: 非 macOS 平台,请在面板里重新登录')
299
+ }
300
+ }
301
+
302
+ if (!changed) {
303
+ log('\n没有需要迁移的账号。')
304
+ return
305
+ }
306
+
307
+ const nextConfig = {
308
+ ...newConfig,
309
+ accounts,
310
+ activeAccountId: newConfig.activeAccountId ?? accounts[0]?.id ?? null,
311
+ }
312
+ if (existsSync(toConfig)) backupFile(toConfig)
313
+ atomicWrite(toConfig, nextConfig)
314
+ log(`\n写入 ${toConfig}`)
315
+ log(dryRun ? '预览完成;去掉 --dry-run 执行。' : '迁移完成。重新加载插件窗口即可看到旧的本地备份。')
316
+ }
317
+
318
+ run()