Compare commits

...

36 Commits

Author SHA1 Message Date
Jarno Rankinen d4213d273b Update README.md 2024-07-03 14:30:31 +03:00
Jarno Rankinen f2e7d4b499 Update README.md 2024-07-03 14:19:23 +03:00
Jarno Rankinen bbb21d59c7 Link to Github in README.md for releases 2024-07-03 14:16:11 +03:00
Jarno Rankinen 25b6965e5e Notes about updating and building locally 2024-07-01 21:42:36 +03:00
Jarno Rankinen e79a8e3f65 add .env file for hydroxide-push testing to gitignore 2024-07-01 21:32:25 +03:00
Jarno Rankinen f63b02a0d0 Mention upstream in readme 2024-07-01 21:16:24 +03:00
Jarno Rankinen d655c9caea Add screenshot, mention free accounts 2024-07-01 21:05:25 +03:00
Jarno Rankinen ea646a404b Method of using the already running container for reconfiguring push 2024-07-01 10:31:45 +03:00
Jarno Rankinen b9be8b529f Add push endpoint auth configuration to readme 2024-07-01 10:20:26 +03:00
Jarno Rankinen 216b77e0ef Use terminal.ReadPasword to read password 2024-07-01 10:14:29 +03:00
Jarno Rankinen 54e545a1b5 HTTP Basic authentication for push endpoint
- Option to input HTTP basic auth username and password when running
  setup-ntfy
- Username and password are stored in notify.json, password is
  base64-encoded
2024-07-01 10:10:42 +03:00
Jarno Rankinen 544eb93e05 Fix typo in notification, tweak readme 2024-06-30 21:53:24 +03:00
Jarno Rankinen cb3c36c6ea Sync with upstream 2024-06-14 17:26:21 +03:00
Jarno Rankinen 377395a532 docs: Update readme: need an empty directory for Podman YAML 2024-06-14 17:21:39 +03:00
Jarno Rankinen 375d7fc0b7 Podman kube pod
- Added YAML for a Podman kube pod
- Support configuring with environment variables
2024-06-14 17:21:39 +03:00
Jarno Rankinen 8a6a9e9380 Login flow fixes
- Validate push URL
- Default values for push URL and topic if not set
- setup-ntfy uses already set or default values if input is empty
2024-06-14 17:21:39 +03:00
Jarno Rankinen 65a6020623 Go update to 1.22
- go: upgraded github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c => v1.0.0
- go: upgraded github.com/cloudflare/circl v1.3.6 => v1.3.7
- go: upgraded github.com/emersion/go-message v0.17.0 => v0.18.0
- go: upgraded golang.org/x/crypto v0.15.0 => v0.21.0
- go: upgraded golang.org/x/sys v0.14.0 => v0.18.0
- go: upgraded golang.org/x/term v0.14.0 => v0.18.0
2024-06-14 17:21:32 +03:00
Jarno Rankinen 58ce783fcb docs: Update readme, delete cmd/hydroxide/ 2024-06-14 17:19:57 +03:00
Jarno Rankinen 27bff1d3a1 ci: attempt to fix issues with token permissions 2024-06-14 17:19:57 +03:00
Jarno Rankinen 234327cdf6 ci: update GitHub build workflow 2024-06-14 17:19:57 +03:00
Jarno Rankinen 3e509a11b6 ci: Build & push container 2024-06-14 17:19:57 +03:00
Jarno Rankinen 69ba3adb80 ci: Fix GoReleaser#1 2024-06-14 17:19:57 +03:00
Jarno Rankinen 23cb629f09 Fix Readme 2024-06-14 17:19:57 +03:00
Jarno Rankinen 94efe4ea80 Build workflow, devcontainer file 2024-06-14 17:19:57 +03:00
Jarno Rankinen 3512aa9530 Update README 2024-06-14 17:19:57 +03:00
Jarno Rankinen 6c18227d0d Read push endpoint from config 2024-06-14 17:19:57 +03:00
Jarno Rankinen 1f0f33ee50 Update README 2024-06-14 17:19:57 +03:00
Jarno Rankinen 14056ff639 Combine auth/setup flow, update readme 2024-06-14 17:19:57 +03:00
Jarno Rankinen f66ce030cf Small tweaks to configuration flow, Dockerfile for running as a container 2024-06-14 17:19:57 +03:00
Jarno Rankinen 26dc3fa6fc Remove binary from repo... 2024-06-14 17:19:57 +03:00
Jarno Rankinen d4451a8a15 Rename package, don't serve IMAP 2024-06-14 17:19:50 +03:00
Jarno Rankinen 308eec6c69 First working version of ntfy push 2024-06-14 17:18:18 +03:00
Simon Ser 15fced74e9 Upgrade dependencies 2024-04-14 17:36:50 +02:00
Simon Ser 04ec4932f4 Upgrade go-webdav 2024-04-14 17:36:25 +02:00
Simon Ser 54ba20031e Upgrade go-smtp 2024-04-14 17:34:11 +02:00
Zane Dufour bcbbd4d533 Bump Go version in go.mod 2024-04-14 17:28:51 +02:00
24 changed files with 938 additions and 775 deletions

View File

@ -0,0 +1,28 @@
// 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"
}

51
.github/workflows/build.yaml vendored Normal file
View File

@ -0,0 +1,51 @@
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

33
.goreleaser.yaml Normal file
View File

@ -0,0 +1,33 @@
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:"

13
Dockerfile Normal file
View File

@ -0,0 +1,13 @@
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"]

167
README.md
View File

@ -1,104 +1,127 @@
# hydroxide # 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)
A third-party, open-source ProtonMail bridge. For power users only, designed to <img src="https://github.com/0ranki/hydroxide-push/assets/50285623/04959566-3d13-4be4-84bd-d7daad3a3166" width="600">
run on a server.
hydroxide supports CardDAV, IMAP and SMTP. ## Push notifications for Proton Mail mobile via a UP provider
Rationale: 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.
* No GUI, only a CLI (so it runs in headless environments) Should work with free accounts too.
* Standard-compliant (we don't care about Microsoft Outlook)
* Fully open-source
Feel free to join the IRC channel: #emersion on Libera Chat. <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>
## How does it work? Pre-built releases and container images are available on [Github](https://github.com/0ranki/hydroxide-push).
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 ## Setup
### Go 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.
hydroxide is implemented in Go. Head to [Go website](https://golang.org) for Login and push gateway details are saved under `$HOME/.config/hydroxide`. The container
setup information. image saves configuration under `/data`, so mount a named volume or host directory there.
The examples below use a named volume.
### Installing If using Docker, substitute `podman` with `docker` in the examples.
Start by installing hydroxide:
Binary:
```shell ```shell
git clone https://github.com/emersion/hydroxide.git ./hydroxide-push auth your.proton@email.address
go build ./cmd/hydroxide ```
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
``` ```
Then you'll need to login to ProtonMail via hydroxide, so that hydroxide can ## Podman pod
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 ```shell
hydroxide auth <username> 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
Once you're logged in, a "bridge password" will be printed. Don't close your ## Updating
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 Binary:
password (a 32-byte random password generated when logging in). - stop the service
- download or build the new version, replace the of the old binary
- restart the service
## Usage Container:
- pull latest image
- restart container
hydroxide can be used in multiple modes. ## Building locally
Clone the repo, then `cd` to the repo root
> 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:
Binary:
> Requires Go 1.22
```shell ```shell
hydroxide smtp CGO_ENABLED=0 go build -o $HOME/.local/bin/hydroxide-push ./cmd/hydroxide-push/
``` ```
Container:
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 ```shell
hydroxide carddav podman build -t localhost/hydroxide-push:latest .
``` ```
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 ## License
MIT MIT

View File

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

View File

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

1
cmd/hydroxide-push/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.env

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

@ -0,0 +1,270 @@
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")
}
}
}

View File

@ -1,572 +0,0 @@
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" "sync"
"time" "time"
"github.com/emersion/hydroxide/protonmail" "github.com/0ranki/hydroxide-push/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

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

25
go.mod
View File

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

43
go.sum
View File

@ -1,40 +1,40 @@
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE= github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= 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/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.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.6 h1:/xbKIqSHbZXHwkhbrhrt2YOHIwYJlXH94E3tI/gDlUg= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.6/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 h1:7aCSuwTBzg7BCPRRaBJD0weKZYdeAykOrY6ktpx8Vvc= 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-bcrypt v0.0.0-20170822072041-6e724a1baa63/go.mod h1:eRwwJnuLVFtYTC+AI2JDJTMcuQUTYhBIK4I6bC5tpqw=
github.com/emersion/go-ical v0.0.0-20200224201310-cd514449c39e/go.mod h1:4xVTBPcT43a1pp3vdaa+FuRdX5XhKCZPpWv7m0z9ByM= github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ=
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= 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-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 h1:Kac75r/EGi6KZAz48HXal9q7EiaXNl+U5HZfyDz0LKM=
github.com/emersion/go-mbox v1.0.3/go.mod h1:Yp9IVuuOYLEuMv4yjgDHvhb5mHOcYH6x92Oas3QqEZI= 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.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
github.com/emersion/go-message v0.17.0 h1:NIdSKHiVUx4qKqdd0HyJFD41cW8iFguM2XJnRZWQH04= github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw= github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 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 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY=
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.19.0 h1:iVCDtR2/JY3RpKoaZ7u6I/sb52S3EzfNHO1fAWVHgng= github.com/emersion/go-smtp v0.21.1 h1:VQeZSZAKk8ueYii1yR5Zalmy7jI287eWDUqSaJ68vRM=
github.com/emersion/go-smtp v0.19.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/emersion/go-smtp v0.21.1/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-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 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/emersion/go-webdav v0.3.2-0.20220524091811-5d845721d8f7 h1:HqrKOBl8HdSnlo8kz72tCU36aK3WwSmpnnz04+dD0oc= github.com/emersion/go-webdav v0.5.0 h1:Ak/BQLgAihJt/UxJbCsEXDPxS5Uw4nZzgIMOq3rkKjc=
github.com/emersion/go-webdav v0.3.2-0.20220524091811-5d845721d8f7/go.mod h1:uSM1VveeKtogBVWaYccTksToczooJ0rrVGNsgnDsr4Q= 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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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-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.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.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.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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/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= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -43,7 +43,6 @@ 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.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.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.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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -56,17 +55,15 @@ 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.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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-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.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.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 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.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -74,8 +71,6 @@ 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.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.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.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 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 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= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@ -0,0 +1,37 @@
## 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" "github.com/emersion/go-imap"
imapbackend "github.com/emersion/go-imap/backend" imapbackend "github.com/emersion/go-imap/backend"
"github.com/emersion/hydroxide/auth" "github.com/0ranki/hydroxide-push/auth"
"github.com/emersion/hydroxide/events" "github.com/0ranki/hydroxide-push/events"
) )
var errNotYetImplemented = errors.New("not yet implemented") var errNotYetImplemented = errors.New("not yet implemented")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

257
ntfy/ntfy.go Normal file
View File

@ -0,0 +1,257 @@
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,10 +11,11 @@ import (
"github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/packet" "github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/emersion/go-message/mail" "github.com/emersion/go-message/mail"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"github.com/emersion/hydroxide/auth" "github.com/0ranki/hydroxide-push/auth"
"github.com/emersion/hydroxide/protonmail" "github.com/0ranki/hydroxide-push/protonmail"
) )
func toPMAddressList(addresses []*mail.Address) []*protonmail.MessageAddress { func toPMAddressList(addresses []*mail.Address) []*protonmail.MessageAddress {
@ -377,7 +378,16 @@ type session struct {
allReceivers []string allReceivers []string
} }
func (s *session) AuthPlain(username, password string) error { 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 {
c, privateKeys, err := s.be.sessions.Auth(username, password) c, privateKeys, err := s.be.sessions.Auth(username, password)
if err != nil { if err != nil {
return err return err
@ -403,6 +413,15 @@ func (s *session) AuthPlain(username, password string) error {
return nil 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 { func (s *session) Mail(from string, options *smtp.MailOptions) error {
if s.c == nil { if s.c == nil {
return smtp.ErrAuthRequired return smtp.ErrAuthRequired