package main import ( "bufio" "bytes" "crypto/tls" "flag" "fmt" "io" "log" "net/http" "os" "github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp/armor" imapserver "github.com/emersion/go-imap/server" "github.com/emersion/go-mbox" "github.com/emersion/go-smtp" "github.com/mattn/go-isatty" "golang.org/x/crypto/ssh/terminal" "github.com/emersion/hydroxide/auth" "github.com/emersion/hydroxide/carddav" "github.com/emersion/hydroxide/config" "github.com/emersion/hydroxide/events" "github.com/emersion/hydroxide/exports" imapbackend "github.com/emersion/hydroxide/imap" "github.com/emersion/hydroxide/imports" "github.com/emersion/hydroxide/protonmail" smtpbackend "github.com/emersion/hydroxide/smtp" ) const defaultAPIEndpoint = "https://mail.proton.me/api" var ( debug bool apiEndpoint string ) func newClient() *protonmail.Client { return &protonmail.Client{ RootURL: apiEndpoint, AppVersion: "web-mail@5.0.2.3", Debug: debug, } } func askPass(prompt string) ([]byte, error) { f := os.Stdin if !isatty.IsTerminal(f.Fd()) { // This can happen if stdin is used for piping data // TODO: the following assumes Unix var err error if f, err = os.Open("/dev/tty"); err != nil { return nil, err } defer f.Close() } fmt.Fprintf(os.Stderr, "%v: ", prompt) b, err := terminal.ReadPassword(int(f.Fd())) if err == nil { fmt.Fprintf(os.Stderr, "\n") } return b, err } func askBridgePass() (string, error) { if v := os.Getenv("HYDROXIDE_BRIDGE_PASS"); v != "" { return v, nil } b, err := askPass("Bridge password") return string(b), err } func listenAndServeSMTP(addr string, debug bool, authManager *auth.Manager, tlsConfig *tls.Config) error { be := smtpbackend.New(authManager) s := smtp.NewServer(be) s.Addr = addr s.Domain = "localhost" // TODO: make this configurable s.AllowInsecureAuth = tlsConfig == nil s.TLSConfig = tlsConfig if debug { s.Debug = os.Stdout } if s.TLSConfig != nil { log.Println("SMTP server listening with TLS on", s.Addr) return s.ListenAndServeTLS() } log.Println("SMTP server listening on", s.Addr) return s.ListenAndServe() } func listenAndServeIMAP(addr string, debug bool, authManager *auth.Manager, eventsManager *events.Manager, tlsConfig *tls.Config) error { be := imapbackend.New(authManager, eventsManager) s := imapserver.New(be) s.Addr = addr s.AllowInsecureAuth = tlsConfig == nil s.TLSConfig = tlsConfig if debug { s.Debug = os.Stdout } if s.TLSConfig != nil { log.Println("IMAP server listening with TLS on", s.Addr) return s.ListenAndServeTLS() } log.Println("IMAP server listening on", s.Addr) return s.ListenAndServe() } func listenAndServeCardDAV(addr string, authManager *auth.Manager, eventsManager *events.Manager, tlsConfig *tls.Config) error { handlers := make(map[string]http.Handler) s := &http.Server{ Addr: addr, TLSConfig: tlsConfig, Handler: http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { resp.Header().Set("WWW-Authenticate", "Basic") username, password, ok := req.BasicAuth() if !ok { resp.WriteHeader(http.StatusUnauthorized) io.WriteString(resp, "Credentials are required") return } c, privateKeys, err := authManager.Auth(username, password) if err != nil { if err == auth.ErrUnauthorized { resp.WriteHeader(http.StatusUnauthorized) } else { resp.WriteHeader(http.StatusInternalServerError) } io.WriteString(resp, err.Error()) return } h, ok := handlers[username] if !ok { ch := make(chan *protonmail.Event) eventsManager.Register(c, username, ch, nil) h = carddav.NewHandler(c, privateKeys, ch) handlers[username] = h } h.ServeHTTP(resp, req) }), } if s.TLSConfig != nil { log.Println("CardDAV server listening with TLS on", s.Addr) return s.ListenAndServeTLS("", "") } log.Println("CardDAV server listening on", s.Addr) return s.ListenAndServe() } func isMbox(br *bufio.Reader) (bool, error) { prefix := []byte("From ") b, err := br.Peek(len(prefix)) if err != nil { return false, err } return bytes.Equal(b, prefix), nil } const usage = `usage: hydroxide [options...] Commands: auth Login to ProtonMail via hydroxide carddav Run hydroxide as a CardDAV server export-secret-keys Export secret keys imap Run hydroxide as an IMAP server import-messages [file] Import messages export-messages [options...] Export messages sendmail -- sendmail(1) interface serve Run all servers smtp Run hydroxide as an SMTP server status View hydroxide status Global options: -debug Enable debug logs -api-endpoint ProtonMail API endpoint -smtp-host example.com Allowed SMTP email hostname on which hydroxide listens, defaults to 127.0.0.1 -imap-host example.com Allowed IMAP email hostname on which hydroxide listens, defaults to 127.0.0.1 -carddav-host example.com Allowed SMTP email hostname on which hydroxide listens, defaults to 127.0.0.1 -smtp-port example.com SMTP port on which hydroxide listens, defaults to 1025 -imap-port example.com IMAP port on which hydroxide listens, defaults to 1143 -carddav-port example.com CardDAV port on which hydroxide listens, defaults to 8080 -disable-imap Disable IMAP for hydroxide serve -disable-smtp Disable SMTP for hydroxide serve -disable-carddav Disable CardDAV for hydroxide serve -tls-cert /path/to/cert.pem Path to the certificate to use for incoming connections (Optional) -tls-key /path/to/key.pem Path to the certificate key to use for incoming connections (Optional) -tls-client-ca /path/to/ca.pem If set, clients must provide a certificate signed by the given CA (Optional) Environment variables: HYDROXIDE_BRIDGE_PASS Don't prompt for the bridge password, use this variable instead ` func main() { flag.BoolVar(&debug, "debug", false, "Enable debug logs") flag.StringVar(&apiEndpoint, "api-endpoint", defaultAPIEndpoint, "ProtonMail API endpoint") 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") disableSMTP := flag.Bool("disable-smtp", false, "Disable SMTP for hydroxide serve") imapHost := flag.String("imap-host", "127.0.0.1", "Allowed IMAP email hostname on which hydroxide listens, defaults to 127.0.0.1") imapPort := flag.String("imap-port", "1143", "IMAP port on which hydroxide listens, defaults to 1143") disableIMAP := flag.Bool("disable-imap", false, "Disable IMAP for hydroxide serve") 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") disableCardDAV := flag.Bool("disable-carddav", false, "Disable CardDAV for hydroxide serve") tlsCert := flag.String("tls-cert", "", "Path to the certificate to use for incoming connections") tlsCertKey := flag.String("tls-key", "", "Path to the certificate key to use for incoming connections") tlsClientCA := flag.String("tls-client-ca", "", "If set, clients must provide a certificate signed by the given CA") authCmd := flag.NewFlagSet("auth", flag.ExitOnError) exportSecretKeysCmd := flag.NewFlagSet("export-secret-keys", flag.ExitOnError) importMessagesCmd := flag.NewFlagSet("import-messages", flag.ExitOnError) exportMessagesCmd := flag.NewFlagSet("export-messages", flag.ExitOnError) sendmailCmd := flag.NewFlagSet("sendmail", flag.ExitOnError) flag.Usage = func() { fmt.Print(usage) } flag.Parse() tlsConfig, err := config.TLS(*tlsCert, *tlsCertKey, *tlsClientCA) if err != nil { log.Fatal(err) } cmd := flag.Arg(0) switch cmd { case "auth": authCmd.Parse(flag.Args()[1:]) username := authCmd.Arg(0) if username == "" { log.Fatal("usage: hydroxide auth ") } c := newClient() var a *protonmail.Auth /*if cachedAuth, ok := auths[username]; ok { var err error a, err = c.AuthRefresh(a) if err != nil { // TODO: handle expired token error log.Fatal(err) } }*/ var loginPassword string if a == nil { if pass, err := askPass("Password"); err != nil { log.Fatal(err) } else { loginPassword = string(pass) } authInfo, err := c.AuthInfo(username) if err != nil { log.Fatal(err) } a, err = c.Auth(username, loginPassword, authInfo) if err != nil { log.Fatal(err) } if a.TwoFactor.Enabled == 1 { if a.TwoFactor.TOTP != 1 { log.Fatal("Only TOTP is supported as a 2FA method") } scanner := bufio.NewScanner(os.Stdin) fmt.Printf("2FA TOTP code: ") scanner.Scan() code := scanner.Text() scope, err := c.AuthTOTP(code) if err != nil { log.Fatal(err) } a.Scope = scope } } var mailboxPassword string if a.PasswordMode == protonmail.PasswordSingle { mailboxPassword = loginPassword } if mailboxPassword == "" { prompt := "Password" if a.PasswordMode == protonmail.PasswordTwo { prompt = "Mailbox password" } if pass, err := askPass(prompt); err != nil { log.Fatal(err) } else { mailboxPassword = string(pass) } } keySalts, err := c.ListKeySalts() if err != nil { log.Fatal(err) } _, err = c.Unlock(a, keySalts, mailboxPassword) if err != nil { log.Fatal(err) } secretKey, bridgePassword, err := auth.GeneratePassword() if err != nil { log.Fatal(err) } err = auth.EncryptAndSave(&auth.CachedAuth{ Auth: *a, LoginPassword: loginPassword, MailboxPassword: mailboxPassword, KeySalts: keySalts, }, username, secretKey) if err != nil { log.Fatal(err) } fmt.Println("Bridge password:", bridgePassword) case "status": usernames, err := auth.ListUsernames() if err != nil { log.Fatal(err) } if len(usernames) == 0 { fmt.Printf("No logged in user.\n") } else { fmt.Printf("%v logged in user(s):\n", len(usernames)) for _, u := range usernames { fmt.Printf("- %v\n", u) } } case "export-secret-keys": exportSecretKeysCmd.Parse(flag.Args()[1:]) username := exportSecretKeysCmd.Arg(0) if username == "" { log.Fatal("usage: hydroxide export-secret-keys ") } bridgePassword, err := askBridgePass() if err != nil { log.Fatal(err) } _, privateKeys, err := auth.NewManager(newClient).Auth(username, bridgePassword) if err != nil { log.Fatal(err) } wc, err := armor.Encode(os.Stdout, openpgp.PrivateKeyType, nil) if err != nil { log.Fatal(err) } for _, key := range privateKeys { if err := key.SerializePrivate(wc, nil); err != nil { log.Fatal(err) } } if err := wc.Close(); err != nil { log.Fatal(err) } case "import-messages": importMessagesCmd.Parse(flag.Args()[1:]) username := importMessagesCmd.Arg(0) archivePath := importMessagesCmd.Arg(1) if username == "" { log.Fatal("usage: hydroxide import-messages [file]") } f := os.Stdin if archivePath != "" { f, err = os.Open(archivePath) if err != nil { log.Fatal(err) } defer f.Close() } bridgePassword, err := askBridgePass() if err != nil { log.Fatal(err) } c, _, err := auth.NewManager(newClient).Auth(username, bridgePassword) if err != nil { log.Fatal(err) } br := bufio.NewReader(f) if ok, err := isMbox(br); err != nil { log.Fatal(err) } else if ok { mr := mbox.NewReader(br) for { r, err := mr.NextMessage() if err == io.EOF { break } else if err != nil { log.Fatal(err) } if err := imports.ImportMessage(c, r); err != nil { log.Fatal(err) } } } else { if err := imports.ImportMessage(c, br); err != nil { log.Fatal(err) } } case "export-messages": // TODO: allow specifying multiple IDs var convID, msgID string exportMessagesCmd.StringVar(&convID, "conversation-id", "", "conversation ID") exportMessagesCmd.StringVar(&msgID, "message-id", "", "message ID") exportMessagesCmd.Parse(flag.Args()[1:]) username := exportMessagesCmd.Arg(0) if (convID == "" && msgID == "") || username == "" { log.Fatal("usage: hydroxide export-messages [-conversation-id ] [-message-id ] ") } bridgePassword, err := askBridgePass() if err != nil { log.Fatal(err) } c, privateKeys, err := auth.NewManager(newClient).Auth(username, bridgePassword) if err != nil { log.Fatal(err) } mboxWriter := mbox.NewWriter(os.Stdout) if convID != "" { if err := exports.ExportConversationMbox(c, privateKeys, mboxWriter, convID); err != nil { log.Fatal(err) } } if msgID != "" { if err := exports.ExportMessageMbox(c, privateKeys, mboxWriter, msgID); err != nil { log.Fatal(err) } } if err := mboxWriter.Close(); err != nil { log.Fatal(err) } case "smtp": addr := *smtpHost + ":" + *smtpPort authManager := auth.NewManager(newClient) log.Fatal(listenAndServeSMTP(addr, debug, authManager, tlsConfig)) case "imap": addr := *imapHost + ":" + *imapPort authManager := auth.NewManager(newClient) eventsManager := events.NewManager() log.Fatal(listenAndServeIMAP(addr, debug, authManager, eventsManager, tlsConfig)) case "carddav": addr := *carddavHost + ":" + *carddavPort authManager := auth.NewManager(newClient) eventsManager := events.NewManager() log.Fatal(listenAndServeCardDAV(addr, authManager, eventsManager, tlsConfig)) case "serve": smtpAddr := *smtpHost + ":" + *smtpPort imapAddr := *imapHost + ":" + *imapPort carddavAddr := *carddavHost + ":" + *carddavPort authManager := auth.NewManager(newClient) eventsManager := events.NewManager() done := make(chan error, 3) if !*disableSMTP { go func() { done <- listenAndServeSMTP(smtpAddr, debug, authManager, tlsConfig) }() } if !*disableIMAP { go func() { done <- listenAndServeIMAP(imapAddr, debug, authManager, eventsManager, tlsConfig) }() } if !*disableCardDAV { go func() { done <- listenAndServeCardDAV(carddavAddr, authManager, eventsManager, tlsConfig) }() } log.Fatal(<-done) case "sendmail": username := flag.Arg(1) if username == "" || flag.Arg(2) != "--" { log.Fatal("usage: hydroxide sendmail -- ") } // TODO: other sendmail flags var dotEOF bool sendmailCmd.BoolVar(&dotEOF, "i", false, "don't treat a line with only a . character as the end of input") sendmailCmd.Parse(flag.Args()[3:]) rcpt := sendmailCmd.Args() bridgePassword, err := askBridgePass() if err != nil { log.Fatal(err) } c, privateKeys, err := auth.NewManager(newClient).Auth(username, bridgePassword) if err != nil { log.Fatal(err) } u, err := c.GetCurrentUser() if err != nil { log.Fatal(err) } addrs, err := c.ListAddresses() if err != nil { log.Fatal(err) } err = smtpbackend.SendMail(c, u, privateKeys, addrs, rcpt, os.Stdin) if err != nil { log.Fatal(err) } default: fmt.Print(usage) if cmd != "help" { log.Fatal("Unrecognized command") } } }