diff --git a/cmd/hydroxide/hydroxide.go b/cmd/hydroxide/hydroxide.go index b0d89ea..4e1e3cf 100644 --- a/cmd/hydroxide/hydroxide.go +++ b/cmd/hydroxide/hydroxide.go @@ -21,6 +21,7 @@ import ( "github.com/emersion/hydroxide/carddav" "github.com/emersion/hydroxide/events" imapbackend "github.com/emersion/hydroxide/imap" + "github.com/emersion/hydroxide/imports" "github.com/emersion/hydroxide/protonmail" smtpbackend "github.com/emersion/hydroxide/smtp" ) @@ -104,7 +105,6 @@ func listenAndServeCardDAV(addr string, authManager *auth.Manager, eventsManager } func main() { - // Register flags smtpHost := flag.String("smtp-host", "127.0.0.1", "Allowed SMTP email hostname on which hydroxide listens, defaults to 127.0.0.1") smtpPort := flag.String("smtp-port", "1025", "SMTP port on which hydroxide listens, defaults to 1025") @@ -114,9 +114,9 @@ func main() { carddavHost := flag.String("carddav-host", "127.0.0.1", "Allowed CardDAV email hostname on which hydroxide listens, defaults to 127.0.0.1") carddavPort := flag.String("carddav-port", "8080", "CardDAV port on which hydroxide listens, defaults to 8080") - // Register arguments authCmd := flag.NewFlagSet("auth", flag.ExitOnError) exportSecretKeysCmd := flag.NewFlagSet("export-secret-keys", flag.ExitOnError) + importMessagesCmd := flag.NewFlagSet("import-messages", flag.ExitOnError) flag.Parse() @@ -124,7 +124,7 @@ func main() { switch cmd { case "auth": authCmd.Parse(os.Args[2:]) - username := authCmd.Arg(1) + username := authCmd.Arg(0) if username == "" { log.Fatal("usage: hydroxide auth ") } @@ -222,7 +222,7 @@ func main() { } case "export-secret-keys": exportSecretKeysCmd.Parse(os.Args[2:]) - username := exportSecretKeysCmd.Arg(1) + username := exportSecretKeysCmd.Arg(0) if username == "" { log.Fatal("usage: hydroxide export-secret-keys ") } @@ -254,6 +254,38 @@ func main() { if err := wc.Close(); err != nil { log.Fatal(err) } + case "import-messages": + // TODO: support for mbox + + importMessagesCmd.Parse(os.Args[2:]) + username := importMessagesCmd.Arg(0) + archivePath := importMessagesCmd.Arg(1) + if username == "" || archivePath == "" { + log.Fatal("usage: hydroxide import-messages ") + } + + f, err := os.Open(archivePath) + if err != nil { + log.Fatal(err) + } + defer f.Close() + + var bridgePassword string + fmt.Printf("Bridge password: ") + if pass, err := gopass.GetPasswd(); err != nil { + log.Fatal(err) + } else { + bridgePassword = string(pass) + } + + c, _, err := auth.NewManager(newClient).Auth(username, bridgePassword) + if err != nil { + log.Fatal(err) + } + + if err := imports.ImportMessage(c, f); err != nil { + log.Fatal(err) + } case "smtp": addr := *smtpHost + ":" + *smtpPort authManager := auth.NewManager(newClient) @@ -292,8 +324,9 @@ func main() { Commands: auth Login to ProtonMail via hydroxide carddav Run hydroxide as a CardDAV server - export-secret-keys Export keys + export-secret-keys Export secret keys imap Run hydroxide as an IMAP server + import-messages Import messages serve Run all servers smtp Run hydroxide as an SMTP server status View hydroxide status diff --git a/imports/messages.go b/imports/messages.go new file mode 100644 index 0000000..ba6bf7e --- /dev/null +++ b/imports/messages.go @@ -0,0 +1,117 @@ +package imports + +import ( + "fmt" + "io" + + "github.com/emersion/go-message/mail" + "golang.org/x/crypto/openpgp" + "golang.org/x/crypto/openpgp/armor" + + "github.com/emersion/hydroxide/protonmail" +) + +func ImportMessage(c *protonmail.Client, r io.Reader) error { + mr, err := mail.CreateReader(r) + if err != nil { + return err + } + defer mr.Close() + + // TODO: support attachments + hdr := mr.Header + var body io.Reader + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } else if err != nil { + return err + } + + if _, ok := p.Header.(*mail.InlineHeader); ok { + hdr.Set("Content-Type", p.Header.Get("Content-Type")) + body = p.Body + break + } + } + if body == nil { + return fmt.Errorf("message has no body") + } + + addrs, err := c.ListAddresses() + if err != nil { + return err + } + // TODO: choose address depending on message header + var importAddr *protonmail.Address + for _, addr := range addrs { + if addr.Send == protonmail.AddressSendPrimary { + importAddr = addr + break + } + } + if importAddr == nil { + return fmt.Errorf("no primary address found") + } + + publicKey, err := importAddr.Keys[0].Entity() + if err != nil { + return err + } + + key := "0" + metadata := map[string]*protonmail.Message{ + key: { + Unread: 1, + LabelIDs: []string{protonmail.LabelInbox}, + Type: protonmail.MessageInbox, + AddressID: importAddr.ID, + }, + } + importer, err := c.Import(metadata) + if err != nil { + return err + } + + w, err := importer.ImportMessage(key) + if err != nil { + return err + } + + mwc, err := mail.CreateSingleInlineWriter(w, hdr) + if err != nil { + return err + } + defer mwc.Close() + + awc, err := armor.Encode(mwc, "PGP MESSAGE", nil) + if err != nil { + return err + } + defer awc.Close() + ewc, err := openpgp.Encrypt(awc, []*openpgp.Entity{publicKey}, nil, nil, nil) + if err != nil { + return err + } + defer ewc.Close() + + if _, err := io.Copy(w, body); err != nil { + return err + } + if err := ewc.Close(); err != nil { + return err + } + if err := awc.Close(); err != nil { + return err + } + if err := mwc.Close(); err != nil { + return err + } + + if _, err := importer.Commit(); err != nil { + return err + } + + return nil +} diff --git a/protonmail/import.go b/protonmail/import.go new file mode 100644 index 0000000..63b9097 --- /dev/null +++ b/protonmail/import.go @@ -0,0 +1,159 @@ +package protonmail + +import ( + "encoding/json" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/textproto" +) + +type ImportResult map[string]ImportMessageResult + +func (res ImportResult) Err() error { + for _, msgRes := range res { + if msgRes.Err != nil { + return msgRes.Err + } + } + return nil +} + +type ImportMessageResult struct { + Err error + MessageID string +} + +type Importer struct { + pw *io.PipeWriter + mw *multipart.Writer + uploaded map[string]bool + closed bool + done <-chan error + result <-chan ImportResult +} + +func (imp *Importer) ImportMessage(key string) (io.Writer, error) { + if uploaded, ok := imp.uploaded[key]; !ok { + return nil, fmt.Errorf("protonmail: unknown import message %q", key) + } else if uploaded { + return nil, fmt.Errorf("protonmail: message %q already imported", key) + } + imp.uploaded[key] = true + + hdr := make(textproto.MIMEHeader) + params := map[string]string{ + "name": key, + "filename": key + ".eml", + } + hdr.Set("Content-Disposition", mime.FormatMediaType("form-data", params)) + hdr.Set("Content-Type", "message/rfc822") + return imp.mw.CreatePart(hdr) +} + +func (imp *Importer) close() error { + if imp.closed { + return fmt.Errorf("protonmail: importer already closed") + } + imp.closed = true + + if err := imp.mw.Close(); err != nil { + return err + } + + return imp.pw.Close() +} + +func (imp *Importer) Commit() (ImportResult, error) { + if err := imp.close(); err != nil { + return nil, err + } + + for key, ok := range imp.uploaded { + if !ok { + return nil, fmt.Errorf("protonmail: message %q has not been imported", key) + } + } + + if err := <-imp.done; err != nil { + return nil, err + } + + return <-imp.result, nil +} + +func (c *Client) Import(metadata map[string]*Message) (*Importer, error) { + pr, pw := io.Pipe() + + mw := multipart.NewWriter(pw) + + done := make(chan error, 1) + result := make(chan ImportResult, 1) + go func() { + defer close(done) + defer close(result) + + req, err := c.newRequest(http.MethodPost, "/import", pr) + if err != nil { + done <- err + return + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + + type messageResp struct { + Name string + Response struct { + resp + MessageID string + } + } + var respData struct { + resp + Responses []messageResp + } + err = c.doJSON(req, &respData) + done <- err + if err != nil { + return + } + + res := make(ImportResult, len(respData.Responses)) + for _, msgData := range respData.Responses { + res[msgData.Name] = ImportMessageResult{ + Err: msgData.Response.Err(), + MessageID: msgData.Response.MessageID, + } + } + result <- res + }() + + // Send metadata + hdr := make(textproto.MIMEHeader) + params := map[string]string{"name": "Metadata"} + hdr.Set("Content-Disposition", mime.FormatMediaType("form-data", params)) + hdr.Set("Content-Type", "application/json") + metadataWriter, err := mw.CreatePart(hdr) + if err != nil { + pw.CloseWithError(fmt.Errorf("protonmail: failed to write metadata")) + return nil, err + } + if err := json.NewEncoder(metadataWriter).Encode(metadata); err != nil { + pw.CloseWithError(fmt.Errorf("protonmail: failed to write metadata")) + return nil, err + } + + uploaded := make(map[string]bool, len(metadata)) + for key := range metadata { + uploaded[key] = false + } + + return &Importer{ + pw: pw, + mw: mw, + uploaded: uploaded, + done: done, + result: result, + }, nil +} diff --git a/protonmail/keys.go b/protonmail/keys.go index 5a2b8f9..d53c243 100644 --- a/protonmail/keys.go +++ b/protonmail/keys.go @@ -14,7 +14,7 @@ type PrivateKeyFlags int const ( PrivateKeyVerify PrivateKeyFlags = 1 - PrivateKeyEncrypt = 2 + PrivateKeyEncrypt PrivateKeyFlags = 2 ) type PrivateKey struct {