@zaclinley/dsh-mailharbor 0.1.0 → 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/CHANGELOG.md +16 -5
- package/README.md +29 -3
- package/README.zh.md +27 -3
- package/lib/client.js +439 -322
- package/lib/client.js.map +4 -4
- package/lib/index.js +44 -34
- package/lib/index.js.map +3 -3
- package/package.json +3 -1
- package/scripts/migrate-legacy.mjs +318 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zaclinley/dsh-mailharbor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"files": [
|
|
44
44
|
"lib",
|
|
45
45
|
"cordis.patch.yml",
|
|
46
|
+
"scripts/migrate-legacy.mjs",
|
|
46
47
|
"README.md",
|
|
47
48
|
"README.zh.md",
|
|
48
49
|
"CHANGELOG.md",
|
|
@@ -106,6 +107,7 @@
|
|
|
106
107
|
"prettier": "^3.9.10",
|
|
107
108
|
"react": "^19.3.1",
|
|
108
109
|
"react-dom": "^19.3.1",
|
|
110
|
+
"smtp-server": "^3.21.3",
|
|
109
111
|
"typescript": "^5.9.3",
|
|
110
112
|
"typescript-eslint": "^8.72.1",
|
|
111
113
|
"vitest": "^5.1.0"
|
|
@@ -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()
|