Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cd0d255
chore(export): install turndown dependencies
janhapke Jun 4, 2026
91ed2a0
refactor(sync): export parseHeaderBlock and extractRawHeader
janhapke Jun 4, 2026
154bae5
feat(export): conversion helpers and frontmatter builder
janhapke Jun 4, 2026
426ef77
feat(export): exportAccount core logic
janhapke Jun 4, 2026
20a6b6a
feat(export): add export CLI command
janhapke Jun 4, 2026
faa52e3
feat(export): integration tests
janhapke Jun 4, 2026
490565f
feat(export): preserve plain text line breaks and fix Markdown hazards
janhapke Jun 4, 2026
b931fcb
feat(export): improve HTML and plain text Markdown quality
janhapke Jun 4, 2026
ec198a3
feat(export): add setext h1 subject heading between frontmatter and body
janhapke Jun 4, 2026
907df57
feat(export): strip all non-attachment images from HTML
janhapke Jun 4, 2026
83da8dd
docs: document export command in README
janhapke Jun 4, 2026
51bfcd4
feat(export): strip preheader padding chars and empty links
janhapke Jun 4, 2026
f86c1f0
feat(export): simplify frontmatter address and attachment format
janhapke Jun 4, 2026
b73597d
feat(export): treat <th> cells with block content as layout tables
janhapke Jun 5, 2026
cae8483
fix(export): treat whitespace-only lines as blank in convertPlainText
janhapke Jun 5, 2026
6165b9b
feat(export): add hard line breaks to layout table prose lines
janhapke Jun 5, 2026
5aa2b84
refactor(export): restructure document layout and frontmatter fields
janhapke Jun 5, 2026
016c18a
docs: update export command docs for new frontmatter fields and body …
janhapke Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,50 @@ If a worktree for that reference already exists it is replaced.

---

### `export`

Export the archive as Markdown files — one `.md` per message — with YAML frontmatter and a human-readable body.

```sh
backmail export
backmail export --only-folder INBOX --only-folder Sent
backmail export --exclude-folder Spam --verbose
```

| Option | Description |
|--------|-------------|
| `--exclude-folder <name>` | Skip a folder (repeatable) |
| `--only-folder <name>` | Restrict to a folder (repeatable) |
| `--verbose` | Log one line per folder and per message |

`--exclude-folder` and `--only-folder` are mutually exclusive.

Output is written to `export/` next to `archive/`, mirroring the folder structure. Each file contains:

- YAML frontmatter with `messageId`, `from`, `to`, `subject`, `received`, `folders`, `formats`, and `attachments`
- The subject as a top-level heading
- Plain text body (when present)
- HTML body converted to Markdown, preceded by a labelled separator block (when both parts are present — layout tables are unwrapped to prose, external images stripped, inline attachments preserved)

Running `export` again is idempotent — existing files are overwritten with identical content.

Output:

```
export: =42 exported
```

If any message fails the export continues and the summary line is tagged `[partial]`:

```
export [partial]: =38 exported
folder INBOX/Archive failed: read error
```

Exit code is non-zero when any folder fails.

---

### `restore`

Re-upload messages from a backup to an IMAP server. Useful for migrating to a new provider or recovering deleted mail.
Expand Down Expand Up @@ -290,6 +334,9 @@ archive/ # git repository
worktrees/ # point-in-time checkouts (outside the git repo)
2024-01-15/
abc1234/
export/ # Markdown export (created by `backmail export`)
INBOX/
2024-01-15_subject_aabbccdd.md
```

Messages are content-addressed by Message-ID, so identical emails that appear in multiple IMAP folders (common with Gmail labels) are stored only once.
Expand Down
35 changes: 35 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"devDependencies": {
"@types/mailparser": "^3.4.6",
"@types/node": "25.6.0",
"@types/turndown": "^5.0.6",
"@vitest/coverage-v8": "^4.1.5",
"tsx": "4.21.0",
"typescript": "6.0.3",
Expand All @@ -63,6 +64,8 @@
"mailparser": "^3.9.8",
"simple-git": "3.36.0",
"slugify": "^1.6.9",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"zod": "^4.3.6"
}
}
46 changes: 45 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function getRepoRoot(): string {
return repoRoot
}

import { syncAccount, getLog, checkoutCommit, listFolders, listMessages, viewMessage, restoreAccount } from '../core/index.js'
import { syncAccount, getLog, checkoutCommit, listFolders, listMessages, viewMessage, restoreAccount, exportAccount } from '../core/index.js'

function getErrorMessage(err: unknown): string {
if (err instanceof Error) {
Expand Down Expand Up @@ -373,4 +373,48 @@ program
}
})

// ── export ───────────────────────────────────────────────────────────────────
program
.command('export')
.description('Export archive messages as Markdown files')
.option('--exclude-folder <name>', 'skip this folder (repeatable)', collectRepeatable, [])
.option('--only-folder <name>', 'restrict to this folder (repeatable)', collectRepeatable, [])
.option('--verbose', 'log one line per exported file')
.action(async (opts: { excludeFolder: string[]; onlyFolder: string[]; verbose?: boolean }) => {
if (opts.excludeFolder.length > 0 && opts.onlyFolder.length > 0) {
console.error('Error: --exclude-folder and --only-folder are mutually exclusive')
process.exit(1)
}

try {
const repoRoot = getRepoRoot()
const archivePath = path.join(repoRoot, 'archive')
const exportPath = path.join(repoRoot, 'export')
const verbose = opts.verbose ?? false

const result = await exportAccount(archivePath, exportPath, {
excludeFolders: opts.excludeFolder,
onlyFolders: opts.onlyFolder,
verbose,
onLog: verbose ? (msg) => console.log(msg) : undefined,
})

const partialTag = result.errors > 0 ? ' [partial]' : ''
console.log(`export${partialTag}: =${result.exported} exported`)

for (const fr of result.folderResults) {
if (fr.error) {
console.error(`folder ${fr.path} failed: ${fr.error.message}`)
}
}

if (result.errors > 0) {
process.exit(1)
}
} catch (err) {
console.error(getErrorMessage(err))
process.exit(1)
}
})

program.parse(process.argv)
Loading
Loading