Compare commits

..

No commits in common. "push-main" and "master" have entirely different histories.

24 changed files with 775 additions and 938 deletions

View File

@ -1,28 +0,0 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/go
{
"name": "Go",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/go:1-1.22-bookworm",
"remoteUser": "vscode",
"containerUser": "vscode",
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,Z",
"workspaceFolder": "/workspace",
"runArgs": ["--userns=keep-id"]
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "go version",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View File

@ -1,51 +0,0 @@
on:
push:
tags:
- "*"
workflow_dispatch:
jobs:
buildBinary:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: '1.22.1'
- name: Build release binaries
uses: goreleaser/goreleaser-action@v5
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
buildContainer:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
name: Build container image
with:
context: .
push: true
platforms: linux/amd64,linux/arm64,linux/arm
tags: |
ghcr.io/0ranki/hydroxide-push:latest

View File

@ -1,33 +0,0 @@
version: 1
before:
hooks:
builds:
- main: ./cmd/hydroxide-push
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
- arm64
- arm
archives:
- format: binary
name_template: >-
{{ .ProjectName }}-
{{- .Os }}-
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"

View File

@ -1,13 +0,0 @@
FROM golang:1.22 as builder
WORKDIR /src
COPY . /src
RUN CGO_ENABLED=0 go build -o /hydroxide-push ./cmd/hydroxide-push/
FROM alpine:latest
VOLUME /data
COPY --from=builder /hydroxide-push /
ENV HOME=/data
WORKDIR /data
ENTRYPOINT ["/hydroxide-push"]
CMD ["notify"]

185
README.md
View File

@ -1,127 +1,104 @@
# hydroxide-push
### *Forked from [Hydroxide](https://github.com/emersion/hydroxide)*
[![.github/workflows/build.yaml](https://github.com/0ranki/hydroxide-push/actions/workflows/build.yaml/badge.svg)](https://github.com/0ranki/hydroxide-push/actions/workflows/build.yaml) ![GitHub Release](https://img.shields.io/github/v/release/0ranki/hydroxide-push)
# hydroxide
<img src="https://github.com/0ranki/hydroxide-push/assets/50285623/04959566-3d13-4be4-84bd-d7daad3a3166" width="600">
A third-party, open-source ProtonMail bridge. For power users only, designed to
run on a server.
## Push notifications for Proton Mail mobile via a UP provider
hydroxide supports CardDAV, IMAP and SMTP.
Protonmail depends on Google services to deliver push notifications,
This is a stripped down version of [Hydroxide](https://github.com/emersion/hydroxide)
to get notified of new mail. See original repo for details on operation.
Rationale:
Should work with free accounts too.
* No GUI, only a CLI (so it runs in headless environments)
* Standard-compliant (we don't care about Microsoft Outlook)
* Fully open-source
<sup><sub>Github is used to build the binaries and container images with Github Actions, and host the pre-built releases.
Mirrored from https://git.oranki.net/jarno/hydroxide-push</sub></sup>
Feel free to join the IRC channel: #emersion on Libera Chat.
Pre-built releases and container images are available on [Github](https://github.com/0ranki/hydroxide-push).
## How does it work?
hydroxide is a server that translates standard protocols (SMTP, IMAP, CardDAV)
into ProtonMail API requests. It allows you to use your preferred e-mail clients
and `git-send-email` with ProtonMail.
+-----------------+ +-------------+ ProtonMail +--------------+
| | IMAP, SMTP | | API | |
| E-mail client <-------------> hydroxide <--------------> ProtonMail |
| | | | | |
+-----------------+ +-------------+ +--------------+
## Setup
Download or build the binary, pull the pre-built container image or build the image yourself.
Simplest way is to run the pre-built container image.
### Go
Login and push gateway details are saved under `$HOME/.config/hydroxide`. The container
image saves configuration under `/data`, so mount a named volume or host directory there.
The examples below use a named volume.
hydroxide is implemented in Go. Head to [Go website](https://golang.org) for
setup information.
If using Docker, substitute `podman` with `docker` in the examples.
### Installing
Start by installing hydroxide:
Binary:
```shell
./hydroxide-push auth your.proton@email.address
```
Container:
```shell
podman run -it --rm -v hydroxide-push:/data ghcr.io/0ranki/hydroxide-push auth your.proton@email.address
```
You will be prompted for the Proton account credentials and the details for the push server. Proton credentials are stored encrypted form.
The auth flow generates a separate password for the bridge to fake a login to the bridge, which is stored in plaintext to `$HOME/.config/notify.json`. Unlike upstream `hydroxide`, there is no service listening on any port, the password isn't useful for anything else.
### Reconfigure push server
Binary:
```shell
hydroxide-push setup-ntfy
```
Container:
```shell
podman run -it --rm -v hydroxide-push:/data ghcr.io/0ranki/hydroxide-push setup-ntfy
```
Alternatively to run the command in an already running container (replace `name-of-container` with the name or id of the running container):
```shell
podman exec -it name-of-container /hydroxide-push setup-ntfy
```
You'll be asked for the base URL of the push server, topic then username and password for HTTP basic authentication.
The push endpoint configuration can be changed while the daemon is running.
Username and password are stored in `notify.json`, the password is only Base64-encoded. You should create a dedicated user that
has write-only access to the topic for the daemon.
**Push topic username and password are cleared each time setup-ntfy is run, they need to be entered manually every time.**
The currently configured values are shown inside braces. Leave input blank to use the current values.
### Start the service
Binary:
```shell
hydroxide-push notify
```
Container:
```shell
podman run -it --rm -v hydroxide-push:/data ghcr.io/0ranki/hydroxide-push
git clone https://github.com/emersion/hydroxide.git
go build ./cmd/hydroxide
```
## Podman pod
Then you'll need to login to ProtonMail via hydroxide, so that hydroxide can
retrieve e-mails from ProtonMail. You can do so with this command:
A Podman kube YAML file is provided in the repo.
> **Note:** If you're using 2FA or just don't want to put your password to a file, use the manual method above. Make sure the volume name (claimName) in the YAML mathces what you use in the commands.
- Download/copy `hydroxide-push-podman.yaml` to an empty directory on the machine you intend to run the daemon on
- Edit the config values at the top of the file
- Start the pod:
```shell
podman kube play ./hydroxide-push-podman.yaml
```
- Latest container image is pulled
- A named volume (`hydroxide-push`) will be created for the configuration
- Login to Proton and push URL configuration is handled automatically, after which the daemon starts
- After the initial setup, the ConfigMap (before `---`) can be removed from the YAML. Optionally to clear the environment variables, run
```shell
podman kube play ./hydroxide-push-podman.yaml --replace
```
The command can also be used to pull the latest version and restart the pod.
- To reauthenticate or clear data, simply remove the named volume or run the `auth` command
## Updating
Binary:
- stop the service
- download or build the new version, replace the of the old binary
- restart the service
Container:
- pull latest image
- restart container
## Building locally
Clone the repo, then `cd` to the repo root
Binary:
> Requires Go 1.22
```shell
CGO_ENABLED=0 go build -o $HOME/.local/bin/hydroxide-push ./cmd/hydroxide-push/
```
Container:
```shell
podman build -t localhost/hydroxide-push:latest .
hydroxide auth <username>
```
Once you're logged in, a "bridge password" will be printed. Don't close your
terminal yet, as this password is not stored anywhere by hydroxide and will be
needed when configuring your e-mail client.
Your ProtonMail credentials are stored on disk encrypted with this bridge
password (a 32-byte random password generated when logging in).
## Usage
hydroxide can be used in multiple modes.
> Don't start hydroxide multiple times, instead you can use `hydroxide serve`.
> This requires ports 1025 (smtp), 1143 (imap), and 8080 (carddav).
### SMTP
To run hydroxide as an SMTP server:
```shell
hydroxide smtp
```
Once the bridge is started, you can configure your e-mail client with the
following settings:
* Hostname: `localhost`
* Port: 1025
* Security: none
* Username: your ProtonMail username
* Password: the bridge password (not your ProtonMail password)
### CardDAV
You must setup an HTTPS reverse proxy to forward requests to `hydroxide`.
```shell
hydroxide carddav
```
Tested on GNOME (Evolution) and Android (DAVDroid).
### IMAP
⚠️ **Warning**: IMAP support is work-in-progress. Here be dragons.
For now, it only supports unencrypted local connections.
```shell
hydroxide imap
```
## License
MIT

View File

@ -13,8 +13,8 @@ import (
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/nacl/secretbox"
"github.com/0ranki/hydroxide-push/config"
"github.com/0ranki/hydroxide-push/protonmail"
"github.com/emersion/hydroxide/config"
"github.com/emersion/hydroxide/protonmail"
)
func authFilePath() (string, error) {

View File

@ -13,10 +13,10 @@ import (
"strings"
"sync"
"github.com/0ranki/hydroxide-push/protonmail"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/emersion/go-vcard"
"github.com/emersion/go-webdav/carddav"
"github.com/emersion/hydroxide/protonmail"
)
// TODO: use a HTTP error
@ -388,5 +388,5 @@ func NewHandler(c *protonmail.Client, privateKeys openpgp.EntityList, events <-c
go b.receiveEvents(events)
}
return &carddav.Handler{Backend: b}
return &carddav.Handler{b}
}

View File

@ -1 +0,0 @@
.env

View File

@ -1,270 +0,0 @@
package main
import (
"bufio"
"crypto/tls"
"flag"
"fmt"
"log"
"os"
"time"
"github.com/0ranki/hydroxide-push/auth"
"github.com/0ranki/hydroxide-push/config"
"github.com/0ranki/hydroxide-push/events"
imapbackend "github.com/0ranki/hydroxide-push/imap"
"github.com/0ranki/hydroxide-push/ntfy"
"github.com/0ranki/hydroxide-push/protonmail"
imapserver "github.com/emersion/go-imap/server"
"golang.org/x/term"
)
const (
defaultAPIEndpoint = "https://mail.proton.me/api"
defaultAppVersion = "Other"
)
var (
debug bool
apiEndpoint string
appVersion string
cfg ntfy.NtfyConfig
)
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 listenEventsAndNotify(addr string, debug bool, authManager *auth.Manager, eventsManager *events.Manager, tlsConfig *tls.Config) {
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
}
ntfy.Login(&cfg, be)
log.Println("Listening for events", s.Addr)
for {
time.Sleep(10 * time.Second)
}
}
func authenticate(authCmd *flag.FlagSet) {
var username string
if os.Getenv("PROTON_ACCT") != "" {
username = os.Getenv("PROTON_ACCT")
} else {
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 os.Getenv("PROTON_ACCT_PASSWORD") != "" {
loginPassword = os.Getenv("PROTON_ACCT_PASSWORD")
} else 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)
}
cfg.BridgePw = bridgePassword
cfg.Setup()
}
const usage = `usage: hydroxide-push [options...] <command>
Commands:
auth <username> Login to ProtonMail via hydroxide
status View hydroxide status
notify Start the notification daemon
setup-ntfy (Re)configure the push endpoint
Global options:
-debug
Enable debug logs
-api-endpoint <url>
ProtonMail API endpoint
-app-version <version>
ProtonMail application version
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")
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)
}
err = cfg.Read()
if err != nil {
fmt.Println(err)
}
cmd := flag.Arg(0)
switch cmd {
case "auth":
authCmd.Parse(flag.Args()[1:])
authenticate(authCmd)
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":
cfg.Setup()
case "notify":
if os.Getenv("PROTON_ACCT_PASSWORD") != "" && os.Getenv("PROTON_ACCT") != "" && os.Getenv("PUSH_URL") != "" && os.Getenv("PUSH_TOPIC") != "" && cfg.BridgePw == "" {
log.Println("Logging in to Proton account using values from environment")
cfg.URL = os.Getenv("PUSH_URL")
cfg.Topic = os.Getenv("PUSH_TOPIC")
cfg.Save()
authenticate(new(flag.FlagSet))
}
authManager := auth.NewManager(newClient)
eventsManager := events.NewManager()
listenEventsAndNotify("0", debug, authManager, eventsManager, tlsConfig)
default:
fmt.Print(usage)
if cmd != "help" {
log.Fatal("Unrecognized command")
}
}
}

572
cmd/hydroxide/main.go Normal file
View File

@ -0,0 +1,572 @@
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"
"golang.org/x/term"
"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"
defaultAppVersion = "Other"
)
var (
debug bool
apiEndpoint string
appVersion 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 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...] <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")
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 <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 "export-secret-keys":
exportSecretKeysCmd.Parse(flag.Args()[1:])
username := exportSecretKeysCmd.Arg(0)
if username == "" {
log.Fatal("usage: hydroxide export-secret-keys <username>")
}
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 <username> [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 <id>] [-message-id <id>] <username>")
}
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 <username> -- <args...>")
}
// 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")
}
}
}

View File

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

View File

@ -12,7 +12,7 @@ import (
"github.com/emersion/go-message/mail"
"github.com/emersion/go-message/textproto"
"github.com/0ranki/hydroxide-push/protonmail"
"github.com/emersion/hydroxide/protonmail"
)
func writeMessage(c *protonmail.Client, privateKeys openpgp.KeyRing, w io.Writer, msg *protonmail.Message) error {

25
go.mod
View File

@ -1,24 +1,19 @@
module github.com/0ranki/hydroxide-push
module github.com/emersion/hydroxide
go 1.22
go 1.13
require (
github.com/ProtonMail/go-crypto v1.0.0
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c
github.com/boltdb/bolt v1.3.1
github.com/cloudflare/circl v1.3.6 // indirect
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63
github.com/emersion/go-imap v1.2.1
github.com/emersion/go-mbox v1.0.3
github.com/emersion/go-message v0.18.1
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43
github.com/emersion/go-smtp v0.21.1
github.com/emersion/go-message v0.17.0
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
github.com/emersion/go-smtp v0.19.0
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9
github.com/emersion/go-webdav v0.5.0
golang.org/x/crypto v0.22.0
golang.org/x/term v0.19.0
)
require (
github.com/cloudflare/circl v1.3.7 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
github.com/emersion/go-webdav v0.3.2-0.20220524091811-5d845721d8f7
golang.org/x/crypto v0.15.0
golang.org/x/term v0.14.0
)

43
go.sum
View File

@ -1,40 +1,40 @@
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE=
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cloudflare/circl v1.3.6 h1:/xbKIqSHbZXHwkhbrhrt2YOHIwYJlXH94E3tI/gDlUg=
github.com/cloudflare/circl v1.3.6/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 h1:7aCSuwTBzg7BCPRRaBJD0weKZYdeAykOrY6ktpx8Vvc=
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63/go.mod h1:eRwwJnuLVFtYTC+AI2JDJTMcuQUTYhBIK4I6bC5tpqw=
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ=
github.com/emersion/go-ical v0.0.0-20200224201310-cd514449c39e/go.mod h1:4xVTBPcT43a1pp3vdaa+FuRdX5XhKCZPpWv7m0z9ByM=
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
github.com/emersion/go-mbox v1.0.3 h1:Kac75r/EGi6KZAz48HXal9q7EiaXNl+U5HZfyDz0LKM=
github.com/emersion/go-mbox v1.0.3/go.mod h1:Yp9IVuuOYLEuMv4yjgDHvhb5mHOcYH6x92Oas3QqEZI=
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-message v0.17.0 h1:NIdSKHiVUx4qKqdd0HyJFD41cW8iFguM2XJnRZWQH04=
github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY=
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.21.1 h1:VQeZSZAKk8ueYii1yR5Zalmy7jI287eWDUqSaJ68vRM=
github.com/emersion/go-smtp v0.21.1/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-smtp v0.19.0 h1:iVCDtR2/JY3RpKoaZ7u6I/sb52S3EzfNHO1fAWVHgng=
github.com/emersion/go-smtp v0.19.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/emersion/go-vcard v0.0.0-20191221110513-5f81fa0d3cc7/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/emersion/go-webdav v0.5.0 h1:Ak/BQLgAihJt/UxJbCsEXDPxS5Uw4nZzgIMOq3rkKjc=
github.com/emersion/go-webdav v0.5.0/go.mod h1:ycyIzTelG5pHln4t+Y32/zBvmrM7+mV7x+V+Gx4ZQno=
github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
github.com/emersion/go-webdav v0.3.2-0.20220524091811-5d845721d8f7 h1:HqrKOBl8HdSnlo8kz72tCU36aK3WwSmpnnz04+dD0oc=
github.com/emersion/go-webdav v0.3.2-0.20220524091811-5d845721d8f7/go.mod h1:uSM1VveeKtogBVWaYccTksToczooJ0rrVGNsgnDsr4Q=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -43,6 +43,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -55,15 +56,17 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8=
golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -71,6 +74,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@ -1,37 +0,0 @@
## Remove this ConfigMap section after the initial run
apiVersion: v1
kind: ConfigMap
metadata:
name: hydroxide-push-config
data:
PROTON_ACCT: "my.account@protonmail.com"
PROTON_ACCT_PASSWORD: "myprotonaccountpassword"
PUSH_URL: "http://ntfy.sh"
PUSH_TOPIC: ""
## Remove the above after first run
---
apiVersion: v1
kind: Pod
metadata:
creationTimestamp: "2024-03-27T07:02:59Z"
labels:
app: hydroxide-push
name: hydroxide-push
spec:
containers:
- args:
- notify
image: ghcr.io/0ranki/hydroxide-push:latest
name: main
volumeMounts:
- mountPath: /data
name: hydroxide-push-pvc
envFrom:
- configMapRef:
name: hydroxide-push-config
optional: true
volumes:
- name: hydroxide-push-pvc
persistentVolumeClaim:
claimName: hydroxide-push

View File

@ -7,8 +7,8 @@ import (
"github.com/emersion/go-imap"
imapbackend "github.com/emersion/go-imap/backend"
"github.com/0ranki/hydroxide-push/auth"
"github.com/0ranki/hydroxide-push/events"
"github.com/emersion/hydroxide/auth"
"github.com/emersion/hydroxide/events"
)
var errNotYetImplemented = errors.New("not yet implemented")

View File

@ -7,7 +7,7 @@ import (
"github.com/boltdb/bolt"
"github.com/0ranki/hydroxide-push/protonmail"
"github.com/emersion/hydroxide/protonmail"
)
func serializeUID(uid uint32) []byte {

View File

@ -6,8 +6,8 @@ import (
"github.com/boltdb/bolt"
"github.com/0ranki/hydroxide-push/config"
"github.com/0ranki/hydroxide-push/protonmail"
"github.com/emersion/hydroxide/config"
"github.com/emersion/hydroxide/protonmail"
)
var ErrNotFound = errors.New("message not found in local database")

View File

@ -10,8 +10,8 @@ import (
"github.com/emersion/go-imap"
imapbackend "github.com/emersion/go-imap/backend"
"github.com/0ranki/hydroxide-push/imap/database"
"github.com/0ranki/hydroxide-push/protonmail"
"github.com/emersion/hydroxide/imap/database"
"github.com/emersion/hydroxide/protonmail"
)
const delimiter = "/"

View File

@ -15,7 +15,7 @@ import (
"github.com/emersion/go-message"
"github.com/emersion/go-message/mail"
"github.com/0ranki/hydroxide-push/protonmail"
"github.com/emersion/hydroxide/protonmail"
)
func messageID(msg *protonmail.Message) string {

View File

@ -1,17 +1,16 @@
package imap
import (
"github.com/0ranki/hydroxide-push/ntfy"
"log"
"strings"
"sync"
"github.com/0ranki/hydroxide-push/events"
"github.com/0ranki/hydroxide-push/imap/database"
"github.com/0ranki/hydroxide-push/protonmail"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/emersion/go-imap"
imapbackend "github.com/emersion/go-imap/backend"
"github.com/emersion/hydroxide/events"
"github.com/emersion/hydroxide/imap/database"
"github.com/emersion/hydroxide/protonmail"
)
var systemMailboxes = []struct {
@ -118,7 +117,7 @@ func newUser(be *backend, username string, c *protonmail.Client, privateKeys ope
go uu.receiveEvents(be.updates, ch)
uu.eventsReceiver = be.eventsManager.Register(c, u.Name, ch, done)
log.Printf("Logged in as user %q", u.Name)
log.Printf("User %q logged in via IMAP", u.Name)
return uu, nil
}
@ -348,77 +347,75 @@ 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)
}
}
}
}

View File

@ -8,7 +8,7 @@ import (
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/emersion/go-message/mail"
"github.com/0ranki/hydroxide-push/protonmail"
"github.com/emersion/hydroxide/protonmail"
)
func ImportMessage(c *protonmail.Client, r io.Reader) error {

View File

@ -1,257 +0,0 @@
package ntfy
import (
"bufio"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"golang.org/x/crypto/ssh/terminal"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"github.com/0ranki/hydroxide-push/auth"
"github.com/0ranki/hydroxide-push/config"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/backend"
)
type NtfyConfig struct {
URL string `json:"url"`
Topic string `json:"topic"`
BridgePw string `json:"bridgePw"`
User string `json:"user"`
Password string `json:"password"`
}
func (cfg *NtfyConfig) Init() {
if cfg.Topic == "" {
r := make([]byte, 12)
_, err := rand.Read(r)
if err != nil {
log.Fatal(err)
}
cfg.Topic = strings.Replace(base64.StdEncoding.EncodeToString(r), "/", "+", -1)
}
if cfg.URL == "" {
cfg.URL = "http://ntfy.sh"
}
}
func (cfg *NtfyConfig) URI() string {
return fmt.Sprintf("%s/%s", cfg.URL, cfg.Topic)
}
func (cfg *NtfyConfig) Save() error {
b, err := json.Marshal(cfg)
if err != nil {
return err
}
path, err := ntfyConfigFile()
if err != nil {
return err
}
return os.WriteFile(path, b, 0600)
}
func ntfyConfigFile() (string, error) {
return config.Path("notify.json")
}
func Notify() {
cfg := NtfyConfig{}
if err := cfg.Read(); err != nil {
log.Printf("error reading configuration: %v\n", err)
return
}
req, _ := http.NewRequest("POST", cfg.URI(), strings.NewReader("New message received"))
if cfg.User != "" && cfg.Password != "" {
pw, err := base64.StdEncoding.DecodeString(cfg.Password)
if err != nil {
log.Printf("Error decoding push endpoint password: %v\n", err)
return
}
req.SetBasicAuth(cfg.User, string(pw))
}
req.Header.Set("Title", "ProtonMail")
req.Header.Set("Click", "dismiss")
req.Header.Set("Tags", "envelope")
if _, err := http.DefaultClient.Do(req); err != nil {
log.Printf("failed to publish to push topic: %v", err)
return
}
log.Printf("Push event sent")
}
// Read reads the configuration from file. Creates the file
// if it does not exist
func (cfg *NtfyConfig) Read() error {
f, err := ntfyConfigFile()
if err == nil {
b, err := os.ReadFile(f)
if err == nil {
err = json.Unmarshal(b, &cfg)
} else if strings.HasSuffix(err.Error(), "no such file or directory") {
cfg.Init()
err = cfg.Save()
}
if err != nil {
log.Fatal(err)
}
cfg.Init()
}
return nil
}
func LoginBridge(cfg *NtfyConfig) error {
if cfg.BridgePw == "" {
cfg.BridgePw = os.Getenv("HYDROXIDE_BRIDGE_PASSWORD")
}
if cfg.BridgePw == "" {
scanner := bufio.NewScanner(os.Stdin)
fmt.Printf("Bridge password: ")
scanner.Scan()
cfg.BridgePw = scanner.Text()
}
return nil
}
func Login(cfg *NtfyConfig, be backend.Backend) {
//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")
}
err = cfg.Read()
if err != nil {
log.Println(err)
}
if len(usernames) == 0 || cfg.URL == "" || cfg.Topic == "" {
executable, _ := os.Executable()
log.Println("login first using " + executable + " auth <protonmail username>")
log.Fatalln("then setup ntfy using " + executable + "setup-ntfy")
}
if cfg.BridgePw == "" {
err = LoginBridge(cfg)
if err != nil {
log.Fatal(err)
}
}
_, err = be.Login(&conn, usernames[0], cfg.BridgePw)
if err != nil {
log.Fatal(err)
}
}
func (cfg *NtfyConfig) Setup() {
// Configure using environment
if os.Getenv("PUSH_URL") != "" && os.Getenv("PUSH_TOPIC") != "" {
cfg.URL = os.Getenv("PUSH_URL")
cfg.Topic = os.Getenv("PUSH_TOPIC")
log.Printf("Current push endpoint: %s\n", cfg.URI())
if os.Getenv("PUSH_USER") != "" && os.Getenv("PUSH_PASSWORD") != "" {
cfg.User = os.Getenv("PUSH_USER")
cfg.Password = base64.StdEncoding.EncodeToString([]byte(os.Getenv("PUSH_PASSWORD")))
log.Println("Authentication for push endpoint configured using environment")
} else {
log.Println("Both PUSH_USER and PUSH_PASSWORD not set, assuming no authentication is necessary.")
}
err := cfg.Save()
if err != nil {
log.Fatal(err)
}
return
}
var n string
if cfg.URL != "" && cfg.Topic != "" {
fmt.Printf("Current push endpoint: %s\n", cfg.URI())
n = "new "
}
if cfg.User != "" && cfg.Password != "" {
fmt.Println("Push is currently configured for basic auth. You'll need to input credentials again")
}
// Read push base URL
notValid := true
scanner := bufio.NewScanner(os.Stdin)
for notValid {
tmpURL := cfg.URL
fmt.Printf("Input %spush server URL ('%s') : ", n, cfg.URL)
scanner.Scan()
if len(scanner.Text()) > 0 {
tmpURL = scanner.Text()
}
if _, err := url.ParseRequestURI(tmpURL); err != nil {
fmt.Printf("Not a valid URL: %s\n", tmpURL)
} else {
notValid = false
cfg.URL = tmpURL
}
}
scanner = bufio.NewScanner(os.Stdin)
// Read push topic
fmt.Printf("Input push topic ('%s'): ", cfg.Topic)
scanner.Scan()
if len(scanner.Text()) > 0 {
cfg.Topic = scanner.Text()
}
fmt.Printf("Using URL %s\n", cfg.URI())
// Configure HTTP Basic Auth for push
// This needs to be input each time the auth flow is done,
// existing values are reset
cfg.User = ""
cfg.Password = ""
fmt.Println("Configuring HTTP basic authentication for push endpoint.")
fmt.Println("Previously set username and password have been cleared.")
fmt.Println("Leave values blank to disable basic authentication.")
scanner = bufio.NewScanner(os.Stdin)
fmt.Printf("Username: ")
scanner.Scan()
if len(scanner.Text()) > 0 {
cfg.User = scanner.Text()
}
fmt.Printf("Password: ")
pwBytes, err := terminal.ReadPassword(0)
if err != nil {
fmt.Printf("Error reading password: %v\n", err)
return
}
if len(pwBytes) > 0 {
// Store the password in base64 for a little obfuscation
cfg.Password = base64.StdEncoding.EncodeToString(pwBytes)
}
// Save bridge password
if len(cfg.BridgePw) == 0 {
err := LoginBridge(cfg)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
} else {
fmt.Println("Bridge password is set")
}
// Save configuration
err = cfg.Save()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Notification configuration saved")
}

View File

@ -11,11 +11,10 @@ import (
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"github.com/0ranki/hydroxide-push/auth"
"github.com/0ranki/hydroxide-push/protonmail"
"github.com/emersion/hydroxide/auth"
"github.com/emersion/hydroxide/protonmail"
)
func toPMAddressList(addresses []*mail.Address) []*protonmail.MessageAddress {
@ -378,16 +377,7 @@ type session struct {
allReceivers []string
}
var _ interface {
smtp.Session
smtp.AuthSession
} = (*session)(nil)
func (s *session) AuthMechanisms() []string {
return []string{sasl.Plain}
}
func (s *session) authPlain(username, password string) error {
func (s *session) AuthPlain(username, password string) error {
c, privateKeys, err := s.be.sessions.Auth(username, password)
if err != nil {
return err
@ -413,15 +403,6 @@ func (s *session) authPlain(username, password string) error {
return nil
}
func (s *session) Auth(mech string) (sasl.Server, error) {
return sasl.NewPlainServer(func(identity, username, password string) error {
if identity != "" && identity != username {
return fmt.Errorf("invalid SASL PLAIN identity")
}
return s.authPlain(username, password)
}), nil
}
func (s *session) Mail(from string, options *smtp.MailOptions) error {
if s.c == nil {
return smtp.ErrAuthRequired