Compare commits
36 Commits
Author | SHA1 | Date |
---|---|---|
Jarno Rankinen | d4213d273b | |
Jarno Rankinen | f2e7d4b499 | |
Jarno Rankinen | bbb21d59c7 | |
Jarno Rankinen | 25b6965e5e | |
Jarno Rankinen | e79a8e3f65 | |
Jarno Rankinen | f63b02a0d0 | |
Jarno Rankinen | d655c9caea | |
Jarno Rankinen | ea646a404b | |
Jarno Rankinen | b9be8b529f | |
Jarno Rankinen | 216b77e0ef | |
Jarno Rankinen | 54e545a1b5 | |
Jarno Rankinen | 544eb93e05 | |
Jarno Rankinen | cb3c36c6ea | |
Jarno Rankinen | 377395a532 | |
Jarno Rankinen | 375d7fc0b7 | |
Jarno Rankinen | 8a6a9e9380 | |
Jarno Rankinen | 65a6020623 | |
Jarno Rankinen | 58ce783fcb | |
Jarno Rankinen | 27bff1d3a1 | |
Jarno Rankinen | 234327cdf6 | |
Jarno Rankinen | 3e509a11b6 | |
Jarno Rankinen | 69ba3adb80 | |
Jarno Rankinen | 23cb629f09 | |
Jarno Rankinen | 94efe4ea80 | |
Jarno Rankinen | 3512aa9530 | |
Jarno Rankinen | 6c18227d0d | |
Jarno Rankinen | 1f0f33ee50 | |
Jarno Rankinen | 14056ff639 | |
Jarno Rankinen | f66ce030cf | |
Jarno Rankinen | 26dc3fa6fc | |
Jarno Rankinen | d4451a8a15 | |
Jarno Rankinen | 308eec6c69 | |
Simon Ser | 15fced74e9 | |
Simon Ser | 04ec4932f4 | |
Simon Ser | 54ba20031e | |
Zane Dufour | bcbbd4d533 |
|
@ -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"
|
||||
}
|
|
@ -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
|
|
@ -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:"
|
|
@ -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"]
|
185
README.md
185
README.md
|
@ -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
|
||||
run on a server.
|
||||
<img src="https://github.com/0ranki/hydroxide-push/assets/50285623/04959566-3d13-4be4-84bd-d7daad3a3166" width="600">
|
||||
|
||||
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)
|
||||
* Standard-compliant (we don't care about Microsoft Outlook)
|
||||
* Fully open-source
|
||||
Should work with free accounts too.
|
||||
|
||||
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
|
||||
|
||||
### 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
|
||||
setup information.
|
||||
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.
|
||||
|
||||
### Installing
|
||||
|
||||
Start by installing hydroxide:
|
||||
If using Docker, substitute `podman` with `docker` in the examples.
|
||||
|
||||
Binary:
|
||||
```shell
|
||||
git clone https://github.com/emersion/hydroxide.git
|
||||
go build ./cmd/hydroxide
|
||||
./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
|
||||
```
|
||||
|
||||
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:
|
||||
## Podman pod
|
||||
|
||||
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
|
||||
hydroxide auth <username>
|
||||
CGO_ENABLED=0 go build -o $HOME/.local/bin/hydroxide-push ./cmd/hydroxide-push/
|
||||
```
|
||||
Container:
|
||||
```shell
|
||||
podman build -t localhost/hydroxide-push:latest .
|
||||
```
|
||||
|
||||
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
|
||||
|
|
|
@ -13,8 +13,8 @@ import (
|
|||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/nacl/secretbox"
|
||||
|
||||
"github.com/emersion/hydroxide/config"
|
||||
"github.com/emersion/hydroxide/protonmail"
|
||||
"github.com/0ranki/hydroxide-push/config"
|
||||
"github.com/0ranki/hydroxide-push/protonmail"
|
||||
)
|
||||
|
||||
func authFilePath() (string, error) {
|
||||
|
|
|
@ -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{b}
|
||||
return &carddav.Handler{Backend: b}
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
.env
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,10 +5,10 @@ import (
|
|||
"sync"
|
||||
"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 {
|
||||
c *protonmail.Client
|
||||
|
|
|
@ -12,7 +12,7 @@ import (
|
|||
"github.com/emersion/go-message/mail"
|
||||
"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 {
|
||||
|
|
25
go.mod
25
go.mod
|
@ -1,19 +1,24 @@
|
|||
module github.com/emersion/hydroxide
|
||||
module github.com/0ranki/hydroxide-push
|
||||
|
||||
go 1.13
|
||||
go 1.22
|
||||
|
||||
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/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.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-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-vcard v0.0.0-20230815062825-8fda7d206ec9
|
||||
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
|
||||
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
|
||||
)
|
||||
|
|
43
go.sum
43
go.sum
|
@ -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 v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
|
||||
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/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.6 h1:/xbKIqSHbZXHwkhbrhrt2YOHIwYJlXH94E3tI/gDlUg=
|
||||
github.com/cloudflare/circl v1.3.6/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/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-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/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.17.0 h1:NIdSKHiVUx4qKqdd0HyJFD41cW8iFguM2XJnRZWQH04=
|
||||
github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw=
|
||||
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-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.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-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-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.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/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/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.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
|
||||
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
|
||||
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/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,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.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=
|
||||
|
@ -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.5.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.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
|
||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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/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.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/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
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=
|
||||
|
@ -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.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=
|
||||
|
|
|
@ -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
|
||||
|
|
@ -7,8 +7,8 @@ import (
|
|||
"github.com/emersion/go-imap"
|
||||
imapbackend "github.com/emersion/go-imap/backend"
|
||||
|
||||
"github.com/emersion/hydroxide/auth"
|
||||
"github.com/emersion/hydroxide/events"
|
||||
"github.com/0ranki/hydroxide-push/auth"
|
||||
"github.com/0ranki/hydroxide-push/events"
|
||||
)
|
||||
|
||||
var errNotYetImplemented = errors.New("not yet implemented")
|
||||
|
|
|
@ -7,7 +7,7 @@ import (
|
|||
|
||||
"github.com/boltdb/bolt"
|
||||
|
||||
"github.com/emersion/hydroxide/protonmail"
|
||||
"github.com/0ranki/hydroxide-push/protonmail"
|
||||
)
|
||||
|
||||
func serializeUID(uid uint32) []byte {
|
||||
|
|
|
@ -6,8 +6,8 @@ import (
|
|||
|
||||
"github.com/boltdb/bolt"
|
||||
|
||||
"github.com/emersion/hydroxide/config"
|
||||
"github.com/emersion/hydroxide/protonmail"
|
||||
"github.com/0ranki/hydroxide-push/config"
|
||||
"github.com/0ranki/hydroxide-push/protonmail"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("message not found in local database")
|
||||
|
|
|
@ -10,8 +10,8 @@ import (
|
|||
"github.com/emersion/go-imap"
|
||||
imapbackend "github.com/emersion/go-imap/backend"
|
||||
|
||||
"github.com/emersion/hydroxide/imap/database"
|
||||
"github.com/emersion/hydroxide/protonmail"
|
||||
"github.com/0ranki/hydroxide-push/imap/database"
|
||||
"github.com/0ranki/hydroxide-push/protonmail"
|
||||
)
|
||||
|
||||
const delimiter = "/"
|
||||
|
|
|
@ -15,7 +15,7 @@ import (
|
|||
"github.com/emersion/go-message"
|
||||
"github.com/emersion/go-message/mail"
|
||||
|
||||
"github.com/emersion/hydroxide/protonmail"
|
||||
"github.com/0ranki/hydroxide-push/protonmail"
|
||||
)
|
||||
|
||||
func messageID(msg *protonmail.Message) string {
|
||||
|
|
141
imap/user.go
141
imap/user.go
|
@ -1,16 +1,17 @@
|
|||
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 {
|
||||
|
@ -117,7 +118,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("User %q logged in via IMAP", u.Name)
|
||||
log.Printf("Logged in as user %q", u.Name)
|
||||
return uu, nil
|
||||
}
|
||||
|
||||
|
@ -347,75 +348,77 @@ func (u *user) receiveEvents(updates chan<- imapbackend.Update, events <-chan *p
|
|||
eventUpdates = append(eventUpdates, update)
|
||||
}
|
||||
}
|
||||
// Send push notification to topic
|
||||
go ntfy.Notify()
|
||||
case protonmail.EventUpdate, protonmail.EventUpdateFlags:
|
||||
log.Println("Received update event for message", eventMessage.ID)
|
||||
createdSeqNums, deletedSeqNums, err := u.db.UpdateMessage(eventMessage.ID, eventMessage.Updated)
|
||||
if err != nil {
|
||||
log.Printf("cannot handle update event for message %s: cannot update message in local DB: %v", eventMessage.ID, err)
|
||||
break
|
||||
}
|
||||
|
||||
for labelID, seqNum := range createdSeqNums {
|
||||
if mbox := u.getMailboxByLabel(labelID); mbox != nil {
|
||||
update := new(imapbackend.MailboxUpdate)
|
||||
update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name)
|
||||
update.MailboxStatus = imap.NewMailboxStatus(mbox.name, []imap.StatusItem{imap.StatusMessages})
|
||||
update.MailboxStatus.Messages = seqNum
|
||||
eventUpdates = append(eventUpdates, update)
|
||||
}
|
||||
}
|
||||
for labelID, seqNum := range deletedSeqNums {
|
||||
if mbox := u.getMailboxByLabel(labelID); mbox != nil {
|
||||
update := new(imapbackend.ExpungeUpdate)
|
||||
update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name)
|
||||
update.SeqNum = seqNum
|
||||
eventUpdates = append(eventUpdates, update)
|
||||
}
|
||||
}
|
||||
|
||||
// Send message updates
|
||||
msg, err := u.db.Message(eventMessage.ID)
|
||||
if err != nil {
|
||||
log.Printf("cannot handle update event for message %s: cannot get updated message from local DB: %v", eventMessage.ID, err)
|
||||
break
|
||||
}
|
||||
for _, labelID := range msg.LabelIDs {
|
||||
if _, created := createdSeqNums[labelID]; created {
|
||||
// This message has been added to the label's mailbox
|
||||
// No need to send a message update
|
||||
continue
|
||||
}
|
||||
|
||||
if mbox := u.getMailboxByLabel(labelID); mbox != nil {
|
||||
seqNum, _, err := mbox.db.FromApiID(eventMessage.ID)
|
||||
if err != nil {
|
||||
log.Printf("cannot handle update event for message %s: cannot get message sequence number in %s: %v", eventMessage.ID, mbox.name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
update := new(imapbackend.MessageUpdate)
|
||||
update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name)
|
||||
update.Message = imap.NewMessage(seqNum, []imap.FetchItem{imap.FetchFlags})
|
||||
update.Message.Flags = mbox.fetchFlags(msg)
|
||||
eventUpdates = append(eventUpdates, update)
|
||||
}
|
||||
}
|
||||
// createdSeqNums, deletedSeqNums, err := u.db.UpdateMessage(eventMessage.ID, eventMessage.Updated)
|
||||
// if err != nil {
|
||||
// log.Printf("cannot handle update event for message %s: cannot update message in local DB: %v", eventMessage.ID, err)
|
||||
// break
|
||||
// }
|
||||
//
|
||||
// for labelID, seqNum := range createdSeqNums {
|
||||
// if mbox := u.getMailboxByLabel(labelID); mbox != nil {
|
||||
// update := new(imapbackend.MailboxUpdate)
|
||||
// update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name)
|
||||
// update.MailboxStatus = imap.NewMailboxStatus(mbox.name, []imap.StatusItem{imap.StatusMessages})
|
||||
// update.MailboxStatus.Messages = seqNum
|
||||
// eventUpdates = append(eventUpdates, update)
|
||||
// }
|
||||
// }
|
||||
// for labelID, seqNum := range deletedSeqNums {
|
||||
// if mbox := u.getMailboxByLabel(labelID); mbox != nil {
|
||||
// update := new(imapbackend.ExpungeUpdate)
|
||||
// update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name)
|
||||
// update.SeqNum = seqNum
|
||||
// eventUpdates = append(eventUpdates, update)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Send message updates
|
||||
// msg, err := u.db.Message(eventMessage.ID)
|
||||
// if err != nil {
|
||||
// log.Printf("cannot handle update event for message %s: cannot get updated message from local DB: %v", eventMessage.ID, err)
|
||||
// break
|
||||
// }
|
||||
// for _, labelID := range msg.LabelIDs {
|
||||
// if _, created := createdSeqNums[labelID]; created {
|
||||
// // This message has been added to the label's mailbox
|
||||
// // No need to send a message update
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// if mbox := u.getMailboxByLabel(labelID); mbox != nil {
|
||||
// seqNum, _, err := mbox.db.FromApiID(eventMessage.ID)
|
||||
// if err != nil {
|
||||
// log.Printf("cannot handle update event for message %s: cannot get message sequence number in %s: %v", eventMessage.ID, mbox.name, err)
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// update := new(imapbackend.MessageUpdate)
|
||||
// update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name)
|
||||
// update.Message = imap.NewMessage(seqNum, []imap.FetchItem{imap.FetchFlags})
|
||||
// update.Message.Flags = mbox.fetchFlags(msg)
|
||||
// eventUpdates = append(eventUpdates, update)
|
||||
// }
|
||||
// }
|
||||
case protonmail.EventDelete:
|
||||
log.Println("Received delete event for message", eventMessage.ID)
|
||||
seqNums, err := u.db.DeleteMessage(eventMessage.ID)
|
||||
if err != nil {
|
||||
log.Printf("cannot handle delete event for message %s: cannot delete message from local DB: %v", eventMessage.ID, err)
|
||||
break
|
||||
}
|
||||
|
||||
for labelID, seqNum := range seqNums {
|
||||
if mbox := u.getMailboxByLabel(labelID); mbox != nil {
|
||||
update := new(imapbackend.ExpungeUpdate)
|
||||
update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name)
|
||||
update.SeqNum = seqNum
|
||||
eventUpdates = append(eventUpdates, update)
|
||||
}
|
||||
}
|
||||
// seqNums, err := u.db.DeleteMessage(eventMessage.ID)
|
||||
// if err != nil {
|
||||
// log.Printf("cannot handle delete event for message %s: cannot delete message from local DB: %v", eventMessage.ID, err)
|
||||
// break
|
||||
// }
|
||||
//
|
||||
// for labelID, seqNum := range seqNums {
|
||||
// if mbox := u.getMailboxByLabel(labelID); mbox != nil {
|
||||
// update := new(imapbackend.ExpungeUpdate)
|
||||
// update.Update = imapbackend.NewUpdate(u.u.Name, mbox.name)
|
||||
// update.SeqNum = seqNum
|
||||
// eventUpdates = append(eventUpdates, update)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ import (
|
|||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
"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 {
|
||||
|
|
|
@ -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")
|
||||
}
|
25
smtp/smtp.go
25
smtp/smtp.go
|
@ -11,10 +11,11 @@ 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/emersion/hydroxide/auth"
|
||||
"github.com/emersion/hydroxide/protonmail"
|
||||
"github.com/0ranki/hydroxide-push/auth"
|
||||
"github.com/0ranki/hydroxide-push/protonmail"
|
||||
)
|
||||
|
||||
func toPMAddressList(addresses []*mail.Address) []*protonmail.MessageAddress {
|
||||
|
@ -377,7 +378,16 @@ type session struct {
|
|||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -403,6 +413,15 @@ 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
|
||||
|
|
Loading…
Reference in New Issue