First working version of ntfy push

This commit is contained in:
Jarno Rankinen 2024-03-26 17:47:46 +02:00
parent ca53aaa50d
commit 2db44c9f5d
6 changed files with 405 additions and 66 deletions

View File

@ -0,0 +1,8 @@
FROM scratch
COPY hydroxide-push /
ENV HOME=/data
ENV HYDROXIDE_BRIDGE_PASS="supersecretpassword"
WORKDIR /data
ENTRYPOINT ["/hydroxide-push"]
CMD ["notify"]

BIN
cmd/hydroxide-push/hydroxide-push Executable file

Binary file not shown.

314
cmd/hydroxide-push/main.go Normal file
View File

@ -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 <protonmail username>")
}
// 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...] <command>
Commands:
auth <username> Login to ProtonMail via hydroxide
carddav Run hydroxide as a CardDAV server
export-secret-keys <username> Export secret keys
imap Run hydroxide as an IMAP server
import-messages <username> [file] Import messages
export-messages [options...] <username> Export messages
sendmail <username> -- <args...> 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 <url>
ProtonMail API endpoint
-app-version <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 <username>")
}
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")
}
}
}

View File

@ -8,7 +8,7 @@ import (
"github.com/emersion/hydroxide/protonmail" "github.com/emersion/hydroxide/protonmail"
) )
const pollInterval = 30 * time.Second const pollInterval = 10 * time.Second
type Receiver struct { type Receiver struct {
c *protonmail.Client c *protonmail.Client

View File

@ -1,6 +1,7 @@
package imap package imap
import ( import (
"github.com/emersion/hydroxide/ntfy"
"log" "log"
"strings" "strings"
"sync" "sync"
@ -347,75 +348,77 @@ func (u *user) receiveEvents(updates chan<- imapbackend.Update, events <-chan *p
eventUpdates = append(eventUpdates, update) eventUpdates = append(eventUpdates, update)
} }
} }
// Send push notification to topic
go ntfy.Notify()
case protonmail.EventUpdate, protonmail.EventUpdateFlags: case protonmail.EventUpdate, protonmail.EventUpdateFlags:
log.Println("Received update event for message", eventMessage.ID) log.Println("Received update event for message", eventMessage.ID)
createdSeqNums, deletedSeqNums, err := u.db.UpdateMessage(eventMessage.ID, eventMessage.Updated) // createdSeqNums, deletedSeqNums, err := u.db.UpdateMessage(eventMessage.ID, eventMessage.Updated)
if err != nil { // if err != nil {
log.Printf("cannot handle update event for message %s: cannot update message in local DB: %v", eventMessage.ID, err) // log.Printf("cannot handle update event for message %s: cannot update message in local DB: %v", eventMessage.ID, err)
break // break
} // }
//
for labelID, seqNum := range createdSeqNums { // for labelID, seqNum := range createdSeqNums {
if mbox := u.getMailboxByLabel(labelID); mbox != nil { // if mbox := u.getMailboxByLabel(labelID); mbox != nil {
update := new(imapbackend.MailboxUpdate) // update := new(imapbackend.MailboxUpdate)
update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name) // update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name)
update.MailboxStatus = imap.NewMailboxStatus(mbox.name, []imap.StatusItem{imap.StatusMessages}) // update.MailboxStatus = imap.NewMailboxStatus(mbox.name, []imap.StatusItem{imap.StatusMessages})
update.MailboxStatus.Messages = seqNum // update.MailboxStatus.Messages = seqNum
eventUpdates = append(eventUpdates, update) // eventUpdates = append(eventUpdates, update)
} // }
} // }
for labelID, seqNum := range deletedSeqNums { // for labelID, seqNum := range deletedSeqNums {
if mbox := u.getMailboxByLabel(labelID); mbox != nil { // if mbox := u.getMailboxByLabel(labelID); mbox != nil {
update := new(imapbackend.ExpungeUpdate) // update := new(imapbackend.ExpungeUpdate)
update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name) // update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name)
update.SeqNum = seqNum // update.SeqNum = seqNum
eventUpdates = append(eventUpdates, update) // eventUpdates = append(eventUpdates, update)
} // }
} // }
//
// Send message updates // // Send message updates
msg, err := u.db.Message(eventMessage.ID) // msg, err := u.db.Message(eventMessage.ID)
if err != nil { // if err != nil {
log.Printf("cannot handle update event for message %s: cannot get updated message from local DB: %v", eventMessage.ID, err) // log.Printf("cannot handle update event for message %s: cannot get updated message from local DB: %v", eventMessage.ID, err)
break // break
} // }
for _, labelID := range msg.LabelIDs { // for _, labelID := range msg.LabelIDs {
if _, created := createdSeqNums[labelID]; created { // if _, created := createdSeqNums[labelID]; created {
// This message has been added to the label's mailbox // // This message has been added to the label's mailbox
// No need to send a message update // // No need to send a message update
continue // continue
} // }
//
if mbox := u.getMailboxByLabel(labelID); mbox != nil { // if mbox := u.getMailboxByLabel(labelID); mbox != nil {
seqNum, _, err := mbox.db.FromApiID(eventMessage.ID) // seqNum, _, err := mbox.db.FromApiID(eventMessage.ID)
if err != nil { // 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) // log.Printf("cannot handle update event for message %s: cannot get message sequence number in %s: %v", eventMessage.ID, mbox.name, err)
continue // continue
} // }
//
update := new(imapbackend.MessageUpdate) // update := new(imapbackend.MessageUpdate)
update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name) // update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name)
update.Message = imap.NewMessage(seqNum, []imap.FetchItem{imap.FetchFlags}) // update.Message = imap.NewMessage(seqNum, []imap.FetchItem{imap.FetchFlags})
update.Message.Flags = mbox.fetchFlags(msg) // update.Message.Flags = mbox.fetchFlags(msg)
eventUpdates = append(eventUpdates, update) // eventUpdates = append(eventUpdates, update)
} // }
} // }
case protonmail.EventDelete: case protonmail.EventDelete:
log.Println("Received delete event for message", eventMessage.ID) log.Println("Received delete event for message", eventMessage.ID)
seqNums, err := u.db.DeleteMessage(eventMessage.ID) // seqNums, err := u.db.DeleteMessage(eventMessage.ID)
if err != nil { // if err != nil {
log.Printf("cannot handle delete event for message %s: cannot delete message from local DB: %v", eventMessage.ID, err) // log.Printf("cannot handle delete event for message %s: cannot delete message from local DB: %v", eventMessage.ID, err)
break // break
} // }
//
for labelID, seqNum := range seqNums { // for labelID, seqNum := range seqNums {
if mbox := u.getMailboxByLabel(labelID); mbox != nil { // if mbox := u.getMailboxByLabel(labelID); mbox != nil {
update := new(imapbackend.ExpungeUpdate) // update := new(imapbackend.ExpungeUpdate)
update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name) // update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name)
update.SeqNum = seqNum // update.SeqNum = seqNum
eventUpdates = append(eventUpdates, update) // eventUpdates = append(eventUpdates, update)
} // }
} // }
} }
} }

14
ntfy/ntfy.go Normal file
View File

@ -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)
}