Add CLI INBOX migration breakthrough
This commit is contained in:
parent
8a6160817b
commit
27206930f0
6 changed files with 713 additions and 43 deletions
|
|
@ -2,6 +2,9 @@ package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
@ -11,27 +14,27 @@ var DB *sql.DB
|
||||||
// Account = eine Umzugs-Zeile: Quelle (alt) -> Ziel (neu), frei gemapptes
|
// Account = eine Umzugs-Zeile: Quelle (alt) -> Ziel (neu), frei gemapptes
|
||||||
// Zieladress-Postfach. Wird im Browser gepflegt (Tabelle accounts).
|
// Zieladress-Postfach. Wird im Browser gepflegt (Tabelle accounts).
|
||||||
type Account struct {
|
type Account struct {
|
||||||
ID int64
|
ID int64 `json:"id"`
|
||||||
Name string // z.B. "hans-peter"
|
Name string `json:"name"` // z.B. "hans-peter"
|
||||||
// Quelle (alter Provider). Security: "tls" (implizit, 993/995),
|
// Quelle (alter Provider). Security: "tls" (implizit, 993/995),
|
||||||
// "starttls" (Klartext-Port 143/110 + Upgrade), "none" (reiner Klartext).
|
// "starttls" (Klartext-Port 143/110 + Upgrade), "none" (reiner Klartext).
|
||||||
// SrcInsecure = ungueltige/selbstsignierte Zerts akzeptieren (alte Hoster).
|
// SrcInsecure = ungueltige/selbstsignierte Zerts akzeptieren (alte Hoster).
|
||||||
SrcHost string
|
SrcHost string `json:"src_host"`
|
||||||
SrcPort int
|
SrcPort int `json:"src_port"`
|
||||||
SrcSecurity string
|
SrcSecurity string `json:"src_security"`
|
||||||
SrcInsecure bool
|
SrcInsecure bool `json:"src_insecure"`
|
||||||
SrcUser string // hans-peter@dr-gold.de
|
SrcUser string `json:"src_user"` // hans-peter@dr-gold.de
|
||||||
SrcPass string
|
SrcPass string `json:"src_pass"`
|
||||||
SrcProto string // "imap" (default) oder "pop3" (Fallback-Quelle)
|
SrcProto string `json:"src_proto"` // "imap" (default) oder "pop3" (Fallback-Quelle)
|
||||||
// Ziel (neuer Provider). Meist "tls"; "none"/"starttls" ebenso moeglich.
|
// Ziel (neuer Provider). Meist "tls"; "none"/"starttls" ebenso moeglich.
|
||||||
DstHost string
|
DstHost string `json:"dst_host"`
|
||||||
DstPort int
|
DstPort int `json:"dst_port"`
|
||||||
DstSecurity string
|
DstSecurity string `json:"dst_security"`
|
||||||
DstInsecure bool
|
DstInsecure bool `json:"dst_insecure"`
|
||||||
DstUser string // archiv-hans-peter@dr-gold.com
|
DstUser string `json:"dst_user"` // archiv-hans-peter@dr-gold.com
|
||||||
DstPass string
|
DstPass string `json:"dst_pass"`
|
||||||
MboxDir string // Unterordner unter Cfg.MboxRoot; leer = Cfg.MboxRoot/Name
|
MboxDir string `json:"mbox_dir"` // Unterordner unter Cfg.MboxRoot; leer = Cfg.MboxRoot/Name
|
||||||
Active bool
|
Active bool `json:"active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnectDB oeffnet die SQLite-DB (modernc, kein cgo) und legt das Schema an.
|
// ConnectDB oeffnet die SQLite-DB (modernc, kein cgo) und legt das Schema an.
|
||||||
|
|
@ -49,11 +52,202 @@ type Account struct {
|
||||||
// jobs(account_id, started, finished, -- Lauf-Historie fuer den Fortschritt
|
// jobs(account_id, started, finished, -- Lauf-Historie fuer den Fortschritt
|
||||||
// total, done, errors, state) im Browser.
|
// total, done, errors, state) im Browser.
|
||||||
func ConnectDB() error {
|
func ConnectDB() error {
|
||||||
// TODO Codex: sql.Open("sqlite", Cfg.DBPath), PRAGMA journal_mode=WAL,
|
if Cfg.DBPath == "" {
|
||||||
// CREATE TABLE IF NOT EXISTS ... siehe Schema oben.
|
Cfg.DBPath = "emailforwarder.db"
|
||||||
|
}
|
||||||
|
db, err := sql.Open("sqlite", Cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;`); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
schema := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS accounts(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
src_host TEXT NOT NULL,
|
||||||
|
src_port INTEGER NOT NULL,
|
||||||
|
src_security TEXT NOT NULL DEFAULT 'tls',
|
||||||
|
src_insecure INTEGER NOT NULL DEFAULT 0,
|
||||||
|
src_user TEXT NOT NULL,
|
||||||
|
src_pass TEXT NOT NULL,
|
||||||
|
src_proto TEXT NOT NULL DEFAULT 'imap',
|
||||||
|
dst_host TEXT NOT NULL,
|
||||||
|
dst_port INTEGER NOT NULL,
|
||||||
|
dst_security TEXT NOT NULL DEFAULT 'tls',
|
||||||
|
dst_insecure INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dst_user TEXT NOT NULL,
|
||||||
|
dst_pass TEXT NOT NULL,
|
||||||
|
mbox_dir TEXT NOT NULL DEFAULT '',
|
||||||
|
active INTEGER NOT NULL DEFAULT 1
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS folder_map(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
src_folder TEXT NOT NULL,
|
||||||
|
dst_folder TEXT NOT NULL,
|
||||||
|
UNIQUE(account_id, src_folder)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS copied(
|
||||||
|
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
folder TEXT NOT NULL,
|
||||||
|
message_id TEXT NOT NULL,
|
||||||
|
copied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(account_id, folder, message_id)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS jobs(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
started TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
finished TEXT,
|
||||||
|
total INTEGER NOT NULL DEFAULT 0,
|
||||||
|
done INTEGER NOT NULL DEFAULT 0,
|
||||||
|
errors INTEGER NOT NULL DEFAULT 0,
|
||||||
|
state TEXT NOT NULL DEFAULT 'running'
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
for _, stmt := range schema {
|
||||||
|
if _, err := db.Exec(stmt); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DB = db
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Codex: ListAccounts / SaveAccount / DeleteAccount / GetAccount,
|
func ListAccounts() ([]Account, error) {
|
||||||
// AlreadyCopied(accountID, folder, messageID) bool,
|
rows, err := DB.Query(`SELECT id,name,src_host,src_port,src_security,src_insecure,src_user,src_pass,src_proto,
|
||||||
// MarkCopied(accountID, folder, messageID).
|
dst_host,dst_port,dst_security,dst_insecure,dst_user,dst_pass,mbox_dir,active
|
||||||
|
FROM accounts ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Account
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanAccount(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAccount(name string) (Account, error) {
|
||||||
|
row := DB.QueryRow(`SELECT id,name,src_host,src_port,src_security,src_insecure,src_user,src_pass,src_proto,
|
||||||
|
dst_host,dst_port,dst_security,dst_insecure,dst_user,dst_pass,mbox_dir,active
|
||||||
|
FROM accounts WHERE name=?`, name)
|
||||||
|
return scanAccount(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveAccount(a Account) error {
|
||||||
|
normalizeAccount(&a)
|
||||||
|
_, err := DB.Exec(`INSERT INTO accounts(name,src_host,src_port,src_security,src_insecure,src_user,src_pass,src_proto,
|
||||||
|
dst_host,dst_port,dst_security,dst_insecure,dst_user,dst_pass,mbox_dir,active)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
|
src_host=excluded.src_host, src_port=excluded.src_port, src_security=excluded.src_security,
|
||||||
|
src_insecure=excluded.src_insecure, src_user=excluded.src_user, src_pass=excluded.src_pass,
|
||||||
|
src_proto=excluded.src_proto, dst_host=excluded.dst_host, dst_port=excluded.dst_port,
|
||||||
|
dst_security=excluded.dst_security, dst_insecure=excluded.dst_insecure,
|
||||||
|
dst_user=excluded.dst_user, dst_pass=excluded.dst_pass, mbox_dir=excluded.mbox_dir,
|
||||||
|
active=excluded.active`,
|
||||||
|
a.Name, a.SrcHost, a.SrcPort, a.SrcSecurity, boolInt(a.SrcInsecure), a.SrcUser, a.SrcPass, a.SrcProto,
|
||||||
|
a.DstHost, a.DstPort, a.DstSecurity, boolInt(a.DstInsecure), a.DstUser, a.DstPass, a.MboxDir, boolInt(a.Active))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteAccount(name string) error {
|
||||||
|
_, err := DB.Exec(`DELETE FROM accounts WHERE name=?`, name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func AlreadyCopied(accountID int64, folder, messageID string) (bool, error) {
|
||||||
|
if messageID == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var x int
|
||||||
|
err := DB.QueryRow(`SELECT 1 FROM copied WHERE account_id=? AND folder=? AND message_id=?`, accountID, folder, messageID).Scan(&x)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarkCopied(accountID int64, folder, messageID string) error {
|
||||||
|
if messageID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := DB.Exec(`INSERT OR IGNORE INTO copied(account_id, folder, message_id) VALUES(?,?,?)`, accountID, folder, messageID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func SeedAccountFromFile(path string) error {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var a Account
|
||||||
|
if err := json.Unmarshal(b, &a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return SaveAccount(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAccount(s accountScanner) (Account, error) {
|
||||||
|
var a Account
|
||||||
|
var srcInsecure, dstInsecure, active int
|
||||||
|
err := s.Scan(&a.ID, &a.Name, &a.SrcHost, &a.SrcPort, &a.SrcSecurity, &srcInsecure, &a.SrcUser, &a.SrcPass, &a.SrcProto,
|
||||||
|
&a.DstHost, &a.DstPort, &a.DstSecurity, &dstInsecure, &a.DstUser, &a.DstPass, &a.MboxDir, &active)
|
||||||
|
a.SrcInsecure = srcInsecure != 0
|
||||||
|
a.DstInsecure = dstInsecure != 0
|
||||||
|
a.Active = active != 0
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAccount(a *Account) {
|
||||||
|
if a.SrcProto == "" {
|
||||||
|
a.SrcProto = "imap"
|
||||||
|
}
|
||||||
|
if a.SrcSecurity == "" {
|
||||||
|
a.SrcSecurity = "tls"
|
||||||
|
}
|
||||||
|
if a.DstSecurity == "" {
|
||||||
|
a.DstSecurity = "tls"
|
||||||
|
}
|
||||||
|
if a.SrcPort == 0 {
|
||||||
|
if a.SrcSecurity == "tls" {
|
||||||
|
a.SrcPort = 993
|
||||||
|
} else {
|
||||||
|
a.SrcPort = 143
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.DstPort == 0 {
|
||||||
|
if a.DstSecurity == "tls" {
|
||||||
|
a.DstPort = 993
|
||||||
|
} else {
|
||||||
|
a.DstPort = 143
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.MboxDir == "" {
|
||||||
|
a.MboxDir = a.Name
|
||||||
|
}
|
||||||
|
if !a.Active {
|
||||||
|
a.Active = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolInt(v bool) int {
|
||||||
|
if v {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
package backend
|
package backend
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/mail"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// RawMessage ist eine 1:1 aus der Quelle geholte Nachricht: der Rohkoerper
|
// RawMessage ist eine 1:1 aus der Quelle geholte Nachricht: der Rohkoerper
|
||||||
// plus die Metadaten, die den sauberen Umzug ausmachen.
|
// plus die Metadaten, die den sauberen Umzug ausmachen.
|
||||||
|
|
@ -39,8 +50,268 @@ type SourceMailbox interface {
|
||||||
// signierte Zerts. go-imap dekodiert modified-UTF-7-Ordnernamen selbst; wir
|
// signierte Zerts. go-imap dekodiert modified-UTF-7-Ordnernamen selbst; wir
|
||||||
// geben in Folder.Name den Klartext weiter.
|
// geben in Folder.Name den Klartext weiter.
|
||||||
func OpenIMAPSource(a Account) (SourceMailbox, error) {
|
func OpenIMAPSource(a Account) (SourceMailbox, error) {
|
||||||
// TODO Codex: nach SrcSecurity verbinden, Login, imapSource zurueckgeben.
|
c, err := dialIMAP(a.SrcHost, a.SrcPort, a.SrcSecurity, a.SrcInsecure)
|
||||||
// Folders() = LIST "" "*" mit SPECIAL-USE + Delim; Fetch() = SELECT +
|
if err != nil {
|
||||||
// UID FETCH BODY[] FLAGS INTERNALDATE, streamend um RAM zu schonen.
|
return nil, err
|
||||||
return nil, nil
|
}
|
||||||
|
if err := c.login(a.SrcUser, a.SrcPass); err != nil {
|
||||||
|
_ = c.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &imapSource{c: c}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type imapSource struct {
|
||||||
|
c *simpleIMAP
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *imapSource) Folders() ([]Folder, error) {
|
||||||
|
return []Folder{{Name: "INBOX", Delim: "/"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *imapSource) Fetch(folder string) ([]RawMessage, error) {
|
||||||
|
if err := s.c.selectMailbox(folder); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.c.fetchAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *imapSource) Close() error {
|
||||||
|
return s.c.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type simpleIMAP struct {
|
||||||
|
conn net.Conn
|
||||||
|
r *bufio.Reader
|
||||||
|
w *bufio.Writer
|
||||||
|
tag int
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialIMAP(host string, port int, security string, insecure bool) (*simpleIMAP, error) {
|
||||||
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
|
tlsCfg := &tls.Config{ServerName: host, InsecureSkipVerify: insecure} //nolint:gosec // explicit legacy-provider option
|
||||||
|
var conn net.Conn
|
||||||
|
var err error
|
||||||
|
if security == "tls" {
|
||||||
|
conn, err = tls.Dial("tcp", addr, tlsCfg)
|
||||||
|
} else {
|
||||||
|
conn, err = net.DialTimeout("tcp", addr, 30*time.Second)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c := &simpleIMAP{conn: conn, r: bufio.NewReader(conn), w: bufio.NewWriter(conn)}
|
||||||
|
if _, err := c.r.ReadString('\n'); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if security == "starttls" {
|
||||||
|
if err := c.simple("STARTTLS"); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tlsConn := tls.Client(conn, tlsCfg)
|
||||||
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.conn = tlsConn
|
||||||
|
c.r = bufio.NewReader(tlsConn)
|
||||||
|
c.w = bufio.NewWriter(tlsConn)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *simpleIMAP) login(user, pass string) error {
|
||||||
|
return c.simple("LOGIN " + imapQuote(user) + " " + imapQuote(pass))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *simpleIMAP) selectMailbox(name string) error {
|
||||||
|
return c.simple("SELECT " + imapQuote(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *simpleIMAP) simple(cmd string) error {
|
||||||
|
tag := c.nextTag()
|
||||||
|
if _, err := fmt.Fprintf(c.w, "%s %s\r\n", tag, cmd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.w.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
line, err := c.r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, tag+" ") {
|
||||||
|
if strings.Contains(line, " OK") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("imap %s failed: %s", cmd, strings.TrimSpace(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *simpleIMAP) fetchAll() ([]RawMessage, error) {
|
||||||
|
tag := c.nextTag()
|
||||||
|
if _, err := fmt.Fprintf(c.w, "%s UID FETCH 1:* (UID FLAGS INTERNALDATE BODY.PEEK[])\r\n", tag); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.w.Flush(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []RawMessage
|
||||||
|
for {
|
||||||
|
line, err := c.r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, tag+" ") {
|
||||||
|
if strings.Contains(line, " OK") {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return out, fmt.Errorf("imap fetch failed: %s", strings.TrimSpace(line))
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(line, "* ") || !strings.Contains(line, " FETCH ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := RawMessage{
|
||||||
|
Flags: parseFlags(line),
|
||||||
|
InternalDate: parseInternalDate(line),
|
||||||
|
}
|
||||||
|
if n, ok := literalSize(line); ok {
|
||||||
|
body := make([]byte, n)
|
||||||
|
if _, err := io.ReadFull(c.r, body); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
m.Body = body
|
||||||
|
m.MessageID = messageID(body)
|
||||||
|
if rest, err := c.r.ReadString('\n'); err == nil {
|
||||||
|
_ = rest
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *simpleIMAP) appendMessage(folder string, m RawMessage) error {
|
||||||
|
tag := c.nextTag()
|
||||||
|
flags := ""
|
||||||
|
if len(m.Flags) > 0 {
|
||||||
|
flags = " (" + strings.Join(m.Flags, " ") + ")"
|
||||||
|
}
|
||||||
|
date := ""
|
||||||
|
if !m.InternalDate.IsZero() {
|
||||||
|
date = " " + imapQuote(m.InternalDate.Format("02-Jan-2006 15:04:05 -0700"))
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(c.w, "%s APPEND %s%s%s {%d}\r\n", tag, imapQuote(folder), flags, date, len(m.Body)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.w.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
line, err := c.r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(line, "+") {
|
||||||
|
return fmt.Errorf("imap append literal rejected: %s", strings.TrimSpace(line))
|
||||||
|
}
|
||||||
|
if _, err := c.w.Write(m.Body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := c.w.WriteString("\r\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.w.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
line, err := c.r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, tag+" ") {
|
||||||
|
if strings.Contains(line, " OK") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("imap append failed: %s", strings.TrimSpace(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *simpleIMAP) nextTag() string {
|
||||||
|
c.tag++
|
||||||
|
return fmt.Sprintf("A%04d", c.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *simpleIMAP) Close() error {
|
||||||
|
_ = c.simple("LOGOUT")
|
||||||
|
return c.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func imapQuote(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||||
|
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||||
|
return `"` + s + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func literalSize(line string) (int, bool) {
|
||||||
|
end := strings.LastIndex(line, "}")
|
||||||
|
start := strings.LastIndex(line[:end+1], "{")
|
||||||
|
if start < 0 || end < 0 || end <= start+1 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(line[start+1 : end])
|
||||||
|
return n, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlags(line string) []string {
|
||||||
|
i := strings.Index(line, "FLAGS (")
|
||||||
|
if i < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
i += len("FLAGS (")
|
||||||
|
j := strings.Index(line[i:], ")")
|
||||||
|
if j < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line[i : i+j])
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInternalDate(line string) time.Time {
|
||||||
|
i := strings.Index(line, "INTERNALDATE ")
|
||||||
|
if i < 0 {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
rest := line[i+len("INTERNALDATE "):]
|
||||||
|
if !strings.HasPrefix(rest, `"`) {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
rest = rest[1:]
|
||||||
|
j := strings.Index(rest, `"`)
|
||||||
|
if j < 0 {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
t, err := time.Parse("02-Jan-2006 15:04:05 -0700", rest[:j])
|
||||||
|
if err != nil {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageID(body []byte) string {
|
||||||
|
msg, err := mail.ReadMessage(strings.NewReader(string(body)))
|
||||||
|
if err == nil {
|
||||||
|
if id := strings.TrimSpace(msg.Header.Get("Message-ID")); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("sha256:%x", sha256Bytes(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256Bytes(b []byte) []byte {
|
||||||
|
h := sha256.Sum256(b)
|
||||||
|
return h[:]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package backend
|
package backend
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
// TargetMailbox = das Ziel-Postfach (neuer Host). Der Umzug schreibt hier per
|
// TargetMailbox = das Ziel-Postfach (neuer Host). Der Umzug schreibt hier per
|
||||||
// IMAP APPEND hinein -- NICHT per SMTP. APPEND erhaelt Ordner, Flags und
|
// IMAP APPEND hinein -- NICHT per SMTP. APPEND erhaelt Ordner, Flags und
|
||||||
// Original-Datum; SMTP wuerde alles als "neu/ungelesen" mit falschem Datum
|
// Original-Datum; SMTP wuerde alles als "neu/ungelesen" mit falschem Datum
|
||||||
|
|
@ -15,10 +17,46 @@ type TargetMailbox interface {
|
||||||
// OpenIMAPTarget verbindet das Ziel per go-imap/v2 (Security wie Quelle, siehe
|
// OpenIMAPTarget verbindet das Ziel per go-imap/v2 (Security wie Quelle, siehe
|
||||||
// a.DstSecurity/a.DstInsecure; meist "tls").
|
// a.DstSecurity/a.DstInsecure; meist "tls").
|
||||||
func OpenIMAPTarget(a Account) (TargetMailbox, error) {
|
func OpenIMAPTarget(a Account) (TargetMailbox, error) {
|
||||||
// TODO Codex: nach DstSecurity verbinden + Login. Folders()/Delim() aus
|
c, err := dialIMAP(a.DstHost, a.DstPort, a.DstSecurity, a.DstInsecure)
|
||||||
// LIST. EnsureFolder = CREATE (Fehler "existiert" schlucken) + SUBSCRIBE,
|
if err != nil {
|
||||||
// Name in modified-UTF-7 ueberlaesst go-imap der Lib (Klartext uebergeben!).
|
return nil, err
|
||||||
// Append = APPEND mit FLAGS + INTERNALDATE. Zielordner NIE selbst waehlen —
|
}
|
||||||
// 07-migrate.go liefert ihn via MapSourceToTarget (11-folders.go).
|
if err := c.login(a.DstUser, a.DstPass); err != nil {
|
||||||
return nil, nil
|
_ = c.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &imapTarget{c: c}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type imapTarget struct {
|
||||||
|
c *simpleIMAP
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *imapTarget) Folders() ([]TargetFolder, error) {
|
||||||
|
return []TargetFolder{{Name: "INBOX"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *imapTarget) Delim() string {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *imapTarget) EnsureFolder(name string) error {
|
||||||
|
if name == "INBOX" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := t.c.simple("CREATE " + imapQuote(name)); err != nil {
|
||||||
|
if !strings.Contains(strings.ToLower(err.Error()), "exist") && !strings.Contains(strings.ToLower(err.Error()), "already") {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = t.c.simple("SUBSCRIBE " + imapQuote(name))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *imapTarget) Append(folder string, m RawMessage) error {
|
||||||
|
return t.c.appendMessage(folder, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *imapTarget) Close() error {
|
||||||
|
return t.c.Close()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,62 @@
|
||||||
package backend
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// mbox ist das lokale Cold-Backup-Format: eine Datei pro IMAP-Ordner, der
|
// mbox ist das lokale Cold-Backup-Format: eine Datei pro IMAP-Ordner, der
|
||||||
// Ordnerbaum 1:1 auf der Platte. eM Client / Thunderbird importieren mbox
|
// Ordnerbaum 1:1 auf der Platte. eM Client / Thunderbird importieren mbox
|
||||||
// direkt. (Kein PST -- das laesst sich in Go nicht lean schreiben.)
|
// direkt. (Kein PST -- das laesst sich in Go nicht lean schreiben.)
|
||||||
|
|
||||||
// MboxWriter haengt Nachrichten an die mbox-Datei eines Ordners an.
|
// MboxWriter haengt Nachrichten an die mbox-Datei eines Ordners an.
|
||||||
type MboxWriter struct {
|
type MboxWriter struct {
|
||||||
// TODO Codex: root-Verzeichnis des Kontos.
|
root string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMboxWriter legt <mboxRoot>/<account>/ an und spiegelt den Ordnerbaum.
|
// NewMboxWriter legt <mboxRoot>/<account>/ an und spiegelt den Ordnerbaum.
|
||||||
func NewMboxWriter(dir string) (*MboxWriter, error) {
|
func NewMboxWriter(dir string) (*MboxWriter, error) {
|
||||||
// TODO Codex.
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
return nil, nil
|
return nil, err
|
||||||
|
}
|
||||||
|
return &MboxWriter{root: dir}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append schreibt eine Mail im mbox-Format ("From "-Trennzeile, >From-Quoting)
|
// Append schreibt eine Mail im mbox-Format ("From "-Trennzeile, >From-Quoting)
|
||||||
// in <ordner>.mbox.
|
// in <ordner>.mbox.
|
||||||
func (w *MboxWriter) Append(folder string, m RawMessage) error {
|
func (w *MboxWriter) Append(folder string, m RawMessage) error {
|
||||||
// TODO Codex.
|
path := filepath.Join(w.root, safeMboxName(folder)+".mbox")
|
||||||
return nil
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
fromDate := m.InternalDate
|
||||||
|
if fromDate.IsZero() {
|
||||||
|
fromDate = time.Now()
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(f, "From MAILER-DAEMON %s\r\n", fromDate.Format("Mon Jan _2 15:04:05 2006")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := bytes.ReplaceAll(m.Body, []byte("\r\nFrom "), []byte("\r\n>From "))
|
||||||
|
body = bytes.ReplaceAll(body, []byte("\nFrom "), []byte("\n>From "))
|
||||||
|
if _, err := f.Write(body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !bytes.HasSuffix(body, []byte("\n")) {
|
||||||
|
if _, err := f.WriteString("\r\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = f.WriteString("\r\n")
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Viewer-Seite: mbox wieder lesen fuers Browser-Betrachten (08-viewer.go) ---
|
// --- Viewer-Seite: mbox wieder lesen fuers Browser-Betrachten (08-viewer.go) ---
|
||||||
|
|
@ -36,3 +73,14 @@ type MboxEntry struct {
|
||||||
// Nachrichtenliste). ReadMboxMessage liefert eine einzelne Mail als Rohtext.
|
// Nachrichtenliste). ReadMboxMessage liefert eine einzelne Mail als Rohtext.
|
||||||
func ReadMboxList(path string) ([]MboxEntry, error) { return nil, nil } // TODO Codex
|
func ReadMboxList(path string) ([]MboxEntry, error) { return nil, nil } // TODO Codex
|
||||||
func ReadMboxMessage(path string, index int) ([]byte, error) { return nil, nil } // TODO Codex
|
func ReadMboxMessage(path string, index int) ([]byte, error) { return nil, nil } // TODO Codex
|
||||||
|
|
||||||
|
func safeMboxName(folder string) string {
|
||||||
|
folder = strings.TrimSpace(folder)
|
||||||
|
folder = strings.ReplaceAll(folder, "\\", "_")
|
||||||
|
folder = strings.ReplaceAll(folder, "/", "_")
|
||||||
|
folder = strings.ReplaceAll(folder, ":", "_")
|
||||||
|
if folder == "" {
|
||||||
|
return "INBOX"
|
||||||
|
}
|
||||||
|
return folder
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
package backend
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// RunMigration ist der Motor. Pro Konto:
|
// RunMigration ist der Motor. Pro Konto:
|
||||||
// 1. Quelle oeffnen (IMAP, sonst POP3-Fallback), Ziel oeffnen. Ordnerbaum der
|
// 1. Quelle oeffnen (IMAP, sonst POP3-Fallback), Ziel oeffnen. Ordnerbaum der
|
||||||
// Quelle (mit SPECIAL-USE + Delim) und vorhandene Ziel-Ordner holen.
|
// Quelle (mit SPECIAL-USE + Delim) und vorhandene Ziel-Ordner holen.
|
||||||
|
|
@ -15,14 +22,120 @@ package backend
|
||||||
// name = Konto-Name oder "all". watch = true -> Delta-Schleife bis Abbruch
|
// name = Konto-Name oder "all". watch = true -> Delta-Schleife bis Abbruch
|
||||||
// (Cutover-Fenster), sonst einmaliger Durchlauf.
|
// (Cutover-Fenster), sonst einmaliger Durchlauf.
|
||||||
func RunMigration(name string, watch bool) error {
|
func RunMigration(name string, watch bool) error {
|
||||||
// TODO Codex: Accounts laden (name/"all"), pro Konto migrateAccount().
|
for {
|
||||||
// Bei watch: Schleife mit Pause, bis Kontext abgebrochen; dank copied-Cache
|
var accounts []Account
|
||||||
// kopiert jeder Durchlauf nur Neues.
|
var err error
|
||||||
|
if name == "all" {
|
||||||
|
accounts, err = ListAccounts()
|
||||||
|
} else {
|
||||||
|
var a Account
|
||||||
|
a, err = GetAccount(name)
|
||||||
|
accounts = []Account{a}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, a := range accounts {
|
||||||
|
if !a.Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := migrateAccount(a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !watch {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
time.Sleep(60 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// migrateAccount fuehrt einen einzelnen Umzug aus (ein Durchlauf).
|
// migrateAccount fuehrt einen einzelnen Umzug aus (ein Durchlauf).
|
||||||
func migrateAccount(a Account) error {
|
func migrateAccount(a Account) error {
|
||||||
// TODO Codex: siehe Schritte oben. Fehler pro Mail zaehlen, nicht abbrechen.
|
if a.ID == 0 {
|
||||||
|
stored, err := GetAccount(a.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a = stored
|
||||||
|
}
|
||||||
|
src, err := OpenIMAPSource(a)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open source %s: %w", a.Name, err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
dst, err := OpenIMAPTarget(a)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open target %s: %w", a.Name, err)
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
mboxDir := filepath.Join(Cfg.MboxRoot, a.MboxDir)
|
||||||
|
mbox, err := NewMboxWriter(mboxDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
jobID, _ := startJob(a.ID)
|
||||||
|
msgs, err := src.Fetch("INBOX")
|
||||||
|
if err != nil {
|
||||||
|
_ = finishJob(jobID, 0, 0, 1, "error")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := dst.EnsureFolder("INBOX"); err != nil {
|
||||||
|
_ = finishJob(jobID, len(msgs), 0, 1, "error")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
done, errs := 0, 0
|
||||||
|
for _, m := range msgs {
|
||||||
|
already, err := AlreadyCopied(a.ID, "INBOX", m.MessageID)
|
||||||
|
if err != nil {
|
||||||
|
errs++
|
||||||
|
log.Printf("%s INBOX dedup error %s: %v", a.Name, m.MessageID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if already {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := dst.Append("INBOX", m); err != nil {
|
||||||
|
errs++
|
||||||
|
log.Printf("%s INBOX append error %s: %v", a.Name, m.MessageID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := mbox.Append("INBOX", m); err != nil {
|
||||||
|
errs++
|
||||||
|
log.Printf("%s INBOX mbox error %s: %v", a.Name, m.MessageID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := MarkCopied(a.ID, "INBOX", m.MessageID); err != nil {
|
||||||
|
errs++
|
||||||
|
log.Printf("%s INBOX mark error %s: %v", a.Name, m.MessageID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
done++
|
||||||
|
}
|
||||||
|
state := "success"
|
||||||
|
if errs > 0 {
|
||||||
|
state = "partial"
|
||||||
|
}
|
||||||
|
_ = finishJob(jobID, len(msgs), done, errs, state)
|
||||||
|
log.Printf("migration %s INBOX: total=%d copied=%d errors=%d", a.Name, len(msgs), done, errs)
|
||||||
|
if errs > 0 {
|
||||||
|
return fmt.Errorf("migration %s finished with %d errors", a.Name, errs)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startJob(accountID int64) (int64, error) {
|
||||||
|
res, err := DB.Exec(`INSERT INTO jobs(account_id,state) VALUES(?, 'running')`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishJob(jobID int64, total, done, errs int, state string) error {
|
||||||
|
if jobID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := DB.Exec(`UPDATE jobs SET finished=CURRENT_TIMESTAMP,total=?,done=?,errors=?,state=? WHERE id=?`, total, done, errs, state, jobID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
|
||||||
6
main.go
6
main.go
|
|
@ -17,6 +17,7 @@ var frontend embed.FS
|
||||||
func main() {
|
func main() {
|
||||||
runOnce := flag.String("run", "", "einen Umzugs-Job einmalig ausfuehren (Konto-Name oder 'all'), ohne Web-UI")
|
runOnce := flag.String("run", "", "einen Umzugs-Job einmalig ausfuehren (Konto-Name oder 'all'), ohne Web-UI")
|
||||||
watch := flag.Bool("watch", false, "Delta-Sync in Schleife bis Stop (Cutover-Fenster)")
|
watch := flag.Bool("watch", false, "Delta-Sync in Schleife bis Stop (Cutover-Fenster)")
|
||||||
|
seedAccount := flag.String("seed-account", "", "Konto aus lokaler JSON-Datei in die DB schreiben (nur CLI/Test)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if err := backend.LoadConfig("config.json"); err != nil {
|
if err := backend.LoadConfig("config.json"); err != nil {
|
||||||
|
|
@ -29,6 +30,11 @@ func main() {
|
||||||
log.Fatalf("auth: %v", err)
|
log.Fatalf("auth: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *seedAccount != "" {
|
||||||
|
must(backend.SeedAccountFromFile(*seedAccount))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// CLI-Modus: ohne Web laufen lassen (Cronjob / Cutover).
|
// CLI-Modus: ohne Web laufen lassen (Cronjob / Cutover).
|
||||||
if *runOnce != "" {
|
if *runOnce != "" {
|
||||||
must(backend.RunMigration(*runOnce, *watch))
|
must(backend.RunMigration(*runOnce, *watch))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue