diff --git a/cmd/hydroxide-push/Dockerfile b/cmd/hydroxide-push/Dockerfile new file mode 100644 index 0000000..0183c87 --- /dev/null +++ b/cmd/hydroxide-push/Dockerfile @@ -0,0 +1,8 @@ +FROM scratch + +COPY hydroxide-push / +ENV HOME=/data +ENV HYDROXIDE_BRIDGE_PASS="supersecretpassword" +WORKDIR /data +ENTRYPOINT ["/hydroxide-push"] +CMD ["notify"] \ No newline at end of file diff --git a/cmd/hydroxide-push/hydroxide-push b/cmd/hydroxide-push/hydroxide-push new file mode 100755 index 0000000..51684ea Binary files /dev/null and b/cmd/hydroxide-push/hydroxide-push differ diff --git a/cmd/hydroxide-push/main.go b/cmd/hydroxide-push/main.go new file mode 100644 index 0000000..4db87e9 --- /dev/null +++ b/cmd/hydroxide-push/main.go @@ -0,0 +1,314 @@ +package main + +import ( + "bufio" + "crypto/tls" + "flag" + "fmt" + "github.com/emersion/go-imap" + imapserver "github.com/emersion/go-imap/server" + "github.com/emersion/hydroxide/auth" + "github.com/emersion/hydroxide/config" + "github.com/emersion/hydroxide/events" + imapbackend "github.com/emersion/hydroxide/imap" + "github.com/emersion/hydroxide/protonmail" + "golang.org/x/term" + "log" + "net" + "os" + "time" +) + +const ( + defaultAPIEndpoint = "https://mail.proton.me/api" + defaultAppVersion = "Other" +) + +var ( + debug bool + apiEndpoint string + appVersion string + + //imapUser *backend.User + ntfyTopic string +) + +func newClient() *protonmail.Client { + return &protonmail.Client{ + RootURL: apiEndpoint, + AppVersion: appVersion, + Debug: debug, + } +} + +func askPass(prompt string) ([]byte, error) { + f := os.Stdin + if !term.IsTerminal(int(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 := term.ReadPassword(int(f.Fd())) + if err == nil { + fmt.Fprintf(os.Stderr, "\n") + } + return b, err +} + +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() + } + go func() { + time.Sleep(1 * time.Second) + c, _ := net.ResolveIPAddr("ip", "127.0.0.1") + conn := imap.ConnInfo{ + RemoteAddr: c, + LocalAddr: c, + TLS: nil, + } + usernames, err := auth.ListUsernames() + if err != nil { + log.Fatal(err) + } + if len(usernames) > 1 { + log.Fatal("only one login supported for now") + } + if len(usernames) == 0 { + executable, _ := os.Executable() + log.Fatal("login first using " + executable + " auth ") + } + // TODO: bridge password + _, err = be.Login(&conn, usernames[0], os.Getenv("HYDROXIDE_BRIDGE_PASS")) + if err != nil { + log.Fatal(err) + } + }() + + log.Println("IMAP server listening on", s.Addr) + return s.ListenAndServe() +} + +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 + -app-version + ProtonMail application version + -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") + flag.StringVar(&appVersion, "app-version", defaultAppVersion, "ProtonMail app version") + flag.StringVar(&ntfyTopic, "topic", "", "ntfy.sh/NextPush topic to push notifications to") + + imapHost := "127.0.0.1" // flag.String("imap-host", "127.0.0.1", "Allowed IMAP email hostname on which hydroxide listens, defaults to 127.0.0.1") + imapPort := "1143" // flag.String("imap-port", "1143", "IMAP port on which hydroxide listens, defaults to 1143") + + 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) + + 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 != 0 { + 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 "setup-ntfy": + + case "notify": + if ntfyTopic == "" { + log.Fatal("please set ntfy.sh topic using --topic") + } + addr := imapHost + ":" + imapPort + authManager := auth.NewManager(newClient) + eventsManager := events.NewManager() + + log.Fatal(listenAndServeIMAP(addr, debug, authManager, eventsManager, tlsConfig)) + + default: + fmt.Print(usage) + if cmd != "help" { + log.Fatal("Unrecognized command") + } + } +} diff --git a/events/events.go b/events/events.go index 4b79d5a..f24a97d 100644 --- a/events/events.go +++ b/events/events.go @@ -8,7 +8,7 @@ import ( "github.com/emersion/hydroxide/protonmail" ) -const pollInterval = 30 * time.Second +const pollInterval = 10 * time.Second type Receiver struct { c *protonmail.Client diff --git a/imap/user.go b/imap/user.go index ff865a2..dfd879d 100644 --- a/imap/user.go +++ b/imap/user.go @@ -1,6 +1,7 @@ package imap import ( + "github.com/emersion/hydroxide/ntfy" "log" "strings" "sync" @@ -347,75 +348,77 @@ func (u *user) receiveEvents(updates chan<- imapbackend.Update, events <-chan *p eventUpdates = append(eventUpdates, update) } } + // Send push notification to topic + go ntfy.Notify() case protonmail.EventUpdate, protonmail.EventUpdateFlags: log.Println("Received update event for message", eventMessage.ID) - createdSeqNums, deletedSeqNums, err := u.db.UpdateMessage(eventMessage.ID, eventMessage.Updated) - if err != nil { - log.Printf("cannot handle update event for message %s: cannot update message in local DB: %v", eventMessage.ID, err) - break - } - - for labelID, seqNum := range createdSeqNums { - if mbox := u.getMailboxByLabel(labelID); mbox != nil { - update := new(imapbackend.MailboxUpdate) - update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name) - update.MailboxStatus = imap.NewMailboxStatus(mbox.name, []imap.StatusItem{imap.StatusMessages}) - update.MailboxStatus.Messages = seqNum - eventUpdates = append(eventUpdates, update) - } - } - for labelID, seqNum := range deletedSeqNums { - if mbox := u.getMailboxByLabel(labelID); mbox != nil { - update := new(imapbackend.ExpungeUpdate) - update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name) - update.SeqNum = seqNum - eventUpdates = append(eventUpdates, update) - } - } - - // Send message updates - msg, err := u.db.Message(eventMessage.ID) - if err != nil { - log.Printf("cannot handle update event for message %s: cannot get updated message from local DB: %v", eventMessage.ID, err) - break - } - for _, labelID := range msg.LabelIDs { - if _, created := createdSeqNums[labelID]; created { - // This message has been added to the label's mailbox - // No need to send a message update - continue - } - - if mbox := u.getMailboxByLabel(labelID); mbox != nil { - seqNum, _, err := mbox.db.FromApiID(eventMessage.ID) - if err != nil { - log.Printf("cannot handle update event for message %s: cannot get message sequence number in %s: %v", eventMessage.ID, mbox.name, err) - continue - } - - update := new(imapbackend.MessageUpdate) - update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name) - update.Message = imap.NewMessage(seqNum, []imap.FetchItem{imap.FetchFlags}) - update.Message.Flags = mbox.fetchFlags(msg) - eventUpdates = append(eventUpdates, update) - } - } + // createdSeqNums, deletedSeqNums, err := u.db.UpdateMessage(eventMessage.ID, eventMessage.Updated) + // if err != nil { + // log.Printf("cannot handle update event for message %s: cannot update message in local DB: %v", eventMessage.ID, err) + // break + // } + // + // for labelID, seqNum := range createdSeqNums { + // if mbox := u.getMailboxByLabel(labelID); mbox != nil { + // update := new(imapbackend.MailboxUpdate) + // update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name) + // update.MailboxStatus = imap.NewMailboxStatus(mbox.name, []imap.StatusItem{imap.StatusMessages}) + // update.MailboxStatus.Messages = seqNum + // eventUpdates = append(eventUpdates, update) + // } + // } + // for labelID, seqNum := range deletedSeqNums { + // if mbox := u.getMailboxByLabel(labelID); mbox != nil { + // update := new(imapbackend.ExpungeUpdate) + // update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name) + // update.SeqNum = seqNum + // eventUpdates = append(eventUpdates, update) + // } + // } + // + // // Send message updates + // msg, err := u.db.Message(eventMessage.ID) + // if err != nil { + // log.Printf("cannot handle update event for message %s: cannot get updated message from local DB: %v", eventMessage.ID, err) + // break + // } + // for _, labelID := range msg.LabelIDs { + // if _, created := createdSeqNums[labelID]; created { + // // This message has been added to the label's mailbox + // // No need to send a message update + // continue + // } + // + // if mbox := u.getMailboxByLabel(labelID); mbox != nil { + // seqNum, _, err := mbox.db.FromApiID(eventMessage.ID) + // if err != nil { + // log.Printf("cannot handle update event for message %s: cannot get message sequence number in %s: %v", eventMessage.ID, mbox.name, err) + // continue + // } + // + // update := new(imapbackend.MessageUpdate) + // update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name) + // update.Message = imap.NewMessage(seqNum, []imap.FetchItem{imap.FetchFlags}) + // update.Message.Flags = mbox.fetchFlags(msg) + // eventUpdates = append(eventUpdates, update) + // } + // } case protonmail.EventDelete: log.Println("Received delete event for message", eventMessage.ID) - seqNums, err := u.db.DeleteMessage(eventMessage.ID) - if err != nil { - log.Printf("cannot handle delete event for message %s: cannot delete message from local DB: %v", eventMessage.ID, err) - break - } - - for labelID, seqNum := range seqNums { - if mbox := u.getMailboxByLabel(labelID); mbox != nil { - update := new(imapbackend.ExpungeUpdate) - update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name) - update.SeqNum = seqNum - eventUpdates = append(eventUpdates, update) - } - } + // seqNums, err := u.db.DeleteMessage(eventMessage.ID) + // if err != nil { + // log.Printf("cannot handle delete event for message %s: cannot delete message from local DB: %v", eventMessage.ID, err) + // break + // } + // + // for labelID, seqNum := range seqNums { + // if mbox := u.getMailboxByLabel(labelID); mbox != nil { + // update := new(imapbackend.ExpungeUpdate) + // update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name) + // update.SeqNum = seqNum + // eventUpdates = append(eventUpdates, update) + // } + // } } } diff --git a/ntfy/ntfy.go b/ntfy/ntfy.go new file mode 100644 index 0000000..6fc7d2e --- /dev/null +++ b/ntfy/ntfy.go @@ -0,0 +1,14 @@ +package ntfy + +import ( + "net/http" + "strings" +) + +func Notify() { + req, _ := http.NewRequest("POST", "https://push.oranki.net/testing20240325", strings.NewReader("New message received")) + req.Header.Set("Title", "ProtoMail") + req.Header.Set("Click", "dismiss") + req.Header.Set("Tags", "envelope") + http.DefaultClient.Do(req) +}