mirror of
https://github.com/ProtonMail/go-proton-api.git
synced 2025-12-23 23:57:50 -05:00
feat: Initial open source commit
This commit is contained in:
28
.github/workflows/check.yml
vendored
Normal file
28
.github/workflows/check.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
name: Lint and Test
|
||||||
|
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Get sources
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Go 1.18
|
||||||
|
uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: '1.18'
|
||||||
|
|
||||||
|
- name: Run golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v3
|
||||||
|
with:
|
||||||
|
version: v1.50.0
|
||||||
|
args: --timeout=180s
|
||||||
|
skip-cache: true
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: go test -v ./...
|
||||||
|
|
||||||
|
- name: Run tests with race check
|
||||||
|
run: go test -v -race ./...
|
||||||
10
CONTRIBUTING.md
Normal file
10
CONTRIBUTING.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Contribution Policy
|
||||||
|
|
||||||
|
By making a contribution to this project:
|
||||||
|
|
||||||
|
1. I assign any and all copyright related to the contribution to Proton AG;
|
||||||
|
2. I certify that the contribution was created in whole by me;
|
||||||
|
3. I understand and agree that this project and the contribution are public
|
||||||
|
and that a record of the contribution (including all personal information I
|
||||||
|
submit with it) is maintained indefinitely and may be redistributed with
|
||||||
|
this project or the open source license(s) involved.
|
||||||
22
LICENSE
Normal file
22
LICENSE
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2020 James Houlahan
|
||||||
|
Copyright (c) 2022 Proton AG
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
23
README.md
Normal file
23
README.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Go Proton API
|
||||||
|
|
||||||
|
<a href="https://github.com/ProtonMail/go-proton-api/actions/workflows/check.yml"><img src="https://github.com/ProtonMail/go-proton-api/actions/workflows/check.yml/badge.svg?branch=master" alt="CI Status"></a>
|
||||||
|
<a href="https://pkg.go.dev/github.com/ProtonMail/go-proton-api"><img src="https://pkg.go.dev/badge/github.com/ProtonMail/go-proton-api" alt="GoDoc"></a>
|
||||||
|
<a href="https://goreportcard.com/report/ProtonMail/go-proton-api"><img src="https://goreportcard.com/badge/ProtonMail/go-proton-api" alt="Go Report Card"></a>
|
||||||
|
<a href="LICENSE"><img src="https://img.shields.io/github/license/ProtonMail/go-proton-api.svg" alt="License"></a>
|
||||||
|
|
||||||
|
This repository holds Go Proton API, a Go library implementing a client and development server for (a subset of) the Proton REST API.
|
||||||
|
|
||||||
|
The license can be found in the [LICENSE](./LICENSE) file.
|
||||||
|
|
||||||
|
For the contribution policy, see [CONTRIBUTING](./CONTRIBUTING.md).
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
Most of the integration tests run locally. The ones that interact with Proton servers require the following environment variables set:
|
||||||
|
|
||||||
|
- ```GO_PROTON_API_TEST_USERNAME```
|
||||||
|
- ```GO_PROTON_API_TEST_PASSWORD```
|
||||||
|
|
||||||
|
## Contribution
|
||||||
|
|
||||||
|
The library is maintained by Proton AG, and is not actively looking for contributors.
|
||||||
46
address.go
Normal file
46
address.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) GetAddresses(ctx context.Context) ([]Address, error) {
|
||||||
|
var res struct {
|
||||||
|
Addresses []Address
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/core/v4/addresses")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(res.Addresses, func(a, b Address) bool {
|
||||||
|
return a.Order < b.Order
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.Addresses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetAddress(ctx context.Context, addressID string) (Address, error) {
|
||||||
|
var res struct {
|
||||||
|
Address Address
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/core/v4/addresses/" + addressID)
|
||||||
|
}); err != nil {
|
||||||
|
return Address{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Address, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) OrderAddresses(ctx context.Context, req OrderAddressesReq) error {
|
||||||
|
return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).Put("/core/v4/addresses/order")
|
||||||
|
})
|
||||||
|
}
|
||||||
27
address_types.go
Normal file
27
address_types.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
type Address struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
|
||||||
|
Send Bool
|
||||||
|
Receive Bool
|
||||||
|
Status AddressStatus
|
||||||
|
|
||||||
|
Order int
|
||||||
|
DisplayName string
|
||||||
|
|
||||||
|
Keys Keys
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderAddressesReq struct {
|
||||||
|
AddressIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddressStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AddressStatusDisabled AddressStatus = iota
|
||||||
|
AddressStatusEnabled
|
||||||
|
AddressStatusDeleting
|
||||||
|
)
|
||||||
33
atomic.go
Normal file
33
atomic.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import "sync/atomic"
|
||||||
|
|
||||||
|
type atomicUint64 struct {
|
||||||
|
v uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *atomicUint64) Load() uint64 { return atomic.LoadUint64(&x.v) }
|
||||||
|
|
||||||
|
func (x *atomicUint64) Store(val uint64) { atomic.StoreUint64(&x.v, val) }
|
||||||
|
|
||||||
|
func (x *atomicUint64) Swap(new uint64) (old uint64) { return atomic.SwapUint64(&x.v, new) }
|
||||||
|
|
||||||
|
func (x *atomicUint64) Add(delta uint64) (new uint64) { return atomic.AddUint64(&x.v, delta) }
|
||||||
|
|
||||||
|
type atomicBool struct {
|
||||||
|
v uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *atomicBool) Load() bool { return atomic.LoadUint32(&x.v) != 0 }
|
||||||
|
|
||||||
|
func (x *atomicBool) Store(val bool) { atomic.StoreUint32(&x.v, b32(val)) }
|
||||||
|
|
||||||
|
func (x *atomicBool) Swap(new bool) (old bool) { return atomic.SwapUint32(&x.v, b32(new)) != 0 }
|
||||||
|
|
||||||
|
func b32(b bool) uint32 {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
79
attachment.go
Normal file
79
attachment.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) GetAttachment(ctx context.Context, attachmentID string) ([]byte, error) {
|
||||||
|
return c.attPool().ProcessOne(ctx, attachmentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UploadAttachment(ctx context.Context, addrKR *crypto.KeyRing, req CreateAttachmentReq) (Attachment, error) {
|
||||||
|
var res struct {
|
||||||
|
Attachment Attachment
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := addrKR.SignDetached(crypto.NewPlainMessage(req.Body))
|
||||||
|
if err != nil {
|
||||||
|
return Attachment{}, fmt.Errorf("failed to sign attachment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enc, err := addrKR.EncryptAttachment(crypto.NewPlainMessage(req.Body), req.Filename)
|
||||||
|
if err != nil {
|
||||||
|
return Attachment{}, fmt.Errorf("failed to encrypt attachment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).
|
||||||
|
SetMultipartFormData(map[string]string{
|
||||||
|
"MessageID": req.MessageID,
|
||||||
|
"Filename": req.Filename,
|
||||||
|
"MIMEType": string(req.MIMEType),
|
||||||
|
"Disposition": string(req.Disposition),
|
||||||
|
"ContentID": req.ContentID,
|
||||||
|
}).
|
||||||
|
SetMultipartFields(
|
||||||
|
&resty.MultipartField{
|
||||||
|
Param: "KeyPackets",
|
||||||
|
FileName: "blob",
|
||||||
|
ContentType: "application/octet-stream",
|
||||||
|
Reader: bytes.NewReader(enc.KeyPacket),
|
||||||
|
},
|
||||||
|
&resty.MultipartField{
|
||||||
|
Param: "DataPacket",
|
||||||
|
FileName: "blob",
|
||||||
|
ContentType: "application/octet-stream",
|
||||||
|
Reader: bytes.NewReader(enc.DataPacket),
|
||||||
|
},
|
||||||
|
&resty.MultipartField{
|
||||||
|
Param: "Signature",
|
||||||
|
FileName: "blob",
|
||||||
|
ContentType: "application/octet-stream",
|
||||||
|
Reader: bytes.NewReader(sig.GetBinary()),
|
||||||
|
},
|
||||||
|
).
|
||||||
|
Post("/mail/v4/attachments")
|
||||||
|
}); err != nil {
|
||||||
|
return Attachment{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Attachment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getAttachment(ctx context.Context, attachmentID string) ([]byte, error) {
|
||||||
|
res, err := c.doRes(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetDoNotParseResponse(true).Get("/mail/v4/attachments/" + attachmentID)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.RawBody().Close()
|
||||||
|
|
||||||
|
return io.ReadAll(res.RawBody())
|
||||||
|
}
|
||||||
36
attachment_types.go
Normal file
36
attachment_types.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
ID string
|
||||||
|
|
||||||
|
Name string
|
||||||
|
Size int64
|
||||||
|
MIMEType rfc822.MIMEType
|
||||||
|
Disposition Disposition
|
||||||
|
Headers Headers
|
||||||
|
|
||||||
|
KeyPackets string
|
||||||
|
Signature string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Disposition string
|
||||||
|
|
||||||
|
const (
|
||||||
|
InlineDisposition Disposition = "inline"
|
||||||
|
AttachmentDisposition Disposition = "attachment"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateAttachmentReq struct {
|
||||||
|
MessageID string
|
||||||
|
|
||||||
|
Filename string
|
||||||
|
MIMEType rfc822.MIMEType
|
||||||
|
Disposition Disposition
|
||||||
|
ContentID string
|
||||||
|
|
||||||
|
Body []byte
|
||||||
|
}
|
||||||
45
auth.go
Normal file
45
auth.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) Auth2FA(ctx context.Context, req Auth2FAReq) error {
|
||||||
|
return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).Post("/core/v4/auth/2fa")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AuthDelete(ctx context.Context) error {
|
||||||
|
return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.Delete("/core/v4/auth")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AuthSessions(ctx context.Context) ([]AuthSession, error) {
|
||||||
|
var res struct {
|
||||||
|
Sessions []AuthSession
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/auth/v4/sessions")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Sessions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AuthRevoke(ctx context.Context, authUID string) error {
|
||||||
|
return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.Delete("/auth/v4/sessions/" + authUID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AuthRevokeAll(ctx context.Context) error {
|
||||||
|
return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.Delete("/auth/v4/sessions")
|
||||||
|
})
|
||||||
|
}
|
||||||
103
auth_test.go
Normal file
103
auth_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package proton_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/ProtonMail/go-proton-api/server"
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAutomaticAuthRefresh(t *testing.T) {
|
||||||
|
wantAuth := proton.Auth{
|
||||||
|
UID: "testUID",
|
||||||
|
AccessToken: "testAcc",
|
||||||
|
RefreshToken: "testRef",
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc("/core/v4/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(wantAuth); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/core/v4/users", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
var gotAuth proton.Auth
|
||||||
|
|
||||||
|
// Create a new client.
|
||||||
|
c := proton.New(proton.WithHostURL(ts.URL)).NewClient("uid", "acc", "ref", time.Now().Add(-time.Second))
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// Register an auth handler.
|
||||||
|
c.AddAuthHandler(func(auth proton.Auth) { gotAuth = auth })
|
||||||
|
|
||||||
|
// Make a request with an access token that already expired one second ago.
|
||||||
|
if _, err := c.GetUser(context.Background()); err != nil {
|
||||||
|
t.Fatal("got unexpected error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The auth callback should have been called.
|
||||||
|
if !cmp.Equal(gotAuth, wantAuth) {
|
||||||
|
t.Fatal("got unexpected auth", gotAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuth(t *testing.T) {
|
||||||
|
s := server.New()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
_, _, err := s.CreateUser("username", "email@pm.me", []byte("password"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(s.GetHostURL()),
|
||||||
|
proton.WithTransport(proton.InsecureTransport()),
|
||||||
|
)
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
// Create one session.
|
||||||
|
c1, auth1, err := m.NewClientWithLogin(context.Background(), "username", []byte("password"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Revoke all other sessions.
|
||||||
|
require.NoError(t, c1.AuthRevokeAll(context.Background()))
|
||||||
|
|
||||||
|
// Create another session.
|
||||||
|
c2, _, err := m.NewClientWithLogin(context.Background(), "username", []byte("password"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// There should be two sessions.
|
||||||
|
sessions, err := c1.AuthSessions(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, sessions, 2)
|
||||||
|
|
||||||
|
// Revoke the first session.
|
||||||
|
require.NoError(t, c2.AuthRevoke(context.Background(), auth1.UID))
|
||||||
|
|
||||||
|
// The first session should no longer work.
|
||||||
|
require.Error(t, c1.AuthDelete(context.Background()))
|
||||||
|
|
||||||
|
// There should be one session remaining.
|
||||||
|
remaining, err := c2.AuthSessions(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, remaining, 1)
|
||||||
|
|
||||||
|
// Delete the last session.
|
||||||
|
require.NoError(t, c2.AuthDelete(context.Background()))
|
||||||
|
}
|
||||||
95
auth_types.go
Normal file
95
auth_types.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
type AuthInfoReq struct {
|
||||||
|
Username string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthInfo struct {
|
||||||
|
Version int
|
||||||
|
Modulus string
|
||||||
|
ServerEphemeral string
|
||||||
|
Salt string
|
||||||
|
SRPSession string
|
||||||
|
TwoFA TwoFAInfo `json:"2FA"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type U2FReq struct {
|
||||||
|
KeyHandle string
|
||||||
|
ClientData string
|
||||||
|
SignatureData string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthReq struct {
|
||||||
|
Username string
|
||||||
|
ClientEphemeral string
|
||||||
|
ClientProof string
|
||||||
|
SRPSession string
|
||||||
|
U2F U2FReq
|
||||||
|
}
|
||||||
|
|
||||||
|
type Auth struct {
|
||||||
|
UserID string
|
||||||
|
|
||||||
|
UID string
|
||||||
|
AccessToken string
|
||||||
|
RefreshToken string
|
||||||
|
ServerProof string
|
||||||
|
ExpiresIn int
|
||||||
|
|
||||||
|
Scope string
|
||||||
|
TwoFA TwoFAInfo `json:"2FA"`
|
||||||
|
PasswordMode PasswordMode
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisteredKey struct {
|
||||||
|
Version string
|
||||||
|
KeyHandle string
|
||||||
|
}
|
||||||
|
|
||||||
|
type U2FInfo struct {
|
||||||
|
Challenge string
|
||||||
|
RegisteredKeys []RegisteredKey
|
||||||
|
}
|
||||||
|
|
||||||
|
type TwoFAInfo struct {
|
||||||
|
Enabled TwoFAStatus
|
||||||
|
U2F U2FInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type TwoFAStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
TwoFADisabled TwoFAStatus = iota
|
||||||
|
TOTPEnabled
|
||||||
|
)
|
||||||
|
|
||||||
|
type PasswordMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
OnePasswordMode PasswordMode = iota + 1
|
||||||
|
TwoPasswordMode
|
||||||
|
)
|
||||||
|
|
||||||
|
type Auth2FAReq struct {
|
||||||
|
TwoFactorCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthRefreshReq struct {
|
||||||
|
UID string
|
||||||
|
RefreshToken string
|
||||||
|
ResponseType string
|
||||||
|
GrantType string
|
||||||
|
RedirectURI string
|
||||||
|
State string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthSession struct {
|
||||||
|
UID string
|
||||||
|
CreateTime int64
|
||||||
|
|
||||||
|
ClientID string
|
||||||
|
MemberID string
|
||||||
|
Revocable Bool
|
||||||
|
|
||||||
|
LocalizedClientName string
|
||||||
|
}
|
||||||
19
block.go
Normal file
19
block.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) GetBlock(ctx context.Context, url string) (io.ReadCloser, error) {
|
||||||
|
res, err := c.doRes(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetDoNotParseResponse(true).Get(url)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.RawBody(), nil
|
||||||
|
}
|
||||||
38
boolean.go
Normal file
38
boolean.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// Bool is a convenience type for boolean values; it converts from APIBool to Go's builtin bool type.
|
||||||
|
type Bool bool
|
||||||
|
|
||||||
|
// APIBool is the boolean type used by the API (0 or 1).
|
||||||
|
type APIBool int
|
||||||
|
|
||||||
|
const (
|
||||||
|
APIFalse APIBool = iota
|
||||||
|
APITrue
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *Bool) UnmarshalJSON(data []byte) error {
|
||||||
|
var v APIBool
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*b = Bool(v == APITrue)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Bool) MarshalJSON() ([]byte, error) {
|
||||||
|
var v APIBool
|
||||||
|
|
||||||
|
if b {
|
||||||
|
v = APITrue
|
||||||
|
} else {
|
||||||
|
v = APIFalse
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(v)
|
||||||
|
}
|
||||||
77
calendar.go
Normal file
77
calendar.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) GetCalendars(ctx context.Context) ([]Calendar, error) {
|
||||||
|
var res struct {
|
||||||
|
Calendars []Calendar
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/calendar/v1")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Calendars, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetCalendar(ctx context.Context, calendarID string) (Calendar, error) {
|
||||||
|
var res struct {
|
||||||
|
Calendar Calendar
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/calendar/v1/" + calendarID)
|
||||||
|
}); err != nil {
|
||||||
|
return Calendar{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Calendar, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetCalendarKeys(ctx context.Context, calendarID string) (CalendarKeys, error) {
|
||||||
|
var res struct {
|
||||||
|
Keys CalendarKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/calendar/v1/" + calendarID + "/keys")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetCalendarMembers(ctx context.Context, calendarID string) ([]CalendarMember, error) {
|
||||||
|
var res struct {
|
||||||
|
Members []CalendarMember
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/calendar/v1/" + calendarID + "/members")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Members, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetCalendarPassphrase(ctx context.Context, calendarID string) (CalendarPassphrase, error) {
|
||||||
|
var res struct {
|
||||||
|
Passphrase CalendarPassphrase
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/calendar/v1/" + calendarID + "/passphrase")
|
||||||
|
}); err != nil {
|
||||||
|
return CalendarPassphrase{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Passphrase, nil
|
||||||
|
}
|
||||||
66
calendar_event.go
Normal file
66
calendar_event.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) CountCalendarEvents(ctx context.Context, calendarID string) (int, error) {
|
||||||
|
var res struct {
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/calendar/v1/" + calendarID + "/events")
|
||||||
|
}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: For now, the query params are partially constant -- should they be configurable?
|
||||||
|
func (c *Client) GetCalendarEvents(ctx context.Context, calendarID string, page, pageSize int, filter url.Values) ([]CalendarEvent, error) {
|
||||||
|
var res struct {
|
||||||
|
Events []CalendarEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetQueryParams(map[string]string{
|
||||||
|
"Page": strconv.Itoa(page),
|
||||||
|
"PageSize": strconv.Itoa(pageSize),
|
||||||
|
}).SetQueryParamsFromValues(filter).SetResult(&res).Get("/calendar/v1/" + calendarID + "/events")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetAllCalendarEvents(ctx context.Context, calendarID string, filter url.Values) ([]CalendarEvent, error) {
|
||||||
|
total, err := c.CountCalendarEvents(ctx, calendarID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchPaged(ctx, total, maxPageSize, func(ctx context.Context, page, pageSize int) ([]CalendarEvent, error) {
|
||||||
|
return c.GetCalendarEvents(ctx, calendarID, page, pageSize, filter)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetCalendarEvent(ctx context.Context, calendarID, eventID string) (CalendarEvent, error) {
|
||||||
|
var res struct {
|
||||||
|
Event CalendarEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/calendar/v1/" + calendarID + "/events/" + eventID)
|
||||||
|
}); err != nil {
|
||||||
|
return CalendarEvent{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Event, nil
|
||||||
|
}
|
||||||
110
calendar_event_types.go
Normal file
110
calendar_event_types.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CalendarEvent struct {
|
||||||
|
ID string
|
||||||
|
UID string
|
||||||
|
CalendarID string
|
||||||
|
SharedEventID string
|
||||||
|
|
||||||
|
CreateTime int64
|
||||||
|
LastEditTime int64
|
||||||
|
StartTime int64
|
||||||
|
StartTimezone string
|
||||||
|
EndTime int64
|
||||||
|
EndTimezone string
|
||||||
|
FullDay Bool
|
||||||
|
|
||||||
|
Author string
|
||||||
|
Permissions CalendarPermissions
|
||||||
|
Attendees []CalendarAttendee
|
||||||
|
|
||||||
|
SharedKeyPacket string
|
||||||
|
CalendarKeyPacket string
|
||||||
|
|
||||||
|
SharedEvents []CalendarEventPart
|
||||||
|
CalendarEvents []CalendarEventPart
|
||||||
|
AttendeesEvents []CalendarEventPart
|
||||||
|
PersonalEvents []CalendarEventPart
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Only personal events have MemberID; should we have a different type for that?
|
||||||
|
type CalendarEventPart struct {
|
||||||
|
MemberID string
|
||||||
|
|
||||||
|
Type CalendarEventType
|
||||||
|
Data string
|
||||||
|
Signature string
|
||||||
|
Author string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (part CalendarEventPart) Decode(calKR *crypto.KeyRing, addrKR *crypto.KeyRing, kp []byte) error {
|
||||||
|
if part.Type&CalendarEventTypeEncrypted != 0 {
|
||||||
|
var enc *crypto.PGPMessage
|
||||||
|
|
||||||
|
if kp != nil {
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(part.Data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
enc = crypto.NewPGPSplitMessage(kp, raw).GetPGPMessage()
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if enc, err = crypto.NewPGPMessageFromArmored(part.Data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dec, err := calKR.Decrypt(enc, nil, crypto.GetUnixTime())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
part.Data = dec.GetString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if part.Type&CalendarEventTypeSigned != 0 {
|
||||||
|
sig, err := crypto.NewPGPSignatureFromArmored(part.Signature)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := addrKR.VerifyDetached(crypto.NewPlainMessageFromString(part.Data), sig, crypto.GetUnixTime()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalendarEventType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CalendarEventTypeClear CalendarEventType = iota
|
||||||
|
CalendarEventTypeEncrypted
|
||||||
|
CalendarEventTypeSigned
|
||||||
|
)
|
||||||
|
|
||||||
|
type CalendarAttendee struct {
|
||||||
|
ID string
|
||||||
|
Token string
|
||||||
|
Status CalendarAttendeeStatus
|
||||||
|
Permissions CalendarPermissions
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: What is this?
|
||||||
|
type CalendarAttendeeStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CalendarAttendeeStatusPending CalendarAttendeeStatus = iota
|
||||||
|
CalendarAttendeeStatusMaybe
|
||||||
|
CalendarAttendeeStatusNo
|
||||||
|
CalendarAttendeeStatusYes
|
||||||
|
)
|
||||||
140
calendar_types.go
Normal file
140
calendar_types.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Calendar struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Color string
|
||||||
|
Display Bool
|
||||||
|
|
||||||
|
Type CalendarType
|
||||||
|
Flags CalendarFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalendarFlag int64
|
||||||
|
|
||||||
|
const (
|
||||||
|
CalendarFlagActive CalendarFlag = 1 << iota
|
||||||
|
CalendarFlagUpdatePassphrase
|
||||||
|
CalendarFlagResetNeeded
|
||||||
|
CalendarFlagIncompleteSetup
|
||||||
|
CalendarFlagLostAccess
|
||||||
|
)
|
||||||
|
|
||||||
|
type CalendarType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CalendarTypeNormal CalendarType = iota
|
||||||
|
CalendarTypeSubscribed
|
||||||
|
)
|
||||||
|
|
||||||
|
type CalendarKey struct {
|
||||||
|
ID string
|
||||||
|
CalendarID string
|
||||||
|
PassphraseID string
|
||||||
|
PrivateKey string
|
||||||
|
Flags CalendarKeyFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
func (key CalendarKey) Unlock(passphrase []byte) (*crypto.Key, error) {
|
||||||
|
lockedKey, err := crypto.NewKeyFromArmored(key.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return lockedKey.Unlock(passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalendarKeys []CalendarKey
|
||||||
|
|
||||||
|
func (keys CalendarKeys) Unlock(passphrase []byte) (*crypto.KeyRing, error) {
|
||||||
|
kr, err := crypto.NewKeyRing(nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
if k, err := key.Unlock(passphrase); err != nil {
|
||||||
|
continue
|
||||||
|
} else if err := kr.AddKey(k); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return kr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: What is this?
|
||||||
|
type CalendarKeyFlag int64
|
||||||
|
|
||||||
|
const (
|
||||||
|
CalendarKeyFlagActive CalendarKeyFlag = 1 << iota
|
||||||
|
CalendarKeyFlagPrimary
|
||||||
|
)
|
||||||
|
|
||||||
|
type CalendarMember struct {
|
||||||
|
ID string
|
||||||
|
Permissions CalendarPermissions
|
||||||
|
Email string
|
||||||
|
Color string
|
||||||
|
Display Bool
|
||||||
|
CalendarID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: What is this?
|
||||||
|
type CalendarPermissions int
|
||||||
|
|
||||||
|
// TODO: Support invitations.
|
||||||
|
type CalendarPassphrase struct {
|
||||||
|
ID string
|
||||||
|
Flags CalendarPassphraseFlag
|
||||||
|
MemberPassphrases []MemberPassphrase
|
||||||
|
}
|
||||||
|
|
||||||
|
func (passphrase CalendarPassphrase) Decrypt(memberID string, addrKR *crypto.KeyRing) ([]byte, error) {
|
||||||
|
for _, passphrase := range passphrase.MemberPassphrases {
|
||||||
|
if passphrase.MemberID == memberID {
|
||||||
|
return passphrase.decrypt(addrKR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("no such member passphrase")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: What is this?
|
||||||
|
type CalendarPassphraseFlag int64
|
||||||
|
|
||||||
|
type MemberPassphrase struct {
|
||||||
|
MemberID string
|
||||||
|
Passphrase string
|
||||||
|
Signature string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (passphrase MemberPassphrase) decrypt(addrKR *crypto.KeyRing) ([]byte, error) {
|
||||||
|
msg, err := crypto.NewPGPMessageFromArmored(passphrase.Passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := crypto.NewPGPSignatureFromArmored(passphrase.Signature)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec, err := addrKR.Decrypt(msg, nil, crypto.GetUnixTime())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := addrKR.VerifyDetached(dec, sig, crypto.GetUnixTime()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dec.GetBinary(), nil
|
||||||
|
}
|
||||||
205
client.go
Normal file
205
client.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bradenaw/juniper/xsync"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// clientID is a unique identifier for a client.
|
||||||
|
var clientID uint64
|
||||||
|
|
||||||
|
// AuthHandler is given any new auths that are returned from the API due to an unexpected auth refresh.
|
||||||
|
type AuthHandler func(Auth)
|
||||||
|
|
||||||
|
// Handler is a generic function that can be registered for a certain event (e.g. deauth, API code).
|
||||||
|
type Handler func()
|
||||||
|
|
||||||
|
// Client is the proton client.
|
||||||
|
type Client struct {
|
||||||
|
m *Manager
|
||||||
|
|
||||||
|
// clientID is this client's unique ID.
|
||||||
|
clientID uint64
|
||||||
|
|
||||||
|
// attPool is the (lazy-initialized) pool of goroutines that fetch attachments.
|
||||||
|
attPool func() *Pool[string, []byte]
|
||||||
|
|
||||||
|
uid string
|
||||||
|
acc string
|
||||||
|
ref string
|
||||||
|
exp time.Time
|
||||||
|
authLock sync.RWMutex
|
||||||
|
|
||||||
|
authHandlers []AuthHandler
|
||||||
|
deauthHandlers []Handler
|
||||||
|
hookLock sync.RWMutex
|
||||||
|
|
||||||
|
deauthOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(m *Manager, uid string) *Client {
|
||||||
|
c := &Client{
|
||||||
|
m: m,
|
||||||
|
uid: uid,
|
||||||
|
clientID: atomic.AddUint64(&clientID, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.attPool = xsync.Lazy(func() *Pool[string, []byte] {
|
||||||
|
return NewPool(m.attPoolSize, c.getAttachment)
|
||||||
|
})
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AddAuthHandler(handler AuthHandler) {
|
||||||
|
c.hookLock.Lock()
|
||||||
|
defer c.hookLock.Unlock()
|
||||||
|
|
||||||
|
c.authHandlers = append(c.authHandlers, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AddDeauthHandler(handler Handler) {
|
||||||
|
c.hookLock.Lock()
|
||||||
|
defer c.hookLock.Unlock()
|
||||||
|
|
||||||
|
c.deauthHandlers = append(c.deauthHandlers, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AddPreRequestHook(hook resty.RequestMiddleware) {
|
||||||
|
c.hookLock.Lock()
|
||||||
|
defer c.hookLock.Unlock()
|
||||||
|
|
||||||
|
c.m.rc.OnBeforeRequest(func(rc *resty.Client, r *resty.Request) error {
|
||||||
|
if clientID, ok := ClientIDFromContext(r.Context()); !ok || clientID != c.clientID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return hook(rc, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AddPostRequestHook(hook resty.ResponseMiddleware) {
|
||||||
|
c.hookLock.Lock()
|
||||||
|
defer c.hookLock.Unlock()
|
||||||
|
|
||||||
|
c.m.rc.OnAfterResponse(func(rc *resty.Client, r *resty.Response) error {
|
||||||
|
if clientID, ok := ClientIDFromContext(r.Request.Context()); !ok || clientID != c.clientID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return hook(rc, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Close() {
|
||||||
|
c.attPool().Done()
|
||||||
|
|
||||||
|
c.authLock.Lock()
|
||||||
|
defer c.authLock.Unlock()
|
||||||
|
|
||||||
|
c.uid = ""
|
||||||
|
c.acc = ""
|
||||||
|
c.ref = ""
|
||||||
|
c.exp = time.Time{}
|
||||||
|
|
||||||
|
c.hookLock.Lock()
|
||||||
|
defer c.hookLock.Unlock()
|
||||||
|
|
||||||
|
c.authHandlers = nil
|
||||||
|
c.deauthHandlers = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) withAuth(acc, ref string, exp time.Time) *Client {
|
||||||
|
c.acc = acc
|
||||||
|
c.ref = ref
|
||||||
|
c.exp = exp
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) do(ctx context.Context, fn func(*resty.Request) (*resty.Response, error)) error {
|
||||||
|
if _, err := c.doRes(ctx, fn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doRes(ctx context.Context, fn func(*resty.Request) (*resty.Response, error)) (*resty.Response, error) {
|
||||||
|
c.hookLock.RLock()
|
||||||
|
defer c.hookLock.RUnlock()
|
||||||
|
|
||||||
|
req, err := c.newReq(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the request.
|
||||||
|
res, err := fn(req)
|
||||||
|
|
||||||
|
// If we receive no response, we can't do anything.
|
||||||
|
if res.RawResponse == nil {
|
||||||
|
return nil, fmt.Errorf("received no response from API: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we receive a 401, notify deauth handlers.
|
||||||
|
if res.StatusCode() == http.StatusUnauthorized {
|
||||||
|
c.deauthOnce.Do(func() {
|
||||||
|
for _, handler := range c.deauthHandlers {
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) newReq(ctx context.Context) (*resty.Request, error) {
|
||||||
|
c.authLock.Lock()
|
||||||
|
defer c.authLock.Unlock()
|
||||||
|
|
||||||
|
r := c.m.r(WithClient(ctx, c.clientID))
|
||||||
|
|
||||||
|
if c.uid != "" {
|
||||||
|
r.SetHeader("x-pm-uid", c.uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(c.exp) {
|
||||||
|
auth, err := c.m.authRefresh(ctx, c.uid, c.ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.acc = auth.AccessToken
|
||||||
|
c.ref = auth.RefreshToken
|
||||||
|
c.exp = time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second)
|
||||||
|
|
||||||
|
if err := c.handleAuth(auth); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.acc != "" {
|
||||||
|
r.SetAuthToken(c.acc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleAuth(auth Auth) error {
|
||||||
|
c.hookLock.RLock()
|
||||||
|
defer c.hookLock.RUnlock()
|
||||||
|
|
||||||
|
for _, handler := range c.authHandlers {
|
||||||
|
handler(auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
141
contact.go
Normal file
141
contact.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) GetContact(ctx context.Context, contactID string) (Contact, error) {
|
||||||
|
var res struct {
|
||||||
|
Contact Contact
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/contacts/v4/" + contactID)
|
||||||
|
}); err != nil {
|
||||||
|
return Contact{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Contact, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CountContacts(ctx context.Context) (int, error) {
|
||||||
|
var res struct {
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/contacts/v4")
|
||||||
|
}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CountContactEmails(ctx context.Context, email string) (int, error) {
|
||||||
|
var res struct {
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).SetQueryParam("Email", email).Get("/contacts/v4/emails")
|
||||||
|
}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetContacts(ctx context.Context, page, pageSize int) ([]Contact, error) {
|
||||||
|
var res struct {
|
||||||
|
Contacts []Contact
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetQueryParams(map[string]string{
|
||||||
|
"Page": strconv.Itoa(page),
|
||||||
|
"PageSize": strconv.Itoa(pageSize),
|
||||||
|
}).SetResult(&res).Get("/contacts/v4")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Contacts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetAllContacts(ctx context.Context) ([]Contact, error) {
|
||||||
|
total, err := c.CountContacts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchPaged(ctx, total, maxPageSize, func(ctx context.Context, page, pageSize int) ([]Contact, error) {
|
||||||
|
return c.GetContacts(ctx, page, pageSize)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetContactEmails(ctx context.Context, email string, page, pageSize int) ([]ContactEmail, error) {
|
||||||
|
var res struct {
|
||||||
|
ContactEmails []ContactEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetQueryParams(map[string]string{
|
||||||
|
"Page": strconv.Itoa(page),
|
||||||
|
"PageSize": strconv.Itoa(pageSize),
|
||||||
|
"Email": email,
|
||||||
|
}).SetResult(&res).Get("/contacts/v4/emails")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.ContactEmails, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetAllContactEmails(ctx context.Context, email string) ([]ContactEmail, error) {
|
||||||
|
total, err := c.CountContactEmails(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchPaged(ctx, total, maxPageSize, func(ctx context.Context, page, pageSize int) ([]ContactEmail, error) {
|
||||||
|
return c.GetContactEmails(ctx, email, page, pageSize)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateContacts(ctx context.Context, req CreateContactsReq) ([]CreateContactsRes, error) {
|
||||||
|
var res struct {
|
||||||
|
Responses []CreateContactsRes
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).Post("/contacts/v4")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Responses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdateContact(ctx context.Context, contactID string, req UpdateContactReq) (Contact, error) {
|
||||||
|
var res struct {
|
||||||
|
Contact Contact
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).Put("/contacts/v4/" + contactID)
|
||||||
|
}); err != nil {
|
||||||
|
return Contact{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Contact, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteContacts(ctx context.Context, req DeleteContactsReq) error {
|
||||||
|
return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).Put("/contacts/v4/delete")
|
||||||
|
})
|
||||||
|
}
|
||||||
374
contact_card.go
Normal file
374
contact_card.go
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/bradenaw/juniper/xslices"
|
||||||
|
"github.com/emersion/go-vcard"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
FieldPMScheme = "X-PM-SCHEME"
|
||||||
|
FieldPMSign = "X-PM-SIGN"
|
||||||
|
FieldPMEncrypt = "X-PM-ENCRYPT"
|
||||||
|
FieldPMMIMEType = "X-PM-MIMETYPE"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Cards []*Card
|
||||||
|
|
||||||
|
func (c *Cards) Merge(kr *crypto.KeyRing) (vcard.Card, error) {
|
||||||
|
merged := newVCard()
|
||||||
|
|
||||||
|
for _, card := range *c {
|
||||||
|
dec, err := card.decode(kr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, fields := range dec {
|
||||||
|
for _, f := range fields {
|
||||||
|
merged.Add(k, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cards) Get(cardType CardType) (*Card, bool) {
|
||||||
|
for _, card := range *c {
|
||||||
|
if card.Type == cardType {
|
||||||
|
return card, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
type Card struct {
|
||||||
|
Type CardType
|
||||||
|
Data string
|
||||||
|
Signature string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CardTypeClear CardType = iota
|
||||||
|
CardTypeEncrypted
|
||||||
|
CardTypeSigned
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCard(kr *crypto.KeyRing, cardType CardType) (*Card, error) {
|
||||||
|
card := &Card{Type: cardType}
|
||||||
|
|
||||||
|
if err := card.encode(kr, newVCard()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return card, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newVCard() vcard.Card {
|
||||||
|
card := make(vcard.Card)
|
||||||
|
|
||||||
|
card.AddValue(vcard.FieldVersion, "4.0")
|
||||||
|
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Card) Get(kr *crypto.KeyRing, key string) ([]*vcard.Field, error) {
|
||||||
|
dec, err := c.decode(kr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dec[key], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Card) Set(kr *crypto.KeyRing, key, value string) error {
|
||||||
|
dec, err := c.decode(kr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if field := dec.Get(key); field != nil {
|
||||||
|
field.Value = value
|
||||||
|
|
||||||
|
return c.encode(kr, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
dec.AddValue(key, value)
|
||||||
|
|
||||||
|
return c.encode(kr, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Card) ChangeType(kr *crypto.KeyRing, cardType CardType) error {
|
||||||
|
dec, err := c.decode(kr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Type = cardType
|
||||||
|
|
||||||
|
return c.encode(kr, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroup returns a type to manipulate the group defined by the given key/value pair.
|
||||||
|
func (c Card) GetGroup(kr *crypto.KeyRing, groupKey, groupValue string) (CardGroup, error) {
|
||||||
|
group, err := c.getGroup(kr, groupKey, groupValue)
|
||||||
|
if err != nil {
|
||||||
|
return CardGroup{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return CardGroup{Card: c, kr: kr, group: group}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGroup removes all values in the group defined by the given key/value pair.
|
||||||
|
func (c *Card) DeleteGroup(kr *crypto.KeyRing, groupKey, groupValue string) error {
|
||||||
|
group, err := c.getGroup(kr, groupKey, groupValue)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.deleteGroup(kr, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardGroup struct {
|
||||||
|
Card
|
||||||
|
|
||||||
|
kr *crypto.KeyRing
|
||||||
|
group string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the values in the group with the given key.
|
||||||
|
func (g CardGroup) Get(key string) ([]string, error) {
|
||||||
|
dec, err := g.decode(g.kr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var fields []*vcard.Field
|
||||||
|
|
||||||
|
for _, field := range dec[key] {
|
||||||
|
if field.Group != g.group {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fields = append(fields, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return xslices.Map(fields, func(field *vcard.Field) string {
|
||||||
|
return field.Value
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set sets the value in the group.
|
||||||
|
func (g *CardGroup) Set(key, value string, params vcard.Params) error {
|
||||||
|
dec, err := g.decode(g.kr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range dec[key] {
|
||||||
|
if field.Group != g.group {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
field.Value = value
|
||||||
|
|
||||||
|
return g.encode(g.kr, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
dec.Add(key, &vcard.Field{
|
||||||
|
Value: value,
|
||||||
|
Group: g.group,
|
||||||
|
Params: params,
|
||||||
|
})
|
||||||
|
|
||||||
|
return g.encode(g.kr, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds a value to the group.
|
||||||
|
func (g *CardGroup) Add(key, value string, params vcard.Params) error {
|
||||||
|
dec, err := g.decode(g.kr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec.Add(key, &vcard.Field{
|
||||||
|
Value: value,
|
||||||
|
Group: g.group,
|
||||||
|
Params: params,
|
||||||
|
})
|
||||||
|
|
||||||
|
return g.encode(g.kr, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove removes the value in the group with the given key/value.
|
||||||
|
func (g *CardGroup) Remove(key, value string) error {
|
||||||
|
dec, err := g.decode(g.kr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, ok := dec[key]
|
||||||
|
if !ok {
|
||||||
|
return errors.New("no such key")
|
||||||
|
}
|
||||||
|
|
||||||
|
var rest []*vcard.Field
|
||||||
|
|
||||||
|
for _, field := range fields {
|
||||||
|
if field.Group != g.group {
|
||||||
|
rest = append(rest, field)
|
||||||
|
} else if field.Value != value {
|
||||||
|
rest = append(rest, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rest) > 0 {
|
||||||
|
dec[key] = rest
|
||||||
|
} else {
|
||||||
|
delete(dec, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return g.encode(g.kr, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveAll removes all values in the group with the given key.
|
||||||
|
func (g *CardGroup) RemoveAll(key string) error {
|
||||||
|
dec, err := g.decode(g.kr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, ok := dec[key]
|
||||||
|
if !ok {
|
||||||
|
return errors.New("no such key")
|
||||||
|
}
|
||||||
|
|
||||||
|
var rest []*vcard.Field
|
||||||
|
|
||||||
|
for _, field := range fields {
|
||||||
|
if field.Group != g.group {
|
||||||
|
rest = append(rest, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rest) > 0 {
|
||||||
|
dec[key] = rest
|
||||||
|
} else {
|
||||||
|
delete(dec, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return g.encode(g.kr, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Card) getGroup(kr *crypto.KeyRing, groupKey, groupValue string) (string, error) {
|
||||||
|
fields, err := c.Get(kr, groupKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range fields {
|
||||||
|
if field.Value != groupValue {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return field.Group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.New("no such field")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Card) deleteGroup(kr *crypto.KeyRing, group string) error {
|
||||||
|
dec, err := c.decode(kr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, fields := range dec {
|
||||||
|
var rest []*vcard.Field
|
||||||
|
|
||||||
|
for _, field := range fields {
|
||||||
|
if field.Group != group {
|
||||||
|
rest = append(rest, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rest) > 0 {
|
||||||
|
dec[key] = rest
|
||||||
|
} else {
|
||||||
|
delete(dec, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.encode(kr, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Card) decode(kr *crypto.KeyRing) (vcard.Card, error) {
|
||||||
|
if c.Type&CardTypeEncrypted != 0 {
|
||||||
|
enc, err := crypto.NewPGPMessageFromArmored(c.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec, err := kr.Decrypt(enc, nil, crypto.GetUnixTime())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data = dec.GetString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Type&CardTypeSigned != 0 {
|
||||||
|
sig, err := crypto.NewPGPSignatureFromArmored(c.Signature)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := kr.VerifyDetached(crypto.NewPlainMessageFromString(c.Data), sig, crypto.GetUnixTime()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return vcard.NewDecoder(strings.NewReader(c.Data)).Decode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Card) encode(kr *crypto.KeyRing, card vcard.Card) error {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
|
||||||
|
if err := vcard.NewEncoder(buf).Encode(card); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Type&CardTypeSigned != 0 {
|
||||||
|
sig, err := kr.SignDetached(crypto.NewPlainMessageFromString(buf.String()))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Signature, err = sig.GetArmored(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Type&CardTypeEncrypted != 0 {
|
||||||
|
enc, err := kr.Encrypt(crypto.NewPlainMessageFromString(buf.String()), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Data, err = enc.GetArmored(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.Data = buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
171
contact_types.go
Normal file
171
contact_types.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/emersion/go-vcard"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RecipientType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
RecipientTypeInternal RecipientType = iota + 1
|
||||||
|
RecipientTypeExternal
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContactSettings struct {
|
||||||
|
MIMEType *rfc822.MIMEType
|
||||||
|
Scheme *EncryptionScheme
|
||||||
|
Sign *bool
|
||||||
|
Encrypt *bool
|
||||||
|
Keys []*crypto.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
type Contact struct {
|
||||||
|
ContactMetadata
|
||||||
|
ContactCards
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Contact) GetSettings(kr *crypto.KeyRing, email string) (ContactSettings, error) {
|
||||||
|
signedCard, ok := c.Cards.Get(CardTypeSigned)
|
||||||
|
if !ok {
|
||||||
|
return ContactSettings{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := signedCard.GetGroup(kr, vcard.FieldEmail, email)
|
||||||
|
if err != nil {
|
||||||
|
return ContactSettings{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings ContactSettings
|
||||||
|
|
||||||
|
scheme, err := group.Get(FieldPMScheme)
|
||||||
|
if err != nil {
|
||||||
|
return ContactSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(scheme) > 0 {
|
||||||
|
switch scheme[0] {
|
||||||
|
case "pgp-inline":
|
||||||
|
settings.Scheme = newPtr(PGPInlineScheme)
|
||||||
|
|
||||||
|
case "pgp-mime":
|
||||||
|
settings.Scheme = newPtr(PGPMIMEScheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeType, err := group.Get(FieldPMMIMEType)
|
||||||
|
if err != nil {
|
||||||
|
return ContactSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mimeType) > 0 {
|
||||||
|
settings.MIMEType = newPtr(rfc822.MIMEType(mimeType[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
sign, err := group.Get(FieldPMSign)
|
||||||
|
if err != nil {
|
||||||
|
return ContactSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sign) > 0 {
|
||||||
|
sign, err := strconv.ParseBool(sign[0])
|
||||||
|
if err != nil {
|
||||||
|
return ContactSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.Sign = newPtr(sign)
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypt, err := group.Get(FieldPMEncrypt)
|
||||||
|
if err != nil {
|
||||||
|
return ContactSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(encrypt) > 0 {
|
||||||
|
encrypt, err := strconv.ParseBool(encrypt[0])
|
||||||
|
if err != nil {
|
||||||
|
return ContactSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.Encrypt = newPtr(encrypt)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := group.Get(vcard.FieldKey)
|
||||||
|
if err != nil {
|
||||||
|
return ContactSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keys) > 0 {
|
||||||
|
for _, key := range keys {
|
||||||
|
dec, err := base64.StdEncoding.DecodeString(strings.SplitN(key, ",", 2)[1])
|
||||||
|
if err != nil {
|
||||||
|
return ContactSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKey, err := crypto.NewKey(dec)
|
||||||
|
if err != nil {
|
||||||
|
return ContactSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.Keys = append(settings.Keys, pubKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactMetadata struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
UID string
|
||||||
|
Size int64
|
||||||
|
CreateTime int64
|
||||||
|
ModifyTime int64
|
||||||
|
ContactEmails []ContactEmail
|
||||||
|
LabelIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactCards struct {
|
||||||
|
Cards Cards
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactEmail struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
Type []string
|
||||||
|
ContactID string
|
||||||
|
LabelIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateContactsReq struct {
|
||||||
|
Contacts []ContactCards
|
||||||
|
Overwrite int
|
||||||
|
Labels int
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateContactsRes struct {
|
||||||
|
Index int
|
||||||
|
|
||||||
|
Response struct {
|
||||||
|
Error
|
||||||
|
Contact Contact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateContactReq struct {
|
||||||
|
Cards Cards
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteContactsReq struct {
|
||||||
|
IDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPtr[T any](v T) *T {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
22
contexts.go
Normal file
22
contexts.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type withClientKeyType struct{}
|
||||||
|
|
||||||
|
var withClientKey withClientKeyType
|
||||||
|
|
||||||
|
// WithClient marks this context as originating from the client with the given ID.
|
||||||
|
func WithClient(parent context.Context, clientID uint64) context.Context {
|
||||||
|
return context.WithValue(parent, withClientKey, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientIDFromContext returns true if this context was marked as originating from a client.
|
||||||
|
func ClientIDFromContext(ctx context.Context) (uint64, bool) {
|
||||||
|
clientID, ok := ctx.Value(withClientKey).(uint64)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientID, true
|
||||||
|
}
|
||||||
311
dialer.go
Normal file
311
dialer.go
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func InsecureTransport() *http.Transport {
|
||||||
|
return &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetCtl can be used to control whether a dialer can dial, and whether the resulting
|
||||||
|
// connection can read or write.
|
||||||
|
type NetCtl struct {
|
||||||
|
canDial atomicBool
|
||||||
|
dialLimit atomicUint64
|
||||||
|
|
||||||
|
canRead atomicBool
|
||||||
|
readLimit atomicUint64
|
||||||
|
|
||||||
|
canWrite atomicBool
|
||||||
|
writeLimit atomicUint64
|
||||||
|
|
||||||
|
onDial []func(net.Conn)
|
||||||
|
onRead []func([]byte)
|
||||||
|
onWrite []func([]byte)
|
||||||
|
|
||||||
|
lock sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNetCtl returns a new NetCtl with all fields set to true.
|
||||||
|
func NewNetCtl() *NetCtl {
|
||||||
|
return &NetCtl{
|
||||||
|
canDial: atomicBool{b32(true)},
|
||||||
|
canRead: atomicBool{b32(true)},
|
||||||
|
canWrite: atomicBool{b32(true)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCanDial sets whether the dialer can dial.
|
||||||
|
func (c *NetCtl) SetCanDial(canDial bool) {
|
||||||
|
c.canDial.Store(canDial)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDialLimit sets the maximum number of times dialers using this controller can dial.
|
||||||
|
func (c *NetCtl) SetDialLimit(limit uint64) {
|
||||||
|
c.dialLimit.Store(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCanRead sets whether the connection can read.
|
||||||
|
func (c *NetCtl) SetCanRead(canRead bool) {
|
||||||
|
c.canRead.Store(canRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetReadLimit sets the maximum number of bytes that can be read.
|
||||||
|
func (c *NetCtl) SetReadLimit(limit uint64) {
|
||||||
|
c.readLimit.Store(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCanWrite sets whether the connection can write.
|
||||||
|
func (c *NetCtl) SetCanWrite(canWrite bool) {
|
||||||
|
c.canWrite.Store(canWrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWriteLimit sets the maximum number of bytes that can be written.
|
||||||
|
func (c *NetCtl) SetWriteLimit(limit uint64) {
|
||||||
|
c.writeLimit.Store(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnDial adds a callback that is called with the created connection when a dial is successful.
|
||||||
|
func (c *NetCtl) OnDial(f func(net.Conn)) {
|
||||||
|
c.lock.Lock()
|
||||||
|
defer c.lock.Unlock()
|
||||||
|
|
||||||
|
c.onDial = append(c.onDial, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnRead adds a callback that is called with the read bytes when a read is successful.
|
||||||
|
func (c *NetCtl) OnRead(f func([]byte)) {
|
||||||
|
c.lock.Lock()
|
||||||
|
defer c.lock.Unlock()
|
||||||
|
|
||||||
|
c.onRead = append(c.onRead, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnWrite adds a callback that is called with the written bytes when a write is successful.
|
||||||
|
func (c *NetCtl) OnWrite(f func([]byte)) {
|
||||||
|
c.lock.Lock()
|
||||||
|
defer c.lock.Unlock()
|
||||||
|
|
||||||
|
c.onWrite = append(c.onWrite, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable is equivalent to disallowing dial, read and write.
|
||||||
|
func (c *NetCtl) Disable() {
|
||||||
|
c.SetCanDial(false)
|
||||||
|
c.SetCanRead(false)
|
||||||
|
c.SetCanWrite(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable is equivalent to allowing dial, read and write.
|
||||||
|
func (c *NetCtl) Enable() {
|
||||||
|
c.SetCanDial(true)
|
||||||
|
c.SetCanRead(true)
|
||||||
|
c.SetCanWrite(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conn is a wrapper around net.Conn that can be used to control whether a connection can read or write.
|
||||||
|
type Conn struct {
|
||||||
|
net.Conn
|
||||||
|
|
||||||
|
ctl *NetCtl
|
||||||
|
|
||||||
|
readLimiter *readLimiter
|
||||||
|
writeLimiter *writeLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reads from the wrapped connection, but only if the controller allows it.
|
||||||
|
func (c *Conn) Read(b []byte) (int, error) {
|
||||||
|
if !c.ctl.canRead.Load() {
|
||||||
|
return 0, errors.New("cannot read")
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := c.readLimiter.read(c.Conn, b)
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range c.ctl.onRead {
|
||||||
|
f(b[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write writes to the wrapped connection, but only if the controller allows it.
|
||||||
|
func (c *Conn) Write(b []byte) (int, error) {
|
||||||
|
if !c.ctl.canWrite.Load() {
|
||||||
|
return 0, errors.New("cannot write")
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := c.writeLimiter.write(c.Conn, b)
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range c.ctl.onWrite {
|
||||||
|
f(b[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialer performs network dialing, but only if the controller allows it.
|
||||||
|
type Dialer struct {
|
||||||
|
ctl *NetCtl
|
||||||
|
|
||||||
|
netDialer *net.Dialer
|
||||||
|
tlsDialer *tls.Dialer
|
||||||
|
tlsConfig *tls.Config
|
||||||
|
|
||||||
|
readLimiter *readLimiter
|
||||||
|
writeLimiter *writeLimiter
|
||||||
|
|
||||||
|
dialCount atomicUint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDialer returns a new dialer using the given net controller.
|
||||||
|
// It optionally uses a provided tls config.
|
||||||
|
func NewDialer(ctl *NetCtl, tlsConfig *tls.Config) *Dialer {
|
||||||
|
return &Dialer{
|
||||||
|
ctl: ctl,
|
||||||
|
|
||||||
|
netDialer: &net.Dialer{},
|
||||||
|
tlsDialer: &tls.Dialer{Config: tlsConfig},
|
||||||
|
tlsConfig: tlsConfig,
|
||||||
|
|
||||||
|
readLimiter: newReadLimiter(ctl),
|
||||||
|
writeLimiter: newWriteLimiter(ctl),
|
||||||
|
|
||||||
|
dialCount: atomicUint64{0},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialContext dials a network connection, but only if the controller allows it.
|
||||||
|
func (d *Dialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return d.dialWithDialer(ctx, network, addr, d.netDialer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialTLSContext dials a TLS network connection, but only if the controller allows it.
|
||||||
|
func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return d.dialWithDialer(ctx, network, addr, d.tlsDialer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialWithDialer dials a network connection using the given dialer, but only if the controller allows it.
|
||||||
|
func (d *Dialer) dialWithDialer(ctx context.Context, network, addr string, dialer dialer) (net.Conn, error) {
|
||||||
|
if !d.ctl.canDial.Load() {
|
||||||
|
return nil, errors.New("cannot dial")
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit := d.ctl.dialLimit.Load(); limit > 0 && d.dialCount.Load() >= limit {
|
||||||
|
return nil, errors.New("dial limit reached")
|
||||||
|
} else {
|
||||||
|
d.dialCount.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := dialer.DialContext(ctx, network, addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.ctl.lock.Lock()
|
||||||
|
defer d.ctl.lock.Unlock()
|
||||||
|
|
||||||
|
for _, f := range d.ctl.onDial {
|
||||||
|
f(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Conn{
|
||||||
|
Conn: conn,
|
||||||
|
ctl: d.ctl,
|
||||||
|
|
||||||
|
readLimiter: d.readLimiter,
|
||||||
|
writeLimiter: d.writeLimiter,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoundTripper returns a new http.RoundTripper that uses the dialer.
|
||||||
|
func (d *Dialer) GetRoundTripper() http.RoundTripper {
|
||||||
|
return &http.Transport{
|
||||||
|
DialContext: d.DialContext,
|
||||||
|
DialTLSContext: d.DialTLSContext,
|
||||||
|
TLSClientConfig: d.tlsConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type dialer interface {
|
||||||
|
DialContext(ctx context.Context, network, addr string) (net.Conn, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type readLimiter struct {
|
||||||
|
ctl *NetCtl
|
||||||
|
|
||||||
|
count atomicUint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// newReadLimiter returns a new io.Reader that reads from r, but only up to limit bytes.
|
||||||
|
func newReadLimiter(ctl *NetCtl) *readLimiter {
|
||||||
|
return &readLimiter{
|
||||||
|
ctl: ctl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (limiter *readLimiter) read(r io.Reader, b []byte) (int, error) {
|
||||||
|
if limit := limiter.ctl.readLimit.Load(); limit > 0 && limiter.count.Load() >= limit {
|
||||||
|
return 0, fmt.Errorf("refusing to read: read limit reached")
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := r.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit := limiter.ctl.readLimit.Load(); limit > 0 {
|
||||||
|
if new := limiter.count.Add(uint64(n)); new >= limit {
|
||||||
|
return 0, fmt.Errorf("read failed: read limit reached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeLimiter struct {
|
||||||
|
ctl *NetCtl
|
||||||
|
|
||||||
|
count atomicUint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWriteLimiter returns a new io.Writer that writes to w, but only up to limit bytes.
|
||||||
|
func newWriteLimiter(ctl *NetCtl) *writeLimiter {
|
||||||
|
return &writeLimiter{
|
||||||
|
ctl: ctl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (limiter *writeLimiter) write(w io.Writer, b []byte) (int, error) {
|
||||||
|
if limit := limiter.ctl.writeLimit.Load(); limit > 0 && limiter.count.Load() >= limit {
|
||||||
|
return 0, fmt.Errorf("refusing to write: write limit reached")
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := w.Write(b)
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit := limiter.ctl.writeLimit.Load(); limit > 0 {
|
||||||
|
if new := limiter.count.Add(uint64(n)); new >= limit {
|
||||||
|
return 0, fmt.Errorf("write failed: write limit reached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
79
dialer_test.go
Normal file
79
dialer_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package proton_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNetCtl_ReadLimit(t *testing.T) {
|
||||||
|
// Create a test http server that writes 100 bytes.
|
||||||
|
// Including the header, this is 217 bytes (100 bytes + 117 bytes).
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, err := w.Write(make([]byte, 100)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// Create a new net controller.
|
||||||
|
netCtl := proton.NewNetCtl()
|
||||||
|
|
||||||
|
// Create a new http client with the dialer.
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: proton.NewDialer(netCtl, &tls.Config{InsecureSkipVerify: true}).GetRoundTripper(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the read limit to 300 bytes -- the first request should succeed, the second should fail.
|
||||||
|
netCtl.SetReadLimit(300)
|
||||||
|
|
||||||
|
// This should succeed.
|
||||||
|
if resp, err := client.Get(ts.URL); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should fail.
|
||||||
|
if _, err := client.Get(ts.URL); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetCtl_WriteLimit(t *testing.T) {
|
||||||
|
// Create a test http server that reads the given body.
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, err := io.ReadAll(r.Body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// Create a new net controller.
|
||||||
|
netCtl := proton.NewNetCtl()
|
||||||
|
|
||||||
|
// Create a new http client with the dialer.
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: proton.NewDialer(netCtl, &tls.Config{InsecureSkipVerify: true}).GetRoundTripper(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the read limit to 300 bytes -- the first request should succeed, the second should fail.
|
||||||
|
netCtl.SetWriteLimit(300)
|
||||||
|
|
||||||
|
// This should succeed.
|
||||||
|
if resp, err := client.Post(ts.URL, "application/octet-stream", bytes.NewReader(make([]byte, 100))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should fail.
|
||||||
|
if _, err := client.Post(ts.URL, "application/octet-stream", bytes.NewReader(make([]byte, 100))); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
204
drive_types.go
Normal file
204
drive_types.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import "github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
|
||||||
|
type Volume struct {
|
||||||
|
ID string // Encrypted volume ID
|
||||||
|
Name string // The volume name
|
||||||
|
OwnerUserID string // Encrypted owner user ID
|
||||||
|
UsedSpace int64 // Space used by files in the volume in bytes
|
||||||
|
MaxSpace int64 // Space limit for the volume in bytes
|
||||||
|
State VolumeState // TODO: What is this?
|
||||||
|
}
|
||||||
|
|
||||||
|
type VolumeState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TODO: VolumeState constants
|
||||||
|
)
|
||||||
|
|
||||||
|
type Share struct {
|
||||||
|
ShareID string // Encrypted share ID
|
||||||
|
Type ShareType // Type of share
|
||||||
|
State ShareState // TODO: What is this?
|
||||||
|
PermissionsMask Permissions // Mask restricting member permissions on the share
|
||||||
|
LinkID string // Encrypted link ID to which the share points (root of share).
|
||||||
|
LinkType LinkType // TODO: What is this?
|
||||||
|
VolumeID string // Encrypted volume ID on which the share is mounted
|
||||||
|
Creator string // Creator address
|
||||||
|
AddressID string
|
||||||
|
Flags ShareFlags // The flag bitmap, with the following values
|
||||||
|
BlockSize int64 // TODO: What is this?
|
||||||
|
Locked bool // TODO: What is this?
|
||||||
|
Key string // The private key, encrypted with a passphrase
|
||||||
|
Passphrase string // The encrypted passphrase
|
||||||
|
PassphraseSignature string // The signature of the passphrase
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Share) GetKeyRing(kr *crypto.KeyRing) (*crypto.KeyRing, error) {
|
||||||
|
encPass, err := crypto.NewPGPMessageFromArmored(s.Passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
decPass, err := kr.Decrypt(encPass, nil, crypto.GetUnixTime())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lockedKey, err := crypto.NewKeyFromArmored(s.Key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockedKey, err := lockedKey.Unlock(decPass.GetBinary())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.NewKeyRing(unlockedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShareType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TODO: ShareType constants
|
||||||
|
)
|
||||||
|
|
||||||
|
type ShareState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TODO: ShareState constants
|
||||||
|
)
|
||||||
|
|
||||||
|
type ShareFlags int
|
||||||
|
|
||||||
|
const (
|
||||||
|
NoFlags ShareFlags = iota
|
||||||
|
PrimaryShare
|
||||||
|
)
|
||||||
|
|
||||||
|
type Link struct {
|
||||||
|
LinkID string // Encrypted file/folder ID
|
||||||
|
ParentLinkID string // Encrypted parent folder ID (LinkID)
|
||||||
|
Type LinkType
|
||||||
|
Name string // Encrypted file name
|
||||||
|
Hash string // HMAC of name encrypted with parent hash key
|
||||||
|
State LinkState // State of the link
|
||||||
|
ExpirationTime int64
|
||||||
|
Size int64
|
||||||
|
MIMEType string
|
||||||
|
Attributes Attributes
|
||||||
|
Permissions Permissions
|
||||||
|
|
||||||
|
NodeKey string
|
||||||
|
NodePassphrase string
|
||||||
|
NodePassphraseSignature string
|
||||||
|
SignatureAddress string
|
||||||
|
|
||||||
|
CreateTime int64
|
||||||
|
ModifyTime int64
|
||||||
|
|
||||||
|
FileProperties FileProperties
|
||||||
|
FolderProperties FolderProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Link) GetKeyRing(kr *crypto.KeyRing) (*crypto.KeyRing, error) {
|
||||||
|
encPass, err := crypto.NewPGPMessageFromArmored(l.NodePassphrase)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
decPass, err := kr.Decrypt(encPass, nil, crypto.GetUnixTime())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lockedKey, err := crypto.NewKeyFromArmored(l.NodeKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockedKey, err := lockedKey.Unlock(decPass.GetBinary())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.NewKeyRing(unlockedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileProperties struct {
|
||||||
|
ContentKeyPacket string
|
||||||
|
ActiveRevision Revision
|
||||||
|
}
|
||||||
|
|
||||||
|
type FolderProperties struct{}
|
||||||
|
|
||||||
|
type LinkType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
FolderLinkType LinkType = iota + 1
|
||||||
|
FileLinkType
|
||||||
|
)
|
||||||
|
|
||||||
|
type LinkState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
DraftLinkState LinkState = iota
|
||||||
|
ActiveLinkState
|
||||||
|
TrashedLinkState
|
||||||
|
DeletedLinkState
|
||||||
|
)
|
||||||
|
|
||||||
|
type Revision struct {
|
||||||
|
ID string // Encrypted Revision ID
|
||||||
|
CreateTime int64 // Unix timestamp of the revision creation time
|
||||||
|
Size int64 // Size of the file in bytes
|
||||||
|
ManifestSignature string // The signature of the root hash
|
||||||
|
SignatureAddress string // The address used to sign the root hash
|
||||||
|
State FileRevisionState // State of revision
|
||||||
|
Blocks []Block
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileRevisionState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
DraftRevisionState FileRevisionState = iota
|
||||||
|
ActiveRevisionState
|
||||||
|
ObsoleteRevisionState
|
||||||
|
)
|
||||||
|
|
||||||
|
type Block struct {
|
||||||
|
Index int
|
||||||
|
URL string
|
||||||
|
EncSignature string
|
||||||
|
SignatureEmail string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkEvent struct {
|
||||||
|
EventID string // Encrypted ID of the Event
|
||||||
|
CreateTime int64 // Time stamp of the creation time of the Event
|
||||||
|
EventType LinkEventType // Type of event
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkEventType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
DeleteLinkEvent LinkEventType = iota
|
||||||
|
CreateLinkEvent
|
||||||
|
UpdateContentsLinkEvent
|
||||||
|
UpdateMetadataLinkEvent
|
||||||
|
)
|
||||||
|
|
||||||
|
type Permissions int
|
||||||
|
|
||||||
|
const (
|
||||||
|
NoPermissions Permissions = 1 << iota
|
||||||
|
ReadPermission
|
||||||
|
WritePermission
|
||||||
|
AdministerMembersPermission
|
||||||
|
AdminPermission
|
||||||
|
SuperAdminPermission
|
||||||
|
)
|
||||||
|
|
||||||
|
type Attributes uint32
|
||||||
102
event.go
Normal file
102
event.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) GetLatestEventID(ctx context.Context) (string, error) {
|
||||||
|
var res struct {
|
||||||
|
Event
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/core/v4/events/latest")
|
||||||
|
}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.EventID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetEvent(ctx context.Context, eventID string) (Event, error) {
|
||||||
|
event, more, err := c.getEvent(ctx, eventID)
|
||||||
|
if err != nil {
|
||||||
|
return Event{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for more {
|
||||||
|
var next Event
|
||||||
|
|
||||||
|
next, more, err = c.getEvent(ctx, event.EventID)
|
||||||
|
if err != nil {
|
||||||
|
return Event{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := event.merge(next); err != nil {
|
||||||
|
return Event{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return event, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEventStreamer returns a new event stream.
|
||||||
|
// It polls the API for new events at random intervals between `period` and `period+jitter`.
|
||||||
|
func (c *Client) NewEventStream(ctx context.Context, period, jitter time.Duration, lastEventID string) <-chan Event {
|
||||||
|
eventCh := make(chan Event)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(eventCh)
|
||||||
|
|
||||||
|
ticker := NewTicker(period, jitter)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
event, err := c.GetEvent(ctx, lastEventID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.EventID == lastEventID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
|
||||||
|
case eventCh <- event:
|
||||||
|
lastEventID = event.EventID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return eventCh
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getEvent(ctx context.Context, eventID string) (Event, bool, error) {
|
||||||
|
var res struct {
|
||||||
|
Event
|
||||||
|
|
||||||
|
More Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/core/v4/events/" + eventID)
|
||||||
|
}); err != nil {
|
||||||
|
return Event{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Event, bool(res.More), nil
|
||||||
|
}
|
||||||
70
event_test.go
Normal file
70
event_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package proton_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/ProtonMail/go-proton-api/server"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEventStreamer(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
s := server.New()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(s.GetHostURL()),
|
||||||
|
proton.WithTransport(proton.InsecureTransport()),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, _, err := s.CreateUser("username", "email@pm.me", []byte("password"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
c, _, err := m.NewClientWithLogin(ctx, "username", []byte("password"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
createTestMessages(t, c, "password", 10)
|
||||||
|
|
||||||
|
latestEventID, err := c.GetLatestEventID(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
eventCh := make(chan proton.Event)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for event := range c.NewEventStream(ctx, time.Second, 0, latestEventID) {
|
||||||
|
eventCh <- event
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Perform some action to generate an event.
|
||||||
|
metadata, err := c.GetMessageMetadata(ctx, proton.MessageFilter{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.LabelMessages(ctx, []string{metadata[0].ID}, proton.TrashLabel))
|
||||||
|
|
||||||
|
// Wait for the first event.
|
||||||
|
<-eventCh
|
||||||
|
|
||||||
|
// Close the client; this should stop the client's event streamer.
|
||||||
|
c.Close()
|
||||||
|
|
||||||
|
// Create a new client and perform some actions with it to generate more events.
|
||||||
|
cc, _, err := m.NewClientWithLogin(ctx, "username", []byte("password"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer cc.Close()
|
||||||
|
|
||||||
|
require.NoError(t, cc.LabelMessages(ctx, []string{metadata[1].ID}, proton.TrashLabel))
|
||||||
|
|
||||||
|
// We should not receive any more events from the original client.
|
||||||
|
select {
|
||||||
|
case <-eventCh:
|
||||||
|
require.Fail(t, "received unexpected event")
|
||||||
|
|
||||||
|
default:
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
140
event_types.go
Normal file
140
event_types.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bradenaw/juniper/xslices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
EventID string
|
||||||
|
|
||||||
|
Refresh RefreshFlag
|
||||||
|
|
||||||
|
User *User
|
||||||
|
|
||||||
|
MailSettings *MailSettings
|
||||||
|
|
||||||
|
Messages []MessageEvent
|
||||||
|
|
||||||
|
Labels []LabelEvent
|
||||||
|
|
||||||
|
Addresses []AddressEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (event Event) String() string {
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
if event.Refresh != 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("refresh: %v", event.Refresh))
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.User != nil {
|
||||||
|
parts = append(parts, "user: [modified]")
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.MailSettings != nil {
|
||||||
|
parts = append(parts, "mail-settings: [modified]")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(event.Messages) > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf(
|
||||||
|
"messages: created=%d, updated=%d, deleted=%d",
|
||||||
|
xslices.CountFunc(event.Messages, func(e MessageEvent) bool { return e.Action == EventCreate }),
|
||||||
|
xslices.CountFunc(event.Messages, func(e MessageEvent) bool { return e.Action == EventUpdate || e.Action == EventUpdateFlags }),
|
||||||
|
xslices.CountFunc(event.Messages, func(e MessageEvent) bool { return e.Action == EventDelete }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(event.Labels) > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf(
|
||||||
|
"labels: created=%d, updated=%d, deleted=%d",
|
||||||
|
xslices.CountFunc(event.Labels, func(e LabelEvent) bool { return e.Action == EventCreate }),
|
||||||
|
xslices.CountFunc(event.Labels, func(e LabelEvent) bool { return e.Action == EventUpdate || e.Action == EventUpdateFlags }),
|
||||||
|
xslices.CountFunc(event.Labels, func(e LabelEvent) bool { return e.Action == EventDelete }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(event.Addresses) > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf(
|
||||||
|
"addresses: created=%d, updated=%d, deleted=%d",
|
||||||
|
xslices.CountFunc(event.Addresses, func(e AddressEvent) bool { return e.Action == EventCreate }),
|
||||||
|
xslices.CountFunc(event.Addresses, func(e AddressEvent) bool { return e.Action == EventUpdate || e.Action == EventUpdateFlags }),
|
||||||
|
xslices.CountFunc(event.Addresses, func(e AddressEvent) bool { return e.Action == EventDelete }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Event %s: %s", event.EventID, strings.Join(parts, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge combines this event with the other event (assumed to be newer!).
|
||||||
|
// TODO: Intelligent merging: if there are multiple EventUpdate(Flags) events, can we just take the latest one?
|
||||||
|
func (event *Event) merge(other Event) error {
|
||||||
|
event.EventID = other.EventID
|
||||||
|
|
||||||
|
if other.User != nil {
|
||||||
|
event.User = other.User
|
||||||
|
}
|
||||||
|
|
||||||
|
if other.MailSettings != nil {
|
||||||
|
event.MailSettings = other.MailSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, label events are simply appended.
|
||||||
|
event.Labels = append(event.Labels, other.Labels...)
|
||||||
|
|
||||||
|
// For now, message events are simply appended.
|
||||||
|
event.Messages = append(event.Messages, other.Messages...)
|
||||||
|
|
||||||
|
// For now, address events are simply appended.
|
||||||
|
event.Addresses = append(event.Addresses, other.Addresses...)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshFlag uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
RefreshMail RefreshFlag = 1 << iota // 1<<0 = 1
|
||||||
|
_ // 1<<1 = 2
|
||||||
|
_ // 1<<2 = 4
|
||||||
|
_ // 1<<3 = 8
|
||||||
|
_ // 1<<4 = 16
|
||||||
|
_ // 1<<5 = 32
|
||||||
|
_ // 1<<6 = 64
|
||||||
|
_ // 1<<7 = 128
|
||||||
|
RefreshAll RefreshFlag = 1<<iota - 1 // 1<<8 - 1 = 255
|
||||||
|
)
|
||||||
|
|
||||||
|
type EventAction int
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventDelete EventAction = iota
|
||||||
|
EventCreate
|
||||||
|
EventUpdate
|
||||||
|
EventUpdateFlags
|
||||||
|
)
|
||||||
|
|
||||||
|
type EventItem struct {
|
||||||
|
ID string
|
||||||
|
Action EventAction
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageEvent struct {
|
||||||
|
EventItem
|
||||||
|
|
||||||
|
Message MessageMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
type LabelEvent struct {
|
||||||
|
EventItem
|
||||||
|
|
||||||
|
Label Label
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddressEvent struct {
|
||||||
|
EventItem
|
||||||
|
|
||||||
|
Address Address
|
||||||
|
}
|
||||||
122
example_test.go
Normal file
122
example_test.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package proton_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleManager_NewClient() {
|
||||||
|
// Create a new manager.
|
||||||
|
m := proton.New()
|
||||||
|
|
||||||
|
// If auth information is already known, it can be used to create a client straight away.
|
||||||
|
c := m.NewClient("...uid...", "...acc...", "...ref...", time.Now().Add(time.Hour))
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// All API operations must be run within a context.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Do something with the client.
|
||||||
|
if _, err := c.GetUser(ctx); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleManager_NewClientWithRefresh() {
|
||||||
|
// Create a new manager.
|
||||||
|
m := proton.New()
|
||||||
|
|
||||||
|
// All API operations must be run within a context.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// If UID/RefreshToken is already known, it can be used to create a new client straight away.
|
||||||
|
c, _, err := m.NewClientWithRefresh(ctx, "...uid...", "...ref...")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// Do something with the client.
|
||||||
|
if _, err := c.GetUser(ctx); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleManager_NewClientWithLogin() {
|
||||||
|
// Create a new manager.
|
||||||
|
m := proton.New()
|
||||||
|
|
||||||
|
// All API operations must be run within a context.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Clients are created via username/password if auth information isn't already known.
|
||||||
|
c, auth, err := m.NewClientWithLogin(ctx, "...user...", []byte("...pass..."))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// If 2FA is necessary, an additional request is required.
|
||||||
|
if auth.TwoFA.Enabled == proton.TOTPEnabled {
|
||||||
|
if err := c.Auth2FA(ctx, proton.Auth2FAReq{TwoFactorCode: "...TOTP..."}); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do something with the client.
|
||||||
|
if _, err := c.GetUser(ctx); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_AddAuthHandler() {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create a new manager.
|
||||||
|
m := proton.New()
|
||||||
|
|
||||||
|
// Create a new client.
|
||||||
|
c := m.NewClient("...uid...", "...acc...", "...ref...", time.Now().Add(time.Hour))
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// Register an auth handler with the client.
|
||||||
|
// This could be used for example to save the auth to keychain.
|
||||||
|
c.AddAuthHandler(func(auth proton.Auth) {
|
||||||
|
// Do something with auth.
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := c.GetUser(ctx); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleClient_NewEventStream() {
|
||||||
|
m := proton.New()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
c, _, err := m.NewClientWithLogin(ctx, "...user...", []byte("...pass..."))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// Get the latest event ID.
|
||||||
|
fromEventID, err := c.GetLatestEventID(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new event streamer.
|
||||||
|
for event := range c.NewEventStream(ctx, 20*time.Second, 20*time.Second, fromEventID) {
|
||||||
|
fmt.Println(event.EventID)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
future.go
Normal file
78
future.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
type Future[T any] struct {
|
||||||
|
resCh chan res[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
type res[T any] struct {
|
||||||
|
val T
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFuture[T any](fn func() (T, error)) *Future[T] {
|
||||||
|
resCh := make(chan res[T])
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
val, err := fn()
|
||||||
|
|
||||||
|
resCh <- res[T]{val: val, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &Future[T]{resCh: resCh}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (job *Future[T]) Then(fn func(T, error)) {
|
||||||
|
go func() {
|
||||||
|
res := <-job.resCh
|
||||||
|
|
||||||
|
fn(res.val, res.err)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (job *Future[T]) Get() (T, error) {
|
||||||
|
res := <-job.resCh
|
||||||
|
|
||||||
|
return res.val, res.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type Group[T any] struct {
|
||||||
|
futures []*Future[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGroup[T any]() *Group[T] {
|
||||||
|
return &Group[T]{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (group *Group[T]) Add(fn func() (T, error)) {
|
||||||
|
group.futures = append(group.futures, NewFuture(fn))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (group *Group[T]) Result() ([]T, error) {
|
||||||
|
var out []T
|
||||||
|
|
||||||
|
for _, future := range group.futures {
|
||||||
|
res, err := future.Get()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (group *Group[T]) ForEach(fn func(T) error) error {
|
||||||
|
for _, future := range group.futures {
|
||||||
|
res, err := future.Get()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fn(res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
48
future_test.go
Normal file
48
future_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFuture(t *testing.T) {
|
||||||
|
resCh := make(chan int)
|
||||||
|
|
||||||
|
NewFuture(func() (int, error) {
|
||||||
|
return 42, nil
|
||||||
|
}).Then(func(res int, err error) {
|
||||||
|
resCh <- res
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Equal(t, 42, <-resCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroup(t *testing.T) {
|
||||||
|
group := NewGroup[int]()
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
i := i
|
||||||
|
|
||||||
|
group.Add(func() (int, error) {
|
||||||
|
// Sleep a random amount of time so that results are returned in a random order.
|
||||||
|
time.Sleep(time.Duration(rand.Int()%10) * time.Millisecond) //nolint:gosec
|
||||||
|
|
||||||
|
// Return the job index [0, 10].
|
||||||
|
return i, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resCh := make(chan int)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
require.Equal(t, group.ForEach(func(res int) error { resCh <- res; return nil }), nil)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Results should be returned in the original order.
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
require.Equal(t, i, <-resCh)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
go.mod
Normal file
60
go.mod
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
module github.com/ProtonMail/go-proton-api
|
||||||
|
|
||||||
|
go 1.18
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Masterminds/semver/v3 v3.1.1
|
||||||
|
github.com/ProtonMail/gluon v0.13.1-0.20221025093924-86bbf0261eb8
|
||||||
|
github.com/ProtonMail/go-crypto v0.0.0-20220824120805-4b6e5c587895
|
||||||
|
github.com/ProtonMail/go-srp v0.0.5
|
||||||
|
github.com/ProtonMail/gopenpgp/v2 v2.4.10
|
||||||
|
github.com/bradenaw/juniper v0.8.0
|
||||||
|
github.com/emersion/go-message v0.16.0
|
||||||
|
github.com/emersion/go-vcard v0.0.0-20220507122617-d4056df0ec4a
|
||||||
|
github.com/gin-gonic/gin v1.8.1
|
||||||
|
github.com/go-resty/resty/v2 v2.7.0
|
||||||
|
github.com/google/go-cmp v0.5.8
|
||||||
|
github.com/google/uuid v1.3.0
|
||||||
|
github.com/stretchr/testify v1.8.0
|
||||||
|
github.com/urfave/cli/v2 v2.20.3
|
||||||
|
go.uber.org/goleak v1.1.12
|
||||||
|
golang.org/x/exp v0.0.0-20220827204233-334a2380cb91
|
||||||
|
google.golang.org/grpc v1.50.1
|
||||||
|
google.golang.org/protobuf v1.28.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
|
||||||
|
github.com/ProtonMail/go-mime v0.0.0-20220429130430-2192574d760f // indirect
|
||||||
|
github.com/cloudflare/circl v1.2.0 // indirect
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||||
|
github.com/cronokirby/saferith v0.33.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.0 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.10.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.9.7 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.2 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/leodido/go-urn v1.2.1 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.1 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.7 // indirect
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||||
|
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 // indirect
|
||||||
|
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b // indirect
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
|
||||||
|
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 // indirect
|
||||||
|
golang.org/x/text v0.3.7 // indirect
|
||||||
|
golang.org/x/tools v0.1.13-0.20220804200503-81c7dc4e4efa // indirect
|
||||||
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
258
go.sum
Normal file
258
go.sum
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
|
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
|
||||||
|
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||||
|
github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I=
|
||||||
|
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
|
||||||
|
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
|
||||||
|
github.com/ProtonMail/gluon v0.13.1-0.20221025093924-86bbf0261eb8 h1:LKyiQdEsAxAocSYUWxSfwlxBwmzJYvO/9td/eAX3oFU=
|
||||||
|
github.com/ProtonMail/gluon v0.13.1-0.20221025093924-86bbf0261eb8/go.mod h1:XW/gcr4jErc5bX5yMqkUq3U+AucC2QZHJ5L231k3Nw4=
|
||||||
|
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
|
||||||
|
github.com/ProtonMail/go-crypto v0.0.0-20220822140716-1678d6eb0cbe/go.mod h1:UBYPn8k0D56RtnR8RFQMjmh4KrZzWJ5o7Z9SYjossQ8=
|
||||||
|
github.com/ProtonMail/go-crypto v0.0.0-20220824120805-4b6e5c587895 h1:NsReiLpErIPzRrnogAXYwSoU7txA977LjDGrbkewJbg=
|
||||||
|
github.com/ProtonMail/go-crypto v0.0.0-20220824120805-4b6e5c587895/go.mod h1:UBYPn8k0D56RtnR8RFQMjmh4KrZzWJ5o7Z9SYjossQ8=
|
||||||
|
github.com/ProtonMail/go-mime v0.0.0-20220302105931-303f85f7fe0f/go.mod h1:NYt+V3/4rEeDuaev/zw1zCq8uqVEuPHzDPo3OZrlGJ4=
|
||||||
|
github.com/ProtonMail/go-mime v0.0.0-20220429130430-2192574d760f h1:4IWzKjHzZxdrW9k4zl/qCwenOVHDbVDADPPHFLjs0Oc=
|
||||||
|
github.com/ProtonMail/go-mime v0.0.0-20220429130430-2192574d760f/go.mod h1:qRZgbeASl2a9OwmsV85aWwRqic0NHPh+9ewGAzb4cgM=
|
||||||
|
github.com/ProtonMail/go-srp v0.0.5 h1:xhUioxZgDbCnpo9JehyFhwwsn9JLWkUGfB0oiKXgiGg=
|
||||||
|
github.com/ProtonMail/go-srp v0.0.5/go.mod h1:06iYHtLXW8vjLtccWj++x3MKy65sIT8yZd7nrJF49rs=
|
||||||
|
github.com/ProtonMail/gopenpgp/v2 v2.4.10 h1:EYgkxzwmQvsa6kxxkgP1AwzkFqKHscF2UINxaSn6rdI=
|
||||||
|
github.com/ProtonMail/gopenpgp/v2 v2.4.10/go.mod h1:CTRA7/toc/4DxDy5Du4hPDnIZnJvXSeQ8LsRTOUJoyc=
|
||||||
|
github.com/bradenaw/juniper v0.8.0 h1:sdanLNdJbLjcLj993VYIwUHlUVkLzvgiD/x9O7cvvxk=
|
||||||
|
github.com/bradenaw/juniper v0.8.0/go.mod h1:Z2B7aJlQ7xbfWsnMLROj5t/5FQ94/MkIdKC30J4WvzI=
|
||||||
|
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
||||||
|
github.com/bwesterb/go-ristretto v1.2.1/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
||||||
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
|
||||||
|
github.com/cloudflare/circl v1.2.0 h1:NheeISPSUcYftKlfrLuOo4T62FkmD4t4jviLfFFYaec=
|
||||||
|
github.com/cloudflare/circl v1.2.0/go.mod h1:Ch2UgYr6ti2KTtlejELlROl0YIYj7SLjAC8M+INXlMk=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo=
|
||||||
|
github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/emersion/go-message v0.16.0 h1:uZLz8ClLv3V5fSFF/fFdW9jXjrZkXIpE1Fn8fKx7pO4=
|
||||||
|
github.com/emersion/go-message v0.16.0/go.mod h1:pDJDgf/xeUIF+eicT6B/hPX/ZbEorKkUMPOxrPVG2eQ=
|
||||||
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
|
||||||
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||||
|
github.com/emersion/go-vcard v0.0.0-20220507122617-d4056df0ec4a h1:cltZpe6s0SJtqK5c/5y2VrIYi8BAtDM6qjmiGYqfTik=
|
||||||
|
github.com/emersion/go-vcard v0.0.0-20220507122617-d4056df0ec4a/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8=
|
||||||
|
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
|
||||||
|
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||||
|
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
|
||||||
|
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||||
|
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
|
||||||
|
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
|
||||||
|
github.com/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0=
|
||||||
|
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
|
||||||
|
github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY=
|
||||||
|
github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I=
|
||||||
|
github.com/goccy/go-json v0.9.7 h1:IcB+Aqpx/iMHu5Yooh7jEzJk1JZ7Pjtmys2ukPr7EeM=
|
||||||
|
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||||
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||||
|
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||||
|
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||||
|
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.1 h1:8e3L2cCQzLFi2CR4g7vGFuFxX7Jl1kKX8gW+iV0GUKU=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
|
||||||
|
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
|
||||||
|
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
|
||||||
|
github.com/urfave/cli/v2 v2.20.3 h1:lOgGidH/N5loaigd9HjFsOIhXSTrzl7tBpHswZ428w4=
|
||||||
|
github.com/urfave/cli/v2 v2.20.3/go.mod h1:1CNUng3PtjQMtRzJO4FMXBQvkGtuYRxxiR9xMa7jMwI=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||||
|
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
|
go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
|
||||||
|
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||||
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/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.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM=
|
||||||
|
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
|
||||||
|
golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 h1:tnebWN09GYg9OLPss1KXj8txwZc6X6uMr6VFdcGNbHw=
|
||||||
|
golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
|
||||||
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||||
|
golang.org/x/mobile v0.0.0-20200801112145-973feb4309de/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
|
||||||
|
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
|
golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY=
|
||||||
|
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/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-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220315194320-039c03cc5b86/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 h1:v6hYoSR9T5oet+pMXwUWkbiVqx/63mlHjefrHmxwfeY=
|
||||||
|
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
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.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
|
golang.org/x/tools v0.1.13-0.20220804200503-81c7dc4e4efa h1:uKcci2q7Qtp6nMTC/AAvfNUAldFtJuHWV9/5QWiypts=
|
||||||
|
golang.org/x/tools v0.1.13-0.20220804200503-81c7dc4e4efa/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 h1:+kGHl1aib/qcwaRi1CbqBZ1rk19r85MNUf8HaBghugY=
|
||||||
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.50.1 h1:DS/BukOZWp8s6p4Dt/tOaJaTQyPyOoCcrjroHuCeLzY=
|
||||||
|
google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
|
||||||
|
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
47
header_types.go
Normal file
47
header_types.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrBadHeader = errors.New("bad header")
|
||||||
|
|
||||||
|
type Headers map[string][]string
|
||||||
|
|
||||||
|
func (h *Headers) UnmarshalJSON(b []byte) error {
|
||||||
|
type rawHeaders map[string]any
|
||||||
|
|
||||||
|
raw := make(rawHeaders)
|
||||||
|
|
||||||
|
if err := json.Unmarshal(b, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
header := make(Headers)
|
||||||
|
|
||||||
|
for key, val := range raw {
|
||||||
|
switch val := val.(type) {
|
||||||
|
case string:
|
||||||
|
header[key] = []string{val}
|
||||||
|
|
||||||
|
case []any:
|
||||||
|
for _, val := range val {
|
||||||
|
switch val := val.(type) {
|
||||||
|
case string:
|
||||||
|
header[key] = append(header[key], val)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ErrBadHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ErrBadHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*h = header
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
54
helper_test.go
Normal file
54
helper_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package proton_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/bradenaw/juniper/iterator"
|
||||||
|
"github.com/bradenaw/juniper/stream"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createTestMessages(t *testing.T, c *proton.Client, pass string, count int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
user, err := c.GetUser(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
addr, err := c.GetAddresses(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
salt, err := c.GetSalts(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
keyPass, err := salt.SaltForKey([]byte(pass), user.Keys.Primary().ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, addrKRs, err := proton.Unlock(user, addr, keyPass)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req := iterator.Collect(iterator.Map(iterator.Counter(count), func(i int) proton.ImportReq {
|
||||||
|
return proton.ImportReq{
|
||||||
|
Metadata: proton.ImportMetadata{
|
||||||
|
AddressID: addr[0].ID,
|
||||||
|
Flags: proton.MessageFlagReceived,
|
||||||
|
Unread: true,
|
||||||
|
},
|
||||||
|
Message: []byte(fmt.Sprintf("From: sender@pm.me\r\nReceiver: recipient@pm.me\r\nSubject: %v\r\n\r\nHello World!", uuid.New())),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
res, err := stream.Collect(ctx, c.ImportMessages(ctx, addrKRs[addr[0].ID], runtime.NumCPU(), runtime.NumCPU(), req...))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, res := range res {
|
||||||
|
require.Equal(t, proton.SuccessCode, res.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
job.go
Normal file
41
job.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type job[In, Out any] struct {
|
||||||
|
ctx context.Context
|
||||||
|
req In
|
||||||
|
|
||||||
|
res chan Out
|
||||||
|
err chan error
|
||||||
|
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newJob[In, Out any](ctx context.Context, req In) *job[In, Out] {
|
||||||
|
return &job[In, Out]{
|
||||||
|
ctx: ctx,
|
||||||
|
req: req,
|
||||||
|
res: make(chan Out),
|
||||||
|
err: make(chan error),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (job *job[In, Out]) result() (Out, error) {
|
||||||
|
return <-job.res, <-job.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (job *job[In, Out]) postSuccess(res Out) {
|
||||||
|
close(job.err)
|
||||||
|
job.res <- res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (job *job[In, Out]) postFailure(err error) {
|
||||||
|
close(job.res)
|
||||||
|
job.err <- err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (job *job[In, Out]) waitDone() {
|
||||||
|
<-job.done
|
||||||
|
}
|
||||||
318
keyring.go
Normal file
318
keyring.go
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp"
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/bradenaw/juniper/xslices"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExtractSignatures(kr *crypto.KeyRing, arm string) ([]Signature, error) {
|
||||||
|
entities := xslices.Map(kr.GetKeys(), func(key *crypto.Key) *openpgp.Entity {
|
||||||
|
return key.GetEntity()
|
||||||
|
})
|
||||||
|
|
||||||
|
p, err := armor.Decode(strings.NewReader(arm))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := openpgp.ReadMessage(p.Body, openpgp.EntityList(entities), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.ReadAll(msg.UnverifiedBody); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !msg.IsSigned {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var signatures []Signature
|
||||||
|
|
||||||
|
for _, signature := range msg.UnverifiedSignatures {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
|
||||||
|
if err := signature.Serialize(buf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
signatures = append(signatures, Signature{
|
||||||
|
Hash: signature.Hash.String(),
|
||||||
|
Data: crypto.NewPGPSignature(buf.Bytes()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return signatures, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Key struct {
|
||||||
|
ID string
|
||||||
|
PrivateKey []byte
|
||||||
|
Token string
|
||||||
|
Signature string
|
||||||
|
Primary Bool
|
||||||
|
Active Bool
|
||||||
|
Flags KeyState
|
||||||
|
}
|
||||||
|
|
||||||
|
func (key *Key) UnmarshalJSON(data []byte) error {
|
||||||
|
type Alias Key
|
||||||
|
|
||||||
|
aux := &struct {
|
||||||
|
PrivateKey string
|
||||||
|
|
||||||
|
*Alias
|
||||||
|
}{
|
||||||
|
Alias: (*Alias)(key),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &aux); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
privKey, err := crypto.NewKeyFromArmored(aux.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := privKey.Serialize()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
key.PrivateKey = raw
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (key Key) MarshalJSON() ([]byte, error) {
|
||||||
|
privKey, err := crypto.NewKey(key.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
arm, err := privKey.Armor()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type Alias Key
|
||||||
|
|
||||||
|
aux := &struct {
|
||||||
|
PrivateKey string
|
||||||
|
|
||||||
|
*Alias
|
||||||
|
}{
|
||||||
|
PrivateKey: arm,
|
||||||
|
Alias: (*Alias)(&key),
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(aux)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Keys []Key
|
||||||
|
|
||||||
|
func (keys Keys) Primary() Key {
|
||||||
|
for _, key := range keys {
|
||||||
|
if key.Primary {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("no primary key available")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (keys Keys) ByID(keyID string) Key {
|
||||||
|
for _, key := range keys {
|
||||||
|
if key.ID == keyID {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("no primary key available")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (keys Keys) Unlock(passphrase []byte, userKR *crypto.KeyRing) (*crypto.KeyRing, error) {
|
||||||
|
kr, err := crypto.NewKeyRing(nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range xslices.Filter(keys, func(key Key) bool { return bool(key.Active) }) {
|
||||||
|
unlocked, err := key.Unlock(passphrase, userKR)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := kr.AddKey(unlocked); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return kr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (keys Keys) TryUnlock(passphrase []byte, userKR *crypto.KeyRing) *crypto.KeyRing {
|
||||||
|
kr, err := keys.Unlock(passphrase, userKR)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return kr
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicKey struct {
|
||||||
|
Flags KeyState
|
||||||
|
PublicKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicKeys []PublicKey
|
||||||
|
|
||||||
|
func (keys PublicKeys) GetKeyRing() (*crypto.KeyRing, error) {
|
||||||
|
kr, err := crypto.NewKeyRing(nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
pubKey, err := crypto.NewKeyFromArmored(key.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := kr.AddKey(pubKey); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return kr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyList struct {
|
||||||
|
Data string
|
||||||
|
Signature string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKeyList(signer *crypto.KeyRing, entries []KeyListEntry) (KeyList, error) {
|
||||||
|
data, err := json.Marshal(entries)
|
||||||
|
if err != nil {
|
||||||
|
return KeyList{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := signer.SignDetached(crypto.NewPlainMessage(data))
|
||||||
|
if err != nil {
|
||||||
|
return KeyList{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
arm, err := sig.GetArmored()
|
||||||
|
if err != nil {
|
||||||
|
return KeyList{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return KeyList{
|
||||||
|
Data: string(data),
|
||||||
|
Signature: arm,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyListEntry struct {
|
||||||
|
Fingerprint string
|
||||||
|
SHA256Fingerprints []string
|
||||||
|
Flags KeyState
|
||||||
|
Primary Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
KeyStateTrusted KeyState = 1 << iota // 2^0 = 1 means the key is not compromised (i.e. if we can trust signatures coming from it)
|
||||||
|
KeyStateActive // 2^1 = 2 means the key is still in use (i.e. not obsolete, we can encrypt messages to it)
|
||||||
|
)
|
||||||
|
|
||||||
|
func (key Key) Unlock(passphrase []byte, userKR *crypto.KeyRing) (*crypto.Key, error) {
|
||||||
|
var secret []byte
|
||||||
|
|
||||||
|
if key.Token == "" || key.Signature == "" {
|
||||||
|
secret = passphrase
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if secret, err = key.getPassphraseFromToken(userKR); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return key.unlock(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (key Key) getPassphraseFromToken(kr *crypto.KeyRing) ([]byte, error) {
|
||||||
|
if kr == nil {
|
||||||
|
return nil, errors.New("no user key was provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := crypto.NewPGPMessageFromArmored(key.Token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := crypto.NewPGPSignatureFromArmored(key.Signature)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := kr.Decrypt(msg, nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = kr.VerifyDetached(token, sig, 0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return token.GetBinary(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (key Key) unlock(passphrase []byte) (*crypto.Key, error) {
|
||||||
|
lk, err := crypto.NewKey(key.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer lk.ClearPrivateParams()
|
||||||
|
|
||||||
|
uk, err := lk.Unlock(passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := uk.Check()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !ok {
|
||||||
|
return nil, errors.New("private and public keys do not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
return uk, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeKeyPacket(packet string) []byte {
|
||||||
|
if packet == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(packet)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
62
keys.go
Normal file
62
keys.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) GetPublicKeys(ctx context.Context, address string) (PublicKeys, RecipientType, error) {
|
||||||
|
var res struct {
|
||||||
|
Keys []PublicKey
|
||||||
|
RecipientType RecipientType
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).SetQueryParam("Email", address).Get("/core/v4/keys")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, RecipientTypeExternal, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Keys, res.RecipientType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateAddressKey(ctx context.Context, req CreateAddressKeyReq) (Key, error) {
|
||||||
|
var res struct {
|
||||||
|
Key Key
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).Post("/core/v4/keys/address")
|
||||||
|
}); err != nil {
|
||||||
|
return Key{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateLegacyAddressKey(ctx context.Context, req CreateAddressKeyReq) (Key, error) {
|
||||||
|
var res struct {
|
||||||
|
Key Key
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).Post("/core/v4/keys")
|
||||||
|
}); err != nil {
|
||||||
|
return Key{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) MakeAddressKeyPrimary(ctx context.Context, keyID string, keyList KeyList) error {
|
||||||
|
return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(struct{ SignedKeyList KeyList }{SignedKeyList: keyList}).Put("/core/v4/keys/" + keyID + "/primary")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteAddressKey(ctx context.Context, keyID string, keyList KeyList) error {
|
||||||
|
return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(struct{ SignedKeyList KeyList }{SignedKeyList: keyList}).Put("/core/v4/keys/" + keyID + "/delete")
|
||||||
|
})
|
||||||
|
}
|
||||||
16
keys_types.go
Normal file
16
keys_types.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
type CreateAddressKeyReq struct {
|
||||||
|
AddressID string
|
||||||
|
PrivateKey string
|
||||||
|
Primary Bool
|
||||||
|
SignedKeyList KeyList
|
||||||
|
|
||||||
|
// The following are only used in "migrated accounts"
|
||||||
|
Token string `json:",omitempty"`
|
||||||
|
Signature string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MakeAddressKeyPrimaryReq struct {
|
||||||
|
SignedKeyList KeyList
|
||||||
|
}
|
||||||
82
label.go
Normal file
82
label.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNoSuchLabel = errors.New("no such label")
|
||||||
|
|
||||||
|
func (c *Client) GetLabel(ctx context.Context, labelID string, labelTypes ...LabelType) (Label, error) {
|
||||||
|
labels, err := c.GetLabels(ctx, labelTypes...)
|
||||||
|
if err != nil {
|
||||||
|
return Label{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, label := range labels {
|
||||||
|
if label.ID == labelID {
|
||||||
|
return label, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Label{}, ErrNoSuchLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetLabels(ctx context.Context, labelTypes ...LabelType) ([]Label, error) {
|
||||||
|
var labels []Label
|
||||||
|
|
||||||
|
for _, labelType := range labelTypes {
|
||||||
|
labelType := labelType
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Labels []Label
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetQueryParam("Type", strconv.Itoa(int(labelType))).SetResult(&res).Get("/core/v4/labels")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
labels = append(labels, res.Labels...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateLabel(ctx context.Context, req CreateLabelReq) (Label, error) {
|
||||||
|
var res struct {
|
||||||
|
Label Label
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).Post("/core/v4/labels")
|
||||||
|
}); err != nil {
|
||||||
|
return Label{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Label, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteLabel(ctx context.Context, labelID string) error {
|
||||||
|
return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.Delete("/core/v4/labels/" + labelID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdateLabel(ctx context.Context, labelID string, req UpdateLabelReq) (Label, error) {
|
||||||
|
var res struct {
|
||||||
|
Label Label
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).Put("/core/v4/labels/" + labelID)
|
||||||
|
}); err != nil {
|
||||||
|
return Label{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Label, nil
|
||||||
|
}
|
||||||
87
label_types.go
Normal file
87
label_types.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
InboxLabel = "0"
|
||||||
|
AllDraftsLabel = "1"
|
||||||
|
AllSentLabel = "2"
|
||||||
|
TrashLabel = "3"
|
||||||
|
SpamLabel = "4"
|
||||||
|
AllMailLabel = "5"
|
||||||
|
ArchiveLabel = "6"
|
||||||
|
SentLabel = "7"
|
||||||
|
DraftsLabel = "8"
|
||||||
|
OutboxLabel = "9"
|
||||||
|
StarredLabel = "10"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Label struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Path []string
|
||||||
|
Color string
|
||||||
|
Type LabelType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (label *Label) UnmarshalJSON(data []byte) error {
|
||||||
|
type Alias Label
|
||||||
|
|
||||||
|
aux := &struct {
|
||||||
|
Path string
|
||||||
|
|
||||||
|
*Alias
|
||||||
|
}{
|
||||||
|
Alias: (*Alias)(label),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &aux); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
label.Path = strings.Split(aux.Path, "/")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (label Label) MarshalJSON() ([]byte, error) {
|
||||||
|
type Alias Label
|
||||||
|
|
||||||
|
aux := &struct {
|
||||||
|
Path string
|
||||||
|
|
||||||
|
*Alias
|
||||||
|
}{
|
||||||
|
Path: strings.Join(label.Path, "/"),
|
||||||
|
Alias: (*Alias)(&label),
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(aux)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateLabelReq struct {
|
||||||
|
Name string
|
||||||
|
Color string
|
||||||
|
Type LabelType
|
||||||
|
|
||||||
|
ParentID string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateLabelReq struct {
|
||||||
|
Name string
|
||||||
|
Color string
|
||||||
|
|
||||||
|
ParentID string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LabelType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LabelTypeLabel LabelType = iota + 1
|
||||||
|
LabelTypeContactGroup
|
||||||
|
LabelTypeFolder
|
||||||
|
LabelTypeSystem
|
||||||
|
)
|
||||||
108
link.go
Normal file
108
link.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) GetLink(ctx context.Context, shareID, linkID string) (Link, error) {
|
||||||
|
var res struct {
|
||||||
|
Link Link
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/drive/shares/" + shareID + "/links/" + linkID)
|
||||||
|
}); err != nil {
|
||||||
|
return Link{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Link, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListChildren(ctx context.Context, shareID, linkID string) ([]Link, error) {
|
||||||
|
var res struct {
|
||||||
|
Links []Link
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/drive/shares/" + shareID + "/folders/" + linkID + "/children")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Links, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListRevisions(ctx context.Context, shareID, linkID string) ([]Revision, error) {
|
||||||
|
var res struct {
|
||||||
|
Revisions []Revision
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/drive/shares/" + shareID + "/files/" + linkID + "/revisions")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Revisions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetRevision(ctx context.Context, shareID, linkID, revisionID string) (Revision, error) {
|
||||||
|
var res struct {
|
||||||
|
Revision Revision
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/drive/shares/" + shareID + "/files/" + linkID + "/revisions/" + revisionID)
|
||||||
|
}); err != nil {
|
||||||
|
return Revision{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Revision, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) VisitLink(ctx context.Context, shareID string, link Link, kr *crypto.KeyRing, fn LinkWalkFunc) error {
|
||||||
|
return c.visitLink(ctx, shareID, link, kr, fn, []string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) visitLink(ctx context.Context, shareID string, link Link, kr *crypto.KeyRing, fn LinkWalkFunc, path []string) error {
|
||||||
|
enc, err := crypto.NewPGPMessageFromArmored(link.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec, err := kr.Decrypt(enc, nil, crypto.GetUnixTime())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
path = append(path, dec.GetString())
|
||||||
|
|
||||||
|
childKR, err := link.GetKeyRing(kr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fn(path, link, childKR); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if link.Type != FolderLinkType {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
children, err := c.ListChildren(ctx, shareID, link.LinkID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, child := range children {
|
||||||
|
if err := c.visitLink(ctx, shareID, child, childKR, fn, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
5
link_types.go
Normal file
5
link_types.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import "github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
|
||||||
|
type LinkWalkFunc func([]string, Link, *crypto.KeyRing) error
|
||||||
105
mail_settings.go
Normal file
105
mail_settings.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) GetMailSettings(ctx context.Context) (MailSettings, error) {
|
||||||
|
var res struct {
|
||||||
|
MailSettings MailSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/mail/v4/settings")
|
||||||
|
}); err != nil {
|
||||||
|
return MailSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.MailSettings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetDisplayName(ctx context.Context, req SetDisplayNameReq) (MailSettings, error) {
|
||||||
|
var res struct {
|
||||||
|
MailSettings MailSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).Put("/mail/v4/settings/display")
|
||||||
|
}); err != nil {
|
||||||
|
return MailSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.MailSettings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetSignature(ctx context.Context, req SetSignatureReq) (MailSettings, error) {
|
||||||
|
var res struct {
|
||||||
|
MailSettings MailSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).Put("/mail/v4/settings/signature")
|
||||||
|
}); err != nil {
|
||||||
|
return MailSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.MailSettings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetDraftMIMEType(ctx context.Context, req SetDraftMIMETypeReq) (MailSettings, error) {
|
||||||
|
var res struct {
|
||||||
|
MailSettings MailSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).Put("/mail/v4/settings/drafttype")
|
||||||
|
}); err != nil {
|
||||||
|
return MailSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.MailSettings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetAttachPublicKey(ctx context.Context, req SetAttachPublicKeyReq) (MailSettings, error) {
|
||||||
|
var res struct {
|
||||||
|
MailSettings MailSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).Put("/mail/v4/settings/attachpublic")
|
||||||
|
}); err != nil {
|
||||||
|
return MailSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.MailSettings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetSignExternalMessages(ctx context.Context, req SetSignExternalMessagesReq) (MailSettings, error) {
|
||||||
|
var res struct {
|
||||||
|
MailSettings MailSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).Put("/mail/v4/settings/sign")
|
||||||
|
}); err != nil {
|
||||||
|
return MailSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.MailSettings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetDefaultPGPScheme(ctx context.Context, req SetDefaultPGPSchemeReq) (MailSettings, error) {
|
||||||
|
var res struct {
|
||||||
|
MailSettings MailSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).Put("/mail/v4/settings/pgpscheme")
|
||||||
|
}); err != nil {
|
||||||
|
return MailSettings{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.MailSettings, nil
|
||||||
|
}
|
||||||
50
mail_settings_types.go
Normal file
50
mail_settings_types.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import "github.com/ProtonMail/gluon/rfc822"
|
||||||
|
|
||||||
|
type MailSettings struct {
|
||||||
|
DisplayName string
|
||||||
|
Signature string
|
||||||
|
DraftMIMEType rfc822.MIMEType
|
||||||
|
AttachPublicKey AttachPublicKey
|
||||||
|
Sign SignExternalMessages
|
||||||
|
PGPScheme EncryptionScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttachPublicKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AttachPublicKeyDisabled AttachPublicKey = iota
|
||||||
|
AttachPublicKeyEnabled
|
||||||
|
)
|
||||||
|
|
||||||
|
type SignExternalMessages int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SignExternalMessagesDisabled SignExternalMessages = iota
|
||||||
|
SignExternalMessagesEnabled
|
||||||
|
)
|
||||||
|
|
||||||
|
type SetDisplayNameReq struct {
|
||||||
|
DisplayName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetSignatureReq struct {
|
||||||
|
Signature string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetDraftMIMETypeReq struct {
|
||||||
|
MIMEType rfc822.MIMEType
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetAttachPublicKeyReq struct {
|
||||||
|
AttachPublicKey AttachPublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetSignExternalMessagesReq struct {
|
||||||
|
Sign SignExternalMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetDefaultPGPSchemeReq struct {
|
||||||
|
PGPScheme EncryptionScheme
|
||||||
|
}
|
||||||
11
main_test.go
Normal file
11
main_test.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/goleak"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
goleak.VerifyTestMain(m, goleak.IgnoreCurrent())
|
||||||
|
}
|
||||||
125
manager.go
Normal file
125
manager.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
rc *resty.Client
|
||||||
|
|
||||||
|
status Status
|
||||||
|
observers []StatusObserver
|
||||||
|
statusLock sync.Mutex
|
||||||
|
|
||||||
|
errHandlers map[Code][]Handler
|
||||||
|
|
||||||
|
attPoolSize int
|
||||||
|
|
||||||
|
verifyProofs bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(opts ...Option) *Manager {
|
||||||
|
builder := newManagerBuilder()
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt.config(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) AddStatusObserver(observer StatusObserver) {
|
||||||
|
m.statusLock.Lock()
|
||||||
|
defer m.statusLock.Unlock()
|
||||||
|
|
||||||
|
m.observers = append(m.observers, observer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) AddPreRequestHook(hook resty.RequestMiddleware) {
|
||||||
|
m.rc.OnBeforeRequest(hook)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) AddPostRequestHook(hook resty.ResponseMiddleware) {
|
||||||
|
m.rc.OnAfterResponse(hook)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) AddErrorHandler(code Code, handler Handler) {
|
||||||
|
m.errHandlers[code] = append(m.errHandlers[code], handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Close() {
|
||||||
|
m.rc.GetClient().CloseIdleConnections()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) r(ctx context.Context) *resty.Request {
|
||||||
|
return m.rc.R().SetContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) handleError(req *resty.Request, err error) {
|
||||||
|
resErr, ok := err.(*resty.ResponseError)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiErr, ok := resErr.Response.Error().(*Error)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, handler := range m.errHandlers[apiErr.Code] {
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) checkConnUp(_ *resty.Client, res *resty.Response) error {
|
||||||
|
m.onConnUp()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) checkConnDown(req *resty.Request, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if res, ok := err.(*resty.ResponseError); ok && res.Response.RawResponse != nil {
|
||||||
|
m.onConnUp()
|
||||||
|
} else {
|
||||||
|
m.onConnDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) onConnDown() {
|
||||||
|
m.statusLock.Lock()
|
||||||
|
defer m.statusLock.Unlock()
|
||||||
|
|
||||||
|
if m.status == StatusDown {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.status = StatusDown
|
||||||
|
|
||||||
|
for _, observer := range m.observers {
|
||||||
|
observer(m.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) onConnUp() {
|
||||||
|
m.statusLock.Lock()
|
||||||
|
defer m.statusLock.Unlock()
|
||||||
|
|
||||||
|
if m.status == StatusUp {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.status = StatusUp
|
||||||
|
|
||||||
|
for _, observer := range m.observers {
|
||||||
|
observer(m.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
123
manager_auth.go
Normal file
123
manager_auth.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-srp"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidProof = errors.New("unexpected server proof")
|
||||||
|
|
||||||
|
func (m *Manager) NewClient(uid, acc, ref string, exp time.Time) *Client {
|
||||||
|
return newClient(m, uid).withAuth(acc, ref, exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) NewClientWithRefresh(ctx context.Context, uid, ref string) (*Client, Auth, error) {
|
||||||
|
c := newClient(m, uid)
|
||||||
|
|
||||||
|
auth, err := m.authRefresh(ctx, uid, ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil, Auth{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.withAuth(auth.AccessToken, auth.RefreshToken, expiresIn(auth.ExpiresIn)), auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) NewClientWithLogin(ctx context.Context, username string, password []byte) (*Client, Auth, error) {
|
||||||
|
info, err := m.getAuthInfo(ctx, AuthInfoReq{Username: username})
|
||||||
|
if err != nil {
|
||||||
|
return nil, Auth{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
srpAuth, err := srp.NewAuth(info.Version, username, password, info.Salt, info.Modulus, info.ServerEphemeral)
|
||||||
|
if err != nil {
|
||||||
|
return nil, Auth{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
proofs, err := srpAuth.GenerateProofs(2048)
|
||||||
|
if err != nil {
|
||||||
|
return nil, Auth{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err := m.auth(ctx, AuthReq{
|
||||||
|
Username: username,
|
||||||
|
ClientProof: base64.StdEncoding.EncodeToString(proofs.ClientProof),
|
||||||
|
ClientEphemeral: base64.StdEncoding.EncodeToString(proofs.ClientEphemeral),
|
||||||
|
SRPSession: info.SRPSession,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, Auth{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
serverProof, err := base64.StdEncoding.DecodeString(auth.ServerProof)
|
||||||
|
if err != nil {
|
||||||
|
return nil, Auth{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.verifyProofs {
|
||||||
|
if !bytes.Equal(serverProof, proofs.ExpectedServerProof) {
|
||||||
|
return nil, Auth{}, ErrInvalidProof
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newClient(m, auth.UID).withAuth(auth.AccessToken, auth.RefreshToken, expiresIn(auth.ExpiresIn)), auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) getAuthInfo(ctx context.Context, req AuthInfoReq) (AuthInfo, error) {
|
||||||
|
var res struct {
|
||||||
|
AuthInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := m.r(ctx).SetBody(req).SetResult(&res).Post("/core/v4/auth/info"); err != nil {
|
||||||
|
return AuthInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.AuthInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) auth(ctx context.Context, req AuthReq) (Auth, error) {
|
||||||
|
var res struct {
|
||||||
|
Auth
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := m.r(ctx).SetBody(req).SetResult(&res).Post("/core/v4/auth"); err != nil {
|
||||||
|
return Auth{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) authRefresh(ctx context.Context, uid, ref string) (Auth, error) {
|
||||||
|
state, err := crypto.RandomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return Auth{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req := AuthRefreshReq{
|
||||||
|
UID: uid,
|
||||||
|
RefreshToken: ref,
|
||||||
|
ResponseType: "token",
|
||||||
|
GrantType: "refresh_token",
|
||||||
|
RedirectURI: "https://protonmail.ch",
|
||||||
|
State: string(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Auth
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := m.r(ctx).SetBody(req).SetResult(&res).Post("/core/v4/auth/refresh"); err != nil {
|
||||||
|
return Auth{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func expiresIn(seconds int) time.Time {
|
||||||
|
return time.Now().Add(time.Duration(seconds) * time.Second)
|
||||||
|
}
|
||||||
98
manager_builder.go
Normal file
98
manager_builder.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultHostURL is the default host of the API.
|
||||||
|
DefaultHostURL = "https://mail.proton.me/api"
|
||||||
|
|
||||||
|
// DefaultAppVersion is the default app version used to communicate with the API.
|
||||||
|
// This must be changed (using the WithAppVersion option) for production use.
|
||||||
|
DefaultAppVersion = "go-proton-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type managerBuilder struct {
|
||||||
|
hostURL string
|
||||||
|
appVersion string
|
||||||
|
transport http.RoundTripper
|
||||||
|
attPoolSize int
|
||||||
|
verifyProofs bool
|
||||||
|
cookieJar http.CookieJar
|
||||||
|
retryCount int
|
||||||
|
logger resty.Logger
|
||||||
|
debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newManagerBuilder() *managerBuilder {
|
||||||
|
return &managerBuilder{
|
||||||
|
hostURL: DefaultHostURL,
|
||||||
|
appVersion: DefaultAppVersion,
|
||||||
|
transport: http.DefaultTransport,
|
||||||
|
attPoolSize: runtime.NumCPU(),
|
||||||
|
verifyProofs: true,
|
||||||
|
cookieJar: nil,
|
||||||
|
retryCount: 3,
|
||||||
|
logger: nil,
|
||||||
|
debug: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (builder *managerBuilder) build() *Manager {
|
||||||
|
m := &Manager{
|
||||||
|
rc: resty.New(),
|
||||||
|
|
||||||
|
errHandlers: make(map[Code][]Handler),
|
||||||
|
|
||||||
|
attPoolSize: builder.attPoolSize,
|
||||||
|
|
||||||
|
verifyProofs: builder.verifyProofs,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the API host.
|
||||||
|
m.rc.SetBaseURL(builder.hostURL)
|
||||||
|
|
||||||
|
// Set the transport.
|
||||||
|
m.rc.SetTransport(builder.transport)
|
||||||
|
|
||||||
|
// Set the cookie jar.
|
||||||
|
m.rc.SetCookieJar(builder.cookieJar)
|
||||||
|
|
||||||
|
// Set the logger.
|
||||||
|
if builder.logger != nil {
|
||||||
|
m.rc.SetLogger(builder.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the debug flag.
|
||||||
|
m.rc.SetDebug(builder.debug)
|
||||||
|
|
||||||
|
// Set app version in header.
|
||||||
|
m.rc.OnBeforeRequest(func(_ *resty.Client, req *resty.Request) error {
|
||||||
|
req.SetHeader("x-pm-appversion", builder.appVersion)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set middleware.
|
||||||
|
m.rc.OnAfterResponse(catchAPIError)
|
||||||
|
m.rc.OnAfterResponse(updateTime)
|
||||||
|
m.rc.OnAfterResponse(m.checkConnUp)
|
||||||
|
m.rc.OnError(m.checkConnDown)
|
||||||
|
m.rc.OnError(m.handleError)
|
||||||
|
|
||||||
|
// Configure retry mechanism.
|
||||||
|
m.rc.SetRetryCount(builder.retryCount)
|
||||||
|
m.rc.SetRetryMaxWaitTime(time.Minute)
|
||||||
|
m.rc.AddRetryCondition(catchTooManyRequests)
|
||||||
|
m.rc.AddRetryCondition(catchDialError)
|
||||||
|
m.rc.SetRetryAfter(catchRetryAfter)
|
||||||
|
|
||||||
|
// Set the data type of API errors.
|
||||||
|
m.rc.SetError(&Error{})
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
48
manager_download.go
Normal file
48
manager_download.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Manager) DownloadAndVerify(ctx context.Context, kr *crypto.KeyRing, url, sig string) ([]byte, error) {
|
||||||
|
fb, err := m.fetchFile(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sb, err := m.fetchFile(ctx, sig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := kr.VerifyDetached(
|
||||||
|
crypto.NewPlainMessage(fb),
|
||||||
|
crypto.NewPGPSignature(sb),
|
||||||
|
crypto.GetUnixTime(),
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fetchFile(ctx context.Context, url string) ([]byte, error) {
|
||||||
|
res, err := m.r(ctx).SetDoNotParseResponse(true).Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := io.ReadAll(res.RawBody())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := res.RawBody().Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
15
manager_ping.go
Normal file
15
manager_ping.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func (m *Manager) Ping(ctx context.Context) error {
|
||||||
|
if res, err := m.r(ctx).Get("/tests/ping"); err != nil {
|
||||||
|
if res.RawResponse != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
20
manager_report.go
Normal file
20
manager_report.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Manager) ReportBug(ctx context.Context, req ReportBugReq, atts ...ReportBugAttachment) error {
|
||||||
|
r := m.r(ctx).SetMultipartFormData(req.toFormData())
|
||||||
|
|
||||||
|
for _, att := range atts {
|
||||||
|
r = r.SetMultipartField(att.Name, att.Filename, string(att.MIMEType), bytes.NewReader(att.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := r.Post("/core/v4/reports/bug"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
50
manager_report_test.go
Normal file
50
manager_report_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package proton_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/ProtonMail/go-proton-api/server"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReportBug(t *testing.T) {
|
||||||
|
s := server.New()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(s.GetHostURL()),
|
||||||
|
proton.WithTransport(proton.InsecureTransport()),
|
||||||
|
)
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
var calls []server.Call
|
||||||
|
|
||||||
|
s.AddCallWatcher(func(call server.Call) {
|
||||||
|
calls = append(calls, call)
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, m.ReportBug(context.Background(), proton.ReportBugReq{
|
||||||
|
OS: "linux",
|
||||||
|
OSVersion: "5.4.0-42-generic",
|
||||||
|
Browser: "firefox",
|
||||||
|
ClientType: proton.ClientTypeEmail,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mimeType, mimeParams, err := mime.ParseMediaType(calls[0].RequestHeader.Get("Content-Type"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "multipart/form-data", mimeType)
|
||||||
|
|
||||||
|
form, err := multipart.NewReader(bytes.NewReader(calls[0].RequestBody), mimeParams["boundary"]).ReadForm(0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, form.Value, 4)
|
||||||
|
require.Equal(t, "linux", form.Value["OS"][0])
|
||||||
|
require.Equal(t, "5.4.0-42-generic", form.Value["OSVersion"][0])
|
||||||
|
require.Equal(t, "firefox", form.Value["Browser"][0])
|
||||||
|
require.Equal(t, "1", form.Value["ClientType"][0])
|
||||||
|
}
|
||||||
72
manager_report_types.go
Normal file
72
manager_report_types.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClientType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ClientTypeEmail ClientType = iota + 1
|
||||||
|
ClientTypeVPN
|
||||||
|
ClientTypeCalendar
|
||||||
|
ClientTypeDrive
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReportBugReq struct {
|
||||||
|
OS string
|
||||||
|
OSVersion string
|
||||||
|
|
||||||
|
Browser string
|
||||||
|
BrowserVersion string
|
||||||
|
BrowserExtensions string
|
||||||
|
|
||||||
|
Resolution string
|
||||||
|
DisplayMode string
|
||||||
|
|
||||||
|
Client string
|
||||||
|
ClientVersion string
|
||||||
|
ClientType ClientType
|
||||||
|
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
|
||||||
|
Country string
|
||||||
|
ISP string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req ReportBugReq) toFormData() map[string]string {
|
||||||
|
b, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]any
|
||||||
|
|
||||||
|
if err := json.Unmarshal(b, &raw); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res := make(map[string]string)
|
||||||
|
|
||||||
|
for key := range raw {
|
||||||
|
if val := fmt.Sprint(raw[key]); val != "" {
|
||||||
|
res[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportBugAttachment struct {
|
||||||
|
Name string
|
||||||
|
Filename string
|
||||||
|
MIMEType rfc822.MIMEType
|
||||||
|
Body []byte
|
||||||
|
}
|
||||||
23
manager_status.go
Normal file
23
manager_status.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
type Status int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusUp Status = iota
|
||||||
|
StatusDown
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Status) String() string {
|
||||||
|
switch s {
|
||||||
|
case StatusUp:
|
||||||
|
return "up"
|
||||||
|
|
||||||
|
case StatusDown:
|
||||||
|
return "down"
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusObserver func(Status)
|
||||||
283
manager_status_test.go
Normal file
283
manager_status_test.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package proton_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/ProtonMail/go-proton-api/server"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatus(t *testing.T) {
|
||||||
|
s := server.New()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
netCtl := proton.NewNetCtl()
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(s.GetHostURL()),
|
||||||
|
proton.WithTransport(proton.NewDialer(netCtl, &tls.Config{InsecureSkipVerify: true}).GetRoundTripper()),
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
called int
|
||||||
|
status proton.Status
|
||||||
|
)
|
||||||
|
|
||||||
|
m.AddStatusObserver(func(val proton.Status) {
|
||||||
|
called++
|
||||||
|
status = val
|
||||||
|
})
|
||||||
|
|
||||||
|
// This should succeed.
|
||||||
|
require.NoError(t, m.Ping(context.Background()))
|
||||||
|
|
||||||
|
// Status should not have been called yet.
|
||||||
|
require.Zero(t, called)
|
||||||
|
|
||||||
|
// Now we simulate a network failure.
|
||||||
|
netCtl.Disable()
|
||||||
|
|
||||||
|
// This should fail.
|
||||||
|
require.Error(t, m.Ping(context.Background()))
|
||||||
|
|
||||||
|
// Status should have been called once and status should indicate network is down.
|
||||||
|
require.Equal(t, 1, called)
|
||||||
|
require.Equal(t, proton.StatusDown, status)
|
||||||
|
|
||||||
|
// Now we simulate a network restoration.
|
||||||
|
netCtl.Enable()
|
||||||
|
|
||||||
|
// This should succeed.
|
||||||
|
require.NoError(t, m.Ping(context.Background()))
|
||||||
|
|
||||||
|
// Status should have been called twice and status should indicate network is up.
|
||||||
|
require.Equal(t, 2, called)
|
||||||
|
require.Equal(t, proton.StatusUp, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatus_NoDial(t *testing.T) {
|
||||||
|
s := server.New()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
netCtl := proton.NewNetCtl()
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(s.GetHostURL()),
|
||||||
|
proton.WithTransport(proton.NewDialer(netCtl, &tls.Config{InsecureSkipVerify: true}).GetRoundTripper()),
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
called int
|
||||||
|
status proton.Status
|
||||||
|
)
|
||||||
|
|
||||||
|
m.AddStatusObserver(func(val proton.Status) {
|
||||||
|
called++
|
||||||
|
status = val
|
||||||
|
})
|
||||||
|
|
||||||
|
// Disable dialing.
|
||||||
|
netCtl.SetCanDial(false)
|
||||||
|
|
||||||
|
// This should fail.
|
||||||
|
require.Error(t, m.Ping(context.Background()))
|
||||||
|
|
||||||
|
// Status should have been called once and status should indicate network is down.
|
||||||
|
require.Equal(t, 1, called)
|
||||||
|
require.Equal(t, proton.StatusDown, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatus_NoRead(t *testing.T) {
|
||||||
|
s := server.New()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
netCtl := proton.NewNetCtl()
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(s.GetHostURL()),
|
||||||
|
proton.WithTransport(proton.NewDialer(netCtl, &tls.Config{InsecureSkipVerify: true}).GetRoundTripper()),
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
called int
|
||||||
|
status proton.Status
|
||||||
|
)
|
||||||
|
|
||||||
|
m.AddStatusObserver(func(val proton.Status) {
|
||||||
|
called++
|
||||||
|
status = val
|
||||||
|
})
|
||||||
|
|
||||||
|
// Disable reading.
|
||||||
|
netCtl.SetCanRead(false)
|
||||||
|
|
||||||
|
// This should fail.
|
||||||
|
require.Error(t, m.Ping(context.Background()))
|
||||||
|
|
||||||
|
// Status should have been called once and status should indicate network is down.
|
||||||
|
require.Equal(t, 1, called)
|
||||||
|
require.Equal(t, proton.StatusDown, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatus_NoWrite(t *testing.T) {
|
||||||
|
s := server.New()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
netCtl := proton.NewNetCtl()
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(s.GetHostURL()),
|
||||||
|
proton.WithTransport(proton.NewDialer(netCtl, &tls.Config{InsecureSkipVerify: true}).GetRoundTripper()),
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
called int
|
||||||
|
status proton.Status
|
||||||
|
)
|
||||||
|
|
||||||
|
m.AddStatusObserver(func(val proton.Status) {
|
||||||
|
called++
|
||||||
|
status = val
|
||||||
|
})
|
||||||
|
|
||||||
|
// Disable writing.
|
||||||
|
netCtl.SetCanWrite(false)
|
||||||
|
|
||||||
|
// This should fail.
|
||||||
|
require.Error(t, m.Ping(context.Background()))
|
||||||
|
|
||||||
|
// Status should have been called once and status should indicate network is down.
|
||||||
|
require.Equal(t, 1, called)
|
||||||
|
require.Equal(t, proton.StatusDown, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatus_NoReadExistingConn(t *testing.T) {
|
||||||
|
s := server.New()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
_, _, err := s.CreateUser("user", "user@pm.me", []byte("pass"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
netCtl := proton.NewNetCtl()
|
||||||
|
|
||||||
|
var dialed int
|
||||||
|
|
||||||
|
netCtl.OnDial(func(net.Conn) {
|
||||||
|
dialed++
|
||||||
|
})
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(s.GetHostURL()),
|
||||||
|
proton.WithTransport(proton.NewDialer(netCtl, &tls.Config{InsecureSkipVerify: true}).GetRoundTripper()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// This should succeed.
|
||||||
|
c, _, err := m.NewClientWithLogin(context.Background(), "user", []byte("pass"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// We should have dialed once.
|
||||||
|
require.Equal(t, 1, dialed)
|
||||||
|
|
||||||
|
// Disable reading on the existing connection.
|
||||||
|
netCtl.SetCanRead(false)
|
||||||
|
|
||||||
|
// This should fail because we won't be able to read the response.
|
||||||
|
require.Error(t, getErr(c.GetUser(context.Background())))
|
||||||
|
|
||||||
|
// We should still have dialed once; the connection should have been reused.
|
||||||
|
require.Equal(t, 1, dialed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatus_NoWriteExistingConn(t *testing.T) {
|
||||||
|
s := server.New()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
_, _, err := s.CreateUser("user", "user@pm.me", []byte("pass"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
netCtl := proton.NewNetCtl()
|
||||||
|
|
||||||
|
var dialed int
|
||||||
|
|
||||||
|
netCtl.OnDial(func(net.Conn) {
|
||||||
|
dialed++
|
||||||
|
})
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(s.GetHostURL()),
|
||||||
|
proton.WithTransport(proton.NewDialer(netCtl, &tls.Config{InsecureSkipVerify: true}).GetRoundTripper()),
|
||||||
|
proton.WithRetryCount(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
// This should succeed.
|
||||||
|
c, _, err := m.NewClientWithLogin(context.Background(), "user", []byte("pass"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// We should have dialed once.
|
||||||
|
require.Equal(t, 1, dialed)
|
||||||
|
|
||||||
|
// Disable reading on the existing connection.
|
||||||
|
netCtl.SetCanWrite(false)
|
||||||
|
|
||||||
|
// This should fail because we won't be able to write the request.
|
||||||
|
require.Error(t, c.LabelMessages(context.Background(), []string{"messageID"}, proton.TrashLabel))
|
||||||
|
|
||||||
|
// We should still have dialed twice; the connection could not be reused because the write failed.
|
||||||
|
require.Equal(t, 2, dialed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatus_ContextCancel(t *testing.T) {
|
||||||
|
s := server.New()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
m := proton.New(proton.WithHostURL(s.GetHostURL()))
|
||||||
|
|
||||||
|
var called int
|
||||||
|
|
||||||
|
m.AddStatusObserver(func(val proton.Status) {
|
||||||
|
called++
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a context that will be canceled.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// This should fail because the context is canceled.
|
||||||
|
require.Error(t, m.Ping(ctx))
|
||||||
|
|
||||||
|
// Status should not have been called; this was not a network error.
|
||||||
|
require.Zero(t, called)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatus_ContextTimeout(t *testing.T) {
|
||||||
|
s := server.New()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
m := proton.New(proton.WithHostURL(s.GetHostURL()))
|
||||||
|
|
||||||
|
var called int
|
||||||
|
|
||||||
|
m.AddStatusObserver(func(val proton.Status) {
|
||||||
|
called++
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a context that will time out.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 0)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// This should fail because the context is canceled.
|
||||||
|
require.Error(t, m.Ping(ctx))
|
||||||
|
|
||||||
|
// Status should have been called; this was a network error (took too long).
|
||||||
|
require.NotZero(t, called)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getErr[T any](_ T, err error) error {
|
||||||
|
return err
|
||||||
|
}
|
||||||
314
manager_test.go
Normal file
314
manager_test.go
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
package proton_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/ProtonMail/go-proton-api/server"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConnectionReuse(t *testing.T) {
|
||||||
|
s := server.New()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
netCtl := proton.NewNetCtl()
|
||||||
|
|
||||||
|
var dialed int
|
||||||
|
|
||||||
|
netCtl.OnDial(func(net.Conn) {
|
||||||
|
dialed++
|
||||||
|
})
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(s.GetHostURL()),
|
||||||
|
proton.WithTransport(proton.NewDialer(netCtl, &tls.Config{InsecureSkipVerify: true}).GetRoundTripper()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// This should succeed; the resulting connection should be reused.
|
||||||
|
require.NoError(t, m.Ping(context.Background()))
|
||||||
|
|
||||||
|
// We should have dialed once.
|
||||||
|
require.Equal(t, 1, dialed)
|
||||||
|
|
||||||
|
// This should succeed; we should not re-dial.
|
||||||
|
require.NoError(t, m.Ping(context.Background()))
|
||||||
|
|
||||||
|
// We should not have re-dialed.
|
||||||
|
require.Equal(t, 1, dialed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRefresh(t *testing.T) {
|
||||||
|
s := server.New()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
_, _, err := s.CreateUser("user", "email@pm.me", []byte("pass"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(s.GetHostURL()),
|
||||||
|
proton.WithTransport(proton.InsecureTransport()),
|
||||||
|
)
|
||||||
|
|
||||||
|
c1, auth, err := m.NewClientWithLogin(context.Background(), "user", []byte("pass"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer c1.Close()
|
||||||
|
|
||||||
|
c2, auth, err := m.NewClientWithRefresh(context.Background(), auth.UID, auth.RefreshToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer c2.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTooManyRequests(t *testing.T) {
|
||||||
|
var numCalls int
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
numCalls++
|
||||||
|
|
||||||
|
if numCalls < 5 {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(ts.URL),
|
||||||
|
proton.WithRetryCount(5),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The call should succeed because the 5th retry should succeed (429s are retried).
|
||||||
|
c := m.NewClient("", "", "", time.Now().Add(time.Hour))
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if _, err := c.GetAddresses(context.Background()); err != nil {
|
||||||
|
t.Fatal("got unexpected error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server should be called 5 times.
|
||||||
|
// The first four calls should return 429 and the last call should return 200.
|
||||||
|
if numCalls != 5 {
|
||||||
|
t.Fatal("expected numCalls to be 5, instead got", numCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUnprocessableEntity(t *testing.T) {
|
||||||
|
var numCalls int
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
numCalls++
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(ts.URL),
|
||||||
|
proton.WithRetryCount(5),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The call should fail because the first call should fail (422s are not retried).
|
||||||
|
c := m.NewClient("", "", "", time.Now().Add(time.Hour))
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if _, err := c.GetAddresses(context.Background()); err == nil {
|
||||||
|
t.Fatal("expected error, instead got", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server should be called 1 time.
|
||||||
|
// The first call should return 422.
|
||||||
|
if numCalls != 1 {
|
||||||
|
t.Fatal("expected numCalls to be 1, instead got", numCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDialFailure(t *testing.T) {
|
||||||
|
var numCalls int
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
numCalls++
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(ts.URL),
|
||||||
|
proton.WithRetryCount(5),
|
||||||
|
proton.WithTransport(newFailingRoundTripper(5)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The call should succeed because the last retry should succeed (dial errors are retried).
|
||||||
|
c := m.NewClient("", "", "", time.Now().Add(time.Hour))
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if _, err := c.GetAddresses(context.Background()); err != nil {
|
||||||
|
t.Fatal("got unexpected error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server should be called 1 time.
|
||||||
|
// The first 4 attempts don't reach the server.
|
||||||
|
if numCalls != 1 {
|
||||||
|
t.Fatal("expected numCalls to be 1, instead got", numCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTooManyDialFailures(t *testing.T) {
|
||||||
|
var numCalls int
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
numCalls++
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// The failingRoundTripper will fail the first 10 times it is used.
|
||||||
|
// This is more than the number of retries we permit.
|
||||||
|
// Thus, dials will fail.
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(ts.URL),
|
||||||
|
proton.WithRetryCount(5),
|
||||||
|
proton.WithTransport(newFailingRoundTripper(10)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The call should fail because every dial will fail and we'll run out of retries.
|
||||||
|
c := m.NewClient("", "", "", time.Now().Add(time.Hour))
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if _, err := c.GetAddresses(context.Background()); err == nil {
|
||||||
|
t.Fatal("expected error, instead got", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server should never be called.
|
||||||
|
if numCalls != 0 {
|
||||||
|
t.Fatal("expected numCalls to be 0, instead got", numCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetriesWithContextTimeout(t *testing.T) {
|
||||||
|
var numCalls int
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
numCalls++
|
||||||
|
|
||||||
|
if numCalls < 5 {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(ts.URL),
|
||||||
|
proton.WithRetryCount(5),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Timeout after 1s.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Theoretically, this should succeed; on the fifth retry, we'll get StatusOK.
|
||||||
|
// However, that will take at least >5s, and we only allow 1s in the context.
|
||||||
|
// Thus, it will fail.
|
||||||
|
c := m.NewClient("", "", "", time.Now().Add(time.Hour))
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if _, err := c.GetAddresses(ctx); err == nil {
|
||||||
|
t.Fatal("expected error, instead got", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReturnErrNoConnection(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// We will fail more times than we retry, so requests should fail with ErrNoConnection.
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(ts.URL),
|
||||||
|
proton.WithRetryCount(5),
|
||||||
|
proton.WithTransport(newFailingRoundTripper(10)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The call should fail because every dial will fail and we'll run out of retries.
|
||||||
|
c := m.NewClient("", "", "", time.Now().Add(time.Hour))
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if _, err := c.GetAddresses(context.Background()); err == nil {
|
||||||
|
t.Fatal("expected error, instead got", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusCallbacks(t *testing.T) {
|
||||||
|
s := server.New()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
ctl := proton.NewNetCtl()
|
||||||
|
|
||||||
|
m := proton.New(
|
||||||
|
proton.WithHostURL(s.GetHostURL()),
|
||||||
|
proton.WithTransport(proton.NewDialer(ctl, &tls.Config{InsecureSkipVerify: true}).GetRoundTripper()),
|
||||||
|
)
|
||||||
|
|
||||||
|
statusCh := make(chan proton.Status, 1)
|
||||||
|
|
||||||
|
m.AddStatusObserver(func(status proton.Status) {
|
||||||
|
statusCh <- status
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ctl.Disable()
|
||||||
|
|
||||||
|
require.Error(t, m.Ping(ctx))
|
||||||
|
require.Equal(t, proton.StatusDown, <-statusCh)
|
||||||
|
|
||||||
|
ctl.Enable()
|
||||||
|
|
||||||
|
require.NoError(t, m.Ping(ctx))
|
||||||
|
require.Equal(t, proton.StatusUp, <-statusCh)
|
||||||
|
|
||||||
|
ctl.SetReadLimit(1)
|
||||||
|
|
||||||
|
require.Error(t, m.Ping(ctx))
|
||||||
|
require.Equal(t, proton.StatusDown, <-statusCh)
|
||||||
|
|
||||||
|
ctl.SetReadLimit(0)
|
||||||
|
|
||||||
|
require.NoError(t, m.Ping(ctx))
|
||||||
|
require.Equal(t, proton.StatusUp, <-statusCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingRoundTripper struct {
|
||||||
|
http.RoundTripper
|
||||||
|
|
||||||
|
fails, calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFailingRoundTripper(fails int) http.RoundTripper {
|
||||||
|
return &failingRoundTripper{
|
||||||
|
RoundTripper: http.DefaultTransport,
|
||||||
|
fails: fails,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *failingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
rt.calls++
|
||||||
|
|
||||||
|
if rt.calls < rt.fails {
|
||||||
|
return nil, errors.New("simulating network error")
|
||||||
|
}
|
||||||
|
|
||||||
|
return rt.RoundTripper.RoundTrip(req)
|
||||||
|
}
|
||||||
274
message.go
Normal file
274
message.go
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/bradenaw/juniper/iterator"
|
||||||
|
"github.com/bradenaw/juniper/parallel"
|
||||||
|
"github.com/bradenaw/juniper/stream"
|
||||||
|
"github.com/bradenaw/juniper/xslices"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxMessageIDs = 1000
|
||||||
|
|
||||||
|
func (c *Client) GetFullMessage(ctx context.Context, messageID string) (FullMessage, error) {
|
||||||
|
message, err := c.GetMessage(ctx, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return FullMessage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
attData, err := c.attPool().ProcessAll(ctx, xslices.Map(message.Attachments, func(att Attachment) string {
|
||||||
|
return att.ID
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return FullMessage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return FullMessage{
|
||||||
|
Message: message,
|
||||||
|
AttData: attData,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetFullMessages(ctx context.Context, workers, buffer int, messageIDs ...string) stream.Stream[FullMessage] {
|
||||||
|
return parallel.MapStream(
|
||||||
|
ctx,
|
||||||
|
stream.FromIterator(iterator.Slice(messageIDs)),
|
||||||
|
workers,
|
||||||
|
buffer,
|
||||||
|
c.GetFullMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetMessage(ctx context.Context, messageID string) (Message, error) {
|
||||||
|
var res struct {
|
||||||
|
Message Message
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/mail/v4/messages/" + messageID)
|
||||||
|
}); err != nil {
|
||||||
|
return Message{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Message, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CountMessages(ctx context.Context) (int, error) {
|
||||||
|
var res struct {
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetQueryParams(map[string]string{
|
||||||
|
"Limit": strconv.Itoa(0),
|
||||||
|
}).SetResult(&res).Get("/mail/v4/messages")
|
||||||
|
}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetMessageMetadata(ctx context.Context, filter MessageFilter) ([]MessageMetadata, error) {
|
||||||
|
total, err := c.CountMessages(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchPaged(ctx, total, maxPageSize, func(ctx context.Context, page, pageSize int) ([]MessageMetadata, error) {
|
||||||
|
return c.getMessageMetadata(ctx, page, pageSize, filter)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetMessageIDs(ctx context.Context, afterID string) ([]string, error) {
|
||||||
|
var messageIDs []string
|
||||||
|
|
||||||
|
for ; ; afterID = messageIDs[len(messageIDs)-1] {
|
||||||
|
page, err := c.getMessageIDs(ctx, afterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(page) == 0 {
|
||||||
|
return messageIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
messageIDs = append(messageIDs, page...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteMessage(ctx context.Context, messageIDs ...string) error {
|
||||||
|
pages := xslices.Chunk(messageIDs, maxPageSize)
|
||||||
|
|
||||||
|
return parallel.DoContext(ctx, runtime.NumCPU(), len(pages), func(ctx context.Context, idx int) error {
|
||||||
|
return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(MessageActionReq{IDs: pages[idx]}).Put("/mail/v4/messages/delete")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) MarkMessagesRead(ctx context.Context, messageIDs ...string) error {
|
||||||
|
pages := xslices.Chunk(messageIDs, maxPageSize)
|
||||||
|
|
||||||
|
return parallel.DoContext(ctx, runtime.NumCPU(), len(pages), func(ctx context.Context, idx int) error {
|
||||||
|
return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(MessageActionReq{IDs: pages[idx]}).Put("/mail/v4/messages/read")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) MarkMessagesUnread(ctx context.Context, messageIDs ...string) error {
|
||||||
|
pages := xslices.Chunk(messageIDs, maxPageSize)
|
||||||
|
|
||||||
|
return parallel.DoContext(ctx, runtime.NumCPU(), len(pages), func(ctx context.Context, idx int) error {
|
||||||
|
req := MessageActionReq{IDs: pages[idx]}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).Put("/mail/v4/messages/unread")
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) LabelMessages(ctx context.Context, messageIDs []string, labelID string) error {
|
||||||
|
res, err := parallel.MapContext(
|
||||||
|
ctx,
|
||||||
|
runtime.NumCPU(),
|
||||||
|
xslices.Chunk(messageIDs, maxPageSize),
|
||||||
|
func(ctx context.Context, messageIDs []string) (LabelMessagesRes, error) {
|
||||||
|
var res LabelMessagesRes
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(LabelMessagesReq{
|
||||||
|
LabelID: labelID,
|
||||||
|
IDs: messageIDs,
|
||||||
|
}).SetResult(&res).Put("/mail/v4/messages/label")
|
||||||
|
}); err != nil {
|
||||||
|
return LabelMessagesRes{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx := xslices.IndexFunc(res, func(res LabelMessagesRes) bool { return !res.ok() }); idx >= 0 {
|
||||||
|
tokens := xslices.Map(res, func(res LabelMessagesRes) UndoToken {
|
||||||
|
return res.UndoToken
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, undoErr := c.UndoActions(ctx, tokens...); undoErr != nil {
|
||||||
|
return fmt.Errorf("failed to undo actions: %w", undoErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to label messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UnlabelMessages(ctx context.Context, messageIDs []string, labelID string) error {
|
||||||
|
res, err := parallel.MapContext(
|
||||||
|
ctx,
|
||||||
|
runtime.NumCPU(),
|
||||||
|
xslices.Chunk(messageIDs, maxPageSize),
|
||||||
|
func(ctx context.Context, messageIDs []string) (LabelMessagesRes, error) {
|
||||||
|
var res LabelMessagesRes
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(LabelMessagesReq{
|
||||||
|
LabelID: labelID,
|
||||||
|
IDs: messageIDs,
|
||||||
|
}).SetResult(&res).Put("/mail/v4/messages/unlabel")
|
||||||
|
}); err != nil {
|
||||||
|
return LabelMessagesRes{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx := xslices.IndexFunc(res, func(res LabelMessagesRes) bool { return !res.ok() }); idx >= 0 {
|
||||||
|
tokens := xslices.Map(res, func(res LabelMessagesRes) UndoToken {
|
||||||
|
return res.UndoToken
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, undoErr := c.UndoActions(ctx, tokens...); undoErr != nil {
|
||||||
|
return fmt.Errorf("failed to undo actions: %w", undoErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to unlabel messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getMessageIDs(ctx context.Context, afterID string) ([]string, error) {
|
||||||
|
var res struct {
|
||||||
|
IDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
if afterID != "" {
|
||||||
|
r = r.SetQueryParam("AfterID", afterID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.SetQueryParam("Limit", strconv.Itoa(maxMessageIDs)).SetResult(&res).Get("/mail/v4/messages/ids")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.IDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getMessageMetadata(ctx context.Context, page, pageSize int, filter MessageFilter) ([]MessageMetadata, error) {
|
||||||
|
var res struct {
|
||||||
|
Messages []MessageMetadata
|
||||||
|
Stale Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
req := struct {
|
||||||
|
MessageFilter
|
||||||
|
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
|
||||||
|
Sort string
|
||||||
|
Desc Bool
|
||||||
|
}{
|
||||||
|
MessageFilter: filter,
|
||||||
|
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
|
||||||
|
Sort: "ID",
|
||||||
|
Desc: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).SetHeader("X-HTTP-Method-Override", "GET").Post("/mail/v4/messages")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !res.Stale {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Messages, nil
|
||||||
|
}
|
||||||
318
message_build.go
Normal file
318
message_build.go
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/emersion/go-message"
|
||||||
|
"github.com/emersion/go-message/textproto"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildRFC822(kr *crypto.KeyRing, msg Message, attData map[string][]byte) ([]byte, error) {
|
||||||
|
if msg.MIMEType == rfc822.MultipartMixed {
|
||||||
|
return buildPGPRFC822(kr, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
header, err := getMixedMessageHeader(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
|
||||||
|
w, err := message.CreateWriter(buf, header)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
inlineAtts []Attachment
|
||||||
|
inlineData [][]byte
|
||||||
|
attachAtts []Attachment
|
||||||
|
attachData [][]byte
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, att := range msg.Attachments {
|
||||||
|
if att.Disposition == InlineDisposition {
|
||||||
|
inlineAtts = append(inlineAtts, att)
|
||||||
|
inlineData = append(inlineData, attData[att.ID])
|
||||||
|
} else {
|
||||||
|
attachAtts = append(attachAtts, att)
|
||||||
|
attachData = append(attachData, attData[att.ID])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(inlineAtts) > 0 {
|
||||||
|
if err := writeRelatedParts(w, kr, msg, inlineAtts, inlineData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if err := writeTextPart(w, kr, msg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, att := range attachAtts {
|
||||||
|
if err := writeAttachmentPart(w, kr, att, attachData[i]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTextPart(w *message.Writer, kr *crypto.KeyRing, msg Message) error {
|
||||||
|
dec, err := msg.Decrypt(kr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
part, err := w.CreatePart(getTextPartHeader(dec, msg.MIMEType))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := part.Write(dec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return part.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAttachmentPart(w *message.Writer, kr *crypto.KeyRing, att Attachment, attData []byte) error {
|
||||||
|
kps, err := base64.StdEncoding.DecodeString(att.KeyPackets)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := crypto.NewPGPSplitMessage(kps, attData).GetPGPMessage()
|
||||||
|
|
||||||
|
dec, err := kr.Decrypt(msg, nil, crypto.GetUnixTime())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
part, err := w.CreatePart(getAttachmentPartHeader(att))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := part.Write(dec.GetBinary()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return part.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRelatedParts(w *message.Writer, kr *crypto.KeyRing, msg Message, atts []Attachment, attData [][]byte) error {
|
||||||
|
var header message.Header
|
||||||
|
|
||||||
|
header.SetContentType(string(rfc822.MultipartRelated), nil)
|
||||||
|
|
||||||
|
rel, err := w.CreatePart(header)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeTextPart(rel, kr, msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, att := range atts {
|
||||||
|
if err := writeAttachmentPart(rel, kr, att, attData[i]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rel.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPGPRFC822(kr *crypto.KeyRing, msg Message) ([]byte, error) {
|
||||||
|
raw, err := textproto.ReadHeader(bufio.NewReader(strings.NewReader(msg.Header)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec, err := msg.Decrypt(kr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sigs, err := ExtractSignatures(kr, msg.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sigs) > 0 {
|
||||||
|
return buildMultipartSignedRFC822(message.Header{Header: raw}, dec, sigs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildMultipartEncryptedRFC822(message.Header{Header: raw}, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMultipartSignedRFC822(header message.Header, body []byte, sig Signature) ([]byte, error) {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
|
||||||
|
boundary := uuid.New().String()
|
||||||
|
|
||||||
|
header.SetContentType("multipart/signed", map[string]string{
|
||||||
|
"micalg": sig.Hash,
|
||||||
|
"protocol": "application/pgp-signature",
|
||||||
|
"boundary": boundary,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := textproto.WriteHeader(buf, header.Header); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
w := rfc822.NewMultipartWriter(buf, boundary)
|
||||||
|
|
||||||
|
bodyHeader, bodyData := rfc822.Split(body)
|
||||||
|
|
||||||
|
if err := w.AddPart(func(w io.Writer) error {
|
||||||
|
if _, err := w.Write(bodyHeader); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := w.Write(bodyData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var sigHeader message.Header
|
||||||
|
|
||||||
|
sigHeader.SetContentType("application/pgp-signature", map[string]string{"name": "OpenPGP_signature.asc"})
|
||||||
|
sigHeader.SetContentDisposition("attachment", map[string]string{"filename": "OpenPGP_signature"})
|
||||||
|
sigHeader.Set("Content-Description", "OpenPGP digital signature")
|
||||||
|
|
||||||
|
sigData, err := sig.Data.GetArmored()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.AddPart(func(w io.Writer) error {
|
||||||
|
if err := textproto.WriteHeader(w, sigHeader.Header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := w.Write([]byte(sigData)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Done(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMultipartEncryptedRFC822(header message.Header, body []byte) ([]byte, error) {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
|
||||||
|
bodyHeader, bodyData := rfc822.Split(body)
|
||||||
|
|
||||||
|
parsedHeader, err := rfc822.NewHeader(bodyHeader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedHeader.Entries(func(key, val string) {
|
||||||
|
header.Set(key, val)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := textproto.WriteHeader(buf, header.Header); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := buf.Write(bodyData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMixedMessageHeader(msg Message) (message.Header, error) {
|
||||||
|
raw, err := textproto.ReadHeader(bufio.NewReader(strings.NewReader(msg.Header)))
|
||||||
|
if err != nil {
|
||||||
|
return message.Header{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
header := message.Header{Header: raw}
|
||||||
|
|
||||||
|
header.SetContentType(string(rfc822.MultipartMixed), nil)
|
||||||
|
|
||||||
|
if date, err := mail.ParseDate(header.Get("Date")); err != nil || date.Before(time.Unix(0, 0)) {
|
||||||
|
if msgTime := time.Unix(msg.Time, 0); msgTime.After(time.Unix(0, 0)) {
|
||||||
|
header.Set("Date", msgTime.In(time.UTC).Format(time.RFC1123Z))
|
||||||
|
} else {
|
||||||
|
header.Del("Date")
|
||||||
|
}
|
||||||
|
|
||||||
|
header.Set("X-Original-Date", date.In(time.UTC).Format(time.RFC1123Z))
|
||||||
|
}
|
||||||
|
|
||||||
|
return header, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTextPartHeader(body []byte, mimeType rfc822.MIMEType) message.Header {
|
||||||
|
var header message.Header
|
||||||
|
|
||||||
|
params := make(map[string]string)
|
||||||
|
|
||||||
|
if utf8.Valid(body) {
|
||||||
|
params["charset"] = "utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
header.SetContentType(string(mimeType), params)
|
||||||
|
|
||||||
|
// Use quoted-printable for all text/... parts
|
||||||
|
header.Set("Content-Transfer-Encoding", "quoted-printable")
|
||||||
|
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAttachmentPartHeader(att Attachment) message.Header {
|
||||||
|
var header message.Header
|
||||||
|
|
||||||
|
for key, val := range att.Headers {
|
||||||
|
for _, val := range val {
|
||||||
|
header.Add(key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All attachments have a content type.
|
||||||
|
header.SetContentType(string(att.MIMEType), map[string]string{"name": mime.QEncoding.Encode("utf-8", att.Name)})
|
||||||
|
|
||||||
|
// All attachments have a content disposition.
|
||||||
|
header.SetContentDisposition(string(att.Disposition), map[string]string{"filename": mime.QEncoding.Encode("utf-8", att.Name)})
|
||||||
|
|
||||||
|
// Use base64 for all attachments except embedded RFC822 messages.
|
||||||
|
if att.MIMEType != rfc822.MessageRFC822 {
|
||||||
|
header.Set("Content-Transfer-Encoding", "base64")
|
||||||
|
} else {
|
||||||
|
header.Del("Content-Transfer-Encoding")
|
||||||
|
}
|
||||||
|
|
||||||
|
return header
|
||||||
|
}
|
||||||
37
message_draft_types.go
Normal file
37
message_draft_types.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DraftTemplate struct {
|
||||||
|
Subject string
|
||||||
|
Sender *mail.Address
|
||||||
|
ToList []*mail.Address
|
||||||
|
CCList []*mail.Address
|
||||||
|
BCCList []*mail.Address
|
||||||
|
Body string
|
||||||
|
MIMEType rfc822.MIMEType
|
||||||
|
|
||||||
|
ExternalID string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateDraftAction int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ReplyAction CreateDraftAction = iota
|
||||||
|
ReplyAllAction
|
||||||
|
ForwardAction
|
||||||
|
AutoResponseAction
|
||||||
|
ReadReceiptAction
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateDraftReq struct {
|
||||||
|
Message DraftTemplate
|
||||||
|
AttachmentKeyPackets []string
|
||||||
|
|
||||||
|
ParentID string `json:",omitempty"`
|
||||||
|
Action CreateDraftAction
|
||||||
|
}
|
||||||
123
message_encrypt.go
Normal file
123
message_encrypt.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CharsetReader returns a charset decoder for the given charset.
|
||||||
|
// If set, it will be used to decode non-utf8 encoded messages.
|
||||||
|
var CharsetReader func(charset string, input io.Reader) (io.Reader, error)
|
||||||
|
|
||||||
|
// EncryptRFC822 encrypts the given message literal as a PGP attachment.
|
||||||
|
func EncryptRFC822(kr *crypto.KeyRing, literal []byte) ([]byte, error) {
|
||||||
|
msg, err := kr.Encrypt(crypto.NewPlainMessage(literal), kr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
armored, err := msg.GetArmored()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
header, _ := rfc822.Split(literal)
|
||||||
|
|
||||||
|
headerParsed, err := rfc822.NewHeader(header)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
boundary := strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||||
|
multipartWriter := rfc822.NewMultipartWriter(buf, boundary)
|
||||||
|
|
||||||
|
{
|
||||||
|
newHeader := rfc822.NewEmptyHeader()
|
||||||
|
|
||||||
|
if value, ok := headerParsed.GetChecked("Message-Id"); ok {
|
||||||
|
newHeader.Set("Message-Id", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := mime.FormatMediaType("multipart/encrypted", map[string]string{
|
||||||
|
"boundary": boundary,
|
||||||
|
"protocol": "application/pgp-encrypted",
|
||||||
|
})
|
||||||
|
newHeader.Set("Mime-version", "1.0")
|
||||||
|
newHeader.Set("Content-Type", contentType)
|
||||||
|
|
||||||
|
if value, ok := headerParsed.GetChecked("From"); ok {
|
||||||
|
newHeader.Set("From", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if value, ok := headerParsed.GetChecked("To"); ok {
|
||||||
|
newHeader.Set("To", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if value, ok := headerParsed.GetChecked("Subject"); ok {
|
||||||
|
newHeader.Set("Subject", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if value, ok := headerParsed.GetChecked("Date"); ok {
|
||||||
|
newHeader.Set("Date", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if value, ok := headerParsed.GetChecked("Received"); ok {
|
||||||
|
newHeader.Set("Received", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.Write(newHeader.Raw())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write PGP control data
|
||||||
|
{
|
||||||
|
pgpControlHeader := rfc822.NewEmptyHeader()
|
||||||
|
pgpControlHeader.Set("Content-Description", "PGP/MIME version identification")
|
||||||
|
pgpControlHeader.Set("Content-Type", "application/pgp-encrypted")
|
||||||
|
if err := multipartWriter.AddPart(func(writer io.Writer) error {
|
||||||
|
if _, err := writer.Write(pgpControlHeader.Raw()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := writer.Write([]byte("Version: 1"))
|
||||||
|
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write PGP attachment
|
||||||
|
{
|
||||||
|
pgpAttachmentHeader := rfc822.NewEmptyHeader()
|
||||||
|
contentType := mime.FormatMediaType("application/octet-stream", map[string]string{
|
||||||
|
"name": "encrypted.asc",
|
||||||
|
})
|
||||||
|
pgpAttachmentHeader.Set("Content-Description", "OpenPGP encrypted message")
|
||||||
|
pgpAttachmentHeader.Set("Content-Disposition", "inline; filename=encrypted.asc")
|
||||||
|
pgpAttachmentHeader.Set("Content-Type", contentType)
|
||||||
|
|
||||||
|
if err := multipartWriter.AddPart(func(writer io.Writer) error {
|
||||||
|
if _, err := writer.Write(pgpAttachmentHeader.Raw()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := writer.Write([]byte(armored))
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// finish messsage
|
||||||
|
if err := multipartWriter.Done(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
96
message_encrypt_test.go
Normal file
96
message_encrypt_test.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEncryptMessage(t *testing.T) {
|
||||||
|
const message = `From: Nathaniel Borenstein <nsb@bellcore.com>
|
||||||
|
To: Ned Freed <ned@innosoft.com>
|
||||||
|
Subject: Sample message (import 2)
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-type: multipart/mixed; boundary="simple boundary"
|
||||||
|
Received: from mail.protonmail.ch by mail.protonmail.ch; Tue, 25 Nov 2016
|
||||||
|
|
||||||
|
This is the preamble. It is to be ignored, though it
|
||||||
|
is a handy place for mail composers to include an
|
||||||
|
explanatory note to non-MIME compliant readers.
|
||||||
|
--simple boundary
|
||||||
|
|
||||||
|
This is implicitly typed plain ASCII text.
|
||||||
|
It does NOT end with a linebreak.
|
||||||
|
--simple boundary
|
||||||
|
Content-type: text/plain; charset=us-ascii
|
||||||
|
|
||||||
|
This is explicitly typed plain ASCII text.
|
||||||
|
It DOES end with a linebreak.
|
||||||
|
|
||||||
|
--simple boundary--
|
||||||
|
This is the epilogue. It is also to be ignored.
|
||||||
|
`
|
||||||
|
key, err := crypto.GenerateKey("foobar", "foo@bar.com", "x25519", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
kr, err := crypto.NewKeyRing(key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
encryptedMessage, err := EncryptRFC822(kr, []byte(message))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
section := rfc822.Parse(encryptedMessage)
|
||||||
|
|
||||||
|
{
|
||||||
|
// Check root header:
|
||||||
|
header, err := section.ParseHeader()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, header.Get("From"), "Nathaniel Borenstein <nsb@bellcore.com>")
|
||||||
|
assert.Equal(t, header.Get("To"), "Ned Freed <ned@innosoft.com>")
|
||||||
|
assert.Equal(t, header.Get("Subject"), "Sample message (import 2)")
|
||||||
|
assert.Equal(t, header.Get("MIME-Version"), "1.0")
|
||||||
|
assert.Equal(t, header.Get("Received"), "from mail.protonmail.ch by mail.protonmail.ch; Tue, 25 Nov 2016")
|
||||||
|
|
||||||
|
mediaType, params, err := rfc822.ParseMediaType(header.Get("Content-Type"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "multipart/encrypted", mediaType)
|
||||||
|
assert.Equal(t, "application/pgp-encrypted", params["protocol"])
|
||||||
|
assert.NotEmpty(t, params["boundary"])
|
||||||
|
}
|
||||||
|
|
||||||
|
children, err := section.Children()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 2, len(children))
|
||||||
|
|
||||||
|
{
|
||||||
|
// check first child.
|
||||||
|
child := children[0]
|
||||||
|
header, err := child.ParseHeader()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, header.Get("Content-Description"), "PGP/MIME version identification")
|
||||||
|
assert.Equal(t, header.Get("Content-Type"), "application/pgp-encrypted")
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("Version: 1"), child.Body())
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// check second child.
|
||||||
|
child := children[1]
|
||||||
|
header, err := child.ParseHeader()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, header.Get("Content-Description"), "OpenPGP encrypted message")
|
||||||
|
assert.Equal(t, header.Get("Content-Disposition"), "inline; filename=encrypted.asc")
|
||||||
|
assert.Equal(t, header.Get("Content-type"), "application/octet-stream; name=encrypted.asc")
|
||||||
|
|
||||||
|
body := child.Body()
|
||||||
|
assert.True(t, bytes.HasPrefix(body, []byte("-----BEGIN PGP MESSAGE-----")))
|
||||||
|
assert.True(t, bytes.HasSuffix(body, []byte("-----END PGP MESSAGE-----")))
|
||||||
|
}
|
||||||
|
}
|
||||||
84
message_import.go
Normal file
84
message_import.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/bradenaw/juniper/iterator"
|
||||||
|
"github.com/bradenaw/juniper/parallel"
|
||||||
|
"github.com/bradenaw/juniper/stream"
|
||||||
|
"github.com/bradenaw/juniper/xslices"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxImportSize = 10
|
||||||
|
|
||||||
|
func (c *Client) ImportMessages(ctx context.Context, addrKR *crypto.KeyRing, workers, buffer int, req ...ImportReq) stream.Stream[ImportRes] {
|
||||||
|
return stream.Flatten(parallel.MapStream(
|
||||||
|
ctx,
|
||||||
|
stream.FromIterator(iterator.Chunk(iterator.Slice(req), maxImportSize)),
|
||||||
|
workers,
|
||||||
|
buffer,
|
||||||
|
func(ctx context.Context, req []ImportReq) (stream.Stream[ImportRes], error) {
|
||||||
|
res, err := c.importMessages(ctx, addrKR, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to import messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, res := range res {
|
||||||
|
if res.Code != SuccessCode {
|
||||||
|
return nil, fmt.Errorf("failed to import message: %w", res.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream.FromIterator(iterator.Slice(res)), nil
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) importMessages(ctx context.Context, addrKR *crypto.KeyRing, req []ImportReq) ([]ImportRes, error) {
|
||||||
|
names := iterator.Collect(iterator.Map(iterator.Counter(len(req)), func(i int) string {
|
||||||
|
return strconv.Itoa(i)
|
||||||
|
}))
|
||||||
|
|
||||||
|
var named []namedImportReq
|
||||||
|
|
||||||
|
for idx, name := range names {
|
||||||
|
named = append(named, namedImportReq{
|
||||||
|
ImportReq: req[idx],
|
||||||
|
Name: name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := buildImportReqFields(addrKR, named)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type namedImportRes struct {
|
||||||
|
Name string
|
||||||
|
Response ImportRes
|
||||||
|
}
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Responses []namedImportRes
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetMultipartFields(fields...).SetResult(&res).Post("/mail/v4/messages/import")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
namedRes := make(map[string]ImportRes, len(res.Responses))
|
||||||
|
|
||||||
|
for _, res := range res.Responses {
|
||||||
|
namedRes[res.Name] = res.Response
|
||||||
|
}
|
||||||
|
|
||||||
|
return xslices.Map(names, func(name string) ImportRes {
|
||||||
|
return namedRes[name]
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
68
message_import_types.go
Normal file
68
message_import_types.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ImportReq struct {
|
||||||
|
Metadata ImportMetadata
|
||||||
|
Message []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type namedImportReq struct {
|
||||||
|
ImportReq
|
||||||
|
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportMetadata struct {
|
||||||
|
AddressID string
|
||||||
|
LabelIDs []string
|
||||||
|
Unread Bool
|
||||||
|
Flags MessageFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportRes struct {
|
||||||
|
Error
|
||||||
|
MessageID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildImportReqFields(addrKR *crypto.KeyRing, req []namedImportReq) ([]*resty.MultipartField, error) {
|
||||||
|
var fields []*resty.MultipartField
|
||||||
|
|
||||||
|
metadata := make(map[string]ImportMetadata)
|
||||||
|
|
||||||
|
for _, req := range req {
|
||||||
|
metadata[req.Name] = req.Metadata
|
||||||
|
|
||||||
|
enc, err := EncryptRFC822(addrKR, req.Message)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fields = append(fields, &resty.MultipartField{
|
||||||
|
Param: req.Name,
|
||||||
|
FileName: req.Name + ".eml",
|
||||||
|
ContentType: string(rfc822.MessageRFC822),
|
||||||
|
Reader: bytes.NewReader(append(enc, "\r\n"...)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(metadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fields = append(fields, &resty.MultipartField{
|
||||||
|
Param: "Metadata",
|
||||||
|
ContentType: "application/json",
|
||||||
|
Reader: bytes.NewReader(b),
|
||||||
|
})
|
||||||
|
|
||||||
|
return fields, nil
|
||||||
|
}
|
||||||
35
message_send.go
Normal file
35
message_send.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) CreateDraft(ctx context.Context, req CreateDraftReq) (Message, error) {
|
||||||
|
var res struct {
|
||||||
|
Message Message
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).Post("/mail/v4/messages")
|
||||||
|
}); err != nil {
|
||||||
|
return Message{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Message, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SendDraft(ctx context.Context, draftID string, req SendDraftReq) (Message, error) {
|
||||||
|
var res struct {
|
||||||
|
Sent Message
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetBody(req).SetResult(&res).Post("/mail/v4/messages/" + draftID)
|
||||||
|
}); err != nil {
|
||||||
|
return Message{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Sent, nil
|
||||||
|
}
|
||||||
308
message_send_types.go
Normal file
308
message_send_types.go
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EncryptionScheme int
|
||||||
|
|
||||||
|
const (
|
||||||
|
InternalScheme EncryptionScheme = 1 << iota
|
||||||
|
EncryptedOutsideScheme
|
||||||
|
ClearScheme
|
||||||
|
PGPInlineScheme
|
||||||
|
PGPMIMEScheme
|
||||||
|
ClearMIMEScheme
|
||||||
|
)
|
||||||
|
|
||||||
|
type SignatureType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
NoSignature SignatureType = iota
|
||||||
|
DetachedSignature
|
||||||
|
AttachedSignature
|
||||||
|
)
|
||||||
|
|
||||||
|
type MessageRecipient struct {
|
||||||
|
Type EncryptionScheme
|
||||||
|
Signature SignatureType
|
||||||
|
|
||||||
|
BodyKeyPacket string `json:",omitempty"`
|
||||||
|
AttachmentKeyPackets map[string]string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessagePackage struct {
|
||||||
|
Addresses map[string]*MessageRecipient
|
||||||
|
MIMEType rfc822.MIMEType
|
||||||
|
Type EncryptionScheme
|
||||||
|
Body string
|
||||||
|
|
||||||
|
BodyKey *SessionKey `json:",omitempty"`
|
||||||
|
AttachmentKeys map[string]*SessionKey `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMessagePackage(mimeType rfc822.MIMEType, encBodyData []byte) *MessagePackage {
|
||||||
|
return &MessagePackage{
|
||||||
|
Addresses: make(map[string]*MessageRecipient),
|
||||||
|
MIMEType: mimeType,
|
||||||
|
Body: base64.StdEncoding.EncodeToString(encBodyData),
|
||||||
|
|
||||||
|
AttachmentKeys: make(map[string]*SessionKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionKey struct {
|
||||||
|
Key string
|
||||||
|
Algorithm string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSessionKey(key *crypto.SessionKey) *SessionKey {
|
||||||
|
return &SessionKey{
|
||||||
|
Key: key.GetBase64Key(),
|
||||||
|
Algorithm: key.Algo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendPreferences struct {
|
||||||
|
// Encrypt indicates whether the email should be encrypted or not.
|
||||||
|
// If it's encrypted, we need to know which public key to use.
|
||||||
|
Encrypt bool
|
||||||
|
|
||||||
|
// PubKey contains an OpenPGP key that can be used for encryption.
|
||||||
|
PubKey *crypto.KeyRing
|
||||||
|
|
||||||
|
// SignatureType indicates how the email should be signed.
|
||||||
|
SignatureType SignatureType
|
||||||
|
|
||||||
|
// EncryptionScheme indicates if we should encrypt body and attachments separately and
|
||||||
|
// what MIME format to give the final encrypted email. The two standard PGP
|
||||||
|
// schemes are PGP/MIME and PGP/Inline. However we use a custom scheme for
|
||||||
|
// internal emails (including the so-called encrypted-to-outside emails,
|
||||||
|
// which even though meant for external users, they don't really get out of
|
||||||
|
// our platform). If the email is sent unencrypted, no PGP scheme is needed.
|
||||||
|
EncryptionScheme EncryptionScheme
|
||||||
|
|
||||||
|
// MIMEType is the MIME type to use for formatting the body of the email
|
||||||
|
// (before encryption/after decryption). The standard possibilities are the
|
||||||
|
// enriched HTML format, text/html, and plain text, text/plain. But it's
|
||||||
|
// also possible to have a multipart/mixed format, which is typically used
|
||||||
|
// for PGP/MIME encrypted emails, where attachments go into the body too.
|
||||||
|
// Because of this, this option is sometimes called MIME format.
|
||||||
|
MIMEType rfc822.MIMEType
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendDraftReq struct {
|
||||||
|
Packages []*MessagePackage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req *SendDraftReq) AddMIMEPackage(
|
||||||
|
kr *crypto.KeyRing,
|
||||||
|
mimeBody string,
|
||||||
|
prefs map[string]SendPreferences,
|
||||||
|
) error {
|
||||||
|
for _, prefs := range prefs {
|
||||||
|
if prefs.MIMEType != rfc822.MultipartMixed {
|
||||||
|
return fmt.Errorf("invalid MIME type for MIME package: %s", prefs.MIMEType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := newMIMEPackage(kr, mimeBody, prefs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Packages = append(req.Packages, pkg)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req *SendDraftReq) AddTextPackage(
|
||||||
|
kr *crypto.KeyRing,
|
||||||
|
body string,
|
||||||
|
mimeType rfc822.MIMEType,
|
||||||
|
prefs map[string]SendPreferences,
|
||||||
|
attKeys map[string]*crypto.SessionKey,
|
||||||
|
) error {
|
||||||
|
pkg, err := newTextPackage(kr, body, mimeType, prefs, attKeys)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Packages = append(req.Packages, pkg)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMIMEPackage(
|
||||||
|
kr *crypto.KeyRing,
|
||||||
|
mimeBody string,
|
||||||
|
prefs map[string]SendPreferences,
|
||||||
|
) (*MessagePackage, error) {
|
||||||
|
decBodyKey, encBodyData, err := encSplit(kr, mimeBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt MIME body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg := newMessagePackage(rfc822.MultipartMixed, encBodyData)
|
||||||
|
|
||||||
|
for addr, prefs := range prefs {
|
||||||
|
if prefs.MIMEType != rfc822.MultipartMixed {
|
||||||
|
return nil, fmt.Errorf("invalid MIME type for MIME package: %s", prefs.MIMEType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if prefs.SignatureType != DetachedSignature {
|
||||||
|
return nil, fmt.Errorf("invalid signature type for MIME package: %d", prefs.SignatureType)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient := &MessageRecipient{
|
||||||
|
Type: prefs.EncryptionScheme,
|
||||||
|
Signature: prefs.SignatureType,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch prefs.EncryptionScheme {
|
||||||
|
case PGPMIMEScheme:
|
||||||
|
if prefs.PubKey == nil {
|
||||||
|
return nil, fmt.Errorf("missing public key for %s", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
encBodyKey, err := prefs.PubKey.EncryptSessionKey(decBodyKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt session key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient.BodyKeyPacket = base64.StdEncoding.EncodeToString(encBodyKey)
|
||||||
|
|
||||||
|
case ClearMIMEScheme:
|
||||||
|
pkg.BodyKey = &SessionKey{
|
||||||
|
Key: decBodyKey.GetBase64Key(),
|
||||||
|
Algorithm: decBodyKey.Algo,
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid encryption scheme for MIME package: %d", prefs.EncryptionScheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg.Addresses[addr] = recipient
|
||||||
|
pkg.Type |= prefs.EncryptionScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
return pkg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTextPackage(
|
||||||
|
kr *crypto.KeyRing,
|
||||||
|
body string,
|
||||||
|
mimeType rfc822.MIMEType,
|
||||||
|
prefs map[string]SendPreferences,
|
||||||
|
attKeys map[string]*crypto.SessionKey,
|
||||||
|
) (*MessagePackage, error) {
|
||||||
|
if mimeType != rfc822.TextPlain && mimeType != rfc822.TextHTML {
|
||||||
|
return nil, fmt.Errorf("invalid MIME type for package: %s", mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
decBodyKey, encBodyData, err := encSplit(kr, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt message body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg := newMessagePackage(mimeType, encBodyData)
|
||||||
|
|
||||||
|
for addr, prefs := range prefs {
|
||||||
|
if prefs.MIMEType != mimeType {
|
||||||
|
return nil, fmt.Errorf("invalid MIME type for package: %s", prefs.MIMEType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if prefs.SignatureType == DetachedSignature && !prefs.Encrypt {
|
||||||
|
if prefs.EncryptionScheme == PGPInlineScheme {
|
||||||
|
return nil, fmt.Errorf("invalid encryption scheme for %s: %d", addr, prefs.EncryptionScheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
if prefs.EncryptionScheme == ClearScheme && mimeType != rfc822.TextPlain {
|
||||||
|
return nil, fmt.Errorf("invalid MIME type for clear package: %s", mimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if prefs.EncryptionScheme == InternalScheme && !prefs.Encrypt {
|
||||||
|
return nil, fmt.Errorf("internal packages must be encrypted")
|
||||||
|
}
|
||||||
|
|
||||||
|
if prefs.EncryptionScheme == PGPInlineScheme && mimeType != rfc822.TextPlain {
|
||||||
|
return nil, fmt.Errorf("invalid MIME type for PGP inline package: %s", mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch prefs.EncryptionScheme {
|
||||||
|
case ClearScheme:
|
||||||
|
pkg.BodyKey = newSessionKey(decBodyKey)
|
||||||
|
|
||||||
|
for attID, attKey := range attKeys {
|
||||||
|
pkg.AttachmentKeys[attID] = newSessionKey(attKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
case InternalScheme, PGPInlineScheme:
|
||||||
|
// ...
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid encryption scheme for package: %d", prefs.EncryptionScheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient := &MessageRecipient{
|
||||||
|
Type: prefs.EncryptionScheme,
|
||||||
|
Signature: prefs.SignatureType,
|
||||||
|
AttachmentKeyPackets: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
if prefs.Encrypt {
|
||||||
|
if prefs.PubKey == nil {
|
||||||
|
return nil, fmt.Errorf("missing public key for %s", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if prefs.SignatureType != DetachedSignature {
|
||||||
|
return nil, fmt.Errorf("invalid signature type for package: %d", prefs.SignatureType)
|
||||||
|
}
|
||||||
|
|
||||||
|
encBodyKey, err := prefs.PubKey.EncryptSessionKey(decBodyKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt session key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient.BodyKeyPacket = base64.StdEncoding.EncodeToString(encBodyKey)
|
||||||
|
|
||||||
|
for attID, attKey := range attKeys {
|
||||||
|
encAttKey, err := prefs.PubKey.EncryptSessionKey(attKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encrypt attachment key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient.AttachmentKeyPackets[attID] = base64.StdEncoding.EncodeToString(encAttKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg.Addresses[addr] = recipient
|
||||||
|
pkg.Type |= prefs.EncryptionScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
return pkg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encSplit(kr *crypto.KeyRing, body string) (*crypto.SessionKey, []byte, error) {
|
||||||
|
encBody, err := kr.Encrypt(crypto.NewPlainMessageFromString(body), kr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to encrypt MIME body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
splitEncBody, err := encBody.SplitMessage()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to split message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decBodyKey, err := kr.DecryptSessionKey(splitEncBody.GetBinaryKeyPacket())
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to decrypt session key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decBodyKey, splitEncBody.GetBinaryDataPacket(), nil
|
||||||
|
}
|
||||||
381
message_send_types_test.go
Normal file
381
message_send_types_test.go
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSendDraftReq_AddMIMEPackage(t *testing.T) {
|
||||||
|
key, err := crypto.GenerateKey("name", "email", "rsa", 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
kr, err := crypto.NewKeyRing(key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mimeBody string
|
||||||
|
prefs map[string]SendPreferences
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Clear MIME with detached signature",
|
||||||
|
mimeBody: "this is a mime body",
|
||||||
|
prefs: map[string]SendPreferences{"mime-sign@email.com": {
|
||||||
|
Encrypt: false,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: ClearMIMEScheme,
|
||||||
|
MIMEType: rfc822.MultipartMixed,
|
||||||
|
}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Clear MIME with no signature (error)",
|
||||||
|
mimeBody: "this is a mime body",
|
||||||
|
prefs: map[string]SendPreferences{"mime-no-sign@email.com": {
|
||||||
|
Encrypt: false,
|
||||||
|
SignatureType: NoSignature,
|
||||||
|
EncryptionScheme: ClearMIMEScheme,
|
||||||
|
MIMEType: rfc822.MultipartMixed,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Clear MIME with plain text (error)",
|
||||||
|
mimeBody: "this is a mime body",
|
||||||
|
prefs: map[string]SendPreferences{"mime-plain@email.com": {
|
||||||
|
Encrypt: false,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: ClearMIMEScheme,
|
||||||
|
MIMEType: rfc822.TextPlain,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Clear MIME with rich text (error)",
|
||||||
|
mimeBody: "this is a mime body",
|
||||||
|
prefs: map[string]SendPreferences{"mime-html@email.com": {
|
||||||
|
Encrypt: false,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: ClearMIMEScheme,
|
||||||
|
MIMEType: rfc822.TextHTML,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PGP MIME with detached signature",
|
||||||
|
mimeBody: "this is a mime body",
|
||||||
|
prefs: map[string]SendPreferences{"mime-encrypted@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: PGPMIMEScheme,
|
||||||
|
MIMEType: rfc822.MultipartMixed,
|
||||||
|
}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PGP MIME with plain text (error)",
|
||||||
|
mimeBody: "this is a mime body",
|
||||||
|
prefs: map[string]SendPreferences{"mime-encrypted-plain@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: PGPMIMEScheme,
|
||||||
|
MIMEType: rfc822.TextPlain,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PGP MIME with rich text (error)",
|
||||||
|
mimeBody: "this is a mime body",
|
||||||
|
prefs: map[string]SendPreferences{"mime-encrypted-plain@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: PGPMIMEScheme,
|
||||||
|
MIMEType: rfc822.TextHTML,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PGP MIME with missing public key (error)",
|
||||||
|
mimeBody: "this is a mime body",
|
||||||
|
prefs: map[string]SendPreferences{"mime-encrypted-no-pubkey@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: PGPMIMEScheme,
|
||||||
|
MIMEType: rfc822.MultipartMixed,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PGP MIME with no signature (error)",
|
||||||
|
mimeBody: "this is a mime body",
|
||||||
|
prefs: map[string]SendPreferences{"mime-encrypted-no-signature@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: NoSignature,
|
||||||
|
EncryptionScheme: PGPMIMEScheme,
|
||||||
|
MIMEType: rfc822.MultipartMixed,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var req SendDraftReq
|
||||||
|
|
||||||
|
if err := req.AddMIMEPackage(kr, tt.mimeBody, tt.prefs); (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("SendDraftReq.AddMIMEPackage() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendDraftReq_AddPackage(t *testing.T) {
|
||||||
|
key, err := crypto.GenerateKey("name", "email", "rsa", 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
kr, err := crypto.NewKeyRing(key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
mimeType rfc822.MIMEType
|
||||||
|
prefs map[string]SendPreferences
|
||||||
|
attKeys map[string]*crypto.SessionKey
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "internal plain text with detached signature",
|
||||||
|
body: "this is a text/plain body",
|
||||||
|
mimeType: rfc822.TextPlain,
|
||||||
|
prefs: map[string]SendPreferences{"internal-plain@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: InternalScheme,
|
||||||
|
MIMEType: rfc822.TextPlain,
|
||||||
|
}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "internal rich text with detached signature",
|
||||||
|
body: "this is a text/html body",
|
||||||
|
mimeType: rfc822.TextHTML,
|
||||||
|
prefs: map[string]SendPreferences{"internal-html@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: InternalScheme,
|
||||||
|
MIMEType: rfc822.TextHTML,
|
||||||
|
}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "internal rich text with bad package content type (error)",
|
||||||
|
body: "this is a text/html body",
|
||||||
|
mimeType: "bad content type",
|
||||||
|
prefs: map[string]SendPreferences{"internal-bad-package-content-type@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: InternalScheme,
|
||||||
|
MIMEType: rfc822.TextHTML,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "internal rich text with bad recipient content type (error)",
|
||||||
|
body: "this is a text/html body",
|
||||||
|
mimeType: rfc822.TextHTML,
|
||||||
|
prefs: map[string]SendPreferences{"internal-bad-recipient-content-type@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: InternalScheme,
|
||||||
|
MIMEType: "bad content type",
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "internal with multipart (error)",
|
||||||
|
body: "this is a text/html body",
|
||||||
|
mimeType: rfc822.MultipartMixed,
|
||||||
|
prefs: map[string]SendPreferences{"internal-multipart-mixed@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: InternalScheme,
|
||||||
|
MIMEType: rfc822.MultipartMixed,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "internal without encryption (error)",
|
||||||
|
body: "this is a text/html body",
|
||||||
|
mimeType: rfc822.TextHTML,
|
||||||
|
prefs: map[string]SendPreferences{"internal-no-encrypt@email.com": {
|
||||||
|
Encrypt: false,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: InternalScheme,
|
||||||
|
MIMEType: rfc822.TextHTML,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "internal without pubkey (error)",
|
||||||
|
body: "this is a text/html body",
|
||||||
|
mimeType: rfc822.TextHTML,
|
||||||
|
prefs: map[string]SendPreferences{"internal-no-pubkey@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: InternalScheme,
|
||||||
|
MIMEType: rfc822.TextHTML,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "internal without signature (error)",
|
||||||
|
body: "this is a text/html body",
|
||||||
|
mimeType: rfc822.TextHTML,
|
||||||
|
prefs: map[string]SendPreferences{"internal-no-sig@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: NoSignature,
|
||||||
|
EncryptionScheme: InternalScheme,
|
||||||
|
MIMEType: rfc822.TextHTML,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clear rich text without signature",
|
||||||
|
body: "this is a text/html body",
|
||||||
|
mimeType: rfc822.TextHTML,
|
||||||
|
prefs: map[string]SendPreferences{"clear-rich@email.com": {
|
||||||
|
Encrypt: false,
|
||||||
|
SignatureType: NoSignature,
|
||||||
|
EncryptionScheme: ClearScheme,
|
||||||
|
MIMEType: rfc822.TextHTML,
|
||||||
|
}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clear plain text without signature",
|
||||||
|
body: "this is a text/plain body",
|
||||||
|
mimeType: rfc822.TextPlain,
|
||||||
|
prefs: map[string]SendPreferences{"clear-plain@email.com": {
|
||||||
|
Encrypt: false,
|
||||||
|
SignatureType: NoSignature,
|
||||||
|
EncryptionScheme: ClearScheme,
|
||||||
|
MIMEType: rfc822.TextPlain,
|
||||||
|
}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clear plain text with signature",
|
||||||
|
body: "this is a text/plain body",
|
||||||
|
mimeType: rfc822.TextPlain,
|
||||||
|
prefs: map[string]SendPreferences{"clear-plain-with-sig@email.com": {
|
||||||
|
Encrypt: false,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: ClearScheme,
|
||||||
|
MIMEType: rfc822.TextPlain,
|
||||||
|
}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clear plain text with bad scheme (error)",
|
||||||
|
body: "this is a text/plain body",
|
||||||
|
mimeType: rfc822.TextPlain,
|
||||||
|
prefs: map[string]SendPreferences{"clear-plain-with-sig@email.com": {
|
||||||
|
Encrypt: false,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: PGPInlineScheme,
|
||||||
|
MIMEType: rfc822.TextPlain,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clear rich text with signature (error)",
|
||||||
|
body: "this is a text/html body",
|
||||||
|
mimeType: rfc822.TextHTML,
|
||||||
|
prefs: map[string]SendPreferences{"clear-plain-with-sig@email.com": {
|
||||||
|
Encrypt: false,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: ClearScheme,
|
||||||
|
MIMEType: rfc822.TextHTML,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted plain text with signature",
|
||||||
|
body: "this is a text/plain body",
|
||||||
|
mimeType: rfc822.TextPlain,
|
||||||
|
prefs: map[string]SendPreferences{"pgp-inline-with-sig@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: PGPInlineScheme,
|
||||||
|
MIMEType: rfc822.TextPlain,
|
||||||
|
}},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted html text with signature (error)",
|
||||||
|
body: "this is a text/html body",
|
||||||
|
mimeType: rfc822.TextHTML,
|
||||||
|
prefs: map[string]SendPreferences{"pgp-inline-rich-with-sig@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: PGPInlineScheme,
|
||||||
|
MIMEType: rfc822.TextHTML,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted mixed text with signature (error)",
|
||||||
|
body: "this is a multipart/mixed body",
|
||||||
|
mimeType: rfc822.MultipartMixed,
|
||||||
|
prefs: map[string]SendPreferences{"pgp-inline-mixed-with-sig@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: PGPInlineScheme,
|
||||||
|
MIMEType: rfc822.MultipartMixed,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted for outside (error)",
|
||||||
|
body: "this is a text/plain body",
|
||||||
|
mimeType: rfc822.TextPlain,
|
||||||
|
prefs: map[string]SendPreferences{"enc-for-outside@email.com": {
|
||||||
|
Encrypt: true,
|
||||||
|
PubKey: kr,
|
||||||
|
SignatureType: DetachedSignature,
|
||||||
|
EncryptionScheme: EncryptedOutsideScheme,
|
||||||
|
MIMEType: rfc822.TextPlain,
|
||||||
|
}},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var req SendDraftReq
|
||||||
|
|
||||||
|
if err := req.AddTextPackage(kr, tt.body, tt.mimeType, tt.prefs, tt.attKeys); (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("SendDraftReq.AddPackage() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
192
message_types.go
Normal file
192
message_types.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MessageMetadata struct {
|
||||||
|
ID string
|
||||||
|
AddressID string
|
||||||
|
LabelIDs []string
|
||||||
|
ExternalID string
|
||||||
|
|
||||||
|
Subject string
|
||||||
|
Sender *mail.Address
|
||||||
|
ToList []*mail.Address
|
||||||
|
CCList []*mail.Address
|
||||||
|
BCCList []*mail.Address
|
||||||
|
ReplyTos []*mail.Address
|
||||||
|
|
||||||
|
Flags MessageFlag
|
||||||
|
Time int64
|
||||||
|
Size int
|
||||||
|
Unread Bool
|
||||||
|
IsReplied Bool
|
||||||
|
IsRepliedAll Bool
|
||||||
|
IsForwarded Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (meta MessageMetadata) Seen() bool {
|
||||||
|
return !bool(meta.Unread)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (meta MessageMetadata) Starred() bool {
|
||||||
|
return slices.Contains(meta.LabelIDs, StarredLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (meta MessageMetadata) IsDraft() bool {
|
||||||
|
return meta.Flags&(MessageFlagReceived|MessageFlagSent) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageFilter struct {
|
||||||
|
ID []string `json:",omitempty"`
|
||||||
|
|
||||||
|
AddressID string `json:",omitempty"`
|
||||||
|
ExternalID string `json:",omitempty"`
|
||||||
|
LabelID string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
MessageMetadata
|
||||||
|
|
||||||
|
Header string
|
||||||
|
ParsedHeaders Headers
|
||||||
|
Body string
|
||||||
|
MIMEType rfc822.MIMEType
|
||||||
|
Attachments []Attachment
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageFlag int64
|
||||||
|
|
||||||
|
const (
|
||||||
|
MessageFlagReceived MessageFlag = 1 << iota
|
||||||
|
MessageFlagSent
|
||||||
|
MessageFlagInternal
|
||||||
|
MessageFlagE2E
|
||||||
|
MessageFlagAuto
|
||||||
|
MessageFlagReplied
|
||||||
|
MessageFlagRepliedAll
|
||||||
|
MessageFlagForwarded
|
||||||
|
MessageFlagAutoReplied
|
||||||
|
MessageFlagImported
|
||||||
|
MessageFlagOpened
|
||||||
|
MessageFlagReceiptSent
|
||||||
|
MessageFlagNotified
|
||||||
|
MessageFlagTouched
|
||||||
|
MessageFlagReceipt
|
||||||
|
MessageFlagProton
|
||||||
|
MessageFlagReceiptRequest
|
||||||
|
MessageFlagPublicKey
|
||||||
|
MessageFlagSign
|
||||||
|
MessageFlagUnsubscribed
|
||||||
|
MessageFlagSPFFail
|
||||||
|
MessageFlagDKIMFail
|
||||||
|
MessageFlagDMARCFail
|
||||||
|
MessageFlagHamManual
|
||||||
|
MessageFlagSpamAuto
|
||||||
|
MessageFlagSpamManual
|
||||||
|
MessageFlagPhishingAuto
|
||||||
|
MessageFlagPhishingManual
|
||||||
|
)
|
||||||
|
|
||||||
|
func (f MessageFlag) Has(flag MessageFlag) bool {
|
||||||
|
return f&flag != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f MessageFlag) Matches(flag MessageFlag) bool {
|
||||||
|
return f&flag == flag
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f MessageFlag) HasAny(flags ...MessageFlag) bool {
|
||||||
|
for _, flag := range flags {
|
||||||
|
if f.Has(flag) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f MessageFlag) HasAll(flags ...MessageFlag) bool {
|
||||||
|
for _, flag := range flags {
|
||||||
|
if !f.Has(flag) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f MessageFlag) Add(flag MessageFlag) MessageFlag {
|
||||||
|
return f | flag
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f MessageFlag) Remove(flag MessageFlag) MessageFlag {
|
||||||
|
return f &^ flag
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f MessageFlag) Toggle(flag MessageFlag) MessageFlag {
|
||||||
|
if f.Has(flag) {
|
||||||
|
return f.Remove(flag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.Add(flag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Message) Decrypt(kr *crypto.KeyRing) ([]byte, error) {
|
||||||
|
enc, err := crypto.NewPGPMessageFromArmored(m.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec, err := kr.Decrypt(enc, nil, crypto.GetUnixTime())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dec.GetBinary(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type FullMessage struct {
|
||||||
|
Message
|
||||||
|
|
||||||
|
AttData [][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type Signature struct {
|
||||||
|
Hash string
|
||||||
|
Data *crypto.PGPSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageActionReq struct {
|
||||||
|
IDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LabelMessagesReq struct {
|
||||||
|
LabelID string
|
||||||
|
IDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LabelMessagesRes struct {
|
||||||
|
Responses []LabelMessageRes
|
||||||
|
UndoToken UndoToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res LabelMessagesRes) ok() bool {
|
||||||
|
for _, resp := range res.Responses {
|
||||||
|
if resp.Response.Code != SuccessCode {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type LabelMessageRes struct {
|
||||||
|
ID string
|
||||||
|
Response Error
|
||||||
|
}
|
||||||
49
message_types_test.go
Normal file
49
message_types_test.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecrypt(t *testing.T) {
|
||||||
|
body, err := os.ReadFile("testdata/body.pgp")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pubKR := loadKeyRing(t, "testdata/pub.asc", nil)
|
||||||
|
prvKR := loadKeyRing(t, "testdata/prv.asc", []byte("password"))
|
||||||
|
|
||||||
|
msg := Message{Body: string(body)}
|
||||||
|
|
||||||
|
sigs, err := ExtractSignatures(prvKR, msg.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
enc, err := crypto.NewPGPMessageFromArmored(msg.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dec, err := prvKR.Decrypt(enc, nil, crypto.GetUnixTime())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, pubKR.VerifyDetached(dec, sigs[0].Data, crypto.GetUnixTime()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadKeyRing(t *testing.T, file string, pass []byte) *crypto.KeyRing {
|
||||||
|
f, err := os.Open(file)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
key, err := crypto.NewKeyFromArmoredReader(f)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if pass != nil {
|
||||||
|
key, err = key.Unlock(pass)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kr, err := crypto.NewKeyRing(key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return kr
|
||||||
|
}
|
||||||
138
option.go
Normal file
138
option.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Option represents a type that can be used to configure the manager.
|
||||||
|
type Option interface {
|
||||||
|
config(*managerBuilder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithHostURL(hostURL string) Option {
|
||||||
|
return &withHostURL{
|
||||||
|
hostURL: hostURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type withHostURL struct {
|
||||||
|
hostURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opt withHostURL) config(builder *managerBuilder) {
|
||||||
|
builder.hostURL = opt.hostURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAppVersion(appVersion string) Option {
|
||||||
|
return &withAppVersion{
|
||||||
|
appVersion: appVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type withAppVersion struct {
|
||||||
|
appVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opt withAppVersion) config(builder *managerBuilder) {
|
||||||
|
builder.appVersion = opt.appVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithTransport(transport http.RoundTripper) Option {
|
||||||
|
return &withTransport{
|
||||||
|
transport: transport,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type withTransport struct {
|
||||||
|
transport http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opt withTransport) config(builder *managerBuilder) {
|
||||||
|
builder.transport = opt.transport
|
||||||
|
}
|
||||||
|
|
||||||
|
type withAttPoolSize struct {
|
||||||
|
attPoolSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opt withAttPoolSize) config(builder *managerBuilder) {
|
||||||
|
builder.attPoolSize = opt.attPoolSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAttPoolSize(attPoolSize int) Option {
|
||||||
|
return &withAttPoolSize{
|
||||||
|
attPoolSize: attPoolSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type withSkipVerifyProofs struct {
|
||||||
|
skipVerifyProofs bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opt withSkipVerifyProofs) config(builder *managerBuilder) {
|
||||||
|
builder.verifyProofs = !opt.skipVerifyProofs
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithSkipVerifyProofs() Option {
|
||||||
|
return &withSkipVerifyProofs{
|
||||||
|
skipVerifyProofs: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithRetryCount(retryCount int) Option {
|
||||||
|
return &withRetryCount{
|
||||||
|
retryCount: retryCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type withRetryCount struct {
|
||||||
|
retryCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opt withRetryCount) config(builder *managerBuilder) {
|
||||||
|
builder.retryCount = opt.retryCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithCookieJar(jar http.CookieJar) Option {
|
||||||
|
return &withCookieJar{
|
||||||
|
jar: jar,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type withCookieJar struct {
|
||||||
|
jar http.CookieJar
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opt withCookieJar) config(builder *managerBuilder) {
|
||||||
|
builder.cookieJar = opt.jar
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithLogger(logger resty.Logger) Option {
|
||||||
|
return &withLogger{
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type withLogger struct {
|
||||||
|
logger resty.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opt withLogger) config(builder *managerBuilder) {
|
||||||
|
builder.logger = opt.logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithDebug(debug bool) Option {
|
||||||
|
return &withDebug{
|
||||||
|
debug: debug,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type withDebug struct {
|
||||||
|
debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opt withDebug) config(builder *managerBuilder) {
|
||||||
|
builder.debug = opt.debug
|
||||||
|
}
|
||||||
2
package.go
Normal file
2
package.go
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Package proton implements types for accessing the Proton API.
|
||||||
|
package proton
|
||||||
33
paging.go
Normal file
33
paging.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/bradenaw/juniper/iterator"
|
||||||
|
"github.com/bradenaw/juniper/parallel"
|
||||||
|
"github.com/bradenaw/juniper/stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxPageSize = 150
|
||||||
|
|
||||||
|
func fetchPaged[T any](
|
||||||
|
ctx context.Context,
|
||||||
|
total, pageSize int,
|
||||||
|
fn func(ctx context.Context, page, pageSize int) ([]T, error),
|
||||||
|
) ([]T, error) {
|
||||||
|
return stream.Collect(ctx, stream.Flatten(parallel.MapStream(
|
||||||
|
ctx,
|
||||||
|
stream.FromIterator(iterator.Counter(total/pageSize+1)),
|
||||||
|
runtime.NumCPU(),
|
||||||
|
runtime.NumCPU(),
|
||||||
|
func(ctx context.Context, page int) (stream.Stream[T], error) {
|
||||||
|
values, err := fn(ctx, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream.FromIterator(iterator.Slice(values)), nil
|
||||||
|
},
|
||||||
|
)))
|
||||||
|
}
|
||||||
166
pool.go
Normal file
166
pool.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrJobCancelled indicates the job was cancelled.
|
||||||
|
var ErrJobCancelled = errors.New("job cancelled by surrounding context")
|
||||||
|
|
||||||
|
// Pool is a worker pool that handles input of type In and returns results of type Out.
|
||||||
|
type Pool[In comparable, Out any] struct {
|
||||||
|
queue *queue.QueuedChannel[*job[In, Out]]
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// doneFunc must be called to free up pool resources.
|
||||||
|
type doneFunc func()
|
||||||
|
|
||||||
|
// New returns a new pool.
|
||||||
|
func NewPool[In comparable, Out any](size int, work func(context.Context, In) (Out, error)) *Pool[In, Out] {
|
||||||
|
pool := &Pool[In, Out]{
|
||||||
|
queue: queue.NewQueuedChannel[*job[In, Out]](0, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < size; i++ {
|
||||||
|
pool.wg.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer pool.wg.Done()
|
||||||
|
|
||||||
|
for job := range pool.queue.GetChannel() {
|
||||||
|
select {
|
||||||
|
case <-job.ctx.Done():
|
||||||
|
job.postFailure(ErrJobCancelled)
|
||||||
|
|
||||||
|
default:
|
||||||
|
res, err := work(job.ctx, job.req)
|
||||||
|
if err != nil {
|
||||||
|
job.postFailure(err)
|
||||||
|
} else {
|
||||||
|
job.postSuccess(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
job.waitDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process submits jobs to the pool. The callback provides access to the result, or an error if one occurred.
|
||||||
|
func (pool *Pool[In, Out]) Process(ctx context.Context, reqs []In, fn func(int, In, Out, error) error) error {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
errList []error
|
||||||
|
lock sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, req := range reqs {
|
||||||
|
req := req
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func(index int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
job, done, err := pool.newJob(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
// Cancel ongoing jobs.
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// Collect the error.
|
||||||
|
errList = append(errList, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
res, err := job.result()
|
||||||
|
|
||||||
|
if err := fn(index, req, res, err); err != nil {
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
// Cancel ongoing jobs.
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// Collect the error.
|
||||||
|
errList = append(errList, err)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// TODO: Join the errors somehow?
|
||||||
|
if len(errList) > 0 {
|
||||||
|
return errList[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessAll submits jobs to the pool. All results are returned once available.
|
||||||
|
func (pool *Pool[In, Out]) ProcessAll(ctx context.Context, reqs []In) ([]Out, error) {
|
||||||
|
data := make([]Out, len(reqs))
|
||||||
|
|
||||||
|
if err := pool.Process(ctx, reqs, func(index int, req In, res Out, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data[index] = res
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessOne submits one job to the pool and returns the result.
|
||||||
|
func (pool *Pool[In, Out]) ProcessOne(ctx context.Context, req In) (Out, error) {
|
||||||
|
job, done, err := pool.newJob(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
var o Out
|
||||||
|
return o, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
return job.result()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pool *Pool[In, Out]) Done() {
|
||||||
|
pool.queue.Close()
|
||||||
|
pool.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// newJob submits a job to the pool. It returns a job handle and a DoneFunc.
|
||||||
|
// The job handle allows the job result to be obtained. The DoneFunc is used to mark the job as done,
|
||||||
|
// which frees up the worker in the pool for reuse.
|
||||||
|
func (pool *Pool[In, Out]) newJob(ctx context.Context, req In) (*job[In, Out], doneFunc, error) {
|
||||||
|
job := newJob[In, Out](ctx, req)
|
||||||
|
|
||||||
|
if !pool.queue.Enqueue(job) {
|
||||||
|
return nil, nil, fmt.Errorf("pool closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return job, func() { close(job.done) }, nil
|
||||||
|
}
|
||||||
173
pool_test.go
Normal file
173
pool_test.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPool_NewJob(t *testing.T) {
|
||||||
|
doubler := newDoubler(runtime.NumCPU())
|
||||||
|
defer doubler.Done()
|
||||||
|
|
||||||
|
job1, done1, err := doubler.newJob(context.Background(), 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer done1()
|
||||||
|
|
||||||
|
job2, done2, err := doubler.newJob(context.Background(), 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer done2()
|
||||||
|
|
||||||
|
res2, err := job2.result()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
res1, err := job1.result()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, res1)
|
||||||
|
assert.Equal(t, 4, res2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPool_NewJob_Done(t *testing.T) {
|
||||||
|
// Create a doubler pool with 2 workers.
|
||||||
|
doubler := newDoubler(2)
|
||||||
|
defer doubler.Done()
|
||||||
|
|
||||||
|
// Start two jobs. Don't mark the jobs as done yet.
|
||||||
|
job1, done1, err := doubler.newJob(context.Background(), 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
job2, done2, err := doubler.newJob(context.Background(), 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Get the first result.
|
||||||
|
res1, _ := job1.result()
|
||||||
|
assert.Equal(t, 2, res1)
|
||||||
|
|
||||||
|
// Get the first result.
|
||||||
|
res2, _ := job2.result()
|
||||||
|
assert.Equal(t, 4, res2)
|
||||||
|
|
||||||
|
// Additional jobs will wait.
|
||||||
|
job3, done3, err := doubler.newJob(context.Background(), 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
job4, done4, err := doubler.newJob(context.Background(), 4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Channel to collect results from jobs 3 and 4.
|
||||||
|
resCh := make(chan int, 2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
res, _ := job3.result()
|
||||||
|
resCh <- res
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
res, _ := job4.result()
|
||||||
|
resCh <- res
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Mark jobs 1 and 2 as done, freeing up the workers.
|
||||||
|
done1()
|
||||||
|
done2()
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, []int{6, 8}, []int{<-resCh, <-resCh})
|
||||||
|
|
||||||
|
// Mark jobs 3 and 4 as done, freeing up the workers.
|
||||||
|
done3()
|
||||||
|
done4()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPool_Process(t *testing.T) {
|
||||||
|
doubler := newDoubler(runtime.NumCPU())
|
||||||
|
defer doubler.Done()
|
||||||
|
|
||||||
|
res := make([]int, 5)
|
||||||
|
|
||||||
|
require.NoError(t, doubler.Process(context.Background(), []int{1, 2, 3, 4, 5}, func(index, reqVal, resVal int, err error) error {
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
res[index] = resVal
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert.Equal(t, []int{
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
6,
|
||||||
|
8,
|
||||||
|
10,
|
||||||
|
}, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPool_Process_Error(t *testing.T) {
|
||||||
|
doubler := newDoublerWithError(runtime.NumCPU())
|
||||||
|
defer doubler.Done()
|
||||||
|
|
||||||
|
assert.Error(t, doubler.Process(context.Background(), []int{1, 2, 3, 4, 5}, func(_int, _ int, _ int, err error) error {
|
||||||
|
return err
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPool_Process_Parallel(t *testing.T) {
|
||||||
|
doubler := newDoubler(runtime.NumCPU(), 100*time.Millisecond)
|
||||||
|
defer doubler.Done()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
require.NoError(t, doubler.Process(context.Background(), []int{1, 2, 3, 4}, func(_ int, _ int, _ int, err error) error {
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPool_ProcessAll(t *testing.T) {
|
||||||
|
doubler := newDoubler(runtime.NumCPU())
|
||||||
|
defer doubler.Done()
|
||||||
|
|
||||||
|
res, err := doubler.ProcessAll(context.Background(), []int{1, 2, 3, 4, 5})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, []int{
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
6,
|
||||||
|
8,
|
||||||
|
10,
|
||||||
|
}, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDoubler(workers int, delay ...time.Duration) *Pool[int, int] {
|
||||||
|
return NewPool(workers, func(ctx context.Context, req int) (int, error) {
|
||||||
|
if len(delay) > 0 {
|
||||||
|
time.Sleep(delay[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return 2 * req, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDoublerWithError(workers int) *Pool[int, int] {
|
||||||
|
return NewPool(workers, func(ctx context.Context, req int) (int, error) {
|
||||||
|
if req%2 == 0 {
|
||||||
|
return 0, errors.New("oops")
|
||||||
|
}
|
||||||
|
|
||||||
|
return 2 * req, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
84
response.go
Normal file
84
response.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Code int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SuccessCode Code = 1000
|
||||||
|
MultiCode Code = 1001
|
||||||
|
InvalidValue Code = 2001
|
||||||
|
AppVersionMissingCode Code = 5001
|
||||||
|
AppVersionBadCode Code = 5003
|
||||||
|
PasswordWrong Code = 8002
|
||||||
|
HumanVerificationRequired Code = 9001
|
||||||
|
PaidPlanRequired Code = 10004
|
||||||
|
AuthRefreshTokenInvalid Code = 10013
|
||||||
|
)
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Code Code
|
||||||
|
Message string `json:"Error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err Error) Error() string {
|
||||||
|
return err.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func catchAPIError(_ *resty.Client, res *resty.Response) error {
|
||||||
|
if !res.IsError() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if apiErr, ok := res.Error().(*Error); ok {
|
||||||
|
err = apiErr
|
||||||
|
} else {
|
||||||
|
err = fmt.Errorf("%v", res.Status())
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%v: %w", res.StatusCode(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTime(_ *resty.Client, res *resty.Response) error {
|
||||||
|
date, err := time.Parse(time.RFC1123, res.Header().Get("Date"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
crypto.UpdateTime(date.Unix())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func catchRetryAfter(_ *resty.Client, res *resty.Response) (time.Duration, error) {
|
||||||
|
if res.StatusCode() == http.StatusTooManyRequests {
|
||||||
|
if after := res.Header().Get("Retry-After"); after != "" {
|
||||||
|
seconds, err := strconv.Atoi(after)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Duration(seconds) * time.Second, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func catchTooManyRequests(res *resty.Response, _ error) bool {
|
||||||
|
return res.StatusCode() == http.StatusTooManyRequests
|
||||||
|
}
|
||||||
|
|
||||||
|
func catchDialError(res *resty.Response, err error) bool {
|
||||||
|
return res.RawResponse == nil
|
||||||
|
}
|
||||||
21
salt.go
Normal file
21
salt.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) GetSalts(ctx context.Context) (Salts, error) {
|
||||||
|
var res struct {
|
||||||
|
KeySalts []Salt
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
|
||||||
|
return r.SetResult(&res).Get("/core/v4/keys/salts")
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.KeySalts, nil
|
||||||
|
}
|
||||||
37
salt_types.go
Normal file
37
salt_types.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-srp"
|
||||||
|
"github.com/bradenaw/juniper/xslices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Salt struct {
|
||||||
|
ID, KeySalt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Salts []Salt
|
||||||
|
|
||||||
|
func (salts Salts) SaltForKey(keyPass []byte, keyID string) ([]byte, error) {
|
||||||
|
idx := xslices.IndexFunc(salts, func(salt Salt) bool {
|
||||||
|
return salt.ID == keyID
|
||||||
|
})
|
||||||
|
|
||||||
|
if idx < 0 {
|
||||||
|
return nil, fmt.Errorf("no salt found for key %s", keyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
keySalt, err := base64.StdEncoding.DecodeString(salts[idx].KeySalt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
saltedKeyPass, err := srp.MailboxPassword(keyPass, keySalt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return saltedKeyPass[len(saltedKeyPass)-31:], nil
|
||||||
|
}
|
||||||
74
server/addresses.go
Normal file
74
server/addresses.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/bradenaw/juniper/xslices"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleGetAddresses() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
addresses, err := s.b.GetAddresses(c.GetString("UserID"))
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"Addresses": addresses,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetAddress() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
addresses, err := s.b.GetAddresses(c.GetString("UserID"))
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"Address": addresses[xslices.IndexFunc(addresses, func(address proton.Address) bool {
|
||||||
|
return address.ID == c.Param("addressID")
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePutAddressesOrder() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req proton.OrderAddressesReq
|
||||||
|
|
||||||
|
if err := c.BindJSON(&req); err != nil {
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addresses, err := s.b.GetAddresses(c.GetString("UserID"))
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.AddressIDs) != len(addresses) {
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, address := range addresses {
|
||||||
|
if !slices.Contains(req.AddressIDs, address.ID) {
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.b.SetAddressOrder(c.GetString("UserID"), req.AddressIDs); err != nil {
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
server/attachments.go
Normal file
66
server/attachments.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handlePostMailAttachments() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
form, err := c.MultipartForm()
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment, err := s.b.CreateAttachment(
|
||||||
|
c.GetString("UserID"),
|
||||||
|
form.Value["MessageID"][0],
|
||||||
|
form.Value["Filename"][0],
|
||||||
|
rfc822.MIMEType(form.Value["MIMEType"][0]),
|
||||||
|
proton.Disposition(form.Value["Disposition"][0]),
|
||||||
|
mustReadFileHeader(form.File["KeyPackets"][0]),
|
||||||
|
mustReadFileHeader(form.File["DataPacket"][0]),
|
||||||
|
string(mustReadFileHeader(form.File["Signature"][0])),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"Attachment": attachment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetMailAttachment() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
attData, err := s.b.GetAttachment(c.Param("attachID"))
|
||||||
|
if err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(http.StatusOK, "application/octet-stream", attData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustReadFileHeader(fh *multipart.FileHeader) []byte {
|
||||||
|
f, err := fh.Open()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
124
server/auth.go
Normal file
124
server/auth.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handlePostAuthInfo() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req proton.AuthInfoReq
|
||||||
|
|
||||||
|
if err := c.BindJSON(&req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := s.b.NewAuthInfo(req.Username)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusUnauthorized, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePostAuth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req proton.AuthReq
|
||||||
|
|
||||||
|
if err := c.BindJSON(&req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientEphemeral, err := base64.StdEncoding.DecodeString(req.ClientEphemeral)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientProof, err := base64.StdEncoding.DecodeString(req.ClientProof)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err := s.b.NewAuth(req.Username, clientEphemeral, clientProof, req.SRPSession)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusUnauthorized, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePostAuthRefresh() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req proton.AuthRefreshReq
|
||||||
|
|
||||||
|
if err := c.BindJSON(&req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err := s.b.NewAuthRef(req.UID, req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusUnauthorized, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteAuth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if err := s.b.DeleteSession(c.GetString("UserID"), c.GetString("AuthUID")); err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusUnauthorized, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetAuthSessions() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
sessions, err := s.b.GetSessions(c.GetString("UserID"))
|
||||||
|
if err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"Sessions": sessions})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteAuthSessions() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
sessions, err := s.b.GetSessions(c.GetString("UserID"))
|
||||||
|
if err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, session := range sessions {
|
||||||
|
if session.UID != c.GetString("AuthUID") {
|
||||||
|
if err := s.b.DeleteSession(c.GetString("UserID"), session.UID); err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteAuthSession() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if err := s.b.DeleteSession(c.GetString("UserID"), c.Param("authUID")); err != nil {
|
||||||
|
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
server/backend/account.go
Normal file
87
server/backend/account.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/bradenaw/juniper/xslices"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type account struct {
|
||||||
|
userID string
|
||||||
|
username string
|
||||||
|
addresses map[string]*address
|
||||||
|
|
||||||
|
auth map[string]auth
|
||||||
|
authLock sync.RWMutex
|
||||||
|
|
||||||
|
keys []key
|
||||||
|
salt []byte
|
||||||
|
verifier []byte
|
||||||
|
|
||||||
|
labelIDs []string
|
||||||
|
messageIDs []string
|
||||||
|
updateIDs []ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAccount(userID, username string, armKey string, salt, verifier []byte) *account {
|
||||||
|
return &account{
|
||||||
|
userID: userID,
|
||||||
|
username: username,
|
||||||
|
addresses: make(map[string]*address),
|
||||||
|
|
||||||
|
auth: make(map[string]auth),
|
||||||
|
keys: []key{{keyID: uuid.NewString(), key: armKey}},
|
||||||
|
salt: salt,
|
||||||
|
verifier: verifier,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *account) toUser() proton.User {
|
||||||
|
return proton.User{
|
||||||
|
ID: acc.userID,
|
||||||
|
Name: acc.username,
|
||||||
|
DisplayName: acc.username,
|
||||||
|
Email: acc.primary().email,
|
||||||
|
Keys: xslices.Map(acc.keys, func(key key) proton.Key {
|
||||||
|
privKey, err := crypto.NewKeyFromArmored(key.key)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawKey, err := privKey.Serialize()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return proton.Key{
|
||||||
|
ID: key.keyID,
|
||||||
|
PrivateKey: rawKey,
|
||||||
|
Primary: key == acc.keys[0],
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *account) primary() *address {
|
||||||
|
for _, addr := range acc.addresses {
|
||||||
|
if addr.order == 1 {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("no primary address")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *account) getAddr(email string) (*address, bool) {
|
||||||
|
for _, addr := range acc.addresses {
|
||||||
|
if addr.email == email {
|
||||||
|
return addr, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
49
server/backend/address.go
Normal file
49
server/backend/address.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/bradenaw/juniper/xslices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type address struct {
|
||||||
|
addrID string
|
||||||
|
email string
|
||||||
|
order int
|
||||||
|
keys []key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (add *address) toAddress() proton.Address {
|
||||||
|
return proton.Address{
|
||||||
|
ID: add.addrID,
|
||||||
|
Email: add.email,
|
||||||
|
|
||||||
|
Send: true,
|
||||||
|
Receive: true,
|
||||||
|
Status: proton.AddressStatusEnabled,
|
||||||
|
|
||||||
|
Order: add.order,
|
||||||
|
DisplayName: add.email,
|
||||||
|
|
||||||
|
Keys: xslices.Map(add.keys, func(key key) proton.Key {
|
||||||
|
privKey, err := crypto.NewKeyFromArmored(key.key)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawKey, err := privKey.Serialize()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return proton.Key{
|
||||||
|
ID: key.keyID,
|
||||||
|
PrivateKey: rawKey,
|
||||||
|
Token: key.tok,
|
||||||
|
Signature: key.sig,
|
||||||
|
Primary: key == add.keys[0],
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
772
server/backend/api.go
Normal file
772
server/backend/api.go
Normal file
@@ -0,0 +1,772 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/bradenaw/juniper/xslices"
|
||||||
|
"golang.org/x/exp/maps"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *Backend) GetUser(userID string) (proton.User, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) (proton.User, error) {
|
||||||
|
return acc.toUser(), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetKeySalts(userID string) ([]proton.Salt, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) ([]proton.Salt, error) {
|
||||||
|
return xslices.Map(acc.keys, func(key key) proton.Salt {
|
||||||
|
return proton.Salt{
|
||||||
|
ID: key.keyID,
|
||||||
|
KeySalt: base64.StdEncoding.EncodeToString(acc.salt),
|
||||||
|
}
|
||||||
|
}), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetMailSettings(userID string) (proton.MailSettings, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) (proton.MailSettings, error) {
|
||||||
|
return proton.MailSettings{
|
||||||
|
DisplayName: acc.username,
|
||||||
|
DraftMIMEType: rfc822.TextHTML,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetAddressID(email string) (string, error) {
|
||||||
|
return withAccEmail(b, email, func(acc *account) (string, error) {
|
||||||
|
addr, ok := acc.getAddr(email)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("no such address: %s", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
return addr.addrID, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetAddresses(userID string) ([]proton.Address, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) ([]proton.Address, error) {
|
||||||
|
return xslices.Map(maps.Values(acc.addresses), func(add *address) proton.Address {
|
||||||
|
return add.toAddress()
|
||||||
|
}), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) SetAddressOrder(userID string, addrIDs []string) error {
|
||||||
|
return b.withAcc(userID, func(acc *account) error {
|
||||||
|
for i, addrID := range addrIDs {
|
||||||
|
if add, ok := acc.addresses[addrID]; ok {
|
||||||
|
add.order = i + 1
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("no such address: %s", addrID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) HasLabel(userID, labelName string) (string, bool, error) {
|
||||||
|
labels, err := b.GetLabels(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, label := range labels {
|
||||||
|
if label.Name == labelName {
|
||||||
|
return label.ID, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetLabel(userID, labelID string) (proton.Label, error) {
|
||||||
|
labels, err := b.GetLabels(userID)
|
||||||
|
if err != nil {
|
||||||
|
return proton.Label{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, label := range labels {
|
||||||
|
if label.ID == labelID {
|
||||||
|
return label, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return proton.Label{}, fmt.Errorf("no such label: %s", labelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetLabels(userID string, types ...proton.LabelType) ([]proton.Label, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) ([]proton.Label, error) {
|
||||||
|
return withLabels(b, func(labels map[string]*label) ([]proton.Label, error) {
|
||||||
|
res := xslices.Map(acc.labelIDs, func(labelID string) proton.Label {
|
||||||
|
return labels[labelID].toLabel(labels)
|
||||||
|
})
|
||||||
|
|
||||||
|
for labelName, labelID := range map[string]string{
|
||||||
|
"Inbox": proton.InboxLabel,
|
||||||
|
"AllDrafts": proton.AllDraftsLabel,
|
||||||
|
"AllSent": proton.AllSentLabel,
|
||||||
|
"Trash": proton.TrashLabel,
|
||||||
|
"Spam": proton.SpamLabel,
|
||||||
|
"All Mail": proton.AllMailLabel,
|
||||||
|
"Archive": proton.ArchiveLabel,
|
||||||
|
"Sent": proton.SentLabel,
|
||||||
|
"Drafts": proton.DraftsLabel,
|
||||||
|
"Outbox": proton.OutboxLabel,
|
||||||
|
"Starred": proton.StarredLabel,
|
||||||
|
} {
|
||||||
|
res = append(res, proton.Label{
|
||||||
|
ID: labelID,
|
||||||
|
Name: labelName,
|
||||||
|
Path: []string{labelName},
|
||||||
|
Type: proton.LabelTypeSystem,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(types) > 0 {
|
||||||
|
res = xslices.Filter(res, func(label proton.Label) bool {
|
||||||
|
return slices.Contains(types, label.Type)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) CreateLabel(userID, labelName, parentID string, labelType proton.LabelType) (proton.Label, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) (proton.Label, error) {
|
||||||
|
return withLabels(b, func(labels map[string]*label) (proton.Label, error) {
|
||||||
|
if parentID != "" {
|
||||||
|
if labelType != proton.LabelTypeFolder {
|
||||||
|
return proton.Label{}, fmt.Errorf("parentID can only be set for folders")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := labels[parentID]; !ok {
|
||||||
|
return proton.Label{}, fmt.Errorf("no such parent label: %s", parentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label := newLabel(labelName, parentID, labelType)
|
||||||
|
|
||||||
|
labels[label.labelID] = label
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&labelCreated{labelID: label.labelID})
|
||||||
|
if err != nil {
|
||||||
|
return proton.Label{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.labelIDs = append(acc.labelIDs, label.labelID)
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
|
||||||
|
return label.toLabel(labels), nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) UpdateLabel(userID, labelID, name, parentID string) (proton.Label, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) (proton.Label, error) {
|
||||||
|
return withLabels(b, func(labels map[string]*label) (proton.Label, error) {
|
||||||
|
if parentID != "" {
|
||||||
|
if labels[labelID].labelType != proton.LabelTypeFolder {
|
||||||
|
return proton.Label{}, fmt.Errorf("parentID can only be set for folders")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := labels[parentID]; !ok {
|
||||||
|
return proton.Label{}, fmt.Errorf("no such parent label: %s", parentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
labels[labelID].name = name
|
||||||
|
labels[labelID].parentID = parentID
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&labelUpdated{labelID: labelID})
|
||||||
|
if err != nil {
|
||||||
|
return proton.Label{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
|
||||||
|
return labels[labelID].toLabel(labels), nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) DeleteLabel(userID, labelID string) error {
|
||||||
|
return b.withAcc(userID, func(acc *account) error {
|
||||||
|
return b.withLabels(func(labels map[string]*label) error {
|
||||||
|
if _, ok := labels[labelID]; !ok {
|
||||||
|
return errors.New("label not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, labelID := range getLabelIDsToDelete(labelID, labels) {
|
||||||
|
delete(labels, labelID)
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&labelDeleted{labelID: labelID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.labelIDs = xslices.Filter(acc.labelIDs, func(otherID string) bool { return otherID != labelID })
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) CountMessages(userID string) (int, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) (int, error) {
|
||||||
|
return len(acc.messageIDs), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetMessageIDs(userID string, afterID string, limit int) ([]string, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) ([]string, error) {
|
||||||
|
if len(acc.messageIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lo, hi int
|
||||||
|
|
||||||
|
if afterID == "" {
|
||||||
|
lo = 0
|
||||||
|
} else {
|
||||||
|
lo = slices.Index(acc.messageIDs, afterID) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit == 0 {
|
||||||
|
hi = len(acc.messageIDs)
|
||||||
|
} else {
|
||||||
|
hi = lo + limit
|
||||||
|
|
||||||
|
if hi > len(acc.messageIDs) {
|
||||||
|
hi = len(acc.messageIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc.messageIDs[lo:hi], nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetMessages(userID string, page, pageSize int, filter proton.MessageFilter) ([]proton.MessageMetadata, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) ([]proton.MessageMetadata, error) {
|
||||||
|
return withMessages(b, func(messages map[string]*message) ([]proton.MessageMetadata, error) {
|
||||||
|
if len(acc.messageIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := xslices.Map(xslices.Chunk(acc.messageIDs, pageSize)[page], func(messageID string) proton.MessageMetadata {
|
||||||
|
return messages[messageID].toMetadata()
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(filter.ID) > 0 {
|
||||||
|
metadata = xslices.Filter(metadata, func(metadata proton.MessageMetadata) bool {
|
||||||
|
return slices.Contains(filter.ID, metadata.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filter.AddressID) != 0 {
|
||||||
|
metadata = xslices.Filter(metadata, func(metadata proton.MessageMetadata) bool {
|
||||||
|
return filter.AddressID == metadata.AddressID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filter.ExternalID) != 0 {
|
||||||
|
metadata = xslices.Filter(metadata, func(metadata proton.MessageMetadata) bool {
|
||||||
|
return filter.ExternalID != metadata.ExternalID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filter.LabelID) != 0 {
|
||||||
|
metadata = xslices.Filter(metadata, func(metadata proton.MessageMetadata) bool {
|
||||||
|
return slices.Contains(metadata.LabelIDs, filter.LabelID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata, nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetMessage(userID, messageID string) (proton.Message, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) (proton.Message, error) {
|
||||||
|
return withMessages(b, func(messages map[string]*message) (proton.Message, error) {
|
||||||
|
return withAtts(b, func(atts map[string]*attachment) (proton.Message, error) {
|
||||||
|
message, ok := messages[messageID]
|
||||||
|
if !ok {
|
||||||
|
return proton.Message{}, errors.New("no such message")
|
||||||
|
}
|
||||||
|
|
||||||
|
return message.toMessage(atts), nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) SetMessagesRead(userID string, read bool, messageIDs ...string) error {
|
||||||
|
return b.withAcc(userID, func(acc *account) error {
|
||||||
|
return b.withMessages(func(messages map[string]*message) error {
|
||||||
|
for _, messageID := range messageIDs {
|
||||||
|
messages[messageID].unread = !read
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&messageUpdated{messageID: messageID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) LabelMessages(userID, labelID string, messageIDs ...string) error {
|
||||||
|
if labelID == proton.AllMailLabel || labelID == proton.AllDraftsLabel || labelID == proton.AllSentLabel {
|
||||||
|
return fmt.Errorf("not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.withAcc(userID, func(acc *account) error {
|
||||||
|
return b.withMessages(func(messages map[string]*message) error {
|
||||||
|
return b.withLabels(func(labels map[string]*label) error {
|
||||||
|
for _, messageID := range messageIDs {
|
||||||
|
message, ok := messages[messageID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
message.addLabel(labelID, labels)
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&messageUpdated{messageID: messageID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) UnlabelMessages(userID, labelID string, messageIDs ...string) error {
|
||||||
|
if labelID == proton.AllMailLabel || labelID == proton.AllDraftsLabel || labelID == proton.AllSentLabel {
|
||||||
|
return fmt.Errorf("not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.withAcc(userID, func(acc *account) error {
|
||||||
|
return b.withMessages(func(messages map[string]*message) error {
|
||||||
|
return b.withLabels(func(labels map[string]*label) error {
|
||||||
|
for _, messageID := range messageIDs {
|
||||||
|
messages[messageID].remLabel(labelID, labels)
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&messageUpdated{messageID: messageID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) DeleteMessage(userID, messageID string) error {
|
||||||
|
return b.withAcc(userID, func(acc *account) error {
|
||||||
|
return b.withMessages(func(messages map[string]*message) error {
|
||||||
|
message, ok := messages[messageID]
|
||||||
|
if !ok {
|
||||||
|
return errors.New("no such message")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, attID := range message.attIDs {
|
||||||
|
if xslices.CountFunc(maps.Values(b.attachments), func(att *attachment) bool {
|
||||||
|
return att.attDataID == b.attachments[attID].attDataID
|
||||||
|
}) == 1 {
|
||||||
|
delete(b.attData, b.attachments[attID].attDataID)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(b.attachments, attID)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(b.messages, messageID)
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&messageDeleted{messageID: messageID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.messageIDs = xslices.Filter(acc.messageIDs, func(otherID string) bool { return otherID != messageID })
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) CreateDraft(
|
||||||
|
userID, addrID string,
|
||||||
|
subject string,
|
||||||
|
sender *mail.Address,
|
||||||
|
toList, ccList, bccList []*mail.Address,
|
||||||
|
armBody string,
|
||||||
|
mimeType rfc822.MIMEType,
|
||||||
|
externalID string,
|
||||||
|
) (proton.Message, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) (proton.Message, error) {
|
||||||
|
return withMessages(b, func(messages map[string]*message) (proton.Message, error) {
|
||||||
|
msg := newMessage(addrID, subject, sender, toList, ccList, bccList, armBody, mimeType, externalID)
|
||||||
|
|
||||||
|
messages[msg.messageID] = msg
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&messageCreated{messageID: msg.messageID})
|
||||||
|
if err != nil {
|
||||||
|
return proton.Message{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.messageIDs = append(acc.messageIDs, msg.messageID)
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
|
||||||
|
return msg.toMessage(nil), nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) SendMessage(userID, messageID string, packages []*proton.MessagePackage) (proton.Message, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) (proton.Message, error) {
|
||||||
|
return withMessages(b, func(messages map[string]*message) (proton.Message, error) {
|
||||||
|
return withLabels(b, func(labels map[string]*label) (proton.Message, error) {
|
||||||
|
return withAtts(b, func(atts map[string]*attachment) (proton.Message, error) {
|
||||||
|
msg := messages[messageID]
|
||||||
|
msg.flags |= proton.MessageFlagSent
|
||||||
|
msg.addLabel(proton.SentLabel, labels)
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&messageUpdated{messageID: messageID})
|
||||||
|
if err != nil {
|
||||||
|
return proton.Message{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
|
||||||
|
for _, pkg := range packages {
|
||||||
|
bodyData, err := base64.StdEncoding.DecodeString(pkg.Body)
|
||||||
|
if err != nil {
|
||||||
|
return proton.Message{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for email, recipient := range pkg.Addresses {
|
||||||
|
if recipient.Type != proton.InternalScheme {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.withAccEmail(email, func(acc *account) error {
|
||||||
|
bodyKey, err := base64.StdEncoding.DecodeString(recipient.BodyKeyPacket)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
armBody, err := crypto.NewPGPSplitMessage(bodyKey, bodyData).GetPGPMessage().GetArmored()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
addrID, err := b.GetAddressID(email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newMsg := newMessage(
|
||||||
|
addrID,
|
||||||
|
msg.subject,
|
||||||
|
msg.sender,
|
||||||
|
msg.toList,
|
||||||
|
msg.ccList,
|
||||||
|
nil, // BCC is not sent to the recipient
|
||||||
|
armBody,
|
||||||
|
msg.mimeType,
|
||||||
|
msg.externalID,
|
||||||
|
)
|
||||||
|
newMsg.flags |= proton.MessageFlagReceived
|
||||||
|
newMsg.addLabel(proton.InboxLabel, labels)
|
||||||
|
newMsg.unread = true
|
||||||
|
messages[newMsg.messageID] = newMsg
|
||||||
|
|
||||||
|
for _, attID := range msg.attIDs {
|
||||||
|
attKey, err := base64.StdEncoding.DecodeString(recipient.AttachmentKeyPackets[attID])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
att := newAttachment(
|
||||||
|
atts[attID].filename,
|
||||||
|
atts[attID].mimeType,
|
||||||
|
atts[attID].disposition,
|
||||||
|
attKey,
|
||||||
|
atts[attID].attDataID,
|
||||||
|
atts[attID].armSig,
|
||||||
|
)
|
||||||
|
atts[att.attachID] = att
|
||||||
|
messages[newMsg.messageID].attIDs = append(messages[newMsg.messageID].attIDs, att.attachID)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&messageCreated{messageID: newMsg.messageID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.messageIDs = append(acc.messageIDs, newMsg.messageID)
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return proton.Message{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg.toMessage(atts), nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) CreateAttachment(
|
||||||
|
userID string,
|
||||||
|
messageID string,
|
||||||
|
filename string,
|
||||||
|
mimeType rfc822.MIMEType,
|
||||||
|
disposition proton.Disposition,
|
||||||
|
keyPackets, dataPacket []byte,
|
||||||
|
armSig string,
|
||||||
|
) (proton.Attachment, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) (proton.Attachment, error) {
|
||||||
|
return withMessages(b, func(messages map[string]*message) (proton.Attachment, error) {
|
||||||
|
return withAtts(b, func(atts map[string]*attachment) (proton.Attachment, error) {
|
||||||
|
att := newAttachment(
|
||||||
|
filename,
|
||||||
|
mimeType,
|
||||||
|
disposition,
|
||||||
|
keyPackets,
|
||||||
|
b.createAttData(dataPacket),
|
||||||
|
armSig,
|
||||||
|
)
|
||||||
|
|
||||||
|
atts[att.attachID] = att
|
||||||
|
|
||||||
|
messages[messageID].attIDs = append(messages[messageID].attIDs, att.attachID)
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&messageUpdated{messageID: messageID})
|
||||||
|
if err != nil {
|
||||||
|
return proton.Attachment{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
|
||||||
|
return att.toAttachment(), nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetAttachment(attachID string) ([]byte, error) {
|
||||||
|
return withAtts(b, func(atts map[string]*attachment) ([]byte, error) {
|
||||||
|
att, ok := atts[attachID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no such attachment: %s", attachID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.attData[att.attDataID], nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetLatestEventID(userID string) (string, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) (string, error) {
|
||||||
|
return acc.updateIDs[len(acc.updateIDs)-1].String(), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetEvent(userID, rawEventID string) (proton.Event, error) {
|
||||||
|
var eventID ID
|
||||||
|
|
||||||
|
if err := eventID.FromString(rawEventID); err != nil {
|
||||||
|
return proton.Event{}, fmt.Errorf("invalid event ID: %s", rawEventID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return withAcc(b, userID, func(acc *account) (proton.Event, error) {
|
||||||
|
return withMessages(b, func(messages map[string]*message) (proton.Event, error) {
|
||||||
|
return withLabels(b, func(labels map[string]*label) (proton.Event, error) {
|
||||||
|
updates, err := withUpdates(b, func(updates map[ID]update) ([]update, error) {
|
||||||
|
return merge(xslices.Map(acc.updateIDs[xslices.Index(acc.updateIDs, eventID)+1:], func(updateID ID) update {
|
||||||
|
return updates[updateID]
|
||||||
|
})), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return proton.Event{}, fmt.Errorf("failed to merge updates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildEvent(updates, acc.addresses, messages, labels, acc.updateIDs[len(acc.updateIDs)-1].String()), nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetPublicKeys(email string) ([]proton.PublicKey, error) {
|
||||||
|
return withAccEmail(b, email, func(acc *account) ([]proton.PublicKey, error) {
|
||||||
|
var keys []proton.PublicKey
|
||||||
|
|
||||||
|
for _, addr := range acc.addresses {
|
||||||
|
if addr.email == email {
|
||||||
|
for _, key := range addr.keys {
|
||||||
|
pubKey, err := key.getPubKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
armKey, err := pubKey.GetArmoredPublicKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keys = append(keys, proton.PublicKey{
|
||||||
|
Flags: proton.KeyStateTrusted | proton.KeyStateActive,
|
||||||
|
PublicKey: armKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLabelIDsToDelete(labelID string, labels map[string]*label) []string {
|
||||||
|
labelIDs := []string{labelID}
|
||||||
|
|
||||||
|
for _, label := range labels {
|
||||||
|
if label.parentID == labelID {
|
||||||
|
labelIDs = append(labelIDs, getLabelIDsToDelete(label.labelID, labels)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return labelIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildEvent(
|
||||||
|
updates []update,
|
||||||
|
addresses map[string]*address,
|
||||||
|
messages map[string]*message,
|
||||||
|
labels map[string]*label,
|
||||||
|
eventID string,
|
||||||
|
) proton.Event {
|
||||||
|
event := proton.Event{EventID: eventID}
|
||||||
|
|
||||||
|
for _, update := range updates {
|
||||||
|
switch update := update.(type) {
|
||||||
|
case *userRefreshed:
|
||||||
|
event.Refresh = update.refresh
|
||||||
|
|
||||||
|
case *messageCreated:
|
||||||
|
event.Messages = append(event.Messages, proton.MessageEvent{
|
||||||
|
EventItem: proton.EventItem{
|
||||||
|
ID: update.messageID,
|
||||||
|
Action: proton.EventCreate,
|
||||||
|
},
|
||||||
|
|
||||||
|
Message: messages[update.messageID].toMetadata(),
|
||||||
|
})
|
||||||
|
|
||||||
|
case *messageUpdated:
|
||||||
|
event.Messages = append(event.Messages, proton.MessageEvent{
|
||||||
|
EventItem: proton.EventItem{
|
||||||
|
ID: update.messageID,
|
||||||
|
Action: proton.EventUpdate,
|
||||||
|
},
|
||||||
|
|
||||||
|
Message: messages[update.messageID].toMetadata(),
|
||||||
|
})
|
||||||
|
|
||||||
|
case *messageDeleted:
|
||||||
|
event.Messages = append(event.Messages, proton.MessageEvent{
|
||||||
|
EventItem: proton.EventItem{
|
||||||
|
ID: update.messageID,
|
||||||
|
Action: proton.EventDelete,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
case *labelCreated:
|
||||||
|
event.Labels = append(event.Labels, proton.LabelEvent{
|
||||||
|
EventItem: proton.EventItem{
|
||||||
|
ID: update.labelID,
|
||||||
|
Action: proton.EventCreate,
|
||||||
|
},
|
||||||
|
|
||||||
|
Label: labels[update.labelID].toLabel(labels),
|
||||||
|
})
|
||||||
|
|
||||||
|
case *labelUpdated:
|
||||||
|
event.Labels = append(event.Labels, proton.LabelEvent{
|
||||||
|
EventItem: proton.EventItem{
|
||||||
|
ID: update.labelID,
|
||||||
|
Action: proton.EventUpdate,
|
||||||
|
},
|
||||||
|
|
||||||
|
Label: labels[update.labelID].toLabel(labels),
|
||||||
|
})
|
||||||
|
|
||||||
|
case *labelDeleted:
|
||||||
|
event.Labels = append(event.Labels, proton.LabelEvent{
|
||||||
|
EventItem: proton.EventItem{
|
||||||
|
ID: update.labelID,
|
||||||
|
Action: proton.EventDelete,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
case *addressCreated:
|
||||||
|
event.Addresses = append(event.Addresses, proton.AddressEvent{
|
||||||
|
EventItem: proton.EventItem{
|
||||||
|
ID: update.addressID,
|
||||||
|
Action: proton.EventCreate,
|
||||||
|
},
|
||||||
|
|
||||||
|
Address: addresses[update.addressID].toAddress(),
|
||||||
|
})
|
||||||
|
|
||||||
|
case *addressUpdated:
|
||||||
|
event.Addresses = append(event.Addresses, proton.AddressEvent{
|
||||||
|
EventItem: proton.EventItem{
|
||||||
|
ID: update.addressID,
|
||||||
|
Action: proton.EventCreate,
|
||||||
|
},
|
||||||
|
|
||||||
|
Address: addresses[update.addressID].toAddress(),
|
||||||
|
})
|
||||||
|
|
||||||
|
case *addressDeleted:
|
||||||
|
event.Addresses = append(event.Addresses, proton.AddressEvent{
|
||||||
|
EventItem: proton.EventItem{
|
||||||
|
ID: update.addressID,
|
||||||
|
Action: proton.EventDelete,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
127
server/backend/api_auth.go
Normal file
127
server/backend/api_auth.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/ProtonMail/go-srp"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *Backend) NewAuthInfo(username string) (proton.AuthInfo, error) {
|
||||||
|
return withAccName(b, username, func(acc *account) (proton.AuthInfo, error) {
|
||||||
|
server, err := srp.NewServerFromSigned(modulus, acc.verifier, 2048)
|
||||||
|
if err != nil {
|
||||||
|
return proton.AuthInfo{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge, err := server.GenerateChallenge()
|
||||||
|
if err != nil {
|
||||||
|
return proton.AuthInfo{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
session := uuid.NewString()
|
||||||
|
|
||||||
|
b.srpLock.Lock()
|
||||||
|
defer b.srpLock.Unlock()
|
||||||
|
|
||||||
|
b.srp[session] = server
|
||||||
|
|
||||||
|
return proton.AuthInfo{
|
||||||
|
Version: 4,
|
||||||
|
Modulus: modulus,
|
||||||
|
ServerEphemeral: base64.StdEncoding.EncodeToString(challenge),
|
||||||
|
Salt: base64.StdEncoding.EncodeToString(acc.salt),
|
||||||
|
SRPSession: session,
|
||||||
|
TwoFA: proton.TwoFAInfo{Enabled: proton.TwoFADisabled},
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) NewAuth(username string, ephemeral, proof []byte, session string) (proton.Auth, error) {
|
||||||
|
return withAccName(b, username, func(acc *account) (proton.Auth, error) {
|
||||||
|
b.srpLock.Lock()
|
||||||
|
defer b.srpLock.Unlock()
|
||||||
|
|
||||||
|
server, ok := b.srp[session]
|
||||||
|
if !ok {
|
||||||
|
return proton.Auth{}, fmt.Errorf("invalid session")
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(b.srp, session)
|
||||||
|
|
||||||
|
serverProof, err := server.VerifyProofs(ephemeral, proof)
|
||||||
|
if !ok {
|
||||||
|
return proton.Auth{}, fmt.Errorf("invalid proof: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authUID, auth := uuid.NewString(), newAuth(b.authLife)
|
||||||
|
|
||||||
|
acc.authLock.Lock()
|
||||||
|
defer acc.authLock.Unlock()
|
||||||
|
|
||||||
|
acc.auth[authUID] = auth
|
||||||
|
|
||||||
|
return auth.toAuth(acc.userID, authUID, serverProof), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) NewAuthRef(authUID, authRef string) (proton.Auth, error) {
|
||||||
|
b.accLock.RLock()
|
||||||
|
defer b.accLock.RUnlock()
|
||||||
|
|
||||||
|
for _, acc := range b.accounts {
|
||||||
|
acc.authLock.Lock()
|
||||||
|
defer acc.authLock.Unlock()
|
||||||
|
|
||||||
|
auth, ok := acc.auth[authUID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth.ref != authRef {
|
||||||
|
return proton.Auth{}, fmt.Errorf("invalid auth ref")
|
||||||
|
}
|
||||||
|
|
||||||
|
newAuth := newAuth(b.authLife)
|
||||||
|
|
||||||
|
acc.auth[authUID] = newAuth
|
||||||
|
|
||||||
|
return newAuth.toAuth(acc.userID, authUID, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return proton.Auth{}, fmt.Errorf("invalid auth")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) VerifyAuth(authUID, authAcc string) (string, error) {
|
||||||
|
return withAccAuth(b, authUID, authAcc, func(acc *account) (string, error) {
|
||||||
|
return acc.userID, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) GetSessions(userID string) ([]proton.AuthSession, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) ([]proton.AuthSession, error) {
|
||||||
|
acc.authLock.RLock()
|
||||||
|
defer acc.authLock.RUnlock()
|
||||||
|
|
||||||
|
var sessions []proton.AuthSession
|
||||||
|
|
||||||
|
for authUID, auth := range acc.auth {
|
||||||
|
sessions = append(sessions, auth.toAuthSession(authUID))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) DeleteSession(userID, authUID string) error {
|
||||||
|
return b.withAcc(userID, func(acc *account) error {
|
||||||
|
acc.authLock.Lock()
|
||||||
|
defer acc.authLock.Unlock()
|
||||||
|
|
||||||
|
delete(acc.auth, authUID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
66
server/backend/attachment.go
Normal file
66
server/backend/attachment.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *Backend) createAttData(dataPacket []byte) string {
|
||||||
|
attDataID := uuid.NewString()
|
||||||
|
|
||||||
|
b.attDataLock.Lock()
|
||||||
|
defer b.attDataLock.Unlock()
|
||||||
|
|
||||||
|
b.attData[attDataID] = dataPacket
|
||||||
|
|
||||||
|
return attDataID
|
||||||
|
}
|
||||||
|
|
||||||
|
type attachment struct {
|
||||||
|
attachID string
|
||||||
|
attDataID string
|
||||||
|
|
||||||
|
filename string
|
||||||
|
mimeType rfc822.MIMEType
|
||||||
|
disposition proton.Disposition
|
||||||
|
|
||||||
|
keyPackets []byte
|
||||||
|
armSig string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAttachment(
|
||||||
|
filename string,
|
||||||
|
mimeType rfc822.MIMEType,
|
||||||
|
disposition proton.Disposition,
|
||||||
|
keyPackets []byte,
|
||||||
|
dataPacketID string,
|
||||||
|
armSig string,
|
||||||
|
) *attachment {
|
||||||
|
return &attachment{
|
||||||
|
attachID: uuid.NewString(),
|
||||||
|
attDataID: dataPacketID,
|
||||||
|
|
||||||
|
filename: filename,
|
||||||
|
mimeType: mimeType,
|
||||||
|
disposition: disposition,
|
||||||
|
|
||||||
|
keyPackets: keyPackets,
|
||||||
|
armSig: armSig,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (att *attachment) toAttachment() proton.Attachment {
|
||||||
|
return proton.Attachment{
|
||||||
|
ID: att.attachID,
|
||||||
|
|
||||||
|
Name: att.filename,
|
||||||
|
MIMEType: att.mimeType,
|
||||||
|
Disposition: att.disposition,
|
||||||
|
|
||||||
|
KeyPackets: base64.StdEncoding.EncodeToString(att.keyPackets),
|
||||||
|
Signature: att.armSig,
|
||||||
|
}
|
||||||
|
}
|
||||||
544
server/backend/backend.go
Normal file
544
server/backend/backend.go
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/ProtonMail/go-srp"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/bradenaw/juniper/xslices"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/exp/maps"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Backend struct {
|
||||||
|
accounts map[string]*account
|
||||||
|
accLock sync.RWMutex
|
||||||
|
|
||||||
|
attachments map[string]*attachment
|
||||||
|
attLock sync.Mutex
|
||||||
|
|
||||||
|
attData map[string][]byte
|
||||||
|
attDataLock sync.Mutex
|
||||||
|
|
||||||
|
messages map[string]*message
|
||||||
|
msgLock sync.Mutex
|
||||||
|
|
||||||
|
labels map[string]*label
|
||||||
|
lblLock sync.Mutex
|
||||||
|
|
||||||
|
updates map[ID]update
|
||||||
|
updatesLock sync.RWMutex
|
||||||
|
|
||||||
|
srp map[string]*srp.Server
|
||||||
|
srpLock sync.Mutex
|
||||||
|
|
||||||
|
authLife time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(authLife time.Duration) *Backend {
|
||||||
|
return &Backend{
|
||||||
|
accounts: make(map[string]*account),
|
||||||
|
attachments: make(map[string]*attachment),
|
||||||
|
attData: make(map[string][]byte),
|
||||||
|
messages: make(map[string]*message),
|
||||||
|
labels: make(map[string]*label),
|
||||||
|
updates: make(map[ID]update),
|
||||||
|
srp: make(map[string]*srp.Server),
|
||||||
|
|
||||||
|
authLife: authLife,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) SetAuthLife(authLife time.Duration) {
|
||||||
|
b.authLife = authLife
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) CreateUser(username string, password []byte) (string, error) {
|
||||||
|
b.accLock.Lock()
|
||||||
|
defer b.accLock.Unlock()
|
||||||
|
|
||||||
|
salt, err := crypto.RandomToken(16)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
passphrase, err := hashPassword(password, salt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
srpAuth, err := srp.NewAuthForVerifier(password, modulus, salt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier, err := srpAuth.GenerateVerifier(2048)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
armKey, err := GenerateKey(username, username, passphrase, "rsa", 2048)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := uuid.NewString()
|
||||||
|
|
||||||
|
b.accounts[userID] = newAccount(userID, username, armKey, salt, verifier)
|
||||||
|
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) RemoveUser(userID string) error {
|
||||||
|
b.accLock.Lock()
|
||||||
|
defer b.accLock.Unlock()
|
||||||
|
|
||||||
|
user, ok := b.accounts[userID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("user %s does not exist", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, labelID := range user.labelIDs {
|
||||||
|
delete(b.labels, labelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, messageID := range user.messageIDs {
|
||||||
|
for _, attID := range b.messages[messageID].attIDs {
|
||||||
|
if xslices.CountFunc(maps.Values(b.attachments), func(att *attachment) bool {
|
||||||
|
return att.attDataID == b.attachments[attID].attDataID
|
||||||
|
}) == 1 {
|
||||||
|
delete(b.attData, b.attachments[attID].attDataID)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(b.attachments, attID)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(b.messages, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(b.accounts, userID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) RefreshUser(userID string, refresh proton.RefreshFlag) error {
|
||||||
|
return b.withAcc(userID, func(acc *account) error {
|
||||||
|
updateID, err := b.newUpdate(&userRefreshed{refresh: refresh})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) CreateUserKey(userID string, password []byte) error {
|
||||||
|
b.accLock.Lock()
|
||||||
|
defer b.accLock.Unlock()
|
||||||
|
|
||||||
|
user, ok := b.accounts[userID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("user %s does not exist", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := crypto.RandomToken(16)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
passphrase, err := hashPassword(password, salt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
armKey, err := GenerateKey(user.username, user.username, passphrase, "rsa", 2048)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.keys = append(user.keys, key{keyID: uuid.NewString(), key: armKey})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) RemoveUserKey(userID, keyID string) error {
|
||||||
|
b.accLock.Lock()
|
||||||
|
defer b.accLock.Unlock()
|
||||||
|
|
||||||
|
user, ok := b.accounts[userID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("user %s does not exist", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := xslices.IndexFunc(user.keys, func(key key) bool {
|
||||||
|
return key.keyID == keyID
|
||||||
|
})
|
||||||
|
|
||||||
|
if idx == -1 {
|
||||||
|
return fmt.Errorf("key %s does not exist", keyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
user.keys = append(user.keys[:idx], user.keys[idx+1:]...)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) CreateAddress(userID, email string, password []byte) (string, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) (string, error) {
|
||||||
|
token, err := crypto.RandomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
armKey, err := GenerateKey(acc.username, email, token, "rsa", 2048)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
passphrase, err := hashPassword([]byte(password), acc.salt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
userKR, err := acc.keys[0].unlock(passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
encToken, sigToken, err := encryptWithSignature(userKR, token)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
addressID := uuid.NewString()
|
||||||
|
|
||||||
|
acc.addresses[addressID] = &address{
|
||||||
|
addrID: addressID,
|
||||||
|
email: email,
|
||||||
|
order: len(acc.addresses) + 1,
|
||||||
|
keys: []key{{
|
||||||
|
keyID: uuid.NewString(),
|
||||||
|
key: armKey,
|
||||||
|
tok: encToken,
|
||||||
|
sig: sigToken,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&addressCreated{addressID: addressID})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
|
||||||
|
return addressID, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) CreateAddressKey(userID, addrID string, password []byte) error {
|
||||||
|
return b.withAcc(userID, func(acc *account) error {
|
||||||
|
token, err := crypto.RandomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
armKey, err := GenerateKey(acc.username, acc.addresses[addrID].email, token, "rsa", 2048)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
passphrase, err := hashPassword([]byte(password), acc.salt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
userKR, err := acc.keys[0].unlock(passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
encToken, sigToken, err := encryptWithSignature(userKR, token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.addresses[addrID].keys = append(acc.addresses[addrID].keys, key{
|
||||||
|
keyID: uuid.NewString(),
|
||||||
|
key: armKey,
|
||||||
|
tok: encToken,
|
||||||
|
sig: sigToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&addressUpdated{addressID: addrID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) RemoveAddress(userID, addrID string) error {
|
||||||
|
return b.withAcc(userID, func(acc *account) error {
|
||||||
|
if _, ok := acc.addresses[addrID]; !ok {
|
||||||
|
return fmt.Errorf("address %s not found", addrID)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(acc.addresses, addrID)
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&addressDeleted{addressID: addrID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) RemoveAddressKey(userID, addrID, keyID string) error {
|
||||||
|
return b.withAcc(userID, func(acc *account) error {
|
||||||
|
idx := xslices.IndexFunc(acc.addresses[addrID].keys, func(key key) bool {
|
||||||
|
return key.keyID == keyID
|
||||||
|
})
|
||||||
|
|
||||||
|
if idx < 0 {
|
||||||
|
return fmt.Errorf("key %s not found", keyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.addresses[addrID].keys = append(acc.addresses[addrID].keys[:idx], acc.addresses[addrID].keys[idx+1:]...)
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&addressUpdated{addressID: addrID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) CreateMessage(
|
||||||
|
userID, addrID string,
|
||||||
|
subject string,
|
||||||
|
sender *mail.Address,
|
||||||
|
toList, ccList, bccList []*mail.Address,
|
||||||
|
armBody string,
|
||||||
|
mimeType rfc822.MIMEType,
|
||||||
|
flags proton.MessageFlag,
|
||||||
|
unread, starred bool,
|
||||||
|
) (string, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) (string, error) {
|
||||||
|
return withMessages(b, func(messages map[string]*message) (string, error) {
|
||||||
|
msg := newMessage(addrID, subject, sender, toList, ccList, bccList, armBody, mimeType, "")
|
||||||
|
|
||||||
|
msg.flags |= flags
|
||||||
|
msg.unread = unread
|
||||||
|
msg.starred = starred
|
||||||
|
|
||||||
|
messages[msg.messageID] = msg
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&messageCreated{messageID: msg.messageID})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.messageIDs = append(acc.messageIDs, msg.messageID)
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
|
||||||
|
return msg.messageID, nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) UpdateDraft(userID, draftID string, changes proton.DraftTemplate) (string, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) (string, error) {
|
||||||
|
return withMessages(b, func(messages map[string]*message) (string, error) {
|
||||||
|
if _, ok := messages[draftID]; !ok {
|
||||||
|
return "", fmt.Errorf("message %q not found", draftID)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages[draftID].applyChanges(changes)
|
||||||
|
|
||||||
|
updateID, err := b.newUpdate(&messageUpdated{messageID: draftID})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.updateIDs = append(acc.updateIDs, updateID)
|
||||||
|
|
||||||
|
return draftID, nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) Encrypt(userID, addrID, decBody string) (string, error) {
|
||||||
|
return withAcc(b, userID, func(acc *account) (string, error) {
|
||||||
|
pubKey, err := acc.addresses[addrID].keys[0].getPubKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
kr, err := crypto.NewKeyRing(pubKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
enc, err := kr.Encrypt(crypto.NewPlainMessageFromString(decBody), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return enc.GetArmored()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) withAcc(userID string, fn func(acc *account) error) error {
|
||||||
|
b.accLock.RLock()
|
||||||
|
defer b.accLock.RUnlock()
|
||||||
|
|
||||||
|
acc, ok := b.accounts[userID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("account %s not found", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fn(acc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) withAccEmail(email string, fn func(acc *account) error) error {
|
||||||
|
b.accLock.RLock()
|
||||||
|
defer b.accLock.RUnlock()
|
||||||
|
|
||||||
|
for _, acc := range b.accounts {
|
||||||
|
for _, addr := range acc.addresses {
|
||||||
|
if addr.email == email {
|
||||||
|
return fn(acc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("account %s not found", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withAcc[T any](b *Backend, userID string, fn func(acc *account) (T, error)) (T, error) {
|
||||||
|
b.accLock.RLock()
|
||||||
|
defer b.accLock.RUnlock()
|
||||||
|
|
||||||
|
for _, acc := range b.accounts {
|
||||||
|
if acc.userID == userID {
|
||||||
|
return fn(acc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return *new(T), fmt.Errorf("account not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func withAccName[T any](b *Backend, username string, fn func(acc *account) (T, error)) (T, error) {
|
||||||
|
b.accLock.RLock()
|
||||||
|
defer b.accLock.RUnlock()
|
||||||
|
|
||||||
|
for _, acc := range b.accounts {
|
||||||
|
if acc.username == username {
|
||||||
|
return fn(acc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return *new(T), fmt.Errorf("account not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func withAccEmail[T any](b *Backend, email string, fn func(acc *account) (T, error)) (T, error) {
|
||||||
|
b.accLock.RLock()
|
||||||
|
defer b.accLock.RUnlock()
|
||||||
|
|
||||||
|
for _, acc := range b.accounts {
|
||||||
|
if _, ok := acc.getAddr(email); ok {
|
||||||
|
return fn(acc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return *new(T), fmt.Errorf("account not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func withAccAuth[T any](b *Backend, authUID, authAcc string, fn func(acc *account) (T, error)) (T, error) {
|
||||||
|
b.accLock.RLock()
|
||||||
|
defer b.accLock.RUnlock()
|
||||||
|
|
||||||
|
for _, acc := range b.accounts {
|
||||||
|
acc.authLock.RLock()
|
||||||
|
defer acc.authLock.RUnlock()
|
||||||
|
|
||||||
|
auth, ok := acc.auth[authUID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth.acc == authAcc {
|
||||||
|
return fn(acc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return *new(T), fmt.Errorf("account not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) withMessages(fn func(map[string]*message) error) error {
|
||||||
|
b.msgLock.Lock()
|
||||||
|
defer b.msgLock.Unlock()
|
||||||
|
|
||||||
|
return fn(b.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withMessages[T any](b *Backend, fn func(map[string]*message) (T, error)) (T, error) {
|
||||||
|
b.msgLock.Lock()
|
||||||
|
defer b.msgLock.Unlock()
|
||||||
|
|
||||||
|
return fn(b.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withAtts[T any](b *Backend, fn func(map[string]*attachment) (T, error)) (T, error) {
|
||||||
|
b.attLock.Lock()
|
||||||
|
defer b.attLock.Unlock()
|
||||||
|
|
||||||
|
return fn(b.attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) withLabels(fn func(map[string]*label) error) error {
|
||||||
|
b.lblLock.Lock()
|
||||||
|
defer b.lblLock.Unlock()
|
||||||
|
|
||||||
|
return fn(b.labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withLabels[T any](b *Backend, fn func(map[string]*label) (T, error)) (T, error) {
|
||||||
|
b.lblLock.Lock()
|
||||||
|
defer b.lblLock.Unlock()
|
||||||
|
|
||||||
|
return fn(b.labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) newUpdate(event update) (ID, error) {
|
||||||
|
return withUpdates(b, func(updates map[ID]update) (ID, error) {
|
||||||
|
updateID := ID(len(updates))
|
||||||
|
|
||||||
|
updates[updateID] = event
|
||||||
|
|
||||||
|
return updateID, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func withUpdates[T any](b *Backend, fn func(map[ID]update) (T, error)) (T, error) {
|
||||||
|
b.updatesLock.Lock()
|
||||||
|
defer b.updatesLock.Unlock()
|
||||||
|
|
||||||
|
return fn(b.updates)
|
||||||
|
}
|
||||||
42
server/backend/crypto.go
Normal file
42
server/backend/crypto.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ProtonMail/go-srp"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/helper"
|
||||||
|
)
|
||||||
|
|
||||||
|
var GenerateKey = helper.GenerateKey
|
||||||
|
|
||||||
|
func hashPassword(password, salt []byte) ([]byte, error) {
|
||||||
|
passphrase, err := srp.MailboxPassword(password, salt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return passphrase[len(passphrase)-31:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptWithSignature(kr *crypto.KeyRing, b []byte) (string, string, error) {
|
||||||
|
enc, err := kr.Encrypt(crypto.NewPlainMessage(b), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
encArm, err := enc.GetArmored()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := kr.SignDetached(crypto.NewPlainMessage(b))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
sigArm, err := sig.GetArmored()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return encArm, sigArm, nil
|
||||||
|
}
|
||||||
39
server/backend/label.go
Normal file
39
server/backend/label.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type label struct {
|
||||||
|
labelID string
|
||||||
|
parentID string
|
||||||
|
name string
|
||||||
|
labelType proton.LabelType
|
||||||
|
messageIDs map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLabel(labelName, parentID string, labelType proton.LabelType) *label {
|
||||||
|
return &label{
|
||||||
|
labelID: uuid.NewString(),
|
||||||
|
parentID: parentID,
|
||||||
|
name: labelName,
|
||||||
|
labelType: labelType,
|
||||||
|
messageIDs: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (label *label) toLabel(labels map[string]*label) proton.Label {
|
||||||
|
var path []string
|
||||||
|
|
||||||
|
for labelID := label.labelID; labelID != ""; labelID = labels[labelID].parentID {
|
||||||
|
path = append([]string{labels[labelID].name}, path...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return proton.Label{
|
||||||
|
ID: label.labelID,
|
||||||
|
Name: label.name,
|
||||||
|
Path: path,
|
||||||
|
Type: label.labelType,
|
||||||
|
}
|
||||||
|
}
|
||||||
300
server/backend/message.go
Normal file
300
server/backend/message.go
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gluon/rfc822"
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/bradenaw/juniper/xslices"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type message struct {
|
||||||
|
messageID string
|
||||||
|
externalID string
|
||||||
|
addrID string
|
||||||
|
labelIDs []string
|
||||||
|
sysLabel *string
|
||||||
|
attIDs []string
|
||||||
|
|
||||||
|
subject string
|
||||||
|
sender *mail.Address
|
||||||
|
toList []*mail.Address
|
||||||
|
ccList []*mail.Address
|
||||||
|
bccList []*mail.Address
|
||||||
|
|
||||||
|
armBody string
|
||||||
|
mimeType rfc822.MIMEType
|
||||||
|
|
||||||
|
flags proton.MessageFlag
|
||||||
|
unread bool
|
||||||
|
starred bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMessage(
|
||||||
|
addrID string,
|
||||||
|
subject string,
|
||||||
|
sender *mail.Address,
|
||||||
|
toList, ccList, bccList []*mail.Address,
|
||||||
|
armBody string,
|
||||||
|
mimeType rfc822.MIMEType,
|
||||||
|
externalID string,
|
||||||
|
) *message {
|
||||||
|
return &message{
|
||||||
|
messageID: uuid.NewString(),
|
||||||
|
externalID: externalID,
|
||||||
|
addrID: addrID,
|
||||||
|
sysLabel: pointer(""),
|
||||||
|
|
||||||
|
subject: subject,
|
||||||
|
sender: sender,
|
||||||
|
toList: toList,
|
||||||
|
ccList: ccList,
|
||||||
|
bccList: bccList,
|
||||||
|
|
||||||
|
armBody: armBody,
|
||||||
|
mimeType: mimeType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *message) toMessage(att map[string]*attachment) proton.Message {
|
||||||
|
return proton.Message{
|
||||||
|
MessageMetadata: msg.toMetadata(),
|
||||||
|
|
||||||
|
Header: msg.getHeader(),
|
||||||
|
ParsedHeaders: msg.getParsedHeaders(),
|
||||||
|
Body: msg.armBody,
|
||||||
|
MIMEType: msg.mimeType,
|
||||||
|
Attachments: xslices.Map(msg.attIDs, func(attID string) proton.Attachment {
|
||||||
|
return att[attID].toAttachment()
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *message) toMetadata() proton.MessageMetadata {
|
||||||
|
labelIDs := []string{proton.AllMailLabel}
|
||||||
|
|
||||||
|
if msg.flags.Has(proton.MessageFlagSent) {
|
||||||
|
labelIDs = append(labelIDs, proton.AllSentLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !msg.flags.HasAny(proton.MessageFlagSent, proton.MessageFlagReceived) {
|
||||||
|
labelIDs = append(labelIDs, proton.AllDraftsLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.starred {
|
||||||
|
labelIDs = append(labelIDs, proton.StarredLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.sysLabel != nil {
|
||||||
|
if *msg.sysLabel != "" {
|
||||||
|
labelIDs = append(labelIDs, *msg.sysLabel)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch {
|
||||||
|
case msg.flags.Has(proton.MessageFlagReceived):
|
||||||
|
labelIDs = append(labelIDs, proton.InboxLabel)
|
||||||
|
|
||||||
|
case msg.flags.Has(proton.MessageFlagSent):
|
||||||
|
labelIDs = append(labelIDs, proton.SentLabel)
|
||||||
|
|
||||||
|
default:
|
||||||
|
labelIDs = append(labelIDs, proton.DraftsLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return proton.MessageMetadata{
|
||||||
|
ID: msg.messageID,
|
||||||
|
ExternalID: msg.externalID,
|
||||||
|
AddressID: msg.addrID,
|
||||||
|
LabelIDs: append(msg.labelIDs, labelIDs...),
|
||||||
|
|
||||||
|
Subject: msg.subject,
|
||||||
|
Sender: msg.sender,
|
||||||
|
ToList: msg.toList,
|
||||||
|
CCList: msg.ccList,
|
||||||
|
BCCList: msg.bccList,
|
||||||
|
|
||||||
|
Flags: msg.flags,
|
||||||
|
Unread: proton.Bool(msg.unread),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *message) getHeader() string {
|
||||||
|
builder := new(strings.Builder)
|
||||||
|
|
||||||
|
builder.WriteString("Subject: " + msg.subject + "\r\n")
|
||||||
|
|
||||||
|
if msg.sender != nil {
|
||||||
|
builder.WriteString("From: " + msg.sender.String() + "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.toList) > 0 {
|
||||||
|
builder.WriteString("To: " + toAddressList(msg.toList) + "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.ccList) > 0 {
|
||||||
|
builder.WriteString("Cc: " + toAddressList(msg.ccList) + "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.bccList) > 0 {
|
||||||
|
builder.WriteString("Bcc: " + toAddressList(msg.bccList) + "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.mimeType != "" {
|
||||||
|
builder.WriteString("Content-Type: " + string(msg.mimeType) + "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *message) getParsedHeaders() proton.Headers {
|
||||||
|
header, err := rfc822.NewHeader([]byte(msg.getHeader()))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed := make(proton.Headers)
|
||||||
|
|
||||||
|
header.Entries(func(key, value string) {
|
||||||
|
parsed[key] = append(parsed[key], value)
|
||||||
|
})
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyChanges will apply non-nil field from passed message.
|
||||||
|
//
|
||||||
|
// NOTE: This is not feature complete. It might panic on non-implemented
|
||||||
|
// changes.
|
||||||
|
func (msg *message) applyChanges(changes proton.DraftTemplate) {
|
||||||
|
if changes.Subject != "" {
|
||||||
|
msg.subject = changes.Subject
|
||||||
|
}
|
||||||
|
|
||||||
|
if changes.Sender != nil {
|
||||||
|
panic("sender change probably not allowed by API on existing draft")
|
||||||
|
}
|
||||||
|
|
||||||
|
if changes.ToList != nil {
|
||||||
|
msg.toList = append([]*mail.Address{}, changes.ToList...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if changes.CCList != nil {
|
||||||
|
msg.ccList = append([]*mail.Address{}, changes.CCList...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if changes.BCCList != nil {
|
||||||
|
msg.bccList = append([]*mail.Address{}, changes.BCCList...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if changes.Body != "" {
|
||||||
|
msg.armBody = changes.Body
|
||||||
|
}
|
||||||
|
|
||||||
|
if changes.MIMEType != "" {
|
||||||
|
msg.mimeType = changes.MIMEType
|
||||||
|
}
|
||||||
|
|
||||||
|
if changes.ExternalID != "" {
|
||||||
|
msg.externalID = changes.ExternalID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *message) addLabel(labelID string, labels map[string]*label) {
|
||||||
|
switch labelID {
|
||||||
|
case proton.InboxLabel, proton.SentLabel, proton.DraftsLabel:
|
||||||
|
msg.addFlagLabel(labelID, labels)
|
||||||
|
|
||||||
|
case proton.TrashLabel, proton.SpamLabel, proton.ArchiveLabel:
|
||||||
|
msg.addSystemLabel(labelID, labels)
|
||||||
|
|
||||||
|
case proton.StarredLabel:
|
||||||
|
msg.starred = true
|
||||||
|
|
||||||
|
default:
|
||||||
|
if label, ok := labels[labelID]; ok {
|
||||||
|
msg.addUserLabel(label, labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *message) addFlagLabel(labelID string, labels map[string]*label) {
|
||||||
|
msg.labelIDs = xslices.Filter(msg.labelIDs, func(otherLabelID string) bool {
|
||||||
|
return labels[otherLabelID].labelType == proton.LabelTypeLabel
|
||||||
|
})
|
||||||
|
|
||||||
|
msg.sysLabel = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *message) addSystemLabel(labelID string, labels map[string]*label) {
|
||||||
|
msg.labelIDs = xslices.Filter(msg.labelIDs, func(otherLabelID string) bool {
|
||||||
|
return labels[otherLabelID].labelType == proton.LabelTypeLabel
|
||||||
|
})
|
||||||
|
|
||||||
|
msg.sysLabel = &labelID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *message) addUserLabel(label *label, labels map[string]*label) {
|
||||||
|
if label.labelType != proton.LabelTypeLabel {
|
||||||
|
msg.labelIDs = xslices.Filter(msg.labelIDs, func(otherLabelID string) bool {
|
||||||
|
return labels[otherLabelID].labelType == proton.LabelTypeLabel
|
||||||
|
})
|
||||||
|
|
||||||
|
msg.sysLabel = pointer("")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(msg.labelIDs, label.labelID) {
|
||||||
|
msg.labelIDs = append(msg.labelIDs, label.labelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *message) remLabel(labelID string, labels map[string]*label) {
|
||||||
|
switch labelID {
|
||||||
|
case proton.InboxLabel, proton.SentLabel, proton.DraftsLabel:
|
||||||
|
msg.remFlagLabel(labelID, labels)
|
||||||
|
|
||||||
|
case proton.TrashLabel, proton.SpamLabel, proton.ArchiveLabel:
|
||||||
|
msg.remSystemLabel(labelID, labels)
|
||||||
|
|
||||||
|
case proton.StarredLabel:
|
||||||
|
msg.starred = false
|
||||||
|
|
||||||
|
default:
|
||||||
|
if label, ok := labels[labelID]; ok {
|
||||||
|
msg.remUserLabel(label, labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *message) remFlagLabel(labelID string, labels map[string]*label) {
|
||||||
|
msg.sysLabel = pointer("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *message) remSystemLabel(labelID string, labels map[string]*label) {
|
||||||
|
if msg.sysLabel != nil && *msg.sysLabel == labelID {
|
||||||
|
msg.sysLabel = pointer("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (msg *message) remUserLabel(label *label, labels map[string]*label) {
|
||||||
|
msg.labelIDs = xslices.Filter(msg.labelIDs, func(otherLabelID string) bool {
|
||||||
|
return otherLabelID != label.labelID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAddressList(addrs []*mail.Address) string {
|
||||||
|
res := make([]string, len(addrs))
|
||||||
|
|
||||||
|
for i, addr := range addrs {
|
||||||
|
res[i] = addr.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(res, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointer[T any](v T) *T {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
1
server/backend/modulus.asc
Normal file
1
server/backend/modulus.asc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
+88jb48lF5TyDBveyHZ7QhSvtc4V3pN8/eQW6kk6ok2egy4lr5Wz9h8iZP3erN9lReSx1Lk+WsLu1b3soDhXX/twTCUhxYwjS8r983aEshZJJq7p5tNroQ5pzrZMbK8Oszjajgdg2YzcMcaJqb9+Doi7egj/esUQ+Q7BWdxeK77Wafj9v7PiW6Ozx6ulppu1mZ+YGnXSXJsl1Cl4nPm7PNkgj4BQT3HLrxakh7Xc3agmepRKO/1jLaOBU/oO17URbA5rwh/ZlAOqEAKH5vJ+hA2acM3Bwsa/K8I/jWicxOoaLZ4RZFpLYvOxGbb4DggR2Ri/C6tNyeEQQKAtxpeV5g==
|
||||||
24
server/backend/modulus.go
Normal file
24
server/backend/modulus.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var modulus string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
arm, err := crypto.NewClearTextMessage(asc, sig).GetArmored()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modulus = arm
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed modulus.asc
|
||||||
|
var asc []byte
|
||||||
|
|
||||||
|
//go:embed modulus.sig
|
||||||
|
var sig []byte
|
||||||
BIN
server/backend/modulus.sig
Normal file
BIN
server/backend/modulus.sig
Normal file
Binary file not shown.
112
server/backend/types.go
Normal file
112
server/backend/types.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ID uint64
|
||||||
|
|
||||||
|
func (v ID) String() string {
|
||||||
|
return base64.StdEncoding.EncodeToString(v.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v ID) Bytes() []byte {
|
||||||
|
if v == 0 {
|
||||||
|
return []byte{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(big.Int).SetUint64(uint64(v)).Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *ID) FromString(s string) error {
|
||||||
|
b, err := base64.StdEncoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*v = ID(new(big.Int).SetBytes(b).Uint64())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type auth struct {
|
||||||
|
acc string
|
||||||
|
ref string
|
||||||
|
|
||||||
|
expiration time.Time
|
||||||
|
creation time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuth(authLife time.Duration) auth {
|
||||||
|
return auth{
|
||||||
|
acc: uuid.NewString(),
|
||||||
|
ref: uuid.NewString(),
|
||||||
|
|
||||||
|
expiration: time.Now().Add(authLife),
|
||||||
|
creation: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *auth) toAuth(userID, authUID string, proof []byte) proton.Auth {
|
||||||
|
return proton.Auth{
|
||||||
|
UserID: userID,
|
||||||
|
|
||||||
|
UID: authUID,
|
||||||
|
AccessToken: auth.acc,
|
||||||
|
RefreshToken: auth.ref,
|
||||||
|
ServerProof: base64.StdEncoding.EncodeToString(proof),
|
||||||
|
ExpiresIn: int(time.Until(auth.expiration).Seconds()),
|
||||||
|
|
||||||
|
TwoFA: proton.TwoFAInfo{Enabled: proton.TwoFADisabled},
|
||||||
|
PasswordMode: proton.OnePasswordMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (auth *auth) toAuthSession(authUID string) proton.AuthSession {
|
||||||
|
return proton.AuthSession{
|
||||||
|
UID: authUID,
|
||||||
|
CreateTime: auth.creation.Unix(),
|
||||||
|
Revocable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type key struct {
|
||||||
|
keyID string
|
||||||
|
key string
|
||||||
|
tok string
|
||||||
|
sig string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (key key) unlock(passphrase []byte) (*crypto.KeyRing, error) {
|
||||||
|
lockedKey, err := crypto.NewKeyFromArmored(key.key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockedKey, err := lockedKey.Unlock(passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.NewKeyRing(unlockedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (key key) getPubKey() (*crypto.Key, error) {
|
||||||
|
privKey, err := crypto.NewKeyFromArmored(key.key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKeyBin, err := privKey.GetPublicKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.NewKey(pubKeyBin)
|
||||||
|
}
|
||||||
23
server/backend/types_test.go
Normal file
23
server/backend/types_test.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestID(t *testing.T) {
|
||||||
|
var v ID
|
||||||
|
|
||||||
|
// We can set the ID from a string.
|
||||||
|
require.NoError(t, v.FromString("AQIDBA=="))
|
||||||
|
|
||||||
|
// We can get the ID as a string.
|
||||||
|
require.Equal(t, "AQIDBA==", v.String())
|
||||||
|
|
||||||
|
// We can get the ID as bytes.
|
||||||
|
require.Equal(t, []byte{1, 2, 3, 4}, v.Bytes())
|
||||||
|
|
||||||
|
// The ID is correct.
|
||||||
|
require.Equal(t, ID(0x01020304), v)
|
||||||
|
}
|
||||||
175
server/backend/updates.go
Normal file
175
server/backend/updates.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
"github.com/bradenaw/juniper/xslices"
|
||||||
|
)
|
||||||
|
|
||||||
|
func merge(updates []update) []update {
|
||||||
|
if len(updates) < 2 {
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
|
||||||
|
if merged := merge(updates[1:]); xslices.IndexFunc(merged, func(other update) bool {
|
||||||
|
return other.replaces(updates[0])
|
||||||
|
}) < 0 {
|
||||||
|
return append([]update{updates[0]}, merged...)
|
||||||
|
} else {
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type update interface {
|
||||||
|
replaces(other update) bool
|
||||||
|
|
||||||
|
_isUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
type baseUpdate struct{}
|
||||||
|
|
||||||
|
func (baseUpdate) replaces(update) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (baseUpdate) _isUpdate() {}
|
||||||
|
|
||||||
|
type userRefreshed struct {
|
||||||
|
baseUpdate
|
||||||
|
|
||||||
|
refresh proton.RefreshFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageCreated struct {
|
||||||
|
baseUpdate
|
||||||
|
messageID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageUpdated struct {
|
||||||
|
baseUpdate
|
||||||
|
messageID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (update *messageUpdated) replaces(other update) bool {
|
||||||
|
switch other := other.(type) {
|
||||||
|
case *messageUpdated:
|
||||||
|
return update.messageID == other.messageID
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageDeleted struct {
|
||||||
|
baseUpdate
|
||||||
|
messageID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (update *messageDeleted) replaces(other update) bool {
|
||||||
|
switch other := other.(type) {
|
||||||
|
case *messageCreated:
|
||||||
|
return update.messageID == other.messageID
|
||||||
|
|
||||||
|
case *messageUpdated:
|
||||||
|
return update.messageID == other.messageID
|
||||||
|
|
||||||
|
case *messageDeleted:
|
||||||
|
if update.messageID != other.messageID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("message deleted twice")
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type labelCreated struct {
|
||||||
|
baseUpdate
|
||||||
|
labelID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type labelUpdated struct {
|
||||||
|
baseUpdate
|
||||||
|
labelID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (update *labelUpdated) replaces(other update) bool {
|
||||||
|
switch other := other.(type) {
|
||||||
|
case *labelUpdated:
|
||||||
|
return update.labelID == other.labelID
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type labelDeleted struct {
|
||||||
|
baseUpdate
|
||||||
|
labelID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (update *labelDeleted) replaces(other update) bool {
|
||||||
|
switch other := other.(type) {
|
||||||
|
case *labelCreated:
|
||||||
|
return update.labelID == other.labelID
|
||||||
|
|
||||||
|
case *labelUpdated:
|
||||||
|
return update.labelID == other.labelID
|
||||||
|
|
||||||
|
case *labelDeleted:
|
||||||
|
if update.labelID != other.labelID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("label deleted twice")
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type addressCreated struct {
|
||||||
|
baseUpdate
|
||||||
|
addressID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type addressUpdated struct {
|
||||||
|
baseUpdate
|
||||||
|
addressID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (update *addressUpdated) replaces(other update) bool {
|
||||||
|
switch other := other.(type) {
|
||||||
|
case *addressUpdated:
|
||||||
|
return update.addressID == other.addressID
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type addressDeleted struct {
|
||||||
|
baseUpdate
|
||||||
|
addressID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (update *addressDeleted) replaces(other update) bool {
|
||||||
|
switch other := other.(type) {
|
||||||
|
case *addressCreated:
|
||||||
|
return update.addressID == other.addressID
|
||||||
|
|
||||||
|
case *addressUpdated:
|
||||||
|
return update.addressID == other.addressID
|
||||||
|
|
||||||
|
case *addressDeleted:
|
||||||
|
if update.addressID != other.addressID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("address deleted twice")
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
63
server/backend/updates_test.go
Normal file
63
server/backend/updates_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_mergeUpdates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
have []update
|
||||||
|
want []update
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single",
|
||||||
|
have: []update{&labelCreated{labelID: "1"}},
|
||||||
|
want: []update{&labelCreated{labelID: "1"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple",
|
||||||
|
have: []update{
|
||||||
|
&labelCreated{labelID: "1"},
|
||||||
|
&labelCreated{labelID: "2"},
|
||||||
|
},
|
||||||
|
want: []update{
|
||||||
|
&labelCreated{labelID: "1"},
|
||||||
|
&labelCreated{labelID: "2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace with updated",
|
||||||
|
have: []update{
|
||||||
|
&labelCreated{labelID: "1"},
|
||||||
|
&labelUpdated{labelID: "1"},
|
||||||
|
&labelUpdated{labelID: "1"},
|
||||||
|
},
|
||||||
|
want: []update{
|
||||||
|
&labelCreated{labelID: "1"},
|
||||||
|
&labelUpdated{labelID: "1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replace with delete",
|
||||||
|
have: []update{
|
||||||
|
&labelCreated{labelID: "1"},
|
||||||
|
&labelUpdated{labelID: "1"},
|
||||||
|
&labelUpdated{labelID: "1"},
|
||||||
|
&labelDeleted{labelID: "1"},
|
||||||
|
},
|
||||||
|
want: []update{
|
||||||
|
&labelDeleted{labelID: "1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := merge(tt.have); !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("mergeUpdates() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
52
server/cache.go
Normal file
52
server/cache.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewAuthCache() AuthCacher {
|
||||||
|
return &authCache{
|
||||||
|
info: make(map[string]proton.AuthInfo),
|
||||||
|
auth: make(map[string]proton.Auth),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type authCache struct {
|
||||||
|
info map[string]proton.AuthInfo
|
||||||
|
auth map[string]proton.Auth
|
||||||
|
lock sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *authCache) GetAuthInfo(username string) (proton.AuthInfo, bool) {
|
||||||
|
c.lock.RLock()
|
||||||
|
defer c.lock.RUnlock()
|
||||||
|
|
||||||
|
info, ok := c.info[username]
|
||||||
|
|
||||||
|
return info, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *authCache) SetAuthInfo(username string, info proton.AuthInfo) {
|
||||||
|
c.lock.Lock()
|
||||||
|
defer c.lock.Unlock()
|
||||||
|
|
||||||
|
c.info[username] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *authCache) GetAuth(username string) (proton.Auth, bool) {
|
||||||
|
c.lock.RLock()
|
||||||
|
defer c.lock.RUnlock()
|
||||||
|
|
||||||
|
auth, ok := c.auth[username]
|
||||||
|
|
||||||
|
return auth, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *authCache) SetAuth(username string, auth proton.Auth) {
|
||||||
|
c.lock.Lock()
|
||||||
|
defer c.lock.Unlock()
|
||||||
|
|
||||||
|
c.auth[username] = auth
|
||||||
|
}
|
||||||
50
server/call.go
Normal file
50
server/call.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Call struct {
|
||||||
|
URL *url.URL
|
||||||
|
Method string
|
||||||
|
Status int
|
||||||
|
|
||||||
|
RequestHeader http.Header
|
||||||
|
RequestBody []byte
|
||||||
|
|
||||||
|
ResponseHeader http.Header
|
||||||
|
ResponseBody []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type callWatcher struct {
|
||||||
|
paths map[string]struct{}
|
||||||
|
callFn func(Call)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCallWatcher(fn func(Call), paths ...string) callWatcher {
|
||||||
|
pathMap := make(map[string]struct{}, len(paths))
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
pathMap[path] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return callWatcher{
|
||||||
|
paths: pathMap,
|
||||||
|
callFn: fn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (watcher *callWatcher) isWatching(path string) bool {
|
||||||
|
if len(watcher.paths) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := watcher.paths[path]
|
||||||
|
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (watcher *callWatcher) publish(call Call) {
|
||||||
|
watcher.callFn(call)
|
||||||
|
}
|
||||||
291
server/cmd/client/client.go
Normal file
291
server/cmd/client/client.go
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-proton-api/server/proto"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := cli.NewApp()
|
||||||
|
|
||||||
|
app.Flags = []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "host",
|
||||||
|
Usage: "host to connect to",
|
||||||
|
Value: "localhost",
|
||||||
|
},
|
||||||
|
&cli.IntFlag{
|
||||||
|
Name: "port",
|
||||||
|
Usage: "port to connect to",
|
||||||
|
Value: 8080,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Commands = []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "info",
|
||||||
|
Action: getInfoAction,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "auth",
|
||||||
|
Subcommands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "revoke",
|
||||||
|
Action: revokeUserAction,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "userID",
|
||||||
|
Usage: "user ID to revoke",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "user",
|
||||||
|
Subcommands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "create",
|
||||||
|
Action: createUserAction,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "username",
|
||||||
|
Usage: "username of the account",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "email",
|
||||||
|
Usage: "email of the account",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "password",
|
||||||
|
Usage: "password of the account",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "address",
|
||||||
|
Subcommands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "create",
|
||||||
|
Action: createAddressAction,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "userID",
|
||||||
|
Usage: "ID of the user to create the address for",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "email",
|
||||||
|
Usage: "email of the account",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "password",
|
||||||
|
Usage: "password of the account",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "remove",
|
||||||
|
Action: removeAddressAction,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "userID",
|
||||||
|
Usage: "ID of the user to remove the address from",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "addressID",
|
||||||
|
Usage: "ID of the address to remove",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "label",
|
||||||
|
Subcommands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "create",
|
||||||
|
Action: createLabelAction,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "userID",
|
||||||
|
Usage: "ID of the user to create the label for",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "name",
|
||||||
|
Usage: "name of the label",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "parentID",
|
||||||
|
Usage: "the ID of the parent label",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "exclusive",
|
||||||
|
Usage: "Create an exclusive label (i.e. a folder)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.Run(os.Args); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInfoAction(c *cli.Context) error {
|
||||||
|
client, err := newServerClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.GetInfo(c.Context, &proto.GetInfoRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return pretty(c.App.Writer, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createUserAction(c *cli.Context) error {
|
||||||
|
client, err := newServerClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.CreateUser(c.Context, &proto.CreateUserRequest{
|
||||||
|
Username: c.String("username"),
|
||||||
|
Email: c.String("email"),
|
||||||
|
Password: []byte(c.String("password")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return pretty(c.App.Writer, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeUserAction(c *cli.Context) error {
|
||||||
|
client, err := newServerClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.RevokeUser(c.Context, &proto.RevokeUserRequest{
|
||||||
|
UserID: c.String("userID"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return pretty(c.App.Writer, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAddressAction(c *cli.Context) error {
|
||||||
|
client, err := newServerClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.CreateAddress(c.Context, &proto.CreateAddressRequest{
|
||||||
|
UserID: c.String("userID"),
|
||||||
|
Email: c.String("email"),
|
||||||
|
Password: []byte(c.String("password")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return pretty(c.App.Writer, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAddressAction(c *cli.Context) error {
|
||||||
|
client, err := newServerClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.RemoveAddress(c.Context, &proto.RemoveAddressRequest{
|
||||||
|
UserID: c.String("userID"),
|
||||||
|
AddrID: c.String("addressID"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return pretty(c.App.Writer, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createLabelAction(c *cli.Context) error {
|
||||||
|
client, err := newServerClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var labelType proto.LabelType
|
||||||
|
|
||||||
|
if c.Bool("exclusive") {
|
||||||
|
labelType = proto.LabelType_FOLDER
|
||||||
|
} else {
|
||||||
|
labelType = proto.LabelType_LABEL
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.CreateLabel(c.Context, &proto.CreateLabelRequest{
|
||||||
|
UserID: c.String("userID"),
|
||||||
|
Name: c.String("name"),
|
||||||
|
Type: labelType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return pretty(c.App.Writer, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServerClient(c *cli.Context) (proto.ServerClient, error) {
|
||||||
|
cc, err := grpc.DialContext(
|
||||||
|
c.Context,
|
||||||
|
net.JoinHostPort(c.String("host"), fmt.Sprint(c.Int("port"))),
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to dial: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return proto.NewServerClient(cc), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pretty[T any](w io.Writer, v T) error {
|
||||||
|
enc, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := w.Write(enc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user