7c690be891
- passwd.j2: append '::::::' so entries have full 8-field passwd format.
Without this Dovecot's userdb lookup fails ('user is missing userdb
info') even though passdb auth succeeds, causing login to return 401.
- main.yml: fix passwd file mode from 0640 to 0644 so Dovecot can read it.
- Add runbook-mail-import.md: full procedure for migrating mbox-format mail
(old Dovecot server with ~/mail/ flat files) to Maildir++ including the
conversion script that handles fetchmail >From envelope lines.
- runbook-configuration.md: reference new runbook in the mail import section.
287 lines
9.3 KiB
Markdown
287 lines
9.3 KiB
Markdown
# Mail Import Runbook
|
|
|
|
How to migrate existing mail from a Dovecot mbox-format server into the
|
|
Maildir++ format used by the mail role on cloud.ladkau.de.
|
|
|
|
## Background
|
|
|
|
The old server stores mail in mbox format:
|
|
|
|
```
|
|
mail_location = mbox:~/mail:INBOX=/var/mail/%u
|
|
```
|
|
|
|
Messages live in flat files under `~/mail/` with a dot-separated naming
|
|
convention (e.g. `companies.amazon`, `Archive.uk.friends.zoe`). The INBOX
|
|
lives at `/var/mail/<username>`.
|
|
|
|
The new server uses Maildir++:
|
|
|
|
```
|
|
mail_location = maildir:/var/mail/%u/Maildir
|
|
```
|
|
|
|
Folders are stored as dot-prefixed subdirectories of `~/<user>/Maildir/`
|
|
(e.g. `.companies.amazon/`, `.Archive.uk.friends.zoe/`), each containing
|
|
`cur/`, `new/`, and `tmp/` subdirectories.
|
|
|
|
## Format quirks to handle
|
|
|
|
**`>From ` escaping.** mbox format escapes lines beginning with `From ` in
|
|
message bodies by prepending `>`. Python's `mailbox.mbox` does not
|
|
automatically undo this when reading. The conversion script must strip the
|
|
leading `>` from any `>From ` line in the message body.
|
|
|
|
**Fetchmail envelope lines.** When fetchmail delivered mail to the old mbox
|
|
store it prepended a `>From fetchmail <date>` line before the RFC 2822
|
|
headers. This line is not a valid RFC 2822 header, so Python's email parser
|
|
fails to parse any headers from the message and treats the entire content
|
|
as a body. The result in Maildir is a message that starts with a blank line
|
|
(the header/body separator for the empty headers section) followed by
|
|
`>From fetchmail...` followed by the actual RFC 2822 headers inside the body.
|
|
IMAP clients see such messages as having no Subject, no From, and no Date.
|
|
The conversion script must detect and strip this envelope line before writing
|
|
the Maildir file.
|
|
|
|
**doveadm backup/import cannot be used** because it requires the source and
|
|
destination hierarchy separator to match. mbox defaults to `/`; Maildir++
|
|
uses `.`. Attempts to override this cause Dovecot to rename folders with
|
|
underscores instead of dots.
|
|
|
|
## Step 1 — Copy mbox files to the staging area
|
|
|
|
On the old server, the mail files live in `~/mail/`. Copy them to the staging
|
|
directory on the new server. The Dovecot container mounts
|
|
`/opt/mail/maildir` as `/var/mail`, so the staging directory is accessible
|
|
from the host at `/opt/mail/maildir/mbox_<user>/`.
|
|
|
|
```bash
|
|
# Run from the old server (or adjust rsync source accordingly)
|
|
rsync -av -e 'ssh -i /path/to/key' \
|
|
~/mail/ \
|
|
root@217.154.207.148:/opt/mail/maildir/mbox_ml/
|
|
```
|
|
|
|
The `INBOX` mbox file lives at `/var/mail/<user>` on the old server, not
|
|
inside `~/mail/`. Copy it separately if you need it:
|
|
|
|
```bash
|
|
scp -i /path/to/key /var/mail/ml \
|
|
root@217.154.207.148:/opt/mail/maildir/mbox_ml/INBOX
|
|
```
|
|
|
|
After copying, the staging directory should contain flat mbox files with
|
|
dot-separated names:
|
|
|
|
```
|
|
/opt/mail/maildir/mbox_ml/
|
|
Archive.de.friends.heiko
|
|
Archive.uk.ex-family.masha
|
|
companies.amazon
|
|
Sent
|
|
Trash
|
|
...
|
|
```
|
|
|
|
Fix ownership so Dovecot (UID/GID 5000) can read the files:
|
|
|
|
```bash
|
|
sudo chown -R 5000:5000 /opt/mail/maildir/mbox_ml/
|
|
```
|
|
|
|
## Step 2 — Run the conversion script
|
|
|
|
Save the script below to the server and run it as root. It reads every mbox
|
|
file in the staging directory and writes a Maildir++ folder for each one
|
|
under `/opt/mail/maildir/<user>/Maildir/`.
|
|
|
|
```bash
|
|
sudo python3 /opt/mail/convert_mbox.py
|
|
```
|
|
|
|
### Conversion script
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
"""Convert flat dot-separated mbox files to Maildir++ folders.
|
|
|
|
Handles two common corruption patterns:
|
|
1. From_ envelope line leaked as first line of message.
|
|
2. Fetchmail >From envelope line before RFC 2822 headers — Python's email
|
|
parser cannot parse headers from such messages and treats all content as
|
|
body, producing an empty headers section followed by >From in the body.
|
|
"""
|
|
import mailbox, os, time, socket
|
|
|
|
# Adjust these paths for the target user.
|
|
MBOX_BASE = '/opt/mail/maildir/mbox_ml'
|
|
MAILDIR_BASE = '/opt/mail/maildir/ml/Maildir'
|
|
|
|
# mbox files to skip (not real mail folders).
|
|
SKIP = {'INBOX', 'subscriptions', 'maildirfolder'}
|
|
|
|
|
|
def ensure_maildir_folder(folder_name):
|
|
path = os.path.join(MAILDIR_BASE, folder_name)
|
|
for sub in ('cur', 'new', 'tmp'):
|
|
os.makedirs(os.path.join(path, sub), exist_ok=True)
|
|
return path
|
|
|
|
|
|
def strip_envelope_line(raw):
|
|
"""Strip a From_/>From_ envelope line, plus any leading blank lines."""
|
|
stripped = raw.lstrip(b'\r\n')
|
|
if stripped.startswith(b'From ') or stripped.startswith(b'>From '):
|
|
nl = stripped.find(b'\n')
|
|
return stripped[nl + 1:] if nl >= 0 else stripped
|
|
return raw
|
|
|
|
|
|
def msg_to_bytes(msg):
|
|
# unixfrom=False explicitly excludes the From_ separator line.
|
|
raw = msg.as_bytes(unixfrom=False)
|
|
|
|
# Case 1: From_ leaked as the very first line.
|
|
if raw.startswith(b'From ') or raw.startswith(b'>From '):
|
|
raw = strip_envelope_line(raw)
|
|
|
|
# Case 2: empty-header message — fetchmail wrote a '>From fetchmail ...'
|
|
# envelope line before the RFC 2822 headers. Python could not parse
|
|
# headers so as_bytes() produces '\n>From fetchmail...\nReturn-Path:...'.
|
|
# Strip the leading blank line and the envelope line.
|
|
elif raw.startswith(b'\n') or raw.startswith(b'\r\n'):
|
|
stripped = raw.lstrip(b'\r\n')
|
|
if stripped.startswith(b'From ') or stripped.startswith(b'>From '):
|
|
nl = stripped.find(b'\n')
|
|
raw = stripped[nl + 1:] if nl >= 0 else stripped
|
|
|
|
# Unescape mbox >From escaping in the message body (>From -> From).
|
|
lines = raw.split(b'\n')
|
|
lines = [line[1:] if line.startswith(b'>From ') else line for line in lines]
|
|
return b'\n'.join(lines)
|
|
|
|
|
|
total_msgs = 0
|
|
new_folders = []
|
|
hostname = socket.gethostname()
|
|
|
|
for fname in sorted(os.listdir(MBOX_BASE)):
|
|
if fname in SKIP or fname.startswith('.'):
|
|
continue
|
|
fpath = os.path.join(MBOX_BASE, fname)
|
|
if not os.path.isfile(fpath):
|
|
continue
|
|
# Skip non-mbox files (e.g. Dovecot index files accidentally copied over).
|
|
try:
|
|
with open(fpath, 'rb') as f:
|
|
if f.read(5) != b'From ':
|
|
continue
|
|
except Exception:
|
|
continue
|
|
|
|
folder_name = '.' + fname # Maildir++ requires a leading dot.
|
|
folder_path = ensure_maildir_folder(folder_name)
|
|
cur_dir = os.path.join(folder_path, 'cur')
|
|
|
|
mbox = mailbox.mbox(fpath)
|
|
count = 0
|
|
for i, msg in enumerate(mbox):
|
|
ts = int(time.time())
|
|
unique = f"{ts}.P{os.getpid()}Q{i}.{hostname}"
|
|
dest = os.path.join(cur_dir, f"{unique}:2,S")
|
|
with open(dest, 'wb') as f:
|
|
f.write(msg_to_bytes(msg))
|
|
count += 1
|
|
mbox.close()
|
|
print(f" {folder_name:55s} {count:5d} msgs")
|
|
total_msgs += count
|
|
new_folders.append(fname) # IMAP name without leading dot.
|
|
|
|
# Write a subscriptions file so IMAP clients see all folders.
|
|
subs_path = os.path.join(MAILDIR_BASE, 'subscriptions')
|
|
with open(subs_path, 'w') as f:
|
|
f.write('V\t2\n')
|
|
for s in sorted(new_folders):
|
|
f.write(s + '\n')
|
|
|
|
print(f"\nDone: {total_msgs} messages in {len(new_folders)} folders.")
|
|
```
|
|
|
|
## Step 3 — Fix ownership and restart Dovecot
|
|
|
|
```bash
|
|
# Ensure Dovecot (UID/GID 5000) owns everything.
|
|
sudo chown -R 5000:5000 /opt/mail/maildir/ml
|
|
|
|
# Restart Dovecot to clear any cached folder lists.
|
|
docker restart dovecot
|
|
```
|
|
|
|
## Step 4 — Clear Roundcube caches
|
|
|
|
Roundcube caches the IMAP folder list and message indices in PostgreSQL. If
|
|
you have already logged in before the migration, stale cache entries will
|
|
prevent the new folders from appearing.
|
|
|
|
```bash
|
|
docker exec mail-db psql -U roundcube -d roundcube \
|
|
-c 'TRUNCATE cache, cache_index, cache_messages, cache_shared, cache_thread, session;'
|
|
```
|
|
|
|
## Step 5 — Verify
|
|
|
|
Log in to Roundcube at https://mail.ladkau.de. The full folder tree should be
|
|
visible. Spot-check a few messages in Trash (the largest folder and the one
|
|
most affected by the fetchmail envelope issue) to confirm Subject, From, and
|
|
Date are populated correctly.
|
|
|
|
To verify programmatically that no messages have the empty-header corruption:
|
|
|
|
```bash
|
|
sudo python3 - <<'EOF'
|
|
import os, random
|
|
|
|
maildir = '/opt/mail/maildir/ml/Maildir'
|
|
bad = good = 0
|
|
for folder in os.listdir(maildir):
|
|
cur = os.path.join(maildir, folder, 'cur')
|
|
if not os.path.isdir(cur):
|
|
continue
|
|
files = os.listdir(cur)
|
|
for f in random.sample(files, min(10, len(files))):
|
|
with open(os.path.join(cur, f), 'rb') as fh:
|
|
start = fh.read(6)
|
|
if start.startswith(b'\n') or start.startswith(b'From ') or start.startswith(b'>From'):
|
|
bad += 1
|
|
else:
|
|
good += 1
|
|
|
|
print(f'Good: {good}, Bad: {bad}')
|
|
EOF
|
|
```
|
|
|
|
All messages should report as Good (bad count = 0).
|
|
|
|
## Step 6 — Clean up staging area
|
|
|
|
Once the migration is confirmed working, remove the staging directory:
|
|
|
|
```bash
|
|
sudo rm -rf /opt/mail/maildir/mbox_ml/
|
|
```
|
|
|
|
## INBOX
|
|
|
|
The mbox INBOX (`/var/mail/ml` on the old server) is skipped by the script
|
|
because the Dovecot configuration on the new server stores INBOX as the root
|
|
Maildir (`/var/mail/ml/Maildir/`), not as a subfolder. To migrate INBOX
|
|
messages, either:
|
|
|
|
- Copy the INBOX mbox file to the staging area as a file named `INBOX` and
|
|
remove `INBOX` from the `SKIP` set — but note the script will create
|
|
`.INBOX` as a subfolder, not the real INBOX. Then use `doveadm move INBOX`
|
|
to relocate the messages.
|
|
- Or use `mb2md` or a similar tool specifically designed for INBOX migration.
|
|
|
|
For most use cases, leaving INBOX empty and starting fresh is acceptable.
|