Add preliminary support for imports
This commit is contained in:
parent
6fe47ba219
commit
fbf85fecaa
|
@ -21,6 +21,7 @@ import (
|
||||||
"github.com/emersion/hydroxide/carddav"
|
"github.com/emersion/hydroxide/carddav"
|
||||||
"github.com/emersion/hydroxide/events"
|
"github.com/emersion/hydroxide/events"
|
||||||
imapbackend "github.com/emersion/hydroxide/imap"
|
imapbackend "github.com/emersion/hydroxide/imap"
|
||||||
|
"github.com/emersion/hydroxide/imports"
|
||||||
"github.com/emersion/hydroxide/protonmail"
|
"github.com/emersion/hydroxide/protonmail"
|
||||||
smtpbackend "github.com/emersion/hydroxide/smtp"
|
smtpbackend "github.com/emersion/hydroxide/smtp"
|
||||||
)
|
)
|
||||||
|
@ -104,7 +105,6 @@ func listenAndServeCardDAV(addr string, authManager *auth.Manager, eventsManager
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
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")
|
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")
|
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")
|
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")
|
carddavPort := flag.String("carddav-port", "8080", "CardDAV port on which hydroxide listens, defaults to 8080")
|
||||||
|
|
||||||
// Register arguments
|
|
||||||
authCmd := flag.NewFlagSet("auth", flag.ExitOnError)
|
authCmd := flag.NewFlagSet("auth", flag.ExitOnError)
|
||||||
exportSecretKeysCmd := flag.NewFlagSet("export-secret-keys", flag.ExitOnError)
|
exportSecretKeysCmd := flag.NewFlagSet("export-secret-keys", flag.ExitOnError)
|
||||||
|
importMessagesCmd := flag.NewFlagSet("import-messages", flag.ExitOnError)
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
@ -124,7 +124,7 @@ func main() {
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "auth":
|
case "auth":
|
||||||
authCmd.Parse(os.Args[2:])
|
authCmd.Parse(os.Args[2:])
|
||||||
username := authCmd.Arg(1)
|
username := authCmd.Arg(0)
|
||||||
if username == "" {
|
if username == "" {
|
||||||
log.Fatal("usage: hydroxide auth <username>")
|
log.Fatal("usage: hydroxide auth <username>")
|
||||||
}
|
}
|
||||||
|
@ -222,7 +222,7 @@ func main() {
|
||||||
}
|
}
|
||||||
case "export-secret-keys":
|
case "export-secret-keys":
|
||||||
exportSecretKeysCmd.Parse(os.Args[2:])
|
exportSecretKeysCmd.Parse(os.Args[2:])
|
||||||
username := exportSecretKeysCmd.Arg(1)
|
username := exportSecretKeysCmd.Arg(0)
|
||||||
if username == "" {
|
if username == "" {
|
||||||
log.Fatal("usage: hydroxide export-secret-keys <username>")
|
log.Fatal("usage: hydroxide export-secret-keys <username>")
|
||||||
}
|
}
|
||||||
|
@ -254,6 +254,38 @@ func main() {
|
||||||
if err := wc.Close(); err != nil {
|
if err := wc.Close(); err != nil {
|
||||||
log.Fatal(err)
|
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 <username> <file>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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":
|
case "smtp":
|
||||||
addr := *smtpHost + ":" + *smtpPort
|
addr := *smtpHost + ":" + *smtpPort
|
||||||
authManager := auth.NewManager(newClient)
|
authManager := auth.NewManager(newClient)
|
||||||
|
@ -292,8 +324,9 @@ func main() {
|
||||||
Commands:
|
Commands:
|
||||||
auth <username> Login to ProtonMail via hydroxide
|
auth <username> Login to ProtonMail via hydroxide
|
||||||
carddav Run hydroxide as a CardDAV server
|
carddav Run hydroxide as a CardDAV server
|
||||||
export-secret-keys Export keys
|
export-secret-keys <username> Export secret keys
|
||||||
imap Run hydroxide as an IMAP server
|
imap Run hydroxide as an IMAP server
|
||||||
|
import-messages <username> <file> Import messages
|
||||||
serve Run all servers
|
serve Run all servers
|
||||||
smtp Run hydroxide as an SMTP server
|
smtp Run hydroxide as an SMTP server
|
||||||
status View hydroxide status
|
status View hydroxide status
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -14,7 +14,7 @@ type PrivateKeyFlags int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PrivateKeyVerify PrivateKeyFlags = 1
|
PrivateKeyVerify PrivateKeyFlags = 1
|
||||||
PrivateKeyEncrypt = 2
|
PrivateKeyEncrypt PrivateKeyFlags = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
type PrivateKey struct {
|
type PrivateKey struct {
|
||||||
|
|
Loading…
Reference in New Issue