├── .github
├── actions
│ ├── govulncheck.sh
│ └── govulncheck
│ │ └── action.yml
└── workflows
│ └── check.yml
├── .gitignore
├── CONTRIBUTING.md
├── COPYING_NOTES.md
├── LICENSE
├── README.md
├── address.go
├── address_test.go
├── address_types.go
├── attachment.go
├── attachment_interfaces.go
├── attachment_test.go
├── attachment_types.go
├── auth.go
├── auth_test.go
├── block.go
├── block_types.go
├── boolean.go
├── calendar.go
├── calendar_event.go
├── calendar_event_types.go
├── calendar_types.go
├── client.go
├── client_features.go
├── contact.go
├── contact_card.go
├── contact_test.go
├── contact_types.go
├── contexts.go
├── core_settings.go
├── core_settings_type.go
├── data.go
├── data_type.go
├── event.go
├── event_drive.go
├── event_drive_types.go
├── event_test.go
├── event_types.go
├── example_test.go
├── future.go
├── future_test.go
├── go.mod
├── go.sum
├── header_types.go
├── header_types_test.go
├── helper_test.go
├── hv.go
├── internal.go
├── job.go
├── keyring.go
├── keyring_test.go
├── keys.go
├── keys_types.go
├── label.go
├── label_types.go
├── link.go
├── link_file.go
├── link_file_types.go
├── link_folder.go
├── link_folder_types.go
├── link_types.go
├── logging.go
├── mail_settings.go
├── mail_settings_types.go
├── main_test.go
├── manager.go
├── manager_auth.go
├── manager_auth_types.go
├── manager_builder.go
├── manager_domains.go
├── manager_download.go
├── manager_features.go
├── manager_ping.go
├── manager_report.go
├── manager_report_test.go
├── manager_report_types.go
├── manager_status.go
├── manager_status_test.go
├── manager_test.go
├── manager_user.go
├── manager_user_types.go
├── message.go
├── message_build.go
├── message_draft_types.go
├── message_encrypt.go
├── message_encrypt_test.go
├── message_import.go
├── message_import_test.go
├── message_import_types.go
├── message_send.go
├── message_send_types.go
├── message_send_types_test.go
├── message_types.go
├── message_types_test.go
├── netctl.go
├── netctl_test.go
├── notification_types.go
├── observability.go
├── observability_types.go
├── option.go
├── organization.go
├── package.go
├── paging.go
├── pool.go
├── pool_test.go
├── response.go
├── response_test.go
├── salt.go
├── salt_types.go
├── server
├── addresses.go
├── attachments.go
├── auth.go
├── backend
│ ├── account.go
│ ├── address.go
│ ├── api.go
│ ├── api_auth.go
│ ├── attachment.go
│ ├── backend.go
│ ├── contact.go
│ ├── core_settings.go
│ ├── crypto.go
│ ├── crypto_fast.go
│ ├── label.go
│ ├── mail_settings.go
│ ├── message.go
│ ├── modulus.asc
│ ├── modulus.go
│ ├── modulus.sig
│ ├── observability.go
│ ├── quark.go
│ ├── report.go
│ ├── types.go
│ ├── types_test.go
│ ├── updates.go
│ └── updates_test.go
├── cache.go
├── call.go
├── cmd
│ ├── client
│ │ └── client.go
│ └── server
│ │ └── main.go
├── contacts.go
├── core_settings.go
├── data.go
├── domains.go
├── errors.go
├── events.go
├── features.go
├── helper_test.go
├── init_test.go
├── keys.go
├── labels.go
├── mail_settings.go
├── main_test.go
├── messages.go
├── ping.go
├── proto
│ ├── server.go
│ ├── server.pb.go
│ ├── server.proto
│ └── server_grpc.pb.go
├── proxy.go
├── quark.go
├── quark_test.go
├── rate_limit.go
├── reports.go
├── router.go
├── server.go
├── server_builder.go
├── server_test.go
└── users.go
├── share.go
├── share_types.go
├── testdata
├── MultipleAttachments.eml
├── body.pgp
├── prv.asc
└── pub.asc
├── ticker.go
├── undo.go
├── undo_types.go
├── unlock.go
├── user.go
├── user_types.go
├── utils
└── dependency_license.sh
├── volume.go
└── volume_types.go
/.github/actions/govulncheck.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -eo pipefail
4 |
5 | main(){
6 | local go_package="$1"
7 | govulncheck -json "$go_package" > vulns.json
8 |
9 | jq -r '.finding | select( (.osv != null) and (.trace[0].function != null) ) | .osv ' < vulns.json > vulns_osv_ids.txt
10 |
11 | ignore GO-2023-2328 "GODT-3124 RESTY race condition"
12 | ignore GO-2025-3563 "BRIDGE-346 net/http request smuggling"
13 | ignore GO-2025-3754 "BRIDGE-388 github.com/cloudflare/circl indirect import from gopenpgp; need to wait for upstream to patch"
14 | ignore GO-2025-3849 "BRIDGE-416 database/sql race condition leading to potential data overwrite"
15 | ignore GO-2025-3956 "BRIDGE-428 LookPath from os/exec may result in binaries listed in the path to be returned"
16 | ignore GO-2025-4010 "BRIDGE-440 IPv6 parsing"
17 | ignore GO-2025-4007 "BRIDGE-440 non-linear scaling w.r.t cert chain lenght when validating chains"
18 | ignore GO-2025-4009 "BRIDGE-440 non-linear scaling w.r.t parsing PEM inputs"
19 | ignore GO-2025-4015 "BRIDGE-440 Reader.ReadResponse excessive CPU usage"
20 | ignore GO-2025-4008 "BRIDGE-440 ALPN negotiation failure contains attacker controlled information (not-escaped)"
21 | ignore GO-2025-4012 "BRIDGE-440 potentially excessive memory usage on HTTP servers via cookies"
22 | ignore GO-2025-4013 "BRIDGE-440 validating cert chains with DSA public keys may cause programs to panic"
23 | ignore GO-2025-4011 "BRIDGE-440 pasing a maliciously crafted DER payloads could allocate excessive memory"
24 | ignore GO-2025-4014 "BRIDGE-440 tarball extraction may read an unbounded amount of data from the archive into memory"
25 |
26 | has_vulns
27 |
28 | echo
29 | echo "No new vulnerabilities found."
30 | }
31 |
32 | ignore(){
33 | echo "ignoring $1 fix: $2"
34 | cp vulns_osv_ids.txt tmp
35 | grep -v "$1" < tmp > vulns_osv_ids.txt || true
36 | rm tmp
37 | }
38 |
39 | has_vulns(){
40 | has=false
41 | while read -r osv; do
42 | jq \
43 | --arg osvid "$osv" \
44 | '.osv | select ( .id == $osvid) | {"id":.id, "ranges": .affected[0].ranges, "import": .affected[0].ecosystem_specific.imports[0].path}' \
45 | < vulns.json
46 | has=true
47 | done < vulns_osv_ids.txt
48 |
49 | if [ "$has" == true ]; then
50 | echo
51 | echo "Vulnerability found"
52 | return 1
53 | fi
54 | }
55 |
56 | main
57 |
--------------------------------------------------------------------------------
/.github/actions/govulncheck/action.yml:
--------------------------------------------------------------------------------
1 | name: 'golang-govulncheck-action'
2 | description: 'Run govulncheck'
3 | inputs:
4 | go-version-input: # version of Go to use for govulncheck
5 | description: 'Version of Go to use for govulncheck'
6 | required: false
7 | go-package:
8 | description: 'Go Package to scan with govulncheck'
9 | required: false
10 | default: './...'
11 | runs:
12 | using: "composite"
13 | steps:
14 | - uses: actions/setup-go@v5
15 | with:
16 | go-version: ${{ inputs.go-version-input }}
17 | check-latest: false
18 | cache: false
19 | - name: Install govulncheck
20 | run: go install golang.org/x/vuln/cmd/govulncheck@latest
21 | shell: bash
22 | - name: Run govulncheck
23 | run: |
24 | chmod +x .github/actions/govulncheck.sh
25 | .github/actions/govulncheck.sh ${{ inputs.go-package }}
26 | shell: bash
--------------------------------------------------------------------------------
/.github/workflows/check.yml:
--------------------------------------------------------------------------------
1 | name: Lint and Test
2 |
3 | on: push
4 |
5 | jobs:
6 | check:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - name: Get sources
10 | uses: actions/checkout@v4
11 |
12 | - name: Set up Go 1.24.4
13 | uses: actions/setup-go@v5
14 | with:
15 | go-version: "1.24.4"
16 |
17 | - name: Run golangci-lint
18 | uses: golangci/golangci-lint-action@v6.5.2
19 | with:
20 | version: v1.64.8
21 | args: --timeout=180s
22 | skip-cache: true
23 |
24 | - name: Run tests
25 | run: go test -v ./...
26 |
27 | - name: Run tests with race check
28 | run: go test -v -race ./...
29 |
30 | - name: Run govulncheck
31 | uses: ./.github/actions/govulncheck
32 | with:
33 | go-version-input: 1.24.4
34 | go-package: ./...
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Editor files
2 | .*.sw?
3 | *~
4 | .idea
5 | .vscode
6 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contribution Policy
2 |
3 | By making a contribution to this project:
4 |
5 | 1. I assign any and all copyright related to the contribution to Proton AG;
6 | 2. I certify that the contribution was created in whole by me;
7 | 3. I understand and agree that this project and the contribution are public
8 | and that a record of the contribution (including all personal information I
9 | submit with it) is maintained indefinitely and may be redistributed with
10 | this project or the open source license(s) involved.
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2020 James Houlahan
4 | Copyright (c) 2022 Proton AG
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Go Proton API
2 |
3 |
4 |
5 |
6 |
7 |
8 | This repository holds Go Proton API, a Go library implementing a client and development server for (a subset of) the Proton REST API.
9 |
10 | The license can be found in the [LICENSE](./LICENSE) file.
11 |
12 | For the contribution policy, see [CONTRIBUTING](./CONTRIBUTING.md).
13 |
14 | ## Environment variables
15 |
16 | Most of the integration tests run locally. The ones that interact with Proton servers require the following environment variables set:
17 |
18 | - ```GO_PROTON_API_TEST_USERNAME```
19 | - ```GO_PROTON_API_TEST_PASSWORD```
20 |
21 | ## Contribution
22 |
23 | The library is maintained by Proton AG, and is not actively looking for contributors.
24 |
--------------------------------------------------------------------------------
/address.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | "golang.org/x/exp/slices"
8 | )
9 |
10 | func (c *Client) GetAddresses(ctx context.Context) ([]Address, error) {
11 | var res struct {
12 | Addresses []Address
13 | }
14 |
15 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
16 | return r.SetResult(&res).Get("/core/v4/addresses")
17 | }); err != nil {
18 | return nil, err
19 | }
20 |
21 | slices.SortFunc(res.Addresses, func(a, b Address) bool {
22 | return a.Order < b.Order
23 | })
24 |
25 | return res.Addresses, nil
26 | }
27 |
28 | func (c *Client) GetAddress(ctx context.Context, addressID string) (Address, error) {
29 | var res struct {
30 | Address Address
31 | }
32 |
33 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
34 | return r.SetResult(&res).Get("/core/v4/addresses/" + addressID)
35 | }); err != nil {
36 | return Address{}, err
37 | }
38 |
39 | return res.Address, nil
40 | }
41 |
42 | func (c *Client) OrderAddresses(ctx context.Context, req OrderAddressesReq) error {
43 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
44 | return r.SetBody(req).Put("/core/v4/addresses/order")
45 | })
46 | }
47 |
48 | func (c *Client) EnableAddress(ctx context.Context, addressID string) error {
49 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
50 | return r.Put("/core/v4/addresses/" + addressID + "/enable")
51 | })
52 | }
53 |
54 | func (c *Client) DisableAddress(ctx context.Context, addressID string) error {
55 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
56 | return r.Put("/core/v4/addresses/" + addressID + "/disable")
57 | })
58 | }
59 |
60 | func (c *Client) DeleteAddress(ctx context.Context, addressID string) error {
61 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
62 | return r.Delete("/core/v4/addresses/" + addressID)
63 | })
64 | }
65 |
--------------------------------------------------------------------------------
/address_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | type Address struct {
4 | ID string
5 | Email string
6 |
7 | Send Bool
8 | Receive Bool
9 | Status AddressStatus
10 | Type AddressType
11 |
12 | Order int
13 | DisplayName string
14 |
15 | Keys Keys
16 | }
17 |
18 | type OrderAddressesReq struct {
19 | AddressIDs []string
20 | }
21 |
22 | type AddressStatus int
23 |
24 | const (
25 | AddressStatusDisabled AddressStatus = iota
26 | AddressStatusEnabled
27 | AddressStatusDeleting
28 | )
29 |
30 | type AddressType int
31 |
32 | const (
33 | AddressTypeOriginal AddressType = iota + 1
34 | AddressTypeAlias
35 | AddressTypeCustom
36 | AddressTypePremium
37 | AddressTypeExternal
38 | )
39 |
40 | // IsBYOEAddress - return a bool corresponding to whether an address is a BYOE address.
41 | // BYOE addresses have sending enabled and are of type `external`.
42 | func (a Address) IsBYOEAddress() bool {
43 | return bool(a.Send) && a.Type == AddressTypeExternal
44 | }
45 |
--------------------------------------------------------------------------------
/attachment.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "io"
8 |
9 | "github.com/ProtonMail/gopenpgp/v2/crypto"
10 | "github.com/go-resty/resty/v2"
11 | )
12 |
13 | func (c *Client) GetAttachment(ctx context.Context, attachmentID string) ([]byte, error) {
14 | var buffer bytes.Buffer
15 | if err := c.getAttachment(ctx, attachmentID, &buffer); err != nil {
16 | return nil, err
17 | }
18 | return buffer.Bytes(), nil
19 | }
20 |
21 | func (c *Client) GetAttachmentInto(ctx context.Context, attachmentID string, reader io.ReaderFrom) error {
22 | return c.getAttachment(ctx, attachmentID, reader)
23 | }
24 |
25 | func (c *Client) UploadAttachment(ctx context.Context, addrKR *crypto.KeyRing, req CreateAttachmentReq) (Attachment, error) {
26 | var res struct {
27 | Attachment Attachment
28 | }
29 |
30 | kr, err := addrKR.FirstKey()
31 | if err != nil {
32 | return res.Attachment, fmt.Errorf("failed to get first key: %w", err)
33 | }
34 |
35 | sig, err := kr.SignDetached(crypto.NewPlainMessage(req.Body))
36 | if err != nil {
37 | return Attachment{}, fmt.Errorf("failed to sign attachment: %w", err)
38 | }
39 |
40 | enc, err := kr.EncryptAttachment(crypto.NewPlainMessage(req.Body), req.Filename)
41 | if err != nil {
42 | return Attachment{}, fmt.Errorf("failed to encrypt attachment: %w", err)
43 | }
44 |
45 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
46 | return r.SetResult(&res).
47 | SetMultipartFormData(map[string]string{
48 | "MessageID": req.MessageID,
49 | "Filename": req.Filename,
50 | "MIMEType": string(req.MIMEType),
51 | "Disposition": string(req.Disposition),
52 | "ContentID": req.ContentID,
53 | }).
54 | SetMultipartFields(
55 | &resty.MultipartField{
56 | Param: "KeyPackets",
57 | FileName: "blob",
58 | ContentType: "application/octet-stream",
59 | Stream: resty.NewByteMultipartStream(enc.KeyPacket),
60 | },
61 | &resty.MultipartField{
62 | Param: "DataPacket",
63 | FileName: "blob",
64 | ContentType: "application/octet-stream",
65 | Stream: resty.NewByteMultipartStream(enc.DataPacket),
66 | },
67 | &resty.MultipartField{
68 | Param: "Signature",
69 | FileName: "blob",
70 | ContentType: "application/octet-stream",
71 | Stream: resty.NewByteMultipartStream(sig.GetBinary()),
72 | },
73 | ).
74 | Post("/mail/v4/attachments")
75 | }); err != nil {
76 | return Attachment{}, err
77 | }
78 |
79 | return res.Attachment, nil
80 | }
81 |
82 | func (c *Client) getAttachment(ctx context.Context, attachmentID string, reader io.ReaderFrom) error {
83 | res, err := c.doRes(ctx, func(req *resty.Request) (*resty.Response, error) {
84 | res, err := req.SetDoNotParseResponse(true).Get("/mail/v4/attachments/" + attachmentID)
85 | return parseResponse(res, err)
86 | })
87 | if err != nil {
88 | return fmt.Errorf("failed to request attachment: %w", err)
89 | }
90 | defer res.RawBody().Close()
91 |
92 | if _, err = reader.ReadFrom(res.RawBody()); err != nil {
93 | return err
94 | }
95 |
96 | return nil
97 | }
98 |
--------------------------------------------------------------------------------
/attachment_interfaces.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "bytes"
5 | "context"
6 |
7 | "github.com/ProtonMail/gluon/async"
8 | "github.com/bradenaw/juniper/parallel"
9 | )
10 |
11 | // AttachmentAllocator abstract the attachment download buffer creation.
12 | type AttachmentAllocator interface {
13 | // NewBuffer should return a new byte buffer for use. Note that this function may be called from multiple go-routines.
14 | NewBuffer() *bytes.Buffer
15 | }
16 |
17 | type DefaultAttachmentAllocator struct{}
18 |
19 | func NewDefaultAttachmentAllocator() *DefaultAttachmentAllocator {
20 | return &DefaultAttachmentAllocator{}
21 | }
22 |
23 | func (DefaultAttachmentAllocator) NewBuffer() *bytes.Buffer {
24 | return bytes.NewBuffer(nil)
25 | }
26 |
27 | // Scheduler allows the user to specify how the attachment data for the message should be downloaded.
28 | type Scheduler interface {
29 | Schedule(ctx context.Context, attachmentIDs []string, storageProvider AttachmentAllocator, downloader func(context.Context, string, *bytes.Buffer) error) ([]*bytes.Buffer, error)
30 | }
31 |
32 | // SequentialScheduler downloads the attachments one by one.
33 | type SequentialScheduler struct{}
34 |
35 | func NewSequentialScheduler() *SequentialScheduler {
36 | return &SequentialScheduler{}
37 | }
38 |
39 | func (SequentialScheduler) Schedule(ctx context.Context, attachmentIDs []string, storageProvider AttachmentAllocator, downloader func(context.Context, string, *bytes.Buffer) error) ([]*bytes.Buffer, error) {
40 | result := make([]*bytes.Buffer, len(attachmentIDs))
41 | for i, v := range attachmentIDs {
42 |
43 | select {
44 | case <-ctx.Done():
45 | return nil, ctx.Err()
46 | default:
47 | }
48 |
49 | buffer := storageProvider.NewBuffer()
50 | if err := downloader(ctx, v, buffer); err != nil {
51 | return nil, err
52 | }
53 |
54 | result[i] = buffer
55 | }
56 |
57 | return result, nil
58 | }
59 |
60 | type ParallelScheduler struct {
61 | workers int
62 | panicHandler async.PanicHandler
63 | }
64 |
65 | func NewParallelScheduler(workers int, panicHandler async.PanicHandler) *ParallelScheduler {
66 | if workers == 0 {
67 | workers = 1
68 | }
69 |
70 | return &ParallelScheduler{workers: workers}
71 | }
72 |
73 | func (p ParallelScheduler) Schedule(ctx context.Context, attachmentIDs []string, storageProvider AttachmentAllocator, downloader func(context.Context, string, *bytes.Buffer) error) ([]*bytes.Buffer, error) {
74 | // If we have less attachments than the maximum works, reduce worker count to match attachment count.
75 | workers := p.workers
76 | if len(attachmentIDs) < workers {
77 | workers = len(attachmentIDs)
78 | }
79 |
80 | return parallel.MapContext(ctx, workers, attachmentIDs, func(ctx context.Context, id string) (*bytes.Buffer, error) {
81 | defer async.HandlePanic(p.panicHandler)
82 |
83 | buffer := storageProvider.NewBuffer()
84 | if err := downloader(ctx, id, buffer); err != nil {
85 | return nil, err
86 | }
87 |
88 | return buffer, nil
89 | })
90 |
91 | }
92 |
--------------------------------------------------------------------------------
/attachment_test.go:
--------------------------------------------------------------------------------
1 | package proton_test
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "net/http"
7 | "sync"
8 | "testing"
9 |
10 | "github.com/ProtonMail/go-proton-api"
11 | "github.com/ProtonMail/go-proton-api/server"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | func TestAttachment_429Response(t *testing.T) {
16 | ctx, cancel := context.WithCancel(context.Background())
17 | defer cancel()
18 |
19 | s := server.New()
20 | defer s.Close()
21 |
22 | m := proton.New(
23 | proton.WithHostURL(s.GetHostURL()),
24 | proton.WithTransport(proton.InsecureTransport()),
25 | )
26 |
27 | _, _, err := s.CreateUser("user", []byte("pass"))
28 | require.NoError(t, err)
29 |
30 | c, _, err := m.NewClientWithLogin(ctx, "user", []byte("pass"))
31 | require.NoError(t, err)
32 |
33 | s.AddStatusHook(func(r *http.Request) (int, bool) {
34 | return http.StatusTooManyRequests, true
35 | })
36 |
37 | _, err = c.GetAttachment(ctx, "someID")
38 | require.Error(t, err)
39 |
40 | apiErr := new(proton.APIError)
41 | require.True(t, errors.As(err, &apiErr), "expected to be API error")
42 | require.Equal(t, 429, apiErr.Status)
43 | require.Equal(t, proton.InvalidValue, apiErr.Code)
44 | require.Equal(t, "Request failed with status 429", apiErr.Message)
45 | }
46 |
47 | func TestAttachment_ContextCancelled(t *testing.T) {
48 | ctx, cancel := context.WithCancel(context.Background())
49 |
50 | s := server.New()
51 | defer s.Close()
52 |
53 | m := proton.New(
54 | proton.WithHostURL(s.GetHostURL()),
55 | proton.WithTransport(proton.InsecureTransport()),
56 | )
57 |
58 | _, _, err := s.CreateUser("user", []byte("pass"))
59 | require.NoError(t, err)
60 |
61 | c, _, err := m.NewClientWithLogin(ctx, "user", []byte("pass"))
62 | require.NoError(t, err)
63 |
64 | wg := sync.WaitGroup{}
65 | wg.Add(1)
66 |
67 | s.AddStatusHook(func(r *http.Request) (int, bool) {
68 | wg.Wait()
69 | return http.StatusTooManyRequests, true
70 | })
71 |
72 | go func() {
73 | _, err = c.GetAttachment(ctx, "someID")
74 | wg.Done()
75 | }()
76 |
77 | cancel()
78 |
79 | wg.Wait()
80 | require.Error(t, err)
81 | require.True(t, errors.Is(err, context.Canceled))
82 | }
83 |
--------------------------------------------------------------------------------
/attachment_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "github.com/ProtonMail/gluon/rfc822"
5 | )
6 |
7 | type Attachment struct {
8 | ID string
9 |
10 | Name string
11 | Size int64
12 | MIMEType rfc822.MIMEType
13 | Disposition Disposition
14 | Headers Headers
15 |
16 | KeyPackets string
17 | Signature string
18 | }
19 |
20 | type Disposition string
21 |
22 | const (
23 | InlineDisposition Disposition = "inline"
24 | AttachmentDisposition Disposition = "attachment"
25 | )
26 |
27 | type CreateAttachmentReq struct {
28 | MessageID string
29 |
30 | Filename string
31 | MIMEType rfc822.MIMEType
32 | Disposition Disposition
33 | ContentID string
34 |
35 | Body []byte
36 | }
37 |
--------------------------------------------------------------------------------
/auth.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | )
8 |
9 | func (c *Client) Auth2FA(ctx context.Context, req Auth2FAReq) error {
10 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
11 | return r.SetBody(req).Post("/auth/v4/2fa")
12 | })
13 | }
14 |
15 | func (c *Client) AuthDelete(ctx context.Context) error {
16 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
17 | return r.Delete("/auth/v4")
18 | })
19 | }
20 |
21 | func (c *Client) AuthSessions(ctx context.Context) ([]AuthSession, error) {
22 | var res struct {
23 | Sessions []AuthSession
24 | }
25 |
26 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
27 | return r.SetResult(&res).Get("/auth/v4/sessions")
28 | }); err != nil {
29 | return nil, err
30 | }
31 |
32 | return res.Sessions, nil
33 | }
34 |
35 | func (c *Client) AuthRevoke(ctx context.Context, authUID string) error {
36 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
37 | return r.Delete("/auth/v4/sessions/" + authUID)
38 | })
39 | }
40 |
41 | func (c *Client) AuthRevokeAll(ctx context.Context) error {
42 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
43 | return r.Delete("/auth/v4/sessions")
44 | })
45 | }
46 |
--------------------------------------------------------------------------------
/block.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 | "io"
6 |
7 | "github.com/go-resty/resty/v2"
8 | )
9 |
10 | func (c *Client) GetBlock(ctx context.Context, bareURL, token string) (io.ReadCloser, error) {
11 | res, err := c.doRes(ctx, func(r *resty.Request) (*resty.Response, error) {
12 | return r.SetHeader("pm-storage-token", token).SetDoNotParseResponse(true).Get(bareURL)
13 | })
14 | if err != nil {
15 | return nil, err
16 | }
17 |
18 | return res.RawBody(), nil
19 | }
20 |
21 | func (c *Client) RequestBlockUpload(ctx context.Context, req BlockUploadReq) ([]BlockUploadLink, error) {
22 | var res struct {
23 | UploadLinks []BlockUploadLink
24 | }
25 |
26 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
27 | return r.SetResult(&res).SetBody(req).Post("/drive/blocks")
28 | }); err != nil {
29 | return nil, err
30 | }
31 |
32 | return res.UploadLinks, nil
33 | }
34 |
35 | func (c *Client) UploadBlock(ctx context.Context, bareURL, token string, block resty.MultiPartStream) error {
36 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
37 | return r.
38 | SetHeader("pm-storage-token", token).
39 | SetMultipartField("Block", "blob", "application/octet-stream", block).
40 | Post(bareURL)
41 | })
42 | }
43 |
--------------------------------------------------------------------------------
/block_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | // Block is a block of file contents. They are split in 4MB blocks although this number may change in the future.
4 | // Each block is its own data packet separated from the key packet which is held by the node,
5 | // which means the sessionKey is the same for every block.
6 | type Block struct {
7 | Index int
8 |
9 | BareURL string // URL to the block
10 | Token string // Token for download URL
11 |
12 | Hash string // Encrypted block's sha256 hash, in base64
13 | EncSignature string // Encrypted signature of the block
14 | SignatureEmail string // Email used to sign the block
15 | }
16 |
17 | type BlockUploadReq struct {
18 | AddressID string
19 | ShareID string
20 | LinkID string
21 | RevisionID string
22 |
23 | BlockList []BlockUploadInfo
24 | }
25 |
26 | type BlockUploadInfo struct {
27 | Index int
28 | Size int64
29 | EncSignature string
30 | Hash string
31 | }
32 |
33 | type BlockUploadLink struct {
34 | Token string
35 | BareURL string
36 | }
37 |
--------------------------------------------------------------------------------
/boolean.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import "encoding/json"
4 |
5 | // Bool is a convenience type for boolean values; it converts from APIBool to Go's builtin bool type.
6 | type Bool bool
7 |
8 | // APIBool is the boolean type used by the API (0 or 1).
9 | type APIBool int
10 |
11 | const (
12 | APIFalse APIBool = iota
13 | APITrue
14 | )
15 |
16 | func (b *Bool) UnmarshalJSON(data []byte) error {
17 | var v APIBool
18 |
19 | if err := json.Unmarshal(data, &v); err != nil {
20 | return err
21 | }
22 |
23 | *b = Bool(v == APITrue)
24 |
25 | return nil
26 | }
27 |
28 | func (b Bool) MarshalJSON() ([]byte, error) {
29 | var v APIBool
30 |
31 | if b {
32 | v = APITrue
33 | } else {
34 | v = APIFalse
35 | }
36 |
37 | return json.Marshal(v)
38 | }
39 |
40 | func (b Bool) String() string {
41 | if b {
42 | return "true"
43 | }
44 |
45 | return "false"
46 | }
47 |
48 | func (b Bool) FormatURL() string {
49 | if b {
50 | return "1"
51 | }
52 |
53 | return "0"
54 | }
55 |
--------------------------------------------------------------------------------
/calendar.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | )
8 |
9 | func (c *Client) GetCalendars(ctx context.Context) ([]Calendar, error) {
10 | var res struct {
11 | Calendars []Calendar
12 | }
13 |
14 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
15 | return r.SetResult(&res).Get("/calendar/v1")
16 | }); err != nil {
17 | return nil, err
18 | }
19 |
20 | return res.Calendars, nil
21 | }
22 |
23 | func (c *Client) GetCalendar(ctx context.Context, calendarID string) (Calendar, error) {
24 | var res struct {
25 | Calendar Calendar
26 | }
27 |
28 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
29 | return r.SetResult(&res).Get("/calendar/v1/" + calendarID)
30 | }); err != nil {
31 | return Calendar{}, err
32 | }
33 |
34 | return res.Calendar, nil
35 | }
36 |
37 | func (c *Client) GetCalendarKeys(ctx context.Context, calendarID string) (CalendarKeys, error) {
38 | var res struct {
39 | Keys CalendarKeys
40 | }
41 |
42 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
43 | return r.SetResult(&res).Get("/calendar/v1/" + calendarID + "/keys")
44 | }); err != nil {
45 | return nil, err
46 | }
47 |
48 | return res.Keys, nil
49 | }
50 |
51 | func (c *Client) GetCalendarMembers(ctx context.Context, calendarID string) ([]CalendarMember, error) {
52 | var res struct {
53 | Members []CalendarMember
54 | }
55 |
56 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
57 | return r.SetResult(&res).Get("/calendar/v1/" + calendarID + "/members")
58 | }); err != nil {
59 | return nil, err
60 | }
61 |
62 | return res.Members, nil
63 | }
64 |
65 | func (c *Client) GetCalendarPassphrase(ctx context.Context, calendarID string) (CalendarPassphrase, error) {
66 | var res struct {
67 | Passphrase CalendarPassphrase
68 | }
69 |
70 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
71 | return r.SetResult(&res).Get("/calendar/v1/" + calendarID + "/passphrase")
72 | }); err != nil {
73 | return CalendarPassphrase{}, err
74 | }
75 |
76 | return res.Passphrase, nil
77 | }
78 |
--------------------------------------------------------------------------------
/calendar_event.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 | "net/url"
6 | "strconv"
7 |
8 | "github.com/go-resty/resty/v2"
9 | )
10 |
11 | func (c *Client) CountCalendarEvents(ctx context.Context, calendarID string) (int, error) {
12 | var res struct {
13 | Total int
14 | }
15 |
16 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
17 | return r.SetResult(&res).Get("/calendar/v1/" + calendarID + "/events")
18 | }); err != nil {
19 | return 0, err
20 | }
21 |
22 | return res.Total, nil
23 | }
24 |
25 | // TODO: For now, the query params are partially constant -- should they be configurable?
26 | func (c *Client) GetCalendarEvents(ctx context.Context, calendarID string, page, pageSize int, filter url.Values) ([]CalendarEvent, error) {
27 | var res struct {
28 | Events []CalendarEvent
29 | }
30 |
31 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
32 | return r.SetQueryParams(map[string]string{
33 | "Page": strconv.Itoa(page),
34 | "PageSize": strconv.Itoa(pageSize),
35 | }).SetQueryParamsFromValues(filter).SetResult(&res).Get("/calendar/v1/" + calendarID + "/events")
36 | }); err != nil {
37 | return nil, err
38 | }
39 |
40 | return res.Events, nil
41 | }
42 |
43 | func (c *Client) GetAllCalendarEvents(ctx context.Context, calendarID string, filter url.Values) ([]CalendarEvent, error) {
44 | total, err := c.CountCalendarEvents(ctx, calendarID)
45 | if err != nil {
46 | return nil, err
47 | }
48 |
49 | return fetchPaged(ctx, total, maxPageSize, c, func(ctx context.Context, page, pageSize int) ([]CalendarEvent, error) {
50 | return c.GetCalendarEvents(ctx, calendarID, page, pageSize, filter)
51 | })
52 | }
53 |
54 | func (c *Client) GetCalendarEvent(ctx context.Context, calendarID, eventID string) (CalendarEvent, error) {
55 | var res struct {
56 | Event CalendarEvent
57 | }
58 |
59 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
60 | return r.SetResult(&res).Get("/calendar/v1/" + calendarID + "/events/" + eventID)
61 | }); err != nil {
62 | return CalendarEvent{}, err
63 | }
64 |
65 | return res.Event, nil
66 | }
67 |
--------------------------------------------------------------------------------
/calendar_event_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "encoding/base64"
5 |
6 | "github.com/ProtonMail/gopenpgp/v2/crypto"
7 | )
8 |
9 | type CalendarEvent struct {
10 | ID string
11 | UID string
12 | CalendarID string
13 | SharedEventID string
14 |
15 | CreateTime int64
16 | LastEditTime int64
17 | StartTime int64
18 | StartTimezone string
19 | EndTime int64
20 | EndTimezone string
21 | FullDay Bool
22 |
23 | Author string
24 | Permissions CalendarPermissions
25 | Attendees []CalendarAttendee
26 |
27 | SharedKeyPacket string
28 | CalendarKeyPacket string
29 |
30 | SharedEvents []CalendarEventPart
31 | CalendarEvents []CalendarEventPart
32 | AttendeesEvents []CalendarEventPart
33 | PersonalEvents []CalendarEventPart
34 | }
35 |
36 | // TODO: Only personal events have MemberID; should we have a different type for that?
37 | type CalendarEventPart struct {
38 | MemberID string
39 |
40 | Type CalendarEventType
41 | Data string
42 | Signature string
43 | Author string
44 | }
45 |
46 | func (part CalendarEventPart) Decode(calKR *crypto.KeyRing, addrKR *crypto.KeyRing, kp []byte) error {
47 | if part.Type&CalendarEventTypeEncrypted != 0 {
48 | var enc *crypto.PGPMessage
49 |
50 | if kp != nil {
51 | raw, err := base64.StdEncoding.DecodeString(part.Data)
52 | if err != nil {
53 | return err
54 | }
55 |
56 | enc = crypto.NewPGPSplitMessage(kp, raw).GetPGPMessage()
57 | } else {
58 | var err error
59 |
60 | if enc, err = crypto.NewPGPMessageFromArmored(part.Data); err != nil {
61 | return err
62 | }
63 | }
64 |
65 | dec, err := calKR.Decrypt(enc, nil, crypto.GetUnixTime())
66 | if err != nil {
67 | return err
68 | }
69 |
70 | part.Data = dec.GetString()
71 | }
72 |
73 | if part.Type&CalendarEventTypeSigned != 0 {
74 | sig, err := crypto.NewPGPSignatureFromArmored(part.Signature)
75 | if err != nil {
76 | return err
77 | }
78 |
79 | if err := addrKR.VerifyDetached(crypto.NewPlainMessageFromString(part.Data), sig, crypto.GetUnixTime()); err != nil {
80 | return err
81 | }
82 | }
83 |
84 | return nil
85 | }
86 |
87 | type CalendarEventType int
88 |
89 | const (
90 | CalendarEventTypeClear CalendarEventType = iota
91 | CalendarEventTypeEncrypted
92 | CalendarEventTypeSigned
93 | )
94 |
95 | type CalendarAttendee struct {
96 | ID string
97 | Token string
98 | Status CalendarAttendeeStatus
99 | Permissions CalendarPermissions
100 | }
101 |
102 | // TODO: What is this?
103 | type CalendarAttendeeStatus int
104 |
105 | const (
106 | CalendarAttendeeStatusPending CalendarAttendeeStatus = iota
107 | CalendarAttendeeStatusMaybe
108 | CalendarAttendeeStatusNo
109 | CalendarAttendeeStatusYes
110 | )
111 |
--------------------------------------------------------------------------------
/calendar_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/ProtonMail/gopenpgp/v2/crypto"
7 | )
8 |
9 | type Calendar struct {
10 | ID string
11 | Name string
12 | Description string
13 | Color string
14 | Display Bool
15 |
16 | Type CalendarType
17 | Flags CalendarFlag
18 | }
19 |
20 | type CalendarFlag int64
21 |
22 | const (
23 | CalendarFlagActive CalendarFlag = 1 << iota
24 | CalendarFlagUpdatePassphrase
25 | CalendarFlagResetNeeded
26 | CalendarFlagIncompleteSetup
27 | CalendarFlagLostAccess
28 | )
29 |
30 | type CalendarType int
31 |
32 | const (
33 | CalendarTypeNormal CalendarType = iota
34 | CalendarTypeSubscribed
35 | )
36 |
37 | type CalendarKey struct {
38 | ID string
39 | CalendarID string
40 | PassphraseID string
41 | PrivateKey string
42 | Flags CalendarKeyFlag
43 | }
44 |
45 | func (key CalendarKey) Unlock(passphrase []byte) (*crypto.Key, error) {
46 | lockedKey, err := crypto.NewKeyFromArmored(key.PrivateKey)
47 | if err != nil {
48 | return nil, err
49 | }
50 |
51 | return lockedKey.Unlock(passphrase)
52 | }
53 |
54 | type CalendarKeys []CalendarKey
55 |
56 | func (keys CalendarKeys) Unlock(passphrase []byte) (*crypto.KeyRing, error) {
57 | kr, err := crypto.NewKeyRing(nil)
58 | if err != nil {
59 | return nil, err
60 | }
61 |
62 | for _, key := range keys {
63 | if k, err := key.Unlock(passphrase); err != nil {
64 | continue
65 | } else if err := kr.AddKey(k); err != nil {
66 | return nil, err
67 | }
68 | }
69 |
70 | return kr, nil
71 | }
72 |
73 | // TODO: What is this?
74 | type CalendarKeyFlag int64
75 |
76 | const (
77 | CalendarKeyFlagActive CalendarKeyFlag = 1 << iota
78 | CalendarKeyFlagPrimary
79 | )
80 |
81 | type CalendarMember struct {
82 | ID string
83 | Permissions CalendarPermissions
84 | Email string
85 | Color string
86 | Display Bool
87 | CalendarID string
88 | }
89 |
90 | // TODO: What is this?
91 | type CalendarPermissions int
92 |
93 | // TODO: Support invitations.
94 | type CalendarPassphrase struct {
95 | ID string
96 | Flags CalendarPassphraseFlag
97 | MemberPassphrases []MemberPassphrase
98 | }
99 |
100 | func (passphrase CalendarPassphrase) Decrypt(memberID string, addrKR *crypto.KeyRing) ([]byte, error) {
101 | for _, passphrase := range passphrase.MemberPassphrases {
102 | if passphrase.MemberID == memberID {
103 | return passphrase.decrypt(addrKR)
104 | }
105 | }
106 |
107 | return nil, errors.New("no such member passphrase")
108 | }
109 |
110 | // TODO: What is this?
111 | type CalendarPassphraseFlag int64
112 |
113 | type MemberPassphrase struct {
114 | MemberID string
115 | Passphrase string
116 | Signature string
117 | }
118 |
119 | func (passphrase MemberPassphrase) decrypt(addrKR *crypto.KeyRing) ([]byte, error) {
120 | msg, err := crypto.NewPGPMessageFromArmored(passphrase.Passphrase)
121 | if err != nil {
122 | return nil, err
123 | }
124 |
125 | sig, err := crypto.NewPGPSignatureFromArmored(passphrase.Signature)
126 | if err != nil {
127 | return nil, err
128 | }
129 |
130 | dec, err := addrKR.Decrypt(msg, nil, crypto.GetUnixTime())
131 | if err != nil {
132 | return nil, err
133 | }
134 |
135 | if err := addrKR.VerifyDetached(dec, sig, crypto.GetUnixTime()); err != nil {
136 | return nil, err
137 | }
138 |
139 | return dec.GetBinary(), nil
140 | }
141 |
--------------------------------------------------------------------------------
/client_features.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | "github.com/google/uuid"
8 | )
9 |
10 | func (c *Client) GetFeatures(ctx context.Context, stickyKey uuid.UUID) (FeatureFlagResult, error) {
11 | res := FeatureFlagResult{}
12 |
13 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
14 | return r.SetResult(&res).Get(getFeatureFlagEndpoint(stickyKey))
15 | }); err != nil {
16 | return FeatureFlagResult{}, err
17 | }
18 |
19 | return res, nil
20 | }
21 |
--------------------------------------------------------------------------------
/contact_test.go:
--------------------------------------------------------------------------------
1 | package proton_test
2 |
3 | import (
4 | "bytes"
5 | "github.com/ProtonMail/gluon/rfc822"
6 | "github.com/ProtonMail/go-proton-api"
7 | "github.com/ProtonMail/gopenpgp/v2/crypto"
8 | "github.com/emersion/go-vcard"
9 | "github.com/stretchr/testify/require"
10 | "testing"
11 | )
12 |
13 | const message = `From: Nathaniel Borenstein
14 | To: Ned Freed
15 | Subject: Sample message (import 2)
16 | MIME-Version: 1.0
17 | Content-type: text/plain
18 |
19 | This is explicitly typed plain ASCII text.
20 | `
21 |
22 | func TestContactSettings(t *testing.T) {
23 | card, err := proton.NewCard(nil, proton.CardTypeClear)
24 | require.NoError(t, err)
25 | var field = vcard.Field{Value: "user@user"}
26 | err = card.Set(nil, vcard.FieldEmail, &field)
27 | require.NoError(t, err)
28 | var contact = proton.Contact{
29 | ContactMetadata: proton.ContactMetadata{},
30 | ContactCards: proton.ContactCards{
31 | Cards: []*proton.Card{card},
32 | },
33 | }
34 | settings, err := contact.GetSettings(nil, "user@user", proton.CardTypeClear)
35 | require.NoError(t, err)
36 |
37 | require.Equal(t, settings.MIMEType, (*rfc822.MIMEType)(nil))
38 | require.Equal(t, settings.Scheme, (*proton.EncryptionScheme)(nil))
39 | require.Equal(t, settings.Sign, (*bool)(nil))
40 | require.Equal(t, settings.Encrypt, (*bool)(nil))
41 | require.Equal(t, settings.Keys, ([]*crypto.Key)(nil))
42 |
43 | key, err := crypto.GenerateKey("user", "user@user", "x25519", 0)
44 | require.NoError(t, err)
45 | encryptedMessage, err := encryptMessage(key)
46 | require.NoError(t, err)
47 |
48 | settings.SetMimeType(rfc822.TextPlain)
49 | settings.SetScheme(proton.PGPInlineScheme)
50 | settings.SetSign(true)
51 | settings.SetEncrypt(true)
52 | settings.SetEncryptUntrusted(true)
53 | settings.AddKey(key)
54 |
55 | err = contact.SetSettings(nil, "user@user", proton.CardTypeClear, settings)
56 | require.NoError(t, err)
57 |
58 | settings, err = contact.GetSettings(nil, "user@user", proton.CardTypeClear)
59 | require.NoError(t, err)
60 |
61 | require.Equal(t, *settings.MIMEType, rfc822.TextPlain)
62 | require.Equal(t, *settings.Scheme, proton.PGPInlineScheme)
63 | require.Equal(t, *settings.Sign, true)
64 | require.Equal(t, *settings.Encrypt, true)
65 | require.Equal(t, *settings.EncryptUntrusted, true)
66 | require.Equal(t, len(settings.Keys), 1)
67 | kr, err := crypto.NewKeyRing(settings.Keys[0])
68 | require.NoError(t, err)
69 |
70 | // check the key
71 | dec, err := decryptBody(kr, encryptedMessage)
72 | require.NoError(t, err)
73 | require.Equal(t, "This is explicitly typed plain ASCII text.\n", dec.GetString())
74 | }
75 |
76 | func encryptMessage(key *crypto.Key) ([]byte, error) {
77 | var buf bytes.Buffer
78 | kr, err := crypto.NewKeyRing(key)
79 | if err != nil {
80 | return buf.Bytes(), err
81 | }
82 | return proton.EncryptRFC822(kr, []byte(message))
83 | }
84 |
85 | func decryptBody(kr *crypto.KeyRing, encryptedMessage []byte) (*crypto.PlainMessage, error) {
86 | section := rfc822.Parse(encryptedMessage)
87 | // Read the body.
88 | body, err := section.DecodedBody()
89 | if err != nil {
90 | return nil, err
91 | }
92 | enc, err := crypto.NewPGPMessageFromArmored(string(body))
93 | if err != nil {
94 | return nil, err
95 | }
96 | return kr.Decrypt(enc, nil, crypto.GetUnixTime())
97 | }
98 |
--------------------------------------------------------------------------------
/contexts.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import "context"
4 |
5 | type withClientKeyType struct{}
6 |
7 | var withClientKey withClientKeyType
8 |
9 | // WithClient marks this context as originating from the client with the given ID.
10 | func WithClient(parent context.Context, clientID uint64) context.Context {
11 | return context.WithValue(parent, withClientKey, clientID)
12 | }
13 |
14 | // ClientIDFromContext returns true if this context was marked as originating from a client.
15 | func ClientIDFromContext(ctx context.Context) (uint64, bool) {
16 | clientID, ok := ctx.Value(withClientKey).(uint64)
17 | if !ok {
18 | return 0, false
19 | }
20 |
21 | return clientID, true
22 | }
23 |
--------------------------------------------------------------------------------
/core_settings.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | )
8 |
9 | func (c *Client) GetUserSettings(ctx context.Context) (UserSettings, error) {
10 | var res struct {
11 | UserSettings UserSettings
12 | }
13 |
14 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
15 | return r.SetResult(&res).Get("/core/v4/settings")
16 | }); err != nil {
17 | return UserSettings{}, err
18 | }
19 |
20 | return res.UserSettings, nil
21 | }
22 |
23 | func (c *Client) SetUserSettingsTelemetry(ctx context.Context, req SetTelemetryReq) (UserSettings, error) {
24 | var res struct {
25 | UserSettings UserSettings
26 | }
27 |
28 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
29 | return r.SetBody(req).SetResult(&res).Put("/core/v4/settings/telemetry")
30 | }); err != nil {
31 | return UserSettings{}, err
32 | }
33 |
34 | return res.UserSettings, nil
35 | }
36 |
37 | func (c *Client) SetUserSettingsCrashReports(ctx context.Context, req SetCrashReportReq) (UserSettings, error) {
38 | var res struct {
39 | UserSettings UserSettings
40 | }
41 |
42 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
43 | return r.SetBody(req).SetResult(&res).Put("/core/v4/settings/crashreports")
44 | }); err != nil {
45 | return UserSettings{}, err
46 | }
47 |
48 | return res.UserSettings, nil
49 | }
50 |
--------------------------------------------------------------------------------
/core_settings_type.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | type UserSettings struct {
4 | Telemetry SettingsBool
5 | CrashReports SettingsBool
6 | }
7 |
8 | type SetTelemetryReq struct {
9 | Telemetry SettingsBool
10 | }
11 |
12 | type SetCrashReportReq struct {
13 | CrashReports SettingsBool
14 | }
15 | type SettingsBool int
16 |
17 | const (
18 | SettingDisabled SettingsBool = iota
19 | SettingEnabled
20 | )
21 |
--------------------------------------------------------------------------------
/data.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | )
8 |
9 | func (c *Client) SendDataEvent(ctx context.Context, req SendStatsReq) error {
10 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
11 | return r.SetBody(req).Post("/data/v1/stats")
12 | })
13 | }
14 |
15 | func (c *Client) SendDataEventMultiple(ctx context.Context, req SendStatsMultiReq) error {
16 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
17 | return r.SetBody(req).Post("/data/v1/stats/multiple")
18 | })
19 | }
20 |
21 | func (m *Manager) SendUnauthDataEvent(ctx context.Context, req SendStatsReq) error {
22 | if _, err := m.r(ctx).SetBody(req).Post("/data/v1/stats"); err != nil {
23 | return err
24 | }
25 | return nil
26 | }
27 |
--------------------------------------------------------------------------------
/data_type.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | type SendStatsReq struct {
4 | MeasurementGroup string
5 | Event string
6 | Values map[string]any
7 | Dimensions map[string]any
8 | }
9 |
10 | type SendStatsMultiReq struct {
11 | EventInfo []SendStatsReq
12 | }
13 |
--------------------------------------------------------------------------------
/event.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/ProtonMail/gluon/async"
8 | "github.com/go-resty/resty/v2"
9 | )
10 |
11 | func (c *Client) GetLatestEventID(ctx context.Context) (string, error) {
12 | var res struct {
13 | Event
14 | }
15 |
16 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
17 | return r.SetResult(&res).Get("/core/v4/events/latest")
18 | }); err != nil {
19 | return "", err
20 | }
21 |
22 | return res.EventID, nil
23 | }
24 |
25 | // maxCollectedEvents limits the number of events which are collected per one GetEvent
26 | // call.
27 | const maxCollectedEvents = 50
28 |
29 | func (c *Client) GetEvent(ctx context.Context, eventID string) ([]Event, bool, error) {
30 | var events []Event
31 |
32 | event, more, err := c.getEvent(ctx, eventID)
33 | if err != nil {
34 | return nil, more, err
35 | }
36 |
37 | events = append(events, event)
38 |
39 | nCollected := 0
40 |
41 | for more {
42 | nCollected++
43 | if nCollected >= maxCollectedEvents {
44 | break
45 | }
46 |
47 | event, more, err = c.getEvent(ctx, event.EventID)
48 | if err != nil {
49 | return nil, false, err
50 | }
51 |
52 | events = append(events, event)
53 | }
54 |
55 | return events, more, nil
56 | }
57 |
58 | // NewEventStreamer returns a new event stream.
59 | // It polls the API for new events at random intervals between `period` and `period+jitter`.
60 | func (c *Client) NewEventStream(ctx context.Context, period, jitter time.Duration, lastEventID string) <-chan Event {
61 | eventCh := make(chan Event)
62 |
63 | go func() {
64 | defer async.HandlePanic(c.m.panicHandler)
65 |
66 | defer close(eventCh)
67 |
68 | ticker := NewTicker(period, jitter, c.m.panicHandler)
69 | defer ticker.Stop()
70 |
71 | for {
72 | select {
73 | case <-ctx.Done():
74 | return
75 |
76 | case <-ticker.C:
77 | // ...
78 | }
79 |
80 | events, _, err := c.GetEvent(ctx, lastEventID)
81 | if err != nil {
82 | continue
83 | }
84 |
85 | if events[len(events)-1].EventID == lastEventID {
86 | continue
87 | }
88 |
89 | for _, evt := range events {
90 | select {
91 | case <-ctx.Done():
92 | return
93 |
94 | case eventCh <- evt:
95 | lastEventID = evt.EventID
96 | }
97 | }
98 | }
99 | }()
100 |
101 | return eventCh
102 | }
103 |
104 | func (c *Client) getEvent(ctx context.Context, eventID string) (Event, bool, error) {
105 | var res struct {
106 | Event
107 |
108 | More Bool
109 | }
110 |
111 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
112 | return r.SetResult(&res).Get("/core/v4/events/" + eventID)
113 | }); err != nil {
114 | return Event{}, false, err
115 | }
116 |
117 | return res.Event, bool(res.More), nil
118 | }
119 |
--------------------------------------------------------------------------------
/event_drive.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | )
8 |
9 | func (c *Client) GetLatestVolumeEventID(ctx context.Context, volumeID string) (string, error) {
10 | var res struct {
11 | EventID string
12 | }
13 |
14 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
15 | return r.SetResult(&res).Get("/drive/volumes/" + volumeID + "/events/latest")
16 | }); err != nil {
17 | return "", err
18 | }
19 |
20 | return res.EventID, nil
21 | }
22 |
23 | func (c *Client) GetLatestShareEventID(ctx context.Context, shareID string) (string, error) {
24 | var res struct {
25 | EventID string
26 | }
27 |
28 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
29 | return r.SetResult(&res).Get("/drive/shares/" + shareID + "/events/latest")
30 | }); err != nil {
31 | return "", err
32 | }
33 |
34 | return res.EventID, nil
35 | }
36 |
37 | func (c *Client) GetVolumeEvent(ctx context.Context, volumeID, eventID string) (DriveEvent, error) {
38 | event, more, err := c.getVolumeEvent(ctx, volumeID, eventID)
39 | if err != nil {
40 | return DriveEvent{}, err
41 | }
42 |
43 | for more {
44 | var next DriveEvent
45 |
46 | next, more, err = c.getVolumeEvent(ctx, volumeID, event.EventID)
47 | if err != nil {
48 | return DriveEvent{}, err
49 | }
50 |
51 | event.Events = append(event.Events, next.Events...)
52 | }
53 |
54 | return event, nil
55 | }
56 |
57 | func (c *Client) GetShareEvent(ctx context.Context, shareID, eventID string) (DriveEvent, error) {
58 | event, more, err := c.getShareEvent(ctx, shareID, eventID)
59 | if err != nil {
60 | return DriveEvent{}, err
61 | }
62 |
63 | for more {
64 | var next DriveEvent
65 |
66 | next, more, err = c.getShareEvent(ctx, shareID, event.EventID)
67 | if err != nil {
68 | return DriveEvent{}, err
69 | }
70 |
71 | event.Events = append(event.Events, next.Events...)
72 | }
73 |
74 | return event, nil
75 | }
76 |
77 | func (c *Client) getVolumeEvent(ctx context.Context, volumeID, eventID string) (DriveEvent, bool, error) {
78 | var res struct {
79 | DriveEvent
80 |
81 | More Bool
82 | }
83 |
84 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
85 | return r.SetResult(&res).Get("/drive/volumes/" + volumeID + "/events/" + eventID)
86 | }); err != nil {
87 | return DriveEvent{}, false, err
88 | }
89 |
90 | return res.DriveEvent, bool(res.More), nil
91 | }
92 |
93 | func (c *Client) getShareEvent(ctx context.Context, shareID, eventID string) (DriveEvent, bool, error) {
94 | var res struct {
95 | DriveEvent
96 |
97 | More Bool
98 | }
99 |
100 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
101 | return r.SetResult(&res).Get("/drive/shares/" + shareID + "/events/" + eventID)
102 | }); err != nil {
103 | return DriveEvent{}, false, err
104 | }
105 |
106 | return res.DriveEvent, bool(res.More), nil
107 | }
108 |
--------------------------------------------------------------------------------
/event_drive_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | type DriveEvent struct {
4 | EventID string
5 |
6 | Events []LinkEvent
7 |
8 | Refresh Bool
9 | }
10 |
11 | type LinkEvent struct {
12 | EventID string
13 |
14 | EventType LinkEventType
15 |
16 | CreateTime int
17 |
18 | Link Link
19 |
20 | Data any
21 | }
22 |
23 | type LinkEventType int
24 |
25 | const (
26 | LinkEventDelete LinkEventType = iota
27 | LinkEventCreate
28 | LinkEventUpdate
29 | LinkEventUpdateMetadata
30 | )
31 |
--------------------------------------------------------------------------------
/event_test.go:
--------------------------------------------------------------------------------
1 | package proton_test
2 |
3 | import (
4 | "context"
5 | "testing"
6 | "time"
7 |
8 | "github.com/ProtonMail/go-proton-api"
9 | "github.com/ProtonMail/go-proton-api/server"
10 | "github.com/google/uuid"
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | func TestEventStreamer(t *testing.T) {
15 | ctx, cancel := context.WithCancel(context.Background())
16 | defer cancel()
17 |
18 | s := server.New()
19 | defer s.Close()
20 |
21 | m := proton.New(
22 | proton.WithHostURL(s.GetHostURL()),
23 | proton.WithTransport(proton.InsecureTransport()),
24 | )
25 |
26 | _, _, err := s.CreateUser("user", []byte("pass"))
27 | require.NoError(t, err)
28 |
29 | c, _, err := m.NewClientWithLogin(ctx, "user", []byte("pass"))
30 | require.NoError(t, err)
31 |
32 | createTestMessages(t, c, "pass", 10)
33 |
34 | latestEventID, err := c.GetLatestEventID(ctx)
35 | require.NoError(t, err)
36 |
37 | eventCh := make(chan proton.Event)
38 |
39 | go func() {
40 | for event := range c.NewEventStream(ctx, time.Second, 0, latestEventID) {
41 | eventCh <- event
42 | }
43 | }()
44 |
45 | // Perform some action to generate an event.
46 | metadata, err := c.GetMessageMetadata(ctx, proton.MessageFilter{})
47 | require.NoError(t, err)
48 | require.NoError(t, c.LabelMessages(ctx, []string{metadata[0].ID}, proton.TrashLabel))
49 |
50 | // Wait for the first event.
51 | <-eventCh
52 |
53 | // Close the client; this should stop the client's event streamer.
54 | c.Close()
55 |
56 | // Create a new client and perform some actions with it to generate more events.
57 | cc, _, err := m.NewClientWithLogin(ctx, "user", []byte("pass"))
58 | require.NoError(t, err)
59 | defer cc.Close()
60 |
61 | require.NoError(t, cc.LabelMessages(ctx, []string{metadata[1].ID}, proton.TrashLabel))
62 |
63 | // We should not receive any more events from the original client.
64 | select {
65 | case <-eventCh:
66 | require.Fail(t, "received unexpected event")
67 |
68 | default:
69 | // ...
70 | }
71 | }
72 |
73 | func TestMaxEventMerge(t *testing.T) {
74 | ctx, cancel := context.WithCancel(context.Background())
75 | defer cancel()
76 |
77 | s := server.New()
78 | defer s.Close()
79 |
80 | s.SetMaxUpdatesPerEvent(1)
81 |
82 | m := proton.New(
83 | proton.WithHostURL(s.GetHostURL()),
84 | proton.WithTransport(proton.InsecureTransport()),
85 | )
86 |
87 | _, _, err := s.CreateUser("user", []byte("pass"))
88 | require.NoError(t, err)
89 |
90 | c, _, err := m.NewClientWithLogin(ctx, "user", []byte("pass"))
91 | require.NoError(t, err)
92 |
93 | latestID, err := c.GetLatestEventID(ctx)
94 | require.NoError(t, err)
95 |
96 | label, err := c.CreateLabel(context.Background(), proton.CreateLabelReq{
97 | Name: uuid.NewString(),
98 | Color: "#f66",
99 | Type: proton.LabelTypeFolder,
100 | })
101 | require.NoError(t, err)
102 |
103 | for i := 0; i < 75; i++ {
104 | _, err := c.UpdateLabel(ctx, label.ID, proton.UpdateLabelReq{Name: uuid.NewString()})
105 | require.NoError(t, err)
106 | }
107 |
108 | events, more, err := c.GetEvent(ctx, latestID)
109 | require.NoError(t, err)
110 | require.True(t, more)
111 | require.Equal(t, 50, len(events))
112 |
113 | events2, more, err := c.GetEvent(ctx, events[len(events)-1].EventID)
114 | require.NotEqual(t, events, events2)
115 | require.NoError(t, err)
116 | require.False(t, more)
117 | require.Equal(t, 26, len(events2))
118 | }
119 |
--------------------------------------------------------------------------------
/event_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/bradenaw/juniper/xslices"
8 | )
9 |
10 | type Event struct {
11 | EventID string
12 |
13 | Refresh RefreshFlag
14 |
15 | User *User
16 |
17 | UserSettings *UserSettings
18 |
19 | MailSettings *MailSettings
20 |
21 | Messages []MessageEvent
22 |
23 | Labels []LabelEvent
24 |
25 | Addresses []AddressEvent
26 |
27 | Notifications []NotificationEvent
28 |
29 | UsedSpace *int64
30 | }
31 |
32 | func (event Event) String() string {
33 | var parts []string
34 |
35 | if event.Refresh != 0 {
36 | parts = append(parts, fmt.Sprintf("refresh: %v", event.Refresh))
37 | }
38 |
39 | if event.User != nil {
40 | parts = append(parts, "user: [modified]")
41 | }
42 |
43 | if event.MailSettings != nil {
44 | parts = append(parts, "mail-settings: [modified]")
45 | }
46 |
47 | if len(event.Messages) > 0 {
48 | parts = append(parts, fmt.Sprintf(
49 | "messages: created=%d, updated=%d, deleted=%d",
50 | xslices.CountFunc(event.Messages, func(e MessageEvent) bool { return e.Action == EventCreate }),
51 | xslices.CountFunc(event.Messages, func(e MessageEvent) bool { return e.Action == EventUpdate || e.Action == EventUpdateFlags }),
52 | xslices.CountFunc(event.Messages, func(e MessageEvent) bool { return e.Action == EventDelete }),
53 | ))
54 | }
55 |
56 | if len(event.Labels) > 0 {
57 | parts = append(parts, fmt.Sprintf(
58 | "labels: created=%d, updated=%d, deleted=%d",
59 | xslices.CountFunc(event.Labels, func(e LabelEvent) bool { return e.Action == EventCreate }),
60 | xslices.CountFunc(event.Labels, func(e LabelEvent) bool { return e.Action == EventUpdate || e.Action == EventUpdateFlags }),
61 | xslices.CountFunc(event.Labels, func(e LabelEvent) bool { return e.Action == EventDelete }),
62 | ))
63 | }
64 |
65 | if len(event.Addresses) > 0 {
66 | parts = append(parts, fmt.Sprintf(
67 | "addresses: created=%d, updated=%d, deleted=%d",
68 | xslices.CountFunc(event.Addresses, func(e AddressEvent) bool { return e.Action == EventCreate }),
69 | xslices.CountFunc(event.Addresses, func(e AddressEvent) bool { return e.Action == EventUpdate || e.Action == EventUpdateFlags }),
70 | xslices.CountFunc(event.Addresses, func(e AddressEvent) bool { return e.Action == EventDelete }),
71 | ))
72 | }
73 |
74 | return fmt.Sprintf("Event %s: %s", event.EventID, strings.Join(parts, ", "))
75 | }
76 |
77 | type RefreshFlag uint8
78 |
79 | const (
80 | RefreshMail RefreshFlag = 1 << iota // 1<<0 = 1
81 | _ // 1<<1 = 2
82 | _ // 1<<2 = 4
83 | _ // 1<<3 = 8
84 | _ // 1<<4 = 16
85 | _ // 1<<5 = 32
86 | _ // 1<<6 = 64
87 | _ // 1<<7 = 128
88 | RefreshAll RefreshFlag = 1< github.com/ProtonMail/resty/v2 v2.0.0-20250929142426-e3dc6308c80b
74 |
--------------------------------------------------------------------------------
/header_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "gitlab.com/c0b/go-ordered-json"
7 | )
8 |
9 | var ErrBadHeader = errors.New("bad header")
10 |
11 | type Headers struct {
12 | Values map[string][]string
13 | Order []string
14 | }
15 |
16 | func (h *Headers) UnmarshalJSON(b []byte) error {
17 | type rawHeaders map[string]any
18 |
19 | raw := make(rawHeaders)
20 |
21 | // Need to use a different type to deserialize, because there still is no official way for json to decode an object
22 | // with the fields in order https://github.com/golang/go/issues/27179.
23 | orderedMap := ordered.NewOrderedMap()
24 | if err := orderedMap.UnmarshalJSON(b); err != nil {
25 | return err
26 | }
27 |
28 | header := Headers{
29 | Values: make(map[string][]string, len(raw)),
30 | Order: make([]string, 0, len(raw)),
31 | }
32 |
33 | iter := orderedMap.EntriesIter()
34 |
35 | for {
36 | entry, ok := iter()
37 | if !ok {
38 | break
39 | }
40 |
41 | switch val := entry.Value.(type) {
42 | case string:
43 | header.Values[entry.Key] = []string{val}
44 |
45 | case []any:
46 | for _, val := range val {
47 | switch val := val.(type) {
48 | case string:
49 | header.Values[entry.Key] = append(header.Values[entry.Key], val)
50 |
51 | default:
52 | return ErrBadHeader
53 | }
54 | }
55 |
56 | default:
57 | return ErrBadHeader
58 | }
59 |
60 | header.Order = append(header.Order, entry.Key)
61 | }
62 |
63 | *h = header
64 |
65 | return nil
66 | }
67 |
68 | func (h Headers) MarshalJSON() ([]byte, error) {
69 | // Manually Serialize to preserve oder
70 | if len(h.Values) == 0 {
71 | return []byte{'{', '}'}, nil
72 | }
73 |
74 | out := make([]byte, 0, 64)
75 |
76 | out = append(out, '{')
77 |
78 | for _, k := range h.Order {
79 | v := h.Values[k]
80 |
81 | if len(v) == 0 {
82 | continue
83 | }
84 |
85 | key, err := json.Marshal(k)
86 | if err != nil {
87 | return nil, err
88 | }
89 |
90 | var val []byte
91 | if len(v) == 1 {
92 | val, err = json.Marshal(v[0])
93 | } else {
94 | val, err = json.Marshal(v)
95 | }
96 | if err != nil {
97 | return nil, err
98 | }
99 |
100 | out = append(out, key...)
101 | out = append(out, ':')
102 | out = append(out, val...)
103 | out = append(out, ',')
104 | }
105 |
106 | out[len(out)-1] = '}'
107 |
108 | return out, nil
109 | }
110 |
--------------------------------------------------------------------------------
/header_types_test.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "encoding/json"
5 | "github.com/stretchr/testify/require"
6 | "testing"
7 | )
8 |
9 | func TestHeaders_MarshalInOrder(t *testing.T) {
10 | jsonBytes := []byte(`{"zz":"v1","foo":["a","b"],"bar":"30"}`)
11 |
12 | var h Headers
13 |
14 | err := json.Unmarshal(jsonBytes, &h)
15 | require.NoError(t, err)
16 |
17 | expectedKeyOrder := []string{"zz", "foo", "bar"}
18 |
19 | require.Equal(t, expectedKeyOrder, h.Order)
20 |
21 | serializedJson, err := json.Marshal(h)
22 | require.NoError(t, err)
23 | require.Equal(t, jsonBytes, serializedJson)
24 | }
25 |
--------------------------------------------------------------------------------
/helper_test.go:
--------------------------------------------------------------------------------
1 | package proton_test
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "runtime"
7 | "testing"
8 |
9 | "github.com/ProtonMail/gluon/async"
10 | "github.com/ProtonMail/go-proton-api"
11 | "github.com/bradenaw/juniper/iterator"
12 | "github.com/bradenaw/juniper/stream"
13 | "github.com/google/uuid"
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | func createTestMessages(t *testing.T, c *proton.Client, pass string, count int) {
18 | t.Helper()
19 |
20 | ctx, cancel := context.WithCancel(context.Background())
21 | defer cancel()
22 |
23 | user, err := c.GetUser(ctx)
24 | require.NoError(t, err)
25 |
26 | addr, err := c.GetAddresses(ctx)
27 | require.NoError(t, err)
28 |
29 | salt, err := c.GetSalts(ctx)
30 | require.NoError(t, err)
31 |
32 | keyPass, err := salt.SaltForKey([]byte(pass), user.Keys.Primary().ID)
33 | require.NoError(t, err)
34 |
35 | _, addrKRs, err := proton.Unlock(user, addr, keyPass, async.NoopPanicHandler{})
36 | require.NoError(t, err)
37 |
38 | req := iterator.Collect(iterator.Map(iterator.Counter(count), func(i int) proton.ImportReq {
39 | return proton.ImportReq{
40 | Metadata: proton.ImportMetadata{
41 | AddressID: addr[0].ID,
42 | Flags: proton.MessageFlagReceived,
43 | Unread: true,
44 | },
45 | Message: []byte(fmt.Sprintf("From: sender@example.com\r\nReceiver: recipient@example.com\r\nSubject: %v\r\n\r\nHello World!", uuid.New())),
46 | }
47 | }))
48 |
49 | str, err := c.ImportMessages(ctx, addrKRs[addr[0].ID], runtime.NumCPU(), runtime.NumCPU(), req...)
50 | require.NoError(t, err)
51 |
52 | res, err := stream.Collect(ctx, str)
53 | require.NoError(t, err)
54 |
55 | for _, res := range res {
56 | require.Equal(t, proton.SuccessCode, res.Code)
57 | }
58 | }
59 |
60 | func importMessage(t *testing.T, c *proton.Client, ctx context.Context, pass string, literal string) (stream.Stream[proton.ImportRes], error) {
61 | t.Helper()
62 |
63 | user, err := c.GetUser(ctx)
64 | require.NoError(t, err)
65 |
66 | addr, err := c.GetAddresses(ctx)
67 | require.NoError(t, err)
68 |
69 | salt, err := c.GetSalts(ctx)
70 | require.NoError(t, err)
71 |
72 | keyPass, err := salt.SaltForKey([]byte(pass), user.Keys.Primary().ID)
73 | require.NoError(t, err)
74 |
75 | _, addrKRs, err := proton.Unlock(user, addr, keyPass, async.NoopPanicHandler{})
76 | require.NoError(t, err)
77 |
78 | req := []proton.ImportReq{
79 | {
80 | Metadata: proton.ImportMetadata{
81 | AddressID: addr[0].ID,
82 | Flags: proton.MessageFlagReceived,
83 | Unread: true,
84 | },
85 | Message: []byte(literal),
86 | },
87 | }
88 |
89 | return c.ImportMessages(ctx, addrKRs[addr[0].ID], runtime.NumCPU(), runtime.NumCPU(), req...)
90 | }
91 |
--------------------------------------------------------------------------------
/hv.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "strings"
7 |
8 | "github.com/go-resty/resty/v2"
9 | )
10 |
11 | // APIHVDetails contains information related to the human verification requests.
12 | type APIHVDetails struct {
13 | Methods []string `json:"HumanVerificationMethods"`
14 | Token string `json:"HumanVerificationToken"`
15 | }
16 |
17 | func addHVToRequest(req *resty.Request, hv *APIHVDetails) *resty.Request {
18 | if hv == nil {
19 | return req
20 | }
21 |
22 | return req.SetHeader(hvPMTokenHeaderField, hv.Token).SetHeader(hvPMTokenType, strings.Join(hv.Methods, ","))
23 | }
24 |
25 | var ErrAPIErrIsNotHVErr = errors.New("not HV error")
26 |
27 | func (err APIError) GetHVDetails() (*APIHVDetails, error) {
28 | if !err.IsHVError() {
29 | return nil, ErrAPIErrIsNotHVErr
30 | }
31 |
32 | r := new(APIHVDetails)
33 |
34 | if err := json.Unmarshal(err.Details, &r); err != nil {
35 | return nil, err
36 | }
37 |
38 | return r, nil
39 | }
40 |
--------------------------------------------------------------------------------
/internal.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "strings"
7 |
8 | "github.com/PuerkitoBio/goquery"
9 | "golang.org/x/net/html"
10 | )
11 |
12 | // Quark runs a quark command.
13 | func (m *Manager) Quark(ctx context.Context, command string, args ...string) error {
14 | if _, err := m.r(ctx).SetQueryParam("strInput", strings.Join(args, " ")).Get("/internal/quark/" + command); err != nil {
15 | return err
16 | }
17 |
18 | return nil
19 | }
20 |
21 | // QuarkRes is the same as Quark, but returns the content extracted from the response body.
22 | func (m *Manager) QuarkRes(ctx context.Context, command string, args ...string) ([]byte, error) {
23 | res, err := m.r(ctx).SetQueryParam("strInput", strings.Join(args, " ")).Get("/internal/quark/" + command)
24 | if err != nil {
25 | return nil, err
26 | }
27 |
28 | doc, err := html.Parse(bytes.NewReader(res.Body()))
29 | if err != nil {
30 | return nil, err
31 | }
32 |
33 | return []byte(strings.TrimSpace(goquery.NewDocumentFromNode(doc).Find(".content").Text())), nil
34 | }
35 |
--------------------------------------------------------------------------------
/job.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import "context"
4 |
5 | type job[In, Out any] struct {
6 | ctx context.Context
7 | req In
8 |
9 | res chan Out
10 | err chan error
11 |
12 | done chan struct{}
13 | }
14 |
15 | func newJob[In, Out any](ctx context.Context, req In) *job[In, Out] {
16 | return &job[In, Out]{
17 | ctx: ctx,
18 | req: req,
19 | res: make(chan Out),
20 | err: make(chan error),
21 | done: make(chan struct{}),
22 | }
23 | }
24 |
25 | func (job *job[In, Out]) Result() (Out, error) {
26 | return <-job.res, <-job.err
27 | }
28 |
29 | func (job *job[In, Out]) postSuccess(res Out) {
30 | close(job.err)
31 | job.res <- res
32 | }
33 |
34 | func (job *job[In, Out]) postFailure(err error) {
35 | close(job.res)
36 | job.err <- err
37 | }
38 |
39 | func (job *job[In, Out]) waitDone() {
40 | <-job.done
41 | }
42 |
--------------------------------------------------------------------------------
/keyring_test.go:
--------------------------------------------------------------------------------
1 | package proton_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/ProtonMail/go-proton-api"
7 | "github.com/ProtonMail/gopenpgp/v2/crypto"
8 | "github.com/ProtonMail/gopenpgp/v2/helper"
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func TestKeyring_Unlock(t *testing.T) {
13 | r := require.New(t)
14 |
15 | newKey := func(id, passphrase string) proton.Key {
16 | arm, err := helper.GenerateKey(id, id+"@email.com", []byte(passphrase), "rsa", 2048)
17 | r.NoError(err)
18 |
19 | privKey, err := crypto.NewKeyFromArmored(arm)
20 | r.NoError(err)
21 |
22 | serial, err := privKey.Serialize()
23 | r.NoError(err)
24 |
25 | return proton.Key{
26 | ID: id,
27 | PrivateKey: serial,
28 | Active: true,
29 | }
30 | }
31 |
32 | keys := proton.Keys{
33 | newKey("1", "good_phrase"),
34 | newKey("2", "good_phrase"),
35 | newKey("3", "bad_phrase"),
36 | }
37 |
38 | _, err := keys.Unlock([]byte("ugly_phrase"), nil)
39 | r.Error(err)
40 |
41 | kr, err := keys.Unlock([]byte("bad_phrase"), nil)
42 | r.NoError(err)
43 | r.Equal(1, kr.CountEntities())
44 |
45 | kr, err = keys.Unlock([]byte("good_phrase"), nil)
46 | r.NoError(err)
47 | r.Equal(2, kr.CountEntities())
48 | }
49 |
--------------------------------------------------------------------------------
/keys.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | )
8 |
9 | func (c *Client) GetPublicKeys(ctx context.Context, address string) (PublicKeys, RecipientType, error) {
10 | var res struct {
11 | Keys []PublicKey
12 | RecipientType RecipientType
13 | }
14 |
15 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
16 | return r.SetResult(&res).SetQueryParam("Email", address).Get("/core/v4/keys")
17 | }); err != nil {
18 | return nil, RecipientTypeExternal, err
19 | }
20 |
21 | return res.Keys, res.RecipientType, nil
22 | }
23 |
24 | func (c *Client) CreateAddressKey(ctx context.Context, req CreateAddressKeyReq) (Key, error) {
25 | var res struct {
26 | Key Key
27 | }
28 |
29 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
30 | return r.SetBody(req).SetResult(&res).Post("/core/v4/keys/address")
31 | }); err != nil {
32 | return Key{}, err
33 | }
34 |
35 | return res.Key, nil
36 | }
37 |
38 | func (c *Client) CreateLegacyAddressKey(ctx context.Context, req CreateAddressKeyReq) (Key, error) {
39 | var res struct {
40 | Key Key
41 | }
42 |
43 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
44 | return r.SetBody(req).SetResult(&res).Post("/core/v4/keys")
45 | }); err != nil {
46 | return Key{}, err
47 | }
48 |
49 | return res.Key, nil
50 | }
51 |
52 | func (c *Client) MakeAddressKeyPrimary(ctx context.Context, keyID string, keyList KeyList) error {
53 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
54 | return r.SetBody(struct{ SignedKeyList KeyList }{SignedKeyList: keyList}).Put("/core/v4/keys/" + keyID + "/primary")
55 | })
56 | }
57 |
58 | func (c *Client) DeleteAddressKey(ctx context.Context, keyID string, keyList KeyList) error {
59 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
60 | return r.SetBody(struct{ SignedKeyList KeyList }{SignedKeyList: keyList}).Post("/core/v4/keys/" + keyID + "/delete")
61 | })
62 | }
63 |
--------------------------------------------------------------------------------
/keys_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "encoding/json"
5 |
6 | "github.com/ProtonMail/gopenpgp/v2/crypto"
7 | )
8 |
9 | type PublicKey struct {
10 | Flags KeyState
11 | PublicKey string
12 | }
13 |
14 | type PublicKeys []PublicKey
15 |
16 | func (keys PublicKeys) GetKeyRing() (*crypto.KeyRing, error) {
17 | kr, err := crypto.NewKeyRing(nil)
18 | if err != nil {
19 | return nil, err
20 | }
21 |
22 | for _, key := range keys {
23 | pubKey, err := crypto.NewKeyFromArmored(key.PublicKey)
24 | if err != nil {
25 | return nil, err
26 | }
27 |
28 | if err := kr.AddKey(pubKey); err != nil {
29 | return nil, err
30 | }
31 | }
32 |
33 | return kr, nil
34 | }
35 |
36 | type KeyList struct {
37 | Data string
38 | Signature string
39 | }
40 |
41 | func NewKeyList(signer *crypto.KeyRing, entries []KeyListEntry) (KeyList, error) {
42 | data, err := json.Marshal(entries)
43 | if err != nil {
44 | return KeyList{}, err
45 | }
46 |
47 | sig, err := signer.SignDetached(crypto.NewPlainMessage(data))
48 | if err != nil {
49 | return KeyList{}, err
50 | }
51 |
52 | arm, err := sig.GetArmored()
53 | if err != nil {
54 | return KeyList{}, err
55 | }
56 |
57 | return KeyList{
58 | Data: string(data),
59 | Signature: arm,
60 | }, nil
61 | }
62 |
63 | type KeyListEntry struct {
64 | Fingerprint string
65 | SHA256Fingerprints []string
66 | Flags KeyState
67 | Primary Bool
68 | }
69 |
70 | type KeyState int
71 |
72 | const (
73 | KeyStateTrusted KeyState = 1 << iota // 2^0 = 1 means the key is not compromised (i.e. if we can trust signatures coming from it)
74 | KeyStateActive // 2^1 = 2 means the key is still in use (i.e. not obsolete, we can encrypt messages to it)
75 | )
76 |
77 | type CreateAddressKeyReq struct {
78 | AddressID string
79 | PrivateKey string
80 | Primary Bool
81 | SignedKeyList KeyList
82 |
83 | // The following are only used in "migrated accounts"
84 | Token string `json:",omitempty"`
85 | Signature string `json:",omitempty"`
86 | }
87 |
88 | type MakeAddressKeyPrimaryReq struct {
89 | SignedKeyList KeyList
90 | }
91 |
--------------------------------------------------------------------------------
/label.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "strconv"
7 |
8 | "github.com/go-resty/resty/v2"
9 | )
10 |
11 | var ErrNoSuchLabel = errors.New("no such label")
12 |
13 | func (c *Client) GetLabel(ctx context.Context, labelID string, labelTypes ...LabelType) (Label, error) {
14 | labels, err := c.GetLabels(ctx, labelTypes...)
15 | if err != nil {
16 | return Label{}, err
17 | }
18 |
19 | for _, label := range labels {
20 | if label.ID == labelID {
21 | return label, nil
22 | }
23 | }
24 |
25 | return Label{}, ErrNoSuchLabel
26 | }
27 |
28 | func (c *Client) GetLabels(ctx context.Context, labelTypes ...LabelType) ([]Label, error) {
29 | var labels []Label
30 |
31 | for _, labelType := range labelTypes {
32 | labelType := labelType
33 |
34 | var res struct {
35 | Labels []Label
36 | }
37 |
38 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
39 | return r.SetQueryParam("Type", strconv.Itoa(int(labelType))).SetResult(&res).Get("/core/v4/labels")
40 | }); err != nil {
41 | return nil, err
42 | }
43 |
44 | labels = append(labels, res.Labels...)
45 | }
46 |
47 | return labels, nil
48 | }
49 |
50 | func (c *Client) CreateLabel(ctx context.Context, req CreateLabelReq) (Label, error) {
51 | var res struct {
52 | Label Label
53 | }
54 |
55 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
56 | return r.SetBody(req).SetResult(&res).Post("/core/v4/labels")
57 | }); err != nil {
58 | return Label{}, err
59 | }
60 |
61 | return res.Label, nil
62 | }
63 |
64 | func (c *Client) DeleteLabel(ctx context.Context, labelID string) error {
65 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
66 | return r.Delete("/core/v4/labels/" + labelID)
67 | })
68 | }
69 |
70 | func (c *Client) UpdateLabel(ctx context.Context, labelID string, req UpdateLabelReq) (Label, error) {
71 | var res struct {
72 | Label Label
73 | }
74 |
75 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
76 | return r.SetBody(req).SetResult(&res).Put("/core/v4/labels/" + labelID)
77 | }); err != nil {
78 | return Label{}, err
79 | }
80 |
81 | return res.Label, nil
82 | }
83 |
--------------------------------------------------------------------------------
/label_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "encoding/json"
5 | "strings"
6 | )
7 |
8 | const (
9 | InboxLabel = "0"
10 | AllDraftsLabel = "1"
11 | AllSentLabel = "2"
12 | TrashLabel = "3"
13 | SpamLabel = "4"
14 | AllMailLabel = "5"
15 | ArchiveLabel = "6"
16 | SentLabel = "7"
17 | DraftsLabel = "8"
18 | OutboxLabel = "9"
19 | StarredLabel = "10"
20 | AllScheduledLabel = "12"
21 | )
22 |
23 | type Label struct {
24 | ID string
25 | ParentID string
26 |
27 | Name string
28 | Path []string
29 | Color string
30 | Type LabelType
31 | }
32 |
33 | func (label *Label) UnmarshalJSON(data []byte) error {
34 | type Alias Label
35 |
36 | aux := &struct {
37 | Path string
38 |
39 | *Alias
40 | }{
41 | Alias: (*Alias)(label),
42 | }
43 |
44 | if err := json.Unmarshal(data, &aux); err != nil {
45 | return err
46 | }
47 |
48 | label.Path = strings.Split(aux.Path, "/")
49 |
50 | return nil
51 | }
52 |
53 | func (label Label) MarshalJSON() ([]byte, error) {
54 | type Alias Label
55 |
56 | aux := &struct {
57 | Path string
58 |
59 | *Alias
60 | }{
61 | Path: strings.Join(label.Path, "/"),
62 | Alias: (*Alias)(&label),
63 | }
64 |
65 | return json.Marshal(aux)
66 | }
67 |
68 | type CreateLabelReq struct {
69 | Name string
70 | Color string
71 | Type LabelType
72 |
73 | ParentID string `json:",omitempty"`
74 | }
75 |
76 | type UpdateLabelReq struct {
77 | Name string
78 | Color string
79 |
80 | ParentID string `json:",omitempty"`
81 | }
82 |
83 | type LabelType int
84 |
85 | const (
86 | LabelTypeLabel LabelType = iota + 1
87 | LabelTypeContactGroup
88 | LabelTypeFolder
89 | LabelTypeSystem
90 | )
91 |
--------------------------------------------------------------------------------
/link.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | )
8 |
9 | func (c *Client) GetLink(ctx context.Context, shareID, linkID string) (Link, error) {
10 | var res struct {
11 | Link Link
12 | }
13 |
14 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
15 | return r.SetResult(&res).Get("/drive/shares/" + shareID + "/links/" + linkID)
16 | }); err != nil {
17 | return Link{}, err
18 | }
19 |
20 | return res.Link, nil
21 | }
22 |
23 | func (c *Client) CreateFile(ctx context.Context, shareID string, req CreateFileReq) (CreateFileRes, error) {
24 | var res struct {
25 | File CreateFileRes
26 | }
27 |
28 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
29 | return r.SetResult(&res).SetBody(req).Post("/drive/shares/" + shareID + "/files")
30 | }); err != nil {
31 | return CreateFileRes{}, err
32 | }
33 |
34 | return res.File, nil
35 | }
36 |
37 | func (c *Client) CreateFolder(ctx context.Context, shareID string, req CreateFolderReq) (CreateFolderRes, error) {
38 | var res struct {
39 | Folder CreateFolderRes
40 | }
41 |
42 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
43 | return r.SetResult(&res).SetBody(req).Post("/drive/shares/" + shareID + "/folders")
44 | }); err != nil {
45 | return CreateFolderRes{}, err
46 | }
47 |
48 | return res.Folder, nil
49 | }
50 |
--------------------------------------------------------------------------------
/link_file.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "strconv"
7 |
8 | "github.com/go-resty/resty/v2"
9 | )
10 |
11 | func (c *Client) ListRevisions(ctx context.Context, shareID, linkID string) ([]RevisionMetadata, error) {
12 | var res struct {
13 | Revisions []RevisionMetadata
14 | }
15 |
16 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
17 | return r.SetResult(&res).Get("/drive/shares/" + shareID + "/files/" + linkID + "/revisions")
18 | }); err != nil {
19 | return nil, err
20 | }
21 |
22 | return res.Revisions, nil
23 | }
24 |
25 | func (c *Client) GetRevision(ctx context.Context, shareID, linkID, revisionID string, fromBlock, pageSize int) (Revision, error) {
26 | if fromBlock < 1 {
27 | return Revision{}, fmt.Errorf("fromBlock must be greater than 0")
28 | } else if pageSize < 1 {
29 | return Revision{}, fmt.Errorf("pageSize must be greater than 0")
30 | }
31 |
32 | var res struct {
33 | Revision Revision
34 | }
35 |
36 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
37 | return r.
38 | SetQueryParams(map[string]string{
39 | "FromBlockIndex": strconv.Itoa(fromBlock),
40 | "PageSize": strconv.Itoa(pageSize),
41 | }).
42 | SetResult(&res).
43 | Get("/drive/shares/" + shareID + "/files/" + linkID + "/revisions/" + revisionID)
44 | }); err != nil {
45 | return Revision{}, err
46 | }
47 |
48 | return res.Revision, nil
49 | }
50 |
51 | func (c *Client) UpdateRevision(ctx context.Context, shareID, linkID, revisionID string, req UpdateRevisionReq) error {
52 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
53 | return r.SetBody(req).Put("/drive/shares/" + shareID + "/files/" + linkID + "/revisions/" + revisionID)
54 | })
55 | }
56 |
--------------------------------------------------------------------------------
/link_file_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | type CreateFileReq struct {
4 | ParentLinkID string
5 |
6 | Name string // Encrypted File Name
7 | Hash string // Encrypted content hash
8 | MIMEType string // MIME Type
9 |
10 | ContentKeyPacket string // The block's key packet, encrypted with the node key.
11 | ContentKeyPacketSignature string // Unencrypted signature of the content session key, signed with the NodeKey
12 |
13 | NodeKey string // The private NodeKey, used to decrypt any file/folder content.
14 | NodePassphrase string // The passphrase used to unlock the NodeKey, encrypted by the owning Link/Share keyring.
15 | NodePassphraseSignature string // The signature of the NodePassphrase
16 |
17 | SignatureAddress string // Signature email address used to sign passphrase and name
18 | }
19 |
20 | type CreateFileRes struct {
21 | ID string // Encrypted Link ID
22 | RevisionID string // Encrypted Revision ID
23 | }
24 |
25 | type UpdateRevisionReq struct {
26 | BlockList []BlockToken
27 | State RevisionState
28 | ManifestSignature string
29 | SignatureAddress string
30 | }
31 |
32 | type BlockToken struct {
33 | Index int
34 | Token string
35 | }
36 |
--------------------------------------------------------------------------------
/link_folder.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "strconv"
7 |
8 | "github.com/bradenaw/juniper/xslices"
9 | "github.com/go-resty/resty/v2"
10 | )
11 |
12 | func (c *Client) ListChildren(ctx context.Context, shareID, linkID string, showAll bool) ([]Link, error) {
13 | var res struct {
14 | Links []Link
15 | }
16 |
17 | var links []Link
18 |
19 | for page := 0; ; page++ {
20 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
21 | return r.
22 | SetQueryParams(map[string]string{
23 | "Page": strconv.Itoa(page),
24 | "PageSize": strconv.Itoa(maxPageSize),
25 | "ShowAll": Bool(showAll).FormatURL(),
26 | }).
27 | SetResult(&res).
28 | Get("/drive/shares/" + shareID + "/folders/" + linkID + "/children")
29 | }); err != nil {
30 | return nil, err
31 | }
32 |
33 | if len(res.Links) == 0 {
34 | break
35 | }
36 |
37 | links = append(links, res.Links...)
38 | }
39 |
40 | return links, nil
41 | }
42 |
43 | func (c *Client) TrashChildren(ctx context.Context, shareID, linkID string, childIDs ...string) error {
44 | var res struct {
45 | Responses []struct {
46 | LinkID string
47 | Response APIError
48 | }
49 | }
50 |
51 | for _, childIDs := range xslices.Chunk(childIDs, maxPageSize) {
52 | req := struct {
53 | LinkIDs []string
54 | }{
55 | LinkIDs: childIDs,
56 | }
57 |
58 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
59 | return r.SetBody(req).SetResult(&res).Post("/drive/shares/" + shareID + "/folders/" + linkID + "/trash_multiple")
60 | }); err != nil {
61 | return err
62 | }
63 |
64 | for _, res := range res.Responses {
65 | if res.Response.Code != SuccessCode {
66 | return fmt.Errorf("failed to trash child: %w", res.Response)
67 | }
68 | }
69 | }
70 |
71 | return nil
72 | }
73 |
74 | func (c *Client) DeleteChildren(ctx context.Context, shareID, linkID string, childIDs ...string) error {
75 | var res struct {
76 | Responses []struct {
77 | LinkID string
78 | Response APIError
79 | }
80 | }
81 |
82 | for _, childIDs := range xslices.Chunk(childIDs, maxPageSize) {
83 | req := struct {
84 | LinkIDs []string
85 | }{
86 | LinkIDs: childIDs,
87 | }
88 |
89 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
90 | return r.SetBody(req).SetResult(&res).Post("/drive/shares/" + shareID + "/folders/" + linkID + "/delete_multiple")
91 | }); err != nil {
92 | return err
93 | }
94 |
95 | for _, res := range res.Responses {
96 | if res.Response.Code != SuccessCode {
97 | return fmt.Errorf("failed to delete child: %w", res.Response)
98 | }
99 | }
100 | }
101 |
102 | return nil
103 | }
104 |
--------------------------------------------------------------------------------
/link_folder_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | type CreateFolderReq struct {
4 | ParentLinkID string
5 |
6 | Name string
7 | Hash string
8 |
9 | NodeKey string
10 | NodeHashKey string
11 |
12 | NodePassphrase string
13 | NodePassphraseSignature string
14 |
15 | SignatureAddress string
16 | }
17 |
18 | type CreateFolderRes struct {
19 | ID string // Encrypted Link ID
20 | }
21 |
--------------------------------------------------------------------------------
/logging.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import "github.com/sirupsen/logrus"
4 |
5 | var log = logrus.WithField("pkg", "gpa")
6 |
--------------------------------------------------------------------------------
/mail_settings.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | )
8 |
9 | func (c *Client) GetMailSettings(ctx context.Context) (MailSettings, error) {
10 | var res struct {
11 | MailSettings MailSettings
12 | }
13 |
14 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
15 | return r.SetResult(&res).Get("/mail/v4/settings")
16 | }); err != nil {
17 | return MailSettings{}, err
18 | }
19 |
20 | return res.MailSettings, nil
21 | }
22 |
23 | func (c *Client) SetDisplayName(ctx context.Context, req SetDisplayNameReq) (MailSettings, error) {
24 | var res struct {
25 | MailSettings MailSettings
26 | }
27 |
28 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
29 | return r.SetBody(req).SetResult(&res).Put("/mail/v4/settings/display")
30 | }); err != nil {
31 | return MailSettings{}, err
32 | }
33 |
34 | return res.MailSettings, nil
35 | }
36 |
37 | func (c *Client) SetSignature(ctx context.Context, req SetSignatureReq) (MailSettings, error) {
38 | var res struct {
39 | MailSettings MailSettings
40 | }
41 |
42 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
43 | return r.SetBody(req).SetResult(&res).Put("/mail/v4/settings/signature")
44 | }); err != nil {
45 | return MailSettings{}, err
46 | }
47 |
48 | return res.MailSettings, nil
49 | }
50 |
51 | func (c *Client) SetDraftMIMEType(ctx context.Context, req SetDraftMIMETypeReq) (MailSettings, error) {
52 | var res struct {
53 | MailSettings MailSettings
54 | }
55 |
56 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
57 | return r.SetBody(req).SetResult(&res).Put("/mail/v4/settings/drafttype")
58 | }); err != nil {
59 | return MailSettings{}, err
60 | }
61 |
62 | return res.MailSettings, nil
63 | }
64 |
65 | func (c *Client) SetAttachPublicKey(ctx context.Context, req SetAttachPublicKeyReq) (MailSettings, error) {
66 | var res struct {
67 | MailSettings MailSettings
68 | }
69 |
70 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
71 | return r.SetBody(req).SetResult(&res).Put("/mail/v4/settings/attachpublic")
72 | }); err != nil {
73 | return MailSettings{}, err
74 | }
75 |
76 | return res.MailSettings, nil
77 | }
78 |
79 | func (c *Client) SetSignExternalMessages(ctx context.Context, req SetSignExternalMessagesReq) (MailSettings, error) {
80 | var res struct {
81 | MailSettings MailSettings
82 | }
83 |
84 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
85 | return r.SetBody(req).SetResult(&res).Put("/mail/v4/settings/sign")
86 | }); err != nil {
87 | return MailSettings{}, err
88 | }
89 |
90 | return res.MailSettings, nil
91 | }
92 |
93 | func (c *Client) SetDefaultPGPScheme(ctx context.Context, req SetDefaultPGPSchemeReq) (MailSettings, error) {
94 | var res struct {
95 | MailSettings MailSettings
96 | }
97 |
98 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
99 | return r.SetBody(req).SetResult(&res).Put("/mail/v4/settings/pgpscheme")
100 | }); err != nil {
101 | return MailSettings{}, err
102 | }
103 |
104 | return res.MailSettings, nil
105 | }
106 |
--------------------------------------------------------------------------------
/mail_settings_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import "github.com/ProtonMail/gluon/rfc822"
4 |
5 | type MailSettings struct {
6 | DisplayName string
7 | Signature string
8 | DraftMIMEType rfc822.MIMEType
9 | AttachPublicKey Bool
10 | Sign SignExternalMessages
11 | PGPScheme EncryptionScheme
12 | }
13 |
14 | type SignExternalMessages int
15 |
16 | const (
17 | SignExternalMessagesDisabled SignExternalMessages = iota
18 | SignExternalMessagesEnabled
19 | )
20 |
21 | type SetDisplayNameReq struct {
22 | DisplayName string
23 | }
24 |
25 | type SetSignatureReq struct {
26 | Signature string
27 | }
28 |
29 | type SetDraftMIMETypeReq struct {
30 | MIMEType rfc822.MIMEType
31 | }
32 |
33 | type SetAttachPublicKeyReq struct {
34 | AttachPublicKey Bool
35 | }
36 |
37 | type SetSignExternalMessagesReq struct {
38 | Sign SignExternalMessages
39 | }
40 |
41 | type SetDefaultPGPSchemeReq struct {
42 | PGPScheme EncryptionScheme
43 | }
44 |
--------------------------------------------------------------------------------
/main_test.go:
--------------------------------------------------------------------------------
1 | package proton_test
2 |
3 | import (
4 | "testing"
5 |
6 | "go.uber.org/goleak"
7 | )
8 |
9 | func TestMain(m *testing.M) {
10 | goleak.VerifyTestMain(m, goleak.IgnoreCurrent())
11 | }
12 |
--------------------------------------------------------------------------------
/manager.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "net"
7 | "sync"
8 |
9 | "github.com/ProtonMail/gluon/async"
10 | "github.com/go-resty/resty/v2"
11 | )
12 |
13 | type Manager struct {
14 | rc *resty.Client
15 |
16 | status Status
17 | observers []StatusObserver
18 | statusLock sync.Mutex
19 |
20 | errHandlers map[Code][]Handler
21 |
22 | verifyProofs bool
23 |
24 | panicHandler async.PanicHandler
25 | }
26 |
27 | func New(opts ...Option) *Manager {
28 | builder := newManagerBuilder()
29 |
30 | for _, opt := range opts {
31 | opt.config(builder)
32 | }
33 |
34 | return builder.build()
35 | }
36 |
37 | func (m *Manager) AddStatusObserver(observer StatusObserver) {
38 | m.statusLock.Lock()
39 | defer m.statusLock.Unlock()
40 |
41 | m.observers = append(m.observers, observer)
42 | }
43 |
44 | func (m *Manager) AddPreRequestHook(hook resty.RequestMiddleware) {
45 | m.rc.OnBeforeRequest(hook)
46 | }
47 |
48 | func (m *Manager) AddPostRequestHook(hook resty.ResponseMiddleware) {
49 | m.rc.OnAfterResponse(hook)
50 | }
51 |
52 | func (m *Manager) AddErrorHandler(code Code, handler Handler) {
53 | m.errHandlers[code] = append(m.errHandlers[code], handler)
54 | }
55 |
56 | func (m *Manager) Close() {
57 | m.rc.GetClient().CloseIdleConnections()
58 | }
59 |
60 | func (m *Manager) r(ctx context.Context) *resty.Request {
61 | return m.rc.R().SetContext(ctx)
62 | }
63 |
64 | func (m *Manager) handleError(req *resty.Request, err error) {
65 | resErr, ok := err.(*resty.ResponseError)
66 | if !ok {
67 | return
68 | }
69 |
70 | apiErr, ok := resErr.Response.Error().(*APIError)
71 | if !ok {
72 | return
73 | }
74 |
75 | for _, handler := range m.errHandlers[apiErr.Code] {
76 | handler()
77 | }
78 | }
79 |
80 | func (m *Manager) checkConnUp(_ *resty.Client, res *resty.Response) error {
81 | m.onConnUp()
82 |
83 | return nil
84 | }
85 |
86 | func (m *Manager) checkConnDown(req *resty.Request, err error) {
87 | switch {
88 | case errors.Is(err, context.Canceled):
89 | return
90 | }
91 |
92 | if res, ok := err.(*resty.ResponseError); ok {
93 | if res.Response.RawResponse == nil {
94 | m.onConnDown()
95 | } else if netErr := new(net.OpError); errors.As(res.Err, &netErr) {
96 | m.onConnDown()
97 | } else {
98 | m.onConnUp()
99 | }
100 | } else {
101 | m.onConnDown()
102 | }
103 | }
104 |
105 | func (m *Manager) onConnDown() {
106 | m.statusLock.Lock()
107 | defer m.statusLock.Unlock()
108 |
109 | if m.status == StatusDown {
110 | return
111 | }
112 |
113 | m.status = StatusDown
114 |
115 | for _, observer := range m.observers {
116 | observer(m.status)
117 | }
118 | }
119 |
120 | func (m *Manager) onConnUp() {
121 | m.statusLock.Lock()
122 | defer m.statusLock.Unlock()
123 |
124 | if m.status == StatusUp {
125 | return
126 | }
127 |
128 | m.status = StatusUp
129 |
130 | for _, observer := range m.observers {
131 | observer(m.status)
132 | }
133 | }
134 |
135 | func (m *Manager) GetStatus() Status {
136 | m.statusLock.Lock()
137 | defer m.statusLock.Unlock()
138 |
139 | return m.status
140 | }
141 |
--------------------------------------------------------------------------------
/manager_auth_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | type AuthInfoReq struct {
4 | Username string
5 | }
6 |
7 | type AuthInfo struct {
8 | Version int
9 | Modulus string
10 | ServerEphemeral string
11 | Salt string
12 | SRPSession string
13 | TwoFA TwoFAInfo `json:"2FA"`
14 | }
15 |
16 | type AuthVerifier struct {
17 | Version int
18 | ModulusID string
19 | Salt string
20 | Verifier string
21 | }
22 |
23 | type AuthModulus struct {
24 | Modulus string
25 | ModulusID string
26 | }
27 |
28 | type FIDO2Req struct {
29 | AuthenticationOptions any
30 | ClientData string
31 | AuthenticatorData string
32 | Signature string
33 | CredentialID []int
34 | }
35 |
36 | type AuthReq struct {
37 | Auth2FAReq `json:",omitempty"`
38 |
39 | Username string
40 | ClientEphemeral string
41 | ClientProof string
42 | SRPSession string
43 | }
44 |
45 | type Auth struct {
46 | UserID string
47 |
48 | UID string
49 | AccessToken string
50 | RefreshToken string
51 | ServerProof string
52 |
53 | Scope string
54 | TwoFA TwoFAInfo `json:"2FA"`
55 | PasswordMode PasswordMode
56 | }
57 |
58 | type RegisteredKey struct {
59 | AttestationFormat string
60 | CredentialID []int
61 | Name string
62 | }
63 |
64 | type FIDO2Info struct {
65 | AuthenticationOptions any
66 | RegisteredKeys []RegisteredKey
67 | }
68 |
69 | type TwoFAInfo struct {
70 | Enabled TwoFAStatus
71 | FIDO2 FIDO2Info
72 | }
73 |
74 | type TwoFAStatus int
75 |
76 | const (
77 | HasTOTP TwoFAStatus = iota + 1
78 | HasFIDO2
79 | HasFIDO2AndTOTP
80 | )
81 |
82 | type PasswordMode int
83 |
84 | const (
85 | OnePasswordMode PasswordMode = iota + 1
86 | TwoPasswordMode
87 | )
88 |
89 | type Auth2FAReq struct {
90 | TwoFactorCode string `json:",omitempty"`
91 | FIDO2 FIDO2Req `json:",omitempty"`
92 | }
93 |
94 | type AuthRefreshReq struct {
95 | UID string
96 | RefreshToken string
97 | ResponseType string
98 | GrantType string
99 | RedirectURI string
100 | State string
101 | AccessToken string `json:",omitempty"`
102 | }
103 |
104 | type AuthSession struct {
105 | UID string
106 | CreateTime int64
107 |
108 | ClientID string
109 | MemberID string
110 | Revocable Bool
111 |
112 | LocalizedClientName string
113 | }
114 |
--------------------------------------------------------------------------------
/manager_builder.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "github.com/ProtonMail/gluon/async"
8 | "github.com/go-resty/resty/v2"
9 | )
10 |
11 | const (
12 | // DefaultHostURL is the default host of the API.
13 | DefaultHostURL = "https://mail.proton.me/api"
14 |
15 | // DefaultAppVersion is the default app version used to communicate with the API.
16 | // This must be changed (using the WithAppVersion option) for production use.
17 | DefaultAppVersion = "go-proton-api"
18 | )
19 |
20 | type managerBuilder struct {
21 | hostURL string
22 | appVersion string
23 | transport http.RoundTripper
24 | verifyProofs bool
25 | cookieJar http.CookieJar
26 | retryCount int
27 | logger resty.Logger
28 | debug bool
29 | panicHandler async.PanicHandler
30 | }
31 |
32 | func newManagerBuilder() *managerBuilder {
33 | return &managerBuilder{
34 | hostURL: DefaultHostURL,
35 | appVersion: DefaultAppVersion,
36 | transport: http.DefaultTransport,
37 | verifyProofs: true,
38 | cookieJar: nil,
39 | retryCount: 3,
40 | logger: nil,
41 | debug: false,
42 | panicHandler: async.NoopPanicHandler{},
43 | }
44 | }
45 |
46 | func (builder *managerBuilder) build() *Manager {
47 | m := &Manager{
48 | rc: resty.New(),
49 |
50 | errHandlers: make(map[Code][]Handler),
51 |
52 | verifyProofs: builder.verifyProofs,
53 |
54 | panicHandler: builder.panicHandler,
55 | }
56 |
57 | // Set the API host.
58 | m.rc.SetBaseURL(builder.hostURL)
59 |
60 | // Set the transport.
61 | m.rc.SetTransport(builder.transport)
62 |
63 | // Set the cookie jar.
64 | m.rc.SetCookieJar(builder.cookieJar)
65 |
66 | // Set the logger.
67 | if builder.logger != nil {
68 | m.rc.SetLogger(builder.logger)
69 | }
70 |
71 | // Set the debug flag.
72 | m.rc.SetDebug(builder.debug)
73 |
74 | // Set app version in header.
75 | m.rc.OnBeforeRequest(func(_ *resty.Client, req *resty.Request) error {
76 | req.SetHeader("x-pm-appversion", builder.appVersion)
77 | return nil
78 | })
79 |
80 | // Set middleware.
81 | m.rc.OnAfterResponse(catchAPIError)
82 | m.rc.OnAfterResponse(updateTime)
83 | m.rc.OnAfterResponse(m.checkConnUp)
84 | m.rc.OnError(m.checkConnDown)
85 | m.rc.OnError(m.handleError)
86 |
87 | // Configure retry mechanism.
88 | m.rc.SetRetryCount(builder.retryCount)
89 | m.rc.SetRetryMaxWaitTime(time.Minute)
90 | m.rc.AddRetryCondition(catchTooManyRequests)
91 | m.rc.AddRetryCondition(catchDialError)
92 | m.rc.AddRetryCondition(catchDropError)
93 | m.rc.SetRetryAfter(catchRetryAfter)
94 |
95 | // Set the data type of API errors.
96 | m.rc.SetError(&APIError{})
97 |
98 | return m
99 | }
100 |
--------------------------------------------------------------------------------
/manager_domains.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import "context"
4 |
5 | func (m *Manager) GetDomains(ctx context.Context) ([]string, error) {
6 | var res struct {
7 | Domains []string
8 | }
9 |
10 | if _, err := m.r(ctx).SetResult(&res).Get("/core/v4/domains/available"); err != nil {
11 | return nil, err
12 | }
13 |
14 | return res.Domains, nil
15 | }
16 |
--------------------------------------------------------------------------------
/manager_download.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 | "io"
6 |
7 | "github.com/ProtonMail/gopenpgp/v2/crypto"
8 | )
9 |
10 | func (m *Manager) DownloadAndVerify(ctx context.Context, kr *crypto.KeyRing, url, sig string) ([]byte, error) {
11 | fb, err := m.fetchFile(ctx, url)
12 | if err != nil {
13 | return nil, err
14 | }
15 |
16 | sb, err := m.fetchFile(ctx, sig)
17 | if err != nil {
18 | return nil, err
19 | }
20 |
21 | if err := kr.VerifyDetached(
22 | crypto.NewPlainMessage(fb),
23 | crypto.NewPGPSignature(sb),
24 | crypto.GetUnixTime(),
25 | ); err != nil {
26 | return nil, err
27 | }
28 |
29 | return fb, nil
30 | }
31 |
32 | func (m *Manager) fetchFile(ctx context.Context, url string) ([]byte, error) {
33 | res, err := m.r(ctx).SetDoNotParseResponse(true).Get(url)
34 | if err != nil {
35 | return nil, err
36 | }
37 |
38 | b, err := io.ReadAll(res.RawBody())
39 | if err != nil {
40 | return nil, err
41 | }
42 |
43 | if err := res.RawBody().Close(); err != nil {
44 | return nil, err
45 | }
46 |
47 | return b, nil
48 | }
49 |
--------------------------------------------------------------------------------
/manager_features.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net/url"
7 |
8 | "github.com/google/uuid"
9 | )
10 |
11 | type FeatureFlagResult struct {
12 | Code int `json:"Code"`
13 | Toggles []FeatureToggle `json:"toggles"`
14 | }
15 |
16 | type FeatureToggle struct {
17 | Name string `json:"name"`
18 | Enabled bool `json:"enabled"`
19 | }
20 |
21 | func getFeatureFlagEndpoint(stickyKey uuid.UUID) string {
22 | params := url.Values{}
23 | params.Set("bridgeStickyKey", stickyKey.String())
24 | path := fmt.Sprintf("/feature/v2/frontend?%s", params.Encode())
25 | return path
26 | }
27 |
28 | func (m *Manager) GetFeatures(ctx context.Context, stickyKey uuid.UUID) (FeatureFlagResult, error) {
29 | responseData := FeatureFlagResult{}
30 |
31 | _, err := m.r(ctx).SetResult(&responseData).Get(getFeatureFlagEndpoint(stickyKey))
32 | if err != nil {
33 | return FeatureFlagResult{}, err
34 | }
35 |
36 | return responseData, nil
37 | }
38 |
--------------------------------------------------------------------------------
/manager_ping.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import "context"
4 |
5 | func (m *Manager) Ping(ctx context.Context) error {
6 | if res, err := m.r(ctx).Get("/tests/ping"); err != nil {
7 | if res.RawResponse != nil {
8 | return nil
9 | }
10 |
11 | return err
12 | }
13 |
14 | return nil
15 | }
16 |
--------------------------------------------------------------------------------
/manager_report.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | )
8 |
9 | func (m *Manager) ReportBug(ctx context.Context, req ReportBugReq, atts ...ReportBugAttachment) (ReportBugRes, error) {
10 | r := m.r(ctx).SetMultipartFormData(req.toFormData())
11 |
12 | for _, att := range atts {
13 | r = r.SetMultipartField(att.Name, att.Filename, string(att.MIMEType), resty.NewByteMultipartStream(att.Body))
14 | }
15 | var res ReportBugRes
16 |
17 | if resp, err := r.SetResult(&res).Post("/core/v4/reports/bug"); err != nil {
18 | if resp != nil {
19 | return ReportBugRes{}, &resty.ResponseError{Response: resp, Err: err}
20 | }
21 | return ReportBugRes{}, err
22 | }
23 |
24 | return res, nil
25 | }
26 |
27 | func (m *Manager) ReportBugAttachement(ctx context.Context, req ReportBugAttachmentReq, atts ...ReportBugAttachment) error {
28 | r := m.r(ctx).SetMultipartFormData(req.toFormData())
29 |
30 | for _, att := range atts {
31 | r = r.SetMultipartField(att.Name, att.Filename, string(att.MIMEType), resty.NewByteMultipartStream(att.Body))
32 | }
33 |
34 | if _, err := r.Post("/core/v4/reports/bug/attachments"); err != nil {
35 | return err
36 | }
37 |
38 | return nil
39 | }
40 |
--------------------------------------------------------------------------------
/manager_report_test.go:
--------------------------------------------------------------------------------
1 | package proton_test
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "mime"
7 | "mime/multipart"
8 | "testing"
9 |
10 | "github.com/ProtonMail/go-proton-api"
11 | "github.com/ProtonMail/go-proton-api/server"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | func TestReportBug(t *testing.T) {
16 | s := server.New()
17 | defer s.Close()
18 |
19 | m := proton.New(
20 | proton.WithHostURL(s.GetHostURL()),
21 | proton.WithTransport(proton.InsecureTransport()),
22 | )
23 | defer m.Close()
24 |
25 | var calls []server.Call
26 |
27 | s.AddCallWatcher(func(call server.Call) {
28 | calls = append(calls, call)
29 | })
30 |
31 | res, err := m.ReportBug(context.Background(), proton.ReportBugReq{
32 | OS: "linux",
33 | OSVersion: "5.4.0-42-generic",
34 | Browser: "firefox",
35 | ClientType: proton.ClientTypeEmail,
36 | })
37 | require.NoError(t, err)
38 | require.Equal(t, (*string)(nil), res.Token)
39 |
40 | mimeType, mimeParams, err := mime.ParseMediaType(calls[0].RequestHeader.Get("Content-Type"))
41 | require.NoError(t, err)
42 | require.Equal(t, "multipart/form-data", mimeType)
43 |
44 | form, err := multipart.NewReader(bytes.NewReader(calls[0].RequestBody), mimeParams["boundary"]).ReadForm(0)
45 | require.NoError(t, err)
46 |
47 | require.Len(t, form.Value, 5)
48 | require.Equal(t, "linux", form.Value["OS"][0])
49 | require.Equal(t, "5.4.0-42-generic", form.Value["OSVersion"][0])
50 | require.Equal(t, "firefox", form.Value["Browser"][0])
51 | require.Equal(t, "1", form.Value["ClientType"][0])
52 | require.Equal(t, "0", form.Value["AsyncAttachments"][0])
53 | }
54 |
55 | func TestReportBugAsync(t *testing.T) {
56 | s := server.New()
57 | defer s.Close()
58 |
59 | m := proton.New(
60 | proton.WithHostURL(s.GetHostURL()),
61 | proton.WithTransport(proton.InsecureTransport()),
62 | )
63 | defer m.Close()
64 |
65 | var calls []server.Call
66 |
67 | s.AddCallWatcher(func(call server.Call) {
68 | calls = append(calls, call)
69 | })
70 |
71 | res, err := m.ReportBug(context.Background(), proton.ReportBugReq{
72 | OS: "linux",
73 | OSVersion: "5.4.0-42-generic",
74 | Browser: "firefox",
75 | ClientType: proton.ClientTypeEmail,
76 | AsyncAttachments: proton.AttachmentTypeAsync,
77 | })
78 | require.NoError(t, err)
79 | require.NotEmpty(t, res.Token)
80 |
81 | mimeType, mimeParams, err := mime.ParseMediaType(calls[0].RequestHeader.Get("Content-Type"))
82 | require.NoError(t, err)
83 | require.Equal(t, "multipart/form-data", mimeType)
84 |
85 | form, err := multipart.NewReader(bytes.NewReader(calls[0].RequestBody), mimeParams["boundary"]).ReadForm(0)
86 | require.NoError(t, err)
87 |
88 | require.Len(t, form.Value, 5)
89 | require.Equal(t, "linux", form.Value["OS"][0])
90 | require.Equal(t, "5.4.0-42-generic", form.Value["OSVersion"][0])
91 | require.Equal(t, "firefox", form.Value["Browser"][0])
92 | require.Equal(t, "1", form.Value["ClientType"][0])
93 | require.Equal(t, "1", form.Value["AsyncAttachments"][0])
94 |
95 | err = m.ReportBugAttachement(context.Background(), proton.ReportBugAttachmentReq{
96 | Product: proton.ClientTypeEmail,
97 | Body: "Comment without any attachment",
98 | Token: *res.Token,
99 | })
100 |
101 | require.NoError(t, err)
102 |
103 | err = m.ReportBugAttachement(context.Background(), proton.ReportBugAttachmentReq{
104 | Product: proton.ClientTypeEmail,
105 | Body: "Comment without any attachment",
106 | Token: "not a good token",
107 | })
108 |
109 | require.Error(t, err)
110 | }
111 |
--------------------------------------------------------------------------------
/manager_report_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 |
7 | "github.com/ProtonMail/gluon/rfc822"
8 | )
9 |
10 | type ClientType int
11 |
12 | const (
13 | ClientTypeEmail ClientType = iota + 1
14 | ClientTypeVPN
15 | ClientTypeCalendar
16 | ClientTypeDrive
17 | )
18 |
19 | type AttachmentType int
20 |
21 | const (
22 | AttachmentTypeSync AttachmentType = iota
23 | AttachmentTypeAsync
24 | )
25 |
26 | type ReportBugReq struct {
27 | OS string
28 | OSVersion string
29 |
30 | Browser string
31 | BrowserVersion string
32 | BrowserExtensions string
33 |
34 | Resolution string
35 | DisplayMode string
36 |
37 | Client string
38 | ClientVersion string
39 | ClientType ClientType
40 |
41 | Title string
42 | Description string
43 |
44 | Username string
45 | Email string
46 |
47 | Country string
48 | ISP string
49 |
50 | AsyncAttachments AttachmentType
51 | }
52 |
53 | type ReportBugAttachmentReq struct {
54 | Product ClientType
55 | Body string
56 | Token string
57 | }
58 |
59 | type ReportBugRes struct {
60 | APIError
61 | Token *string
62 | }
63 |
64 | func (req ReportBugReq) toFormData() map[string]string {
65 | b, err := json.Marshal(req)
66 | if err != nil {
67 | panic(err)
68 | }
69 | return bytesToFormData(b)
70 | }
71 |
72 | func (req ReportBugAttachmentReq) toFormData() map[string]string {
73 | b, err := json.Marshal(req)
74 | if err != nil {
75 | panic(err)
76 | }
77 | return bytesToFormData(b)
78 | }
79 |
80 | func bytesToFormData(buff []byte) map[string]string {
81 | var raw map[string]any
82 |
83 | if err := json.Unmarshal(buff, &raw); err != nil {
84 | panic(err)
85 | }
86 |
87 | res := make(map[string]string)
88 |
89 | for key := range raw {
90 | if val := fmt.Sprint(raw[key]); val != "" {
91 | res[key] = val
92 | }
93 | }
94 |
95 | return res
96 | }
97 |
98 | type ReportBugAttachment struct {
99 | Name string
100 | Filename string
101 | MIMEType rfc822.MIMEType
102 | Body []byte
103 | }
104 |
--------------------------------------------------------------------------------
/manager_status.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | type Status int
4 |
5 | const (
6 | StatusUp Status = iota
7 | StatusDown
8 | )
9 |
10 | func (s Status) String() string {
11 | switch s {
12 | case StatusUp:
13 | return "up"
14 |
15 | case StatusDown:
16 | return "down"
17 |
18 | default:
19 | return "unknown"
20 | }
21 | }
22 |
23 | type StatusObserver func(Status)
24 |
--------------------------------------------------------------------------------
/manager_user.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 | )
6 |
7 | func (m *Manager) GetCaptcha(ctx context.Context, token string) ([]byte, error) {
8 | res, err := m.r(ctx).SetQueryParam("Token", token).SetQueryParam("ForceWebMessaging", "1").Get("/core/v4/captcha")
9 | if err != nil {
10 | return nil, err
11 | }
12 |
13 | return res.Body(), nil
14 | }
15 |
16 | func (m *Manager) SendVerificationCode(ctx context.Context, req SendVerificationCodeReq) error {
17 | if _, err := m.r(ctx).SetBody(req).Post("/core/v4/users/code"); err != nil {
18 | return err
19 | }
20 |
21 | return nil
22 | }
23 |
24 | func (m *Manager) CreateUser(ctx context.Context, req CreateUserReq) (User, error) {
25 | var res struct {
26 | User User
27 | }
28 |
29 | if _, err := m.r(ctx).SetBody(req).SetResult(&res).Post("/core/v4/users"); err != nil {
30 | return User{}, err
31 | }
32 |
33 | return res.User, nil
34 | }
35 |
36 | func (m *Manager) GetUsernameAvailable(ctx context.Context, username string) error {
37 | if _, err := m.r(ctx).SetQueryParam("Name", username).Get("/core/v4/users/available"); err != nil {
38 | return err
39 | }
40 |
41 | return nil
42 | }
43 |
--------------------------------------------------------------------------------
/manager_user_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | type TokenType string
4 |
5 | const (
6 | EmailTokenType TokenType = "email"
7 | SMSTokenType TokenType = "sms"
8 | )
9 |
10 | type SendVerificationCodeReq struct {
11 | Username string
12 | Type TokenType
13 | Destination TokenDestination
14 | }
15 |
16 | type TokenDestination struct {
17 | Address string
18 | Phone string
19 | }
20 |
21 | type UserType int
22 |
23 | const (
24 | MailUserType UserType = iota + 1
25 | VPNUserType
26 | )
27 |
28 | type CreateUserReq struct {
29 | Type UserType
30 | Username string
31 | Domain string
32 | Auth AuthVerifier
33 | }
34 |
--------------------------------------------------------------------------------
/message_draft_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "net/mail"
5 |
6 | "github.com/ProtonMail/gluon/rfc822"
7 | )
8 |
9 | type DraftTemplate struct {
10 | Subject string
11 | Sender *mail.Address
12 | ToList []*mail.Address
13 | CCList []*mail.Address
14 | BCCList []*mail.Address
15 | Body string
16 | MIMEType rfc822.MIMEType
17 | Unread Bool
18 |
19 | ExternalID string `json:",omitempty"`
20 | }
21 |
22 | type CreateDraftAction int
23 |
24 | const (
25 | ReplyAction CreateDraftAction = iota
26 | ReplyAllAction
27 | ForwardAction
28 | AutoResponseAction
29 | ReadReceiptAction
30 | )
31 |
32 | type CreateDraftReq struct {
33 | Message DraftTemplate
34 | AttachmentKeyPackets []string
35 |
36 | ParentID string `json:",omitempty"`
37 | Action CreateDraftAction
38 | }
39 |
40 | type UpdateDraftReq struct {
41 | Message DraftTemplate
42 | AttachmentKeyPackets []string
43 | }
44 |
--------------------------------------------------------------------------------
/message_import_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "encoding/json"
5 |
6 | "github.com/ProtonMail/gluon/rfc822"
7 | "github.com/go-resty/resty/v2"
8 | )
9 |
10 | type ImportReq struct {
11 | Metadata ImportMetadata
12 | Message []byte
13 | encryptedMessage []byte
14 | }
15 |
16 | func (r ImportReq) GetEncryptedMessageLength() int {
17 | return len(r.encryptedMessage)
18 | }
19 |
20 | type namedImportReq struct {
21 | ImportReq
22 |
23 | Name string
24 | }
25 |
26 | type ImportMetadata struct {
27 | AddressID string
28 | LabelIDs []string
29 | Unread Bool
30 | Flags MessageFlag
31 | }
32 |
33 | type ImportRes struct {
34 | APIError
35 | MessageID string
36 | }
37 |
38 | func buildImportReqFields(req []namedImportReq) ([]*resty.MultipartField, error) {
39 | var fields []*resty.MultipartField
40 |
41 | metadata := make(map[string]ImportMetadata, len(req))
42 |
43 | for _, req := range req {
44 | metadata[req.Name] = req.Metadata
45 |
46 | fields = append(fields, &resty.MultipartField{
47 | Param: req.Name,
48 | FileName: req.Name + ".eml",
49 | ContentType: string(rfc822.MessageRFC822),
50 | Stream: resty.NewByteMultipartStream(append(req.encryptedMessage, "\r\n"...)),
51 | })
52 | }
53 |
54 | b, err := json.Marshal(metadata)
55 | if err != nil {
56 | return nil, err
57 | }
58 |
59 | fields = append(fields, &resty.MultipartField{
60 | Param: "Metadata",
61 | ContentType: "application/json",
62 | Stream: resty.NewByteMultipartStream(b),
63 | })
64 |
65 | return fields, nil
66 | }
67 |
--------------------------------------------------------------------------------
/message_send.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 | "fmt"
6 |
7 | "github.com/ProtonMail/gopenpgp/v2/crypto"
8 | "github.com/go-resty/resty/v2"
9 | )
10 |
11 | func (c *Client) CreateDraft(ctx context.Context, addrKR *crypto.KeyRing, req CreateDraftReq) (Message, error) {
12 | var res struct {
13 | Message Message
14 | }
15 |
16 | kr, err := addrKR.FirstKey()
17 | if err != nil {
18 | return Message{}, fmt.Errorf("failed to get first key: %w", err)
19 | }
20 |
21 | enc, err := kr.Encrypt(crypto.NewPlainMessageFromString(req.Message.Body), nil)
22 | if err != nil {
23 | return Message{}, fmt.Errorf("failed to encrypt draft: %w", err)
24 | }
25 |
26 | arm, err := enc.GetArmored()
27 | if err != nil {
28 | return Message{}, fmt.Errorf("failed to armor draft: %w", err)
29 | }
30 |
31 | req.Message.Body = arm
32 |
33 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
34 | return r.SetBody(req).SetResult(&res).Post("/mail/v4/messages")
35 | }); err != nil {
36 | return Message{}, err
37 | }
38 |
39 | return res.Message, nil
40 | }
41 |
42 | func (c *Client) UpdateDraft(ctx context.Context, draftID string, addrKR *crypto.KeyRing, req UpdateDraftReq) (Message, error) {
43 | var res struct {
44 | Message Message
45 | }
46 |
47 | if req.Message.Body != "" {
48 | kr, err := addrKR.FirstKey()
49 | if err != nil {
50 | return Message{}, fmt.Errorf("failed to get first key: %w", err)
51 | }
52 |
53 | enc, err := kr.Encrypt(crypto.NewPlainMessageFromString(req.Message.Body), nil)
54 | if err != nil {
55 | return Message{}, fmt.Errorf("failed to encrypt draft: %w", err)
56 | }
57 |
58 | arm, err := enc.GetArmored()
59 | if err != nil {
60 | return Message{}, fmt.Errorf("failed to armor draft: %w", err)
61 | }
62 |
63 | req.Message.Body = arm
64 | }
65 |
66 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
67 | return r.SetBody(req).SetResult(&res).Put("/mail/v4/messages/" + draftID)
68 | }); err != nil {
69 | return Message{}, err
70 | }
71 |
72 | return res.Message, nil
73 | }
74 |
75 | func (c *Client) SendDraft(ctx context.Context, draftID string, req SendDraftReq) (Message, error) {
76 | var res struct {
77 | Sent Message
78 | }
79 |
80 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
81 | return r.SetBody(req).SetResult(&res).Post("/mail/v4/messages/" + draftID)
82 | }); err != nil {
83 | return Message{}, err
84 | }
85 |
86 | return res.Sent, nil
87 | }
88 |
--------------------------------------------------------------------------------
/message_types_test.go:
--------------------------------------------------------------------------------
1 | package proton_test
2 |
3 | import (
4 | "os"
5 | "testing"
6 |
7 | "github.com/ProtonMail/go-proton-api"
8 | "github.com/ProtonMail/gopenpgp/v2/crypto"
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func TestDecrypt(t *testing.T) {
13 | body, err := os.ReadFile("testdata/body.pgp")
14 | require.NoError(t, err)
15 |
16 | pubKR := loadKeyRing(t, "testdata/pub.asc", nil)
17 | prvKR := loadKeyRing(t, "testdata/prv.asc", []byte("password"))
18 |
19 | msg := proton.Message{Body: string(body)}
20 |
21 | sigs, err := proton.ExtractSignatures(prvKR, msg.Body)
22 | require.NoError(t, err)
23 |
24 | enc, err := crypto.NewPGPMessageFromArmored(msg.Body)
25 | require.NoError(t, err)
26 |
27 | dec, err := prvKR.Decrypt(enc, nil, crypto.GetUnixTime())
28 | require.NoError(t, err)
29 | require.NoError(t, pubKR.VerifyDetached(dec, sigs[0].Data, crypto.GetUnixTime()))
30 | }
31 |
32 | func loadKeyRing(t *testing.T, file string, pass []byte) *crypto.KeyRing {
33 | f, err := os.Open(file)
34 | require.NoError(t, err)
35 |
36 | defer f.Close()
37 |
38 | key, err := crypto.NewKeyFromArmoredReader(f)
39 | require.NoError(t, err)
40 |
41 | if pass != nil {
42 | key, err = key.Unlock(pass)
43 | require.NoError(t, err)
44 | }
45 |
46 | kr, err := crypto.NewKeyRing(key)
47 | require.NoError(t, err)
48 |
49 | return kr
50 | }
51 |
--------------------------------------------------------------------------------
/netctl_test.go:
--------------------------------------------------------------------------------
1 | package proton_test
2 |
3 | import (
4 | "bytes"
5 | "crypto/tls"
6 | "io"
7 | "net/http"
8 | "net/http/httptest"
9 | "testing"
10 |
11 | "github.com/ProtonMail/go-proton-api"
12 | )
13 |
14 | func TestNetCtl_ReadLimit(t *testing.T) {
15 | // Create a test http server that writes 100 bytes.
16 | // Including the header, this is 217 bytes (100 bytes + 117 bytes).
17 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
18 | if _, err := w.Write(make([]byte, 100)); err != nil {
19 | t.Fatal(err)
20 | }
21 | }))
22 | defer ts.Close()
23 |
24 | // Create a new net controller.
25 | ctl := proton.NewNetCtl()
26 |
27 | // Set the read limit to 300 bytes -- the first request should succeed, the second should fail.
28 | ctl.SetReadLimit(300)
29 |
30 | // Create a new http client with the dialer.
31 | client := &http.Client{
32 | Transport: ctl.NewRoundTripper(&tls.Config{InsecureSkipVerify: true}),
33 | }
34 |
35 | // This should succeed.
36 | if resp, err := client.Get(ts.URL); err != nil {
37 | t.Fatal(err)
38 | } else {
39 | resp.Body.Close()
40 | }
41 |
42 | // This should fail.
43 | if _, err := client.Get(ts.URL); err == nil {
44 | t.Fatal("expected error")
45 | }
46 | }
47 |
48 | func TestNetCtl_WriteLimit(t *testing.T) {
49 | // Create a test http server that reads the given body.
50 | ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
51 | if _, err := io.ReadAll(r.Body); err != nil {
52 | t.Fatal(err)
53 | }
54 | }))
55 | defer ts.Close()
56 |
57 | // Create a new net controller.
58 | ctl := proton.NewNetCtl()
59 |
60 | // Set the read limit to 300 bytes -- the first request should succeed, the second should fail.
61 | ctl.SetWriteLimit(300)
62 |
63 | // Create a new http client with the dialer.
64 | client := &http.Client{
65 | Transport: ctl.NewRoundTripper(&tls.Config{InsecureSkipVerify: true}),
66 | }
67 |
68 | // This should succeed.
69 | if resp, err := client.Post(ts.URL, "application/octet-stream", bytes.NewReader(make([]byte, 100))); err != nil {
70 | t.Fatal(err)
71 | } else {
72 | resp.Body.Close()
73 | }
74 |
75 | // This should fail.
76 | if _, err := client.Post(ts.URL, "application/octet-stream", bytes.NewReader(make([]byte, 100))); err == nil {
77 | t.Fatal("expected error")
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/notification_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | type NotificationPayload struct {
4 | Title string
5 | Subtitle string
6 | Body string
7 | }
8 |
9 | type NotificationEvent struct {
10 | ID string
11 | UID string
12 | UserID string
13 | Type string
14 | Time int64
15 | Payload NotificationPayload
16 | }
17 |
--------------------------------------------------------------------------------
/observability.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | )
8 |
9 | func (c *Client) SendObservabilityBatch(ctx context.Context, req ObservabilityBatch) error {
10 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
11 | return r.SetHeader("Priority", "u=6").SetBody(req).Post("/data/v1/metrics")
12 | })
13 | }
14 |
--------------------------------------------------------------------------------
/observability_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | type ObservabilityBatch struct {
4 | Metrics []ObservabilityMetric `json:"Metrics"`
5 | }
6 |
7 | type ObservabilityMetric struct {
8 | Name string `json:"Name"`
9 | Version int `json:"Version"`
10 | Timestamp int64 `json:"Timestamp"` // Unix timestamp
11 | Data interface{} `json:"Data"`
12 |
13 | ShouldCache bool `json:"-"` // Internal field, indicating whether we should cache the given observability metric.
14 | }
15 |
--------------------------------------------------------------------------------
/option.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/ProtonMail/gluon/async"
7 | "github.com/go-resty/resty/v2"
8 | )
9 |
10 | // Option represents a type that can be used to configure the manager.
11 | type Option interface {
12 | config(*managerBuilder)
13 | }
14 |
15 | func WithHostURL(hostURL string) Option {
16 | return &withHostURL{
17 | hostURL: hostURL,
18 | }
19 | }
20 |
21 | type withHostURL struct {
22 | hostURL string
23 | }
24 |
25 | func (opt withHostURL) config(builder *managerBuilder) {
26 | builder.hostURL = opt.hostURL
27 | }
28 |
29 | func WithAppVersion(appVersion string) Option {
30 | return &withAppVersion{
31 | appVersion: appVersion,
32 | }
33 | }
34 |
35 | type withAppVersion struct {
36 | appVersion string
37 | }
38 |
39 | func (opt withAppVersion) config(builder *managerBuilder) {
40 | builder.appVersion = opt.appVersion
41 | }
42 |
43 | func WithTransport(transport http.RoundTripper) Option {
44 | return &withTransport{
45 | transport: transport,
46 | }
47 | }
48 |
49 | type withTransport struct {
50 | transport http.RoundTripper
51 | }
52 |
53 | func (opt withTransport) config(builder *managerBuilder) {
54 | builder.transport = opt.transport
55 | }
56 |
57 | type withSkipVerifyProofs struct {
58 | skipVerifyProofs bool
59 | }
60 |
61 | func (opt withSkipVerifyProofs) config(builder *managerBuilder) {
62 | builder.verifyProofs = !opt.skipVerifyProofs
63 | }
64 |
65 | func WithSkipVerifyProofs() Option {
66 | return &withSkipVerifyProofs{
67 | skipVerifyProofs: true,
68 | }
69 | }
70 |
71 | func WithRetryCount(retryCount int) Option {
72 | return &withRetryCount{
73 | retryCount: retryCount,
74 | }
75 | }
76 |
77 | type withRetryCount struct {
78 | retryCount int
79 | }
80 |
81 | func (opt withRetryCount) config(builder *managerBuilder) {
82 | builder.retryCount = opt.retryCount
83 | }
84 |
85 | func WithCookieJar(jar http.CookieJar) Option {
86 | return &withCookieJar{
87 | jar: jar,
88 | }
89 | }
90 |
91 | type withCookieJar struct {
92 | jar http.CookieJar
93 | }
94 |
95 | func (opt withCookieJar) config(builder *managerBuilder) {
96 | builder.cookieJar = opt.jar
97 | }
98 |
99 | func WithLogger(logger resty.Logger) Option {
100 | return &withLogger{
101 | logger: logger,
102 | }
103 | }
104 |
105 | type withLogger struct {
106 | logger resty.Logger
107 | }
108 |
109 | func (opt withLogger) config(builder *managerBuilder) {
110 | builder.logger = opt.logger
111 | }
112 |
113 | func WithDebug(debug bool) Option {
114 | return &withDebug{
115 | debug: debug,
116 | }
117 | }
118 |
119 | type withDebug struct {
120 | debug bool
121 | }
122 |
123 | func (opt withDebug) config(builder *managerBuilder) {
124 | builder.debug = opt.debug
125 | }
126 |
127 | func WithPanicHandler(panicHandler async.PanicHandler) Option {
128 | return &withPanicHandler{
129 | panicHandler: panicHandler,
130 | }
131 | }
132 |
133 | type withPanicHandler struct {
134 | panicHandler async.PanicHandler
135 | }
136 |
137 | func (opt withPanicHandler) config(builder *managerBuilder) {
138 | builder.panicHandler = opt.panicHandler
139 | }
140 |
--------------------------------------------------------------------------------
/organization.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | )
8 |
9 | type OrganizationResponse struct {
10 | Code int
11 | Organization organization
12 | }
13 |
14 | type organization struct {
15 | Name string
16 | DisplayName string
17 | PlanName string
18 | MaxMembers int
19 | }
20 |
21 | func (c *Client) GetOrganizationData(ctx context.Context) (OrganizationResponse, error) {
22 | var res OrganizationResponse
23 |
24 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
25 | return r.SetResult(&res).Get("/core/v4/organizations")
26 | }); err != nil {
27 | return res, err
28 | }
29 |
30 | return res, nil
31 | }
32 |
--------------------------------------------------------------------------------
/package.go:
--------------------------------------------------------------------------------
1 | // Package proton implements types for accessing the Proton API.
2 | package proton
3 |
--------------------------------------------------------------------------------
/paging.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 | "runtime"
6 |
7 | "github.com/ProtonMail/gluon/async"
8 | "github.com/bradenaw/juniper/iterator"
9 | "github.com/bradenaw/juniper/parallel"
10 | "github.com/bradenaw/juniper/stream"
11 | )
12 |
13 | const maxPageSize = 150
14 |
15 | func fetchPaged[T any](
16 | ctx context.Context,
17 | total, pageSize int, c *Client,
18 | fn func(ctx context.Context, page, pageSize int) ([]T, error),
19 | ) ([]T, error) {
20 | return stream.Collect(ctx, stream.Flatten(parallel.MapStream(
21 | ctx,
22 | stream.FromIterator(iterator.Counter(total/pageSize+1)),
23 | runtime.NumCPU(),
24 | runtime.NumCPU(),
25 | func(ctx context.Context, page int) (stream.Stream[T], error) {
26 | defer async.HandlePanic(c.m.panicHandler)
27 |
28 | values, err := fn(ctx, page, pageSize)
29 | if err != nil {
30 | return nil, err
31 | }
32 |
33 | return stream.FromIterator(iterator.Slice(values)), nil
34 | },
35 | )))
36 | }
37 |
--------------------------------------------------------------------------------
/response_test.go:
--------------------------------------------------------------------------------
1 | package proton_test
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "net"
8 | "net/http"
9 | "net/http/httptest"
10 | "net/url"
11 | "testing"
12 |
13 | "github.com/ProtonMail/go-proton-api"
14 | "github.com/ProtonMail/go-proton-api/server"
15 | "github.com/stretchr/testify/require"
16 | )
17 |
18 | func TestNetError_DropOnWrite(t *testing.T) {
19 | l, err := net.Listen("tcp", ":0")
20 | require.NoError(t, err)
21 |
22 | dropListener := proton.NewListener(l, proton.NewDropConn)
23 |
24 | // Use a custom listener that drops all writes.
25 | dropListener.SetCanWrite(false)
26 |
27 | // Simulate a server that refuses to write.
28 | s := server.New(server.WithListener(dropListener))
29 | defer s.Close()
30 |
31 | m := proton.New(proton.WithHostURL(s.GetHostURL()))
32 | defer m.Close()
33 |
34 | // This should fail with a URL error.
35 | pingErr := m.Ping(context.Background())
36 |
37 | if urlErr := new(url.Error); !errors.As(pingErr, &urlErr) {
38 | t.Fatalf("expected a url.Error, got %T: %v", pingErr, pingErr)
39 | }
40 | }
41 |
42 | func TestAPIError_DeserializeWithoutDetails(t *testing.T) {
43 | errJson := `
44 | {
45 | "Status": 400,
46 | "Code": 1000,
47 | "Error": "Foo Bar"
48 | }
49 | `
50 | var err proton.APIError
51 |
52 | require.NoError(t, json.Unmarshal([]byte(errJson), &err))
53 | require.Nil(t, err.Details)
54 | }
55 |
56 | func TestAPIError_DeserializeWithoutDetailsValue(t *testing.T) {
57 | errJson := `
58 | {
59 | "Status": 400,
60 | "Code": 1000,
61 | "Error": "Foo Bar",
62 | "Details": 20
63 | }
64 | `
65 | var err proton.APIError
66 |
67 | require.NoError(t, json.Unmarshal([]byte(errJson), &err))
68 | require.NotNil(t, err.Details)
69 | require.Equal(t, `20`, err.DetailsToString())
70 | }
71 |
72 | func TestAPIError_DeserializeWithDetailsObject(t *testing.T) {
73 | errJson := `
74 | {
75 | "Status": 400,
76 | "Code": 1000,
77 | "Error": "Foo Bar",
78 | "Details": {"object2":{"v":20},"foo":"bar"}
79 | }
80 | `
81 | var err proton.APIError
82 |
83 | require.NoError(t, json.Unmarshal([]byte(errJson), &err))
84 | require.NotNil(t, err.Details)
85 | require.Equal(t, `{"object2":{"v":20},"foo":"bar"}`, err.DetailsToString())
86 | }
87 |
88 | func TestAPIError_DeserializeWithDetailsArray(t *testing.T) {
89 | errJson := `
90 | {
91 | "Status": 400,
92 | "Code": 1000,
93 | "Error": "Foo Bar",
94 | "Details": [{"object2":{"v":20},"foo":"bar"},499,"hello"]
95 | }
96 | `
97 | var err proton.APIError
98 |
99 | require.NoError(t, json.Unmarshal([]byte(errJson), &err))
100 | require.NotNil(t, err.Details)
101 | require.Equal(t, `[{"object2":{"v":20},"foo":"bar"},499,"hello"]`, err.DetailsToString())
102 | }
103 |
104 | func TestAPIError_DeserializeWithHV(t *testing.T) {
105 | errJson := `
106 | {
107 | "Status": 422,
108 | "Code": 9001,
109 | "Error": "Foo Bar",
110 | "Details": {
111 | "HumanVerificationMethods": ["captcha", "foo"],
112 | "HumanVerificationToken": "token"
113 | }
114 | }
115 | `
116 | var err proton.APIError
117 |
118 | require.NoError(t, json.Unmarshal([]byte(errJson), &err))
119 | require.NotNil(t, err.Details)
120 | require.True(t, err.IsHVError())
121 | hv, e := err.GetHVDetails()
122 | require.NoError(t, e)
123 | require.Equal(t, []string{"captcha", "foo"}, hv.Methods)
124 | require.Equal(t, "token", hv.Token)
125 | }
126 |
127 | func TestNetError_RouteInErrorMessage(t *testing.T) {
128 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
129 | w.WriteHeader(http.StatusBadRequest)
130 | }))
131 | defer s.Close()
132 |
133 | m := proton.New(proton.WithHostURL(s.URL))
134 | defer m.Close()
135 |
136 | pingErr := m.Quark(context.Background(), "test/ping")
137 |
138 | require.Error(t, pingErr)
139 | require.Contains(t, pingErr.Error(), "GET")
140 | require.Contains(t, pingErr.Error(), "/test/ping")
141 | }
142 |
--------------------------------------------------------------------------------
/salt.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | )
8 |
9 | func (c *Client) GetSalts(ctx context.Context) (Salts, error) {
10 | var res struct {
11 | KeySalts []Salt
12 | }
13 |
14 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
15 | return r.SetResult(&res).Get("/core/v4/keys/salts")
16 | }); err != nil {
17 | return nil, err
18 | }
19 |
20 | return res.KeySalts, nil
21 | }
22 |
--------------------------------------------------------------------------------
/salt_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "encoding/base64"
5 | "fmt"
6 |
7 | "github.com/ProtonMail/go-srp"
8 | "github.com/bradenaw/juniper/xslices"
9 | )
10 |
11 | type Salt struct {
12 | ID, KeySalt string
13 | }
14 |
15 | type Salts []Salt
16 |
17 | func (salts Salts) SaltForKey(keyPass []byte, keyID string) ([]byte, error) {
18 | idx := xslices.IndexFunc(salts, func(salt Salt) bool {
19 | return salt.ID == keyID
20 | })
21 |
22 | if idx < 0 {
23 | return nil, fmt.Errorf("no salt found for key %s", keyID)
24 | }
25 |
26 | keySalt, err := base64.StdEncoding.DecodeString(salts[idx].KeySalt)
27 | if err != nil {
28 | return nil, err
29 | }
30 |
31 | saltedKeyPass, err := srp.MailboxPassword(keyPass, keySalt)
32 | if err != nil {
33 | return nil, nil
34 | }
35 |
36 | return saltedKeyPass[len(saltedKeyPass)-31:], nil
37 | }
38 |
--------------------------------------------------------------------------------
/server/addresses.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/ProtonMail/go-proton-api"
7 | "github.com/gin-gonic/gin"
8 | "golang.org/x/exp/slices"
9 | )
10 |
11 | func (s *Server) handleGetAddresses() gin.HandlerFunc {
12 | return func(c *gin.Context) {
13 | addresses, err := s.b.GetAddresses(c.GetString("UserID"))
14 | if err != nil {
15 | c.AbortWithStatus(http.StatusInternalServerError)
16 | return
17 | }
18 |
19 | c.JSON(http.StatusOK, gin.H{
20 | "Addresses": addresses,
21 | })
22 | }
23 | }
24 |
25 | func (s *Server) handleGetAddress() gin.HandlerFunc {
26 | return func(c *gin.Context) {
27 | address, err := s.b.GetAddress(c.GetString("UserID"), c.Param("addressID"))
28 | if err != nil {
29 | c.AbortWithStatus(http.StatusInternalServerError)
30 | return
31 | }
32 |
33 | c.JSON(http.StatusOK, gin.H{
34 | "Address": address,
35 | })
36 | }
37 | }
38 |
39 | func (s *Server) handlePutAddressEnable() gin.HandlerFunc {
40 | return func(c *gin.Context) {
41 | if err := s.b.EnableAddress(c.GetString("UserID"), c.Param("addressID")); err != nil {
42 | c.AbortWithStatus(http.StatusInternalServerError)
43 | return
44 | }
45 | }
46 | }
47 |
48 | func (s *Server) handlePutAddressDisable() gin.HandlerFunc {
49 | return func(c *gin.Context) {
50 | if err := s.b.DisableAddress(c.GetString("UserID"), c.Param("addressID")); err != nil {
51 | c.AbortWithStatus(http.StatusInternalServerError)
52 | return
53 | }
54 | }
55 | }
56 |
57 | func (s *Server) handleDeleteAddress() gin.HandlerFunc {
58 | return func(c *gin.Context) {
59 | if err := s.b.DeleteAddress(c.GetString("UserID"), c.Param("addressID")); err != nil {
60 | c.AbortWithStatus(http.StatusInternalServerError)
61 | return
62 | }
63 | }
64 | }
65 |
66 | func (s *Server) handlePutAddressesOrder() gin.HandlerFunc {
67 | return func(c *gin.Context) {
68 | var req proton.OrderAddressesReq
69 |
70 | if err := c.BindJSON(&req); err != nil {
71 | c.AbortWithStatus(http.StatusBadRequest)
72 | return
73 | }
74 |
75 | addresses, err := s.b.GetAddresses(c.GetString("UserID"))
76 | if err != nil {
77 | c.AbortWithStatus(http.StatusInternalServerError)
78 | return
79 | }
80 |
81 | if len(req.AddressIDs) != len(addresses) {
82 | c.AbortWithStatus(http.StatusBadRequest)
83 | return
84 | }
85 |
86 | for _, address := range addresses {
87 | if !slices.Contains(req.AddressIDs, address.ID) {
88 | c.AbortWithStatus(http.StatusBadRequest)
89 | return
90 | }
91 | }
92 |
93 | if err := s.b.SetAddressOrder(c.GetString("UserID"), req.AddressIDs); err != nil {
94 | c.AbortWithStatus(http.StatusInternalServerError)
95 | return
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/server/attachments.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "io"
5 | "mime/multipart"
6 | "net/http"
7 |
8 | "github.com/ProtonMail/gluon/rfc822"
9 | "github.com/ProtonMail/go-proton-api"
10 | "github.com/gin-gonic/gin"
11 | )
12 |
13 | func (s *Server) handlePostMailAttachments() gin.HandlerFunc {
14 | return func(c *gin.Context) {
15 | form, err := c.MultipartForm()
16 | if err != nil {
17 | c.AbortWithStatus(http.StatusBadRequest)
18 | return
19 | }
20 |
21 | attachment, err := s.b.CreateAttachment(
22 | c.GetString("UserID"),
23 | form.Value["MessageID"][0],
24 | form.Value["Filename"][0],
25 | rfc822.MIMEType(form.Value["MIMEType"][0]),
26 | proton.Disposition(form.Value["Disposition"][0]),
27 | form.Value["ContentID"][0],
28 | mustReadFileHeader(form.File["KeyPackets"][0]),
29 | mustReadFileHeader(form.File["DataPacket"][0]),
30 | string(mustReadFileHeader(form.File["Signature"][0])),
31 | )
32 | if err != nil {
33 | _ = c.AbortWithError(http.StatusUnprocessableEntity, err)
34 | return
35 | }
36 |
37 | c.JSON(http.StatusOK, gin.H{
38 | "Attachment": attachment,
39 | })
40 | }
41 | }
42 |
43 | func (s *Server) handleGetMailAttachment() gin.HandlerFunc {
44 | return func(c *gin.Context) {
45 | attData, err := s.b.GetAttachment(c.Param("attachID"))
46 | if err != nil {
47 | _ = c.AbortWithError(http.StatusUnprocessableEntity, err)
48 | return
49 | }
50 |
51 | c.Data(http.StatusOK, "application/octet-stream", attData)
52 | }
53 | }
54 |
55 | func mustReadFileHeader(fh *multipart.FileHeader) []byte {
56 | f, err := fh.Open()
57 | if err != nil {
58 | panic(err)
59 | }
60 |
61 | data, err := io.ReadAll(f)
62 | if err != nil {
63 | panic(err)
64 | }
65 |
66 | return data
67 | }
68 |
--------------------------------------------------------------------------------
/server/auth.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "encoding/base64"
5 | "net/http"
6 |
7 | "github.com/ProtonMail/go-proton-api"
8 | "github.com/gin-gonic/gin"
9 | "github.com/sirupsen/logrus"
10 | )
11 |
12 | var log = logrus.WithField("pkg", "gpa/server")
13 |
14 | func (s *Server) handlePostAuthInfo() gin.HandlerFunc {
15 | return func(c *gin.Context) {
16 | var req proton.AuthInfoReq
17 |
18 | if err := c.BindJSON(&req); err != nil {
19 | return
20 | }
21 |
22 | info, err := s.b.NewAuthInfo(req.Username)
23 | if err != nil {
24 | log.WithError(err).Errorf("User '%v' failed auth info", req.Username)
25 | _ = c.AbortWithError(http.StatusUnauthorized, err)
26 | return
27 | }
28 |
29 | c.JSON(http.StatusOK, info)
30 | }
31 | }
32 |
33 | func (s *Server) handlePostAuth() gin.HandlerFunc {
34 | return func(c *gin.Context) {
35 | var req proton.AuthReq
36 |
37 | if err := c.BindJSON(&req); err != nil {
38 | return
39 | }
40 |
41 | clientEphemeral, err := base64.StdEncoding.DecodeString(req.ClientEphemeral)
42 | if err != nil {
43 | _ = c.AbortWithError(http.StatusBadRequest, err)
44 | return
45 | }
46 |
47 | clientProof, err := base64.StdEncoding.DecodeString(req.ClientProof)
48 | if err != nil {
49 | _ = c.AbortWithError(http.StatusBadRequest, err)
50 | return
51 | }
52 |
53 | auth, err := s.b.NewAuth(req.Username, clientEphemeral, clientProof, req.SRPSession)
54 | if err != nil {
55 | log.WithError(err).Errorf("User '%v' not authorized", req.Username)
56 | _ = c.AbortWithError(http.StatusUnauthorized, err)
57 | return
58 | }
59 |
60 | c.JSON(http.StatusOK, auth)
61 | }
62 | }
63 |
64 | func (s *Server) handlePostAuthRefresh() gin.HandlerFunc {
65 | return func(c *gin.Context) {
66 | var req proton.AuthRefreshReq
67 |
68 | if err := c.BindJSON(&req); err != nil {
69 | return
70 | }
71 |
72 | auth, err := s.b.NewAuthRef(req.UID, req.RefreshToken)
73 | if err != nil {
74 | _ = c.AbortWithError(http.StatusUnprocessableEntity, err)
75 | return
76 | }
77 |
78 | c.JSON(http.StatusOK, auth)
79 | }
80 | }
81 |
82 | func (s *Server) handleDeleteAuth() gin.HandlerFunc {
83 | return func(c *gin.Context) {
84 | if err := s.b.DeleteSession(c.GetString("UserID"), c.GetString("AuthUID")); err != nil {
85 | _ = c.AbortWithError(http.StatusUnauthorized, err)
86 | return
87 | }
88 | }
89 | }
90 |
91 | func (s *Server) handleGetAuthSessions() gin.HandlerFunc {
92 | return func(c *gin.Context) {
93 | sessions, err := s.b.GetSessions(c.GetString("UserID"))
94 | if err != nil {
95 | _ = c.AbortWithError(http.StatusInternalServerError, err)
96 | return
97 | }
98 |
99 | c.JSON(http.StatusOK, gin.H{"Sessions": sessions})
100 | }
101 | }
102 |
103 | func (s *Server) handleDeleteAuthSessions() gin.HandlerFunc {
104 | return func(c *gin.Context) {
105 | sessions, err := s.b.GetSessions(c.GetString("UserID"))
106 | if err != nil {
107 | _ = c.AbortWithError(http.StatusInternalServerError, err)
108 | return
109 | }
110 |
111 | for _, session := range sessions {
112 | if session.UID != c.GetString("AuthUID") {
113 | if err := s.b.DeleteSession(c.GetString("UserID"), session.UID); err != nil {
114 | _ = c.AbortWithError(http.StatusInternalServerError, err)
115 | return
116 | }
117 | }
118 | }
119 | }
120 | }
121 |
122 | func (s *Server) handleDeleteAuthSession() gin.HandlerFunc {
123 | return func(c *gin.Context) {
124 | if err := s.b.DeleteSession(c.GetString("UserID"), c.Param("authUID")); err != nil {
125 | _ = c.AbortWithError(http.StatusInternalServerError, err)
126 | return
127 | }
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/server/backend/account.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | "github.com/ProtonMail/go-proton-api"
5 | "github.com/ProtonMail/gopenpgp/v2/crypto"
6 | "github.com/bradenaw/juniper/xslices"
7 | "github.com/google/uuid"
8 | )
9 |
10 | type account struct {
11 | userID string
12 | username string
13 | addresses map[string]*address
14 | mailSettings *mailSettings
15 | userSettings proton.UserSettings
16 | contacts map[string]*proton.Contact
17 | contactCounter int
18 |
19 | auth map[string]auth
20 |
21 | keys []key
22 | salt []byte
23 | verifier []byte
24 |
25 | labelIDs []string
26 | messageIDs []string
27 | updateIDs []ID
28 | }
29 |
30 | func newAccount(userID, username string, armKey string, salt, verifier []byte) *account {
31 | return &account{
32 | userID: userID,
33 | username: username,
34 | addresses: make(map[string]*address),
35 | mailSettings: newMailSettings(username),
36 | userSettings: newUserSettings(),
37 | contacts: make(map[string]*proton.Contact),
38 |
39 | auth: make(map[string]auth),
40 | keys: []key{{keyID: uuid.NewString(), key: armKey}},
41 | salt: salt,
42 | verifier: verifier,
43 | }
44 | }
45 |
46 | func (acc *account) toUser() proton.User {
47 | return proton.User{
48 | ID: acc.userID,
49 | Name: acc.username,
50 | DisplayName: acc.username,
51 | Email: acc.primary().email,
52 | Keys: xslices.Map(acc.keys, func(key key) proton.Key {
53 | privKey, err := crypto.NewKeyFromArmored(key.key)
54 | if err != nil {
55 | panic(err)
56 | }
57 |
58 | rawKey, err := privKey.Serialize()
59 | if err != nil {
60 | panic(err)
61 | }
62 |
63 | return proton.Key{
64 | ID: key.keyID,
65 | PrivateKey: rawKey,
66 | Primary: key == acc.keys[0],
67 | Active: true,
68 | }
69 | }),
70 | }
71 | }
72 |
73 | func (acc *account) primary() *address {
74 | for _, addr := range acc.addresses {
75 | if addr.order == 1 {
76 | return addr
77 | }
78 | }
79 |
80 | panic("no primary address")
81 | }
82 |
83 | func (acc *account) getAddr(email string) (*address, bool) {
84 | for _, addr := range acc.addresses {
85 | if addr.email == email {
86 | return addr, true
87 | }
88 | }
89 |
90 | return nil, false
91 | }
92 |
--------------------------------------------------------------------------------
/server/backend/address.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | "github.com/ProtonMail/go-proton-api"
5 | "github.com/ProtonMail/gopenpgp/v2/crypto"
6 | "github.com/bradenaw/juniper/xslices"
7 | )
8 |
9 | type address struct {
10 | addrID string
11 | email string
12 | displayName string
13 | order int
14 | status proton.AddressStatus
15 | addrType proton.AddressType
16 | keys []key
17 | allowSend bool
18 | }
19 |
20 | func (add *address) toAddress() proton.Address {
21 | return proton.Address{
22 | ID: add.addrID,
23 | Email: add.email,
24 |
25 | Send: proton.Bool(add.allowSend),
26 | Receive: true,
27 | Status: add.status,
28 | Type: add.addrType,
29 |
30 | Order: add.order,
31 | DisplayName: add.displayName,
32 |
33 | Keys: xslices.Map(add.keys, func(key key) proton.Key {
34 | privKey, err := crypto.NewKeyFromArmored(key.key)
35 | if err != nil {
36 | panic(err)
37 | }
38 |
39 | rawKey, err := privKey.Serialize()
40 | if err != nil {
41 | panic(err)
42 | }
43 |
44 | return proton.Key{
45 | ID: key.keyID,
46 | PrivateKey: rawKey,
47 | Token: key.tok,
48 | Signature: key.sig,
49 | Primary: key == add.keys[0],
50 | Active: true,
51 | }
52 | }),
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/server/backend/api_auth.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | "encoding/base64"
5 | "fmt"
6 |
7 | "github.com/ProtonMail/go-proton-api"
8 | "github.com/ProtonMail/go-srp"
9 | "github.com/google/uuid"
10 | )
11 |
12 | func (b *Backend) NewAuthInfo(username string) (proton.AuthInfo, error) {
13 | return writeBackendRetErr(b, func(b *unsafeBackend) (proton.AuthInfo, error) {
14 | return withAccName(b, username, func(acc *account) (proton.AuthInfo, error) {
15 | server, err := srp.NewServerFromSigned(modulus, acc.verifier, 2048)
16 | if err != nil {
17 | log.WithError(err).Errorf("Failed to create SRP Server")
18 | return proton.AuthInfo{}, fmt.Errorf("failed to create new srp server %w", err)
19 | }
20 |
21 | challenge, err := server.GenerateChallenge()
22 | if err != nil {
23 | log.WithError(err).Errorf("Failed to generate srp challeng")
24 | return proton.AuthInfo{}, fmt.Errorf("failed to generate srp challend %w", err)
25 | }
26 |
27 | session := uuid.NewString()
28 |
29 | b.srp[session] = server
30 |
31 | return proton.AuthInfo{
32 | Version: 4,
33 | Modulus: modulus,
34 | ServerEphemeral: base64.StdEncoding.EncodeToString(challenge),
35 | Salt: base64.StdEncoding.EncodeToString(acc.salt),
36 | SRPSession: session,
37 | }, nil
38 | })
39 | })
40 | }
41 |
42 | func (b *Backend) NewAuth(username string, ephemeral, proof []byte, session string) (proton.Auth, error) {
43 | return writeBackendRetErr(b, func(b *unsafeBackend) (proton.Auth, error) {
44 | return withAccName(b, username, func(acc *account) (proton.Auth, error) {
45 | server, ok := b.srp[session]
46 | if !ok {
47 | log.Errorf("Session '%v' not found for user='%v'", session, username)
48 | return proton.Auth{}, fmt.Errorf("invalid session")
49 | }
50 |
51 | delete(b.srp, session)
52 |
53 | serverProof, err := server.VerifyProofs(ephemeral, proof)
54 | if err != nil {
55 | return proton.Auth{}, fmt.Errorf("invalid proof: %w", err)
56 | }
57 |
58 | authUID, auth := uuid.NewString(), newAuth(b.authLife)
59 |
60 | acc.auth[authUID] = auth
61 |
62 | return auth.toAuth(acc.userID, authUID, serverProof), nil
63 | })
64 | })
65 | }
66 |
67 | func (b *Backend) NewAuthRef(authUID, authRef string) (proton.Auth, error) {
68 | return writeBackendRetErr(b, func(b *unsafeBackend) (proton.Auth, error) {
69 | for _, acc := range b.accounts {
70 | auth, ok := acc.auth[authUID]
71 | if !ok {
72 | continue
73 | }
74 |
75 | if auth.ref != authRef {
76 | return proton.Auth{}, fmt.Errorf("invalid auth ref")
77 | }
78 |
79 | newAuth := newAuth(b.authLife)
80 |
81 | acc.auth[authUID] = newAuth
82 |
83 | return newAuth.toAuth(acc.userID, authUID, nil), nil
84 | }
85 |
86 | return proton.Auth{}, fmt.Errorf("invalid auth")
87 | })
88 | }
89 |
90 | func (b *Backend) VerifyAuth(authUID, authAcc string) (string, error) {
91 | return writeBackendRetErr(b, func(b *unsafeBackend) (string, error) {
92 | return withAccAuth(b, authUID, authAcc, func(acc *account) (string, error) {
93 | return acc.userID, nil
94 | })
95 | })
96 | }
97 |
98 | func (b *Backend) GetSessions(userID string) ([]proton.AuthSession, error) {
99 | return readBackendRetErr(b, func(b *unsafeBackend) ([]proton.AuthSession, error) {
100 | return withAcc(b, userID, func(acc *account) ([]proton.AuthSession, error) {
101 | var sessions []proton.AuthSession
102 |
103 | for authUID, auth := range acc.auth {
104 | sessions = append(sessions, auth.toAuthSession(authUID))
105 | }
106 |
107 | return sessions, nil
108 | })
109 | })
110 | }
111 |
112 | func (b *Backend) DeleteSession(userID, authUID string) error {
113 | return writeBackendRet(b, func(b *unsafeBackend) error {
114 | return b.withAcc(userID, func(acc *account) error {
115 | delete(acc.auth, authUID)
116 |
117 | return nil
118 | })
119 | })
120 | }
121 |
--------------------------------------------------------------------------------
/server/backend/attachment.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | "encoding/base64"
5 |
6 | "github.com/ProtonMail/gluon/rfc822"
7 | "github.com/ProtonMail/go-proton-api"
8 | "github.com/google/uuid"
9 | )
10 |
11 | func (b *unsafeBackend) createAttData(dataPacket []byte) string {
12 | attDataID := uuid.NewString()
13 |
14 | b.attData[attDataID] = dataPacket
15 |
16 | return attDataID
17 | }
18 |
19 | type attachment struct {
20 | attachID string
21 | attDataID string
22 |
23 | filename string
24 | mimeType rfc822.MIMEType
25 | disposition proton.Disposition
26 | contentID string
27 |
28 | keyPackets []byte
29 | armSig string
30 | }
31 |
32 | func newAttachment(
33 | filename string,
34 | mimeType rfc822.MIMEType,
35 | disposition proton.Disposition,
36 | contentID string,
37 | keyPackets []byte,
38 | dataPacketID string,
39 | armSig string,
40 | ) *attachment {
41 | return &attachment{
42 | attachID: uuid.NewString(),
43 | attDataID: dataPacketID,
44 |
45 | filename: filename,
46 | mimeType: mimeType,
47 | disposition: disposition,
48 | contentID: contentID,
49 |
50 | keyPackets: keyPackets,
51 | armSig: armSig,
52 | }
53 | }
54 |
55 | func (att *attachment) toAttachment() proton.Attachment {
56 | return proton.Attachment{
57 | ID: att.attachID,
58 |
59 | Name: att.filename,
60 | MIMEType: att.mimeType,
61 | Disposition: att.disposition,
62 |
63 | KeyPackets: base64.StdEncoding.EncodeToString(att.keyPackets),
64 | Signature: att.armSig,
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/server/backend/contact.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | "github.com/ProtonMail/go-proton-api"
5 | "github.com/ProtonMail/gopenpgp/v2/crypto"
6 | "github.com/bradenaw/juniper/xslices"
7 | "github.com/emersion/go-vcard"
8 | "strconv"
9 | "sync/atomic"
10 | )
11 |
12 | var globalContactID int32
13 |
14 | func ContactCardToContact(card *proton.Card, contactID string, kr *crypto.KeyRing) (proton.Contact, error) {
15 | emails, err := card.Get(kr, vcard.FieldEmail)
16 | if err != nil {
17 | return proton.Contact{}, err
18 | }
19 | names, err := card.Get(kr, vcard.FieldFormattedName)
20 | if err != nil {
21 | return proton.Contact{}, err
22 | }
23 | return proton.Contact{
24 | ContactMetadata: proton.ContactMetadata{
25 | ID: contactID,
26 | Name: names[0].Value,
27 | ContactEmails: xslices.Map(emails, func(email *vcard.Field) proton.ContactEmail {
28 | id := atomic.AddInt32(&globalContactID, 1)
29 | return proton.ContactEmail{
30 | ID: strconv.Itoa(int(id)),
31 | Name: names[0].Value,
32 | Email: email.Value,
33 | ContactID: contactID,
34 | }
35 | }),
36 | },
37 | ContactCards: proton.ContactCards{Cards: proton.Cards{card}},
38 | }, nil
39 | }
40 |
--------------------------------------------------------------------------------
/server/backend/core_settings.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | "github.com/ProtonMail/go-proton-api"
5 | )
6 |
7 | func newUserSettings() proton.UserSettings {
8 | return proton.UserSettings{Telemetry: proton.SettingEnabled, CrashReports: proton.SettingEnabled}
9 | }
10 |
--------------------------------------------------------------------------------
/server/backend/crypto.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | "github.com/ProtonMail/go-srp"
5 | "github.com/ProtonMail/gopenpgp/v2/crypto"
6 | "github.com/ProtonMail/gopenpgp/v2/helper"
7 | )
8 |
9 | var GenerateKey = helper.GenerateKey
10 |
11 | func hashPassword(password, salt []byte) ([]byte, error) {
12 | passphrase, err := srp.MailboxPassword(password, salt)
13 | if err != nil {
14 | return nil, err
15 | }
16 |
17 | return passphrase[len(passphrase)-31:], nil
18 | }
19 |
20 | func encryptWithSignature(kr *crypto.KeyRing, b []byte) (string, string, error) {
21 | enc, err := kr.Encrypt(crypto.NewPlainMessage(b), nil)
22 | if err != nil {
23 | return "", "", err
24 | }
25 |
26 | encArm, err := enc.GetArmored()
27 | if err != nil {
28 | return "", "", err
29 | }
30 |
31 | sig, err := kr.SignDetached(crypto.NewPlainMessage(b))
32 | if err != nil {
33 | return "", "", err
34 | }
35 |
36 | sigArm, err := sig.GetArmored()
37 | if err != nil {
38 | return "", "", err
39 | }
40 |
41 | return encArm, sigArm, nil
42 | }
43 |
--------------------------------------------------------------------------------
/server/backend/crypto_fast.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import "github.com/ProtonMail/gopenpgp/v2/crypto"
4 |
5 | var preCompKey *crypto.Key
6 |
7 | func init() {
8 | key, err := crypto.GenerateKey("name", "email", "rsa", 1024)
9 | if err != nil {
10 | panic(err)
11 | }
12 |
13 | preCompKey = key
14 | }
15 |
16 | // FastGenerateKey is a fast version of GenerateKey that uses a pre-computed key.
17 | // This is useful for testing but is incredibly insecure.
18 | func FastGenerateKey(_, _ string, passphrase []byte, _ string, _ int) (string, error) {
19 | encKey, err := preCompKey.Lock(passphrase)
20 | if err != nil {
21 | return "", err
22 | }
23 |
24 | return encKey.Armor()
25 | }
26 |
--------------------------------------------------------------------------------
/server/backend/label.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | "github.com/ProtonMail/go-proton-api"
5 | "github.com/google/uuid"
6 | )
7 |
8 | type label struct {
9 | labelID string
10 | parentID string
11 | name string
12 | labelType proton.LabelType
13 | messageIDs map[string]struct{}
14 | }
15 |
16 | func newLabel(labelName, parentID string, labelType proton.LabelType) *label {
17 | return &label{
18 | labelID: uuid.NewString(),
19 | parentID: parentID,
20 | name: labelName,
21 | labelType: labelType,
22 | messageIDs: make(map[string]struct{}),
23 | }
24 | }
25 |
26 | func (label *label) toLabel(labels map[string]*label) proton.Label {
27 | var path []string
28 |
29 | for labelID := label.labelID; labelID != ""; labelID = labels[labelID].parentID {
30 | path = append([]string{labels[labelID].name}, path...)
31 | }
32 |
33 | return proton.Label{
34 | ID: label.labelID,
35 | ParentID: label.parentID,
36 | Name: label.name,
37 | Path: path,
38 | Type: label.labelType,
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/server/backend/mail_settings.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | "github.com/ProtonMail/gluon/rfc822"
5 | "github.com/ProtonMail/go-proton-api"
6 | )
7 |
8 | type mailSettings struct {
9 | displayName string
10 | sign proton.SignExternalMessages
11 | pgpScheme proton.EncryptionScheme
12 | draftMIMEType rfc822.MIMEType
13 | attachPubKey bool
14 | }
15 |
16 | func newMailSettings(displayName string) *mailSettings {
17 | return &mailSettings{
18 | displayName: displayName,
19 | draftMIMEType: rfc822.TextHTML,
20 | attachPubKey: false,
21 | sign: 0,
22 | pgpScheme: 0,
23 | }
24 | }
25 |
26 | func (settings *mailSettings) toMailSettings() proton.MailSettings {
27 | return proton.MailSettings{
28 | DisplayName: settings.displayName,
29 | DraftMIMEType: settings.draftMIMEType,
30 | AttachPublicKey: proton.Bool(settings.attachPubKey),
31 | Sign: settings.sign,
32 | PGPScheme: settings.pgpScheme,
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/server/backend/modulus.asc:
--------------------------------------------------------------------------------
1 | +88jb48lF5TyDBveyHZ7QhSvtc4V3pN8/eQW6kk6ok2egy4lr5Wz9h8iZP3erN9lReSx1Lk+WsLu1b3soDhXX/twTCUhxYwjS8r983aEshZJJq7p5tNroQ5pzrZMbK8Oszjajgdg2YzcMcaJqb9+Doi7egj/esUQ+Q7BWdxeK77Wafj9v7PiW6Ozx6ulppu1mZ+YGnXSXJsl1Cl4nPm7PNkgj4BQT3HLrxakh7Xc3agmepRKO/1jLaOBU/oO17URbA5rwh/ZlAOqEAKH5vJ+hA2acM3Bwsa/K8I/jWicxOoaLZ4RZFpLYvOxGbb4DggR2Ri/C6tNyeEQQKAtxpeV5g==
--------------------------------------------------------------------------------
/server/backend/modulus.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/ProtonMail/gopenpgp/v2/crypto"
7 | )
8 |
9 | var modulus string
10 |
11 | func init() {
12 | arm, err := crypto.NewClearTextMessage(asc, sig).GetArmored()
13 | if err != nil {
14 | panic(err)
15 | }
16 |
17 | modulus = arm
18 | }
19 |
20 | //go:embed modulus.asc
21 | var asc []byte
22 |
23 | //go:embed modulus.sig
24 | var sig []byte
25 |
--------------------------------------------------------------------------------
/server/backend/modulus.sig:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ProtonMail/go-proton-api/HEAD/server/backend/modulus.sig
--------------------------------------------------------------------------------
/server/backend/observability.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/ProtonMail/go-proton-api"
7 | )
8 |
9 | type ObservabilityStatistics struct {
10 | Metrics []proton.ObservabilityMetric
11 | RequestTime []time.Time
12 | }
13 |
14 | func NewObservabilityStatistics() ObservabilityStatistics {
15 | return ObservabilityStatistics{
16 | Metrics: make([]proton.ObservabilityMetric, 0),
17 | RequestTime: make([]time.Time, 0),
18 | }
19 | }
20 |
21 | func (b *Backend) PushObservabilityMetrics(metrics []proton.ObservabilityMetric) {
22 | writeBackend(b, func(b *unsafeBackend) {
23 | b.observabilityStatistics.Metrics = append(b.observabilityStatistics.Metrics, metrics...)
24 | b.observabilityStatistics.RequestTime = append(b.observabilityStatistics.RequestTime, time.Now())
25 | })
26 | }
27 |
28 | func (b *Backend) GetObservabilityStatistics() ObservabilityStatistics {
29 | return readBackendRet(b, func(b *unsafeBackend) ObservabilityStatistics {
30 | return b.observabilityStatistics
31 | })
32 | }
33 |
--------------------------------------------------------------------------------
/server/backend/report.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import "github.com/google/uuid"
4 |
5 | func (b *Backend) CreateCSTicket() string {
6 | tokenUUID, err := uuid.NewUUID()
7 | if err != nil {
8 | return ""
9 | }
10 |
11 | return writeBackendRet(b, func(b *unsafeBackend) string {
12 | token := tokenUUID.String()
13 | b.csTicket = append(b.csTicket, token)
14 | return token
15 | })
16 | }
17 |
18 | func (b *Backend) GetCSTicket(token string) bool {
19 | return readBackendRet(b, func(b *unsafeBackend) bool {
20 | for _, ticket := range b.csTicket {
21 | if ticket == token {
22 | return true
23 | }
24 | }
25 | return false
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/server/backend/types.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | "encoding/base64"
5 | "math/big"
6 | "time"
7 |
8 | "github.com/ProtonMail/go-proton-api"
9 | "github.com/ProtonMail/gopenpgp/v2/crypto"
10 | "github.com/google/uuid"
11 | )
12 |
13 | type ID uint64
14 |
15 | func (v ID) String() string {
16 | return base64.URLEncoding.EncodeToString(v.Bytes())
17 | }
18 |
19 | func (v ID) Bytes() []byte {
20 | if v == 0 {
21 | return []byte{0}
22 | }
23 |
24 | return new(big.Int).SetUint64(uint64(v)).Bytes()
25 | }
26 |
27 | func (v *ID) FromString(s string) error {
28 | b, err := base64.URLEncoding.DecodeString(s)
29 | if err != nil {
30 | return err
31 | }
32 |
33 | *v = ID(new(big.Int).SetBytes(b).Uint64())
34 |
35 | return nil
36 | }
37 |
38 | type auth struct {
39 | acc string
40 | ref string
41 |
42 | creation time.Time
43 | }
44 |
45 | func newAuth(authLife time.Duration) auth {
46 | return auth{
47 | acc: uuid.NewString(),
48 | ref: uuid.NewString(),
49 |
50 | creation: time.Now(),
51 | }
52 | }
53 |
54 | func (auth *auth) toAuth(userID, authUID string, proof []byte) proton.Auth {
55 | return proton.Auth{
56 | UserID: userID,
57 |
58 | UID: authUID,
59 | AccessToken: auth.acc,
60 | RefreshToken: auth.ref,
61 | ServerProof: base64.StdEncoding.EncodeToString(proof),
62 |
63 | PasswordMode: proton.OnePasswordMode,
64 | }
65 | }
66 |
67 | func (auth *auth) toAuthSession(authUID string) proton.AuthSession {
68 | return proton.AuthSession{
69 | UID: authUID,
70 | CreateTime: auth.creation.Unix(),
71 | Revocable: true,
72 | }
73 | }
74 |
75 | type key struct {
76 | keyID string
77 | key string
78 | tok string
79 | sig string
80 | }
81 |
82 | func (key key) unlock(passphrase []byte) (*crypto.KeyRing, error) {
83 | lockedKey, err := crypto.NewKeyFromArmored(key.key)
84 | if err != nil {
85 | return nil, err
86 | }
87 |
88 | unlockedKey, err := lockedKey.Unlock(passphrase)
89 | if err != nil {
90 | return nil, err
91 | }
92 |
93 | return crypto.NewKeyRing(unlockedKey)
94 | }
95 |
96 | func (key key) getPubKey() (*crypto.Key, error) {
97 | privKey, err := crypto.NewKeyFromArmored(key.key)
98 | if err != nil {
99 | return nil, err
100 | }
101 |
102 | pubKeyBin, err := privKey.GetPublicKey()
103 | if err != nil {
104 | return nil, err
105 | }
106 |
107 | return crypto.NewKey(pubKeyBin)
108 | }
109 |
--------------------------------------------------------------------------------
/server/backend/types_test.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/require"
7 | )
8 |
9 | func TestID(t *testing.T) {
10 | var v ID
11 |
12 | // We can set the ID from a string.
13 | require.NoError(t, v.FromString("AQIDBA=="))
14 |
15 | // We can get the ID as a string.
16 | require.Equal(t, "AQIDBA==", v.String())
17 |
18 | // We can get the ID as bytes.
19 | require.Equal(t, []byte{1, 2, 3, 4}, v.Bytes())
20 |
21 | // The ID is correct.
22 | require.Equal(t, ID(0x01020304), v)
23 | }
24 |
--------------------------------------------------------------------------------
/server/backend/updates_test.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | func Test_mergeUpdates(t *testing.T) {
9 | tests := []struct {
10 | name string
11 | have []update
12 | want []update
13 | }{
14 | {
15 | name: "single",
16 | have: []update{&labelCreated{labelID: "1"}},
17 | want: []update{&labelCreated{labelID: "1"}},
18 | },
19 | {
20 | name: "multiple",
21 | have: []update{
22 | &labelCreated{labelID: "1"},
23 | &labelCreated{labelID: "2"},
24 | },
25 | want: []update{
26 | &labelCreated{labelID: "1"},
27 | &labelCreated{labelID: "2"},
28 | },
29 | },
30 | {
31 | name: "replace with updated",
32 | have: []update{
33 | &labelCreated{labelID: "1"},
34 | &labelUpdated{labelID: "1"},
35 | &labelUpdated{labelID: "1"},
36 | },
37 | want: []update{
38 | &labelCreated{labelID: "1"},
39 | &labelUpdated{labelID: "1"},
40 | },
41 | },
42 | {
43 | name: "replace with delete",
44 | have: []update{
45 | &labelCreated{labelID: "1"},
46 | &labelUpdated{labelID: "1"},
47 | &labelUpdated{labelID: "1"},
48 | &labelDeleted{labelID: "1"},
49 | },
50 | want: []update{
51 | &labelDeleted{labelID: "1"},
52 | },
53 | },
54 | }
55 |
56 | for _, tt := range tests {
57 | t.Run(tt.name, func(t *testing.T) {
58 | if got := merge(tt.have); !reflect.DeepEqual(got, tt.want) {
59 | t.Errorf("mergeUpdates() = %v, want %v", got, tt.want)
60 | }
61 | })
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/server/cache.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "sync"
5 |
6 | "github.com/ProtonMail/go-proton-api"
7 | )
8 |
9 | func NewAuthCache() AuthCacher {
10 | return &authCache{
11 | info: make(map[string]proton.AuthInfo),
12 | auth: make(map[string]proton.Auth),
13 | }
14 | }
15 |
16 | type authCache struct {
17 | info map[string]proton.AuthInfo
18 | auth map[string]proton.Auth
19 | lock sync.RWMutex
20 | }
21 |
22 | func (c *authCache) GetAuthInfo(username string) (proton.AuthInfo, bool) {
23 | c.lock.RLock()
24 | defer c.lock.RUnlock()
25 |
26 | info, ok := c.info[username]
27 |
28 | return info, ok
29 | }
30 |
31 | func (c *authCache) SetAuthInfo(username string, info proton.AuthInfo) {
32 | c.lock.Lock()
33 | defer c.lock.Unlock()
34 |
35 | c.info[username] = info
36 | }
37 |
38 | func (c *authCache) GetAuth(username string) (proton.Auth, bool) {
39 | c.lock.RLock()
40 | defer c.lock.RUnlock()
41 |
42 | auth, ok := c.auth[username]
43 |
44 | return auth, ok
45 | }
46 |
47 | func (c *authCache) SetAuth(username string, auth proton.Auth) {
48 | c.lock.Lock()
49 | defer c.lock.Unlock()
50 |
51 | c.auth[username] = auth
52 | }
53 |
--------------------------------------------------------------------------------
/server/call.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 | "net/url"
6 | "time"
7 | )
8 |
9 | type Call struct {
10 | URL *url.URL
11 | Method string
12 | Status int
13 |
14 | Time time.Time
15 | Duration time.Duration
16 |
17 | RequestHeader http.Header
18 | RequestBody []byte
19 |
20 | ResponseHeader http.Header
21 | ResponseBody []byte
22 | }
23 |
24 | type callWatcher struct {
25 | paths map[string]struct{}
26 | callFn func(Call)
27 | }
28 |
29 | func newCallWatcher(fn func(Call), paths ...string) callWatcher {
30 | pathMap := make(map[string]struct{}, len(paths))
31 |
32 | for _, path := range paths {
33 | pathMap[path] = struct{}{}
34 | }
35 |
36 | return callWatcher{
37 | paths: pathMap,
38 | callFn: fn,
39 | }
40 | }
41 |
42 | func (watcher *callWatcher) isWatching(path string) bool {
43 | if len(watcher.paths) == 0 {
44 | return true
45 | }
46 |
47 | _, ok := watcher.paths[path]
48 |
49 | return ok
50 | }
51 |
52 | func (watcher *callWatcher) publish(call Call) {
53 | watcher.callFn(call)
54 | }
55 |
--------------------------------------------------------------------------------
/server/cmd/server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "net"
8 | "os"
9 |
10 | "github.com/ProtonMail/go-proton-api"
11 | "github.com/ProtonMail/go-proton-api/server"
12 | "github.com/ProtonMail/go-proton-api/server/proto"
13 | "github.com/urfave/cli/v2"
14 | "google.golang.org/grpc"
15 | )
16 |
17 | func main() {
18 | app := cli.NewApp()
19 |
20 | app.Flags = []cli.Flag{
21 | &cli.IntFlag{
22 | Name: "port",
23 | Aliases: []string{"p"},
24 | Usage: "port to serve gRPC on",
25 | Value: 8080,
26 | },
27 | &cli.BoolFlag{
28 | Name: "tls",
29 | },
30 | }
31 |
32 | app.Action = run
33 |
34 | if err := app.Run(os.Args); err != nil {
35 | log.Fatal(err)
36 | }
37 | }
38 |
39 | func run(c *cli.Context) error {
40 | s := server.New(server.WithTLS(c.Bool("tls")))
41 | defer s.Close()
42 |
43 | return newService(s).run(c.Int("port"))
44 | }
45 |
46 | type service struct {
47 | proto.UnimplementedServerServer
48 |
49 | server *server.Server
50 |
51 | gRPCServer *grpc.Server
52 | }
53 |
54 | func newService(server *server.Server) *service {
55 | s := &service{
56 | server: server,
57 |
58 | gRPCServer: grpc.NewServer(),
59 | }
60 |
61 | proto.RegisterServerServer(s.gRPCServer, s)
62 |
63 | return s
64 | }
65 |
66 | func (s *service) GetInfo(ctx context.Context, req *proto.GetInfoRequest) (*proto.GetInfoResponse, error) {
67 | return &proto.GetInfoResponse{
68 | HostURL: s.server.GetHostURL(),
69 | ProxyURL: s.server.GetProxyURL(),
70 | }, nil
71 | }
72 |
73 | func (s *service) CreateUser(ctx context.Context, req *proto.CreateUserRequest) (*proto.CreateUserResponse, error) {
74 | userID, addrID, err := s.server.CreateUser(req.Username, req.Password)
75 | if err != nil {
76 | return nil, err
77 | }
78 |
79 | return &proto.CreateUserResponse{
80 | UserID: userID,
81 | AddrID: addrID,
82 | }, nil
83 | }
84 |
85 | func (s *service) RevokeUser(ctx context.Context, req *proto.RevokeUserRequest) (*proto.RevokeUserResponse, error) {
86 | if err := s.server.RevokeUser(req.UserID); err != nil {
87 | return nil, err
88 | }
89 |
90 | return &proto.RevokeUserResponse{}, nil
91 | }
92 |
93 | func (s *service) CreateAddress(ctx context.Context, req *proto.CreateAddressRequest) (*proto.CreateAddressResponse, error) {
94 | addrID, err := s.server.CreateAddress(req.UserID, req.Email, req.Password, true)
95 | if err != nil {
96 | return nil, err
97 | }
98 |
99 | return &proto.CreateAddressResponse{
100 | AddrID: addrID,
101 | }, nil
102 | }
103 |
104 | func (s *service) RemoveAddress(ctx context.Context, req *proto.RemoveAddressRequest) (*proto.RemoveAddressResponse, error) {
105 | if err := s.server.RemoveAddress(req.UserID, req.AddrID); err != nil {
106 | return nil, err
107 | }
108 |
109 | return &proto.RemoveAddressResponse{}, nil
110 | }
111 |
112 | func (s *service) CreateLabel(ctx context.Context, req *proto.CreateLabelRequest) (*proto.CreateLabelResponse, error) {
113 | var labelType proton.LabelType
114 |
115 | switch req.Type {
116 | case proto.LabelType_FOLDER:
117 | labelType = proton.LabelTypeFolder
118 |
119 | case proto.LabelType_LABEL:
120 | labelType = proton.LabelTypeLabel
121 | }
122 |
123 | labelID, err := s.server.CreateLabel(req.UserID, req.Name, req.ParentID, labelType)
124 | if err != nil {
125 | return nil, err
126 | }
127 |
128 | return &proto.CreateLabelResponse{
129 | LabelID: labelID,
130 | }, nil
131 | }
132 |
133 | func (s *service) run(port int) error {
134 | listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
135 | if err != nil {
136 | return err
137 | }
138 |
139 | return s.gRPCServer.Serve(listener)
140 | }
141 |
--------------------------------------------------------------------------------
/server/core_settings.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/ProtonMail/go-proton-api"
7 | "github.com/gin-gonic/gin"
8 | )
9 |
10 | func (s *Server) handleGetUserSettings() gin.HandlerFunc {
11 | return func(c *gin.Context) {
12 | settings, err := s.b.GetUserSettings(c.GetString("UserID"))
13 | if err != nil {
14 | c.AbortWithStatus(http.StatusInternalServerError)
15 | return
16 | }
17 |
18 | c.JSON(http.StatusOK, gin.H{
19 | "UserSettings": settings,
20 | })
21 | }
22 | }
23 |
24 | func (s *Server) handlePutUserSettingsTelemetry() gin.HandlerFunc {
25 | return func(c *gin.Context) {
26 | var req proton.SetTelemetryReq
27 |
28 | if err := c.ShouldBindJSON(&req); err != nil {
29 | c.AbortWithStatus(http.StatusBadRequest)
30 | return
31 | }
32 |
33 | settings, err := s.b.SetUserSettingsTelemetry(c.GetString("UserID"), req.Telemetry)
34 | if err != nil {
35 | c.AbortWithStatus(http.StatusInternalServerError)
36 | return
37 | }
38 |
39 | c.JSON(http.StatusOK, gin.H{
40 | "UserSettings": settings,
41 | })
42 | }
43 | }
44 |
45 | func (s *Server) handlePutUserSettingsCrashReports() gin.HandlerFunc {
46 | return func(c *gin.Context) {
47 | var req proton.SetCrashReportReq
48 |
49 | if err := c.ShouldBindJSON(&req); err != nil {
50 | c.AbortWithStatus(http.StatusBadRequest)
51 | return
52 | }
53 |
54 | settings, err := s.b.SetUserSettingsCrashReports(c.GetString("UserID"), req.CrashReports)
55 | if err != nil {
56 | c.AbortWithStatus(http.StatusInternalServerError)
57 | return
58 | }
59 |
60 | c.JSON(http.StatusOK, gin.H{
61 | "UserSettings": settings,
62 | })
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/server/data.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/ProtonMail/go-proton-api"
7 | "github.com/gin-gonic/gin"
8 | )
9 |
10 | func (s *Server) handlePostDataStats() gin.HandlerFunc {
11 | return func(c *gin.Context) {
12 | var req proton.SendStatsReq
13 |
14 | if err := c.BindJSON(&req); err != nil {
15 | c.AbortWithStatus(http.StatusBadRequest)
16 | return
17 | }
18 |
19 | if !validateSendStatReq(&req) {
20 | c.AbortWithStatus(http.StatusBadRequest)
21 | return
22 | }
23 |
24 | c.JSON(http.StatusOK, gin.H{
25 | "Code": proton.SuccessCode,
26 | })
27 | }
28 | }
29 |
30 | func (s *Server) handlePostDataStatsMultiple() gin.HandlerFunc {
31 | return func(c *gin.Context) {
32 | var req proton.SendStatsMultiReq
33 |
34 | if err := c.BindJSON(&req); err != nil {
35 | c.AbortWithStatus(http.StatusBadRequest)
36 | return
37 | }
38 |
39 | for _, event := range req.EventInfo {
40 | if !validateSendStatReq(&event) {
41 | c.AbortWithStatus(http.StatusBadRequest)
42 | return
43 | }
44 | }
45 |
46 | c.JSON(http.StatusOK, gin.H{
47 | "Code": proton.SuccessCode,
48 | })
49 | }
50 | }
51 |
52 | func validateSendStatReq(req *proton.SendStatsReq) bool {
53 | return req.MeasurementGroup != ""
54 | }
55 |
56 | func (s *Server) handleObservabilityPost() gin.HandlerFunc {
57 | return func(c *gin.Context) {
58 | var req proton.ObservabilityBatch
59 | if err := c.BindJSON(&req); err != nil {
60 | c.AbortWithStatus(http.StatusBadRequest)
61 | return
62 | }
63 |
64 | s.b.PushObservabilityMetrics(req.Metrics)
65 |
66 | c.JSON(http.StatusOK, gin.H{
67 | "Code": proton.SuccessCode,
68 | })
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/server/domains.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 | )
8 |
9 | func (s *Server) handleGetDomainsAvailable() gin.HandlerFunc {
10 | return func(c *gin.Context) {
11 | c.JSON(http.StatusOK, gin.H{
12 | "Domains": []string{s.domain},
13 | })
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/server/errors.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import "errors"
4 |
5 | var (
6 | ErrNoSuchUser = errors.New("no such user")
7 | ErrNoSuchAddress = errors.New("no such address")
8 | ErrNoSuchLabel = errors.New("no such label")
9 | )
10 |
--------------------------------------------------------------------------------
/server/events.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/ProtonMail/go-proton-api"
7 | "github.com/gin-gonic/gin"
8 | )
9 |
10 | func (s *Server) handleGetEvents() gin.HandlerFunc {
11 | return func(c *gin.Context) {
12 | event, more, err := s.b.GetEvent(c.GetString("UserID"), c.Param("eventID"))
13 | if err != nil {
14 | _ = c.AbortWithError(http.StatusBadRequest, err)
15 | return
16 | }
17 |
18 | c.JSON(
19 | http.StatusOK,
20 | struct {
21 | proton.Event
22 | More proton.Bool
23 | }{
24 | event,
25 | proton.Bool(more),
26 | },
27 | )
28 | }
29 | }
30 |
31 | func (s *Server) handleGetEventsLatest() gin.HandlerFunc {
32 | return func(c *gin.Context) {
33 | eventID, err := s.b.GetLatestEventID(c.GetString("UserID"))
34 | if err != nil {
35 | _ = c.AbortWithError(http.StatusBadRequest, err)
36 | return
37 | }
38 |
39 | c.JSON(http.StatusOK, gin.H{
40 | "EventID": eventID,
41 | })
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/server/features.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 | )
8 |
9 | func (s *Server) handleGetFeatures() gin.HandlerFunc {
10 | return func(c *gin.Context) {
11 | ff := s.b.GetFeatureFlags()
12 | c.JSON(http.StatusOK, gin.H{
13 | "Code": 1000,
14 | "Toggles": ff,
15 | })
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/server/helper_test.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/google/uuid"
8 | )
9 |
10 | func newMessageLiteral(from, to string) []byte {
11 | return []byte(fmt.Sprintf("From: %v\r\nReceiver: %v\r\nSubject: %v\r\n\r\nHello World!", from, to, uuid.New()))
12 | }
13 |
14 | func newMessageLiteralWithSubject(from, to, subject string) []byte {
15 | return []byte(fmt.Sprintf("From: %v\r\nReceiver: %v\r\nSubject: %v\r\n\r\nHello World!", from, to, subject))
16 | }
17 |
18 | func newMessageLiteralWithSubjectAndSize(from, to, subject string, paddingSize int) []byte {
19 | padding := strings.Repeat("A", paddingSize)
20 | return []byte(fmt.Sprintf("From: %v\r\nReceiver: %v\r\nSubject: %v\r\n\r\nHello World!Padding:%s", from, to, subject, padding))
21 | }
22 |
--------------------------------------------------------------------------------
/server/init_test.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import "github.com/ProtonMail/go-proton-api/server/backend"
4 |
5 | func init() {
6 | backend.GenerateKey = backend.FastGenerateKey
7 | }
8 |
--------------------------------------------------------------------------------
/server/keys.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/ProtonMail/go-proton-api"
7 | "github.com/gin-gonic/gin"
8 | )
9 |
10 | func (s *Server) handleGetKeys() gin.HandlerFunc {
11 | return func(c *gin.Context) {
12 | if pubKeys, err := s.b.GetPublicKeys(c.Query("Email")); err == nil && len(pubKeys) > 0 {
13 | c.JSON(http.StatusOK, gin.H{
14 | "Keys": pubKeys,
15 | "RecipientType": proton.RecipientTypeInternal,
16 | })
17 | } else {
18 | c.JSON(http.StatusOK, gin.H{
19 | "RecipientType": proton.RecipientTypeExternal,
20 | })
21 | }
22 | }
23 | }
24 |
25 | func (s *Server) handleGetKeySalts() gin.HandlerFunc {
26 | return func(c *gin.Context) {
27 | salts, err := s.b.GetKeySalts(c.GetString("UserID"))
28 | if err != nil {
29 | c.AbortWithStatus(http.StatusInternalServerError)
30 | return
31 | }
32 |
33 | c.JSON(http.StatusOK, gin.H{
34 | "KeySalts": salts,
35 | })
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/server/labels.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/ProtonMail/go-proton-api"
8 | "github.com/bradenaw/juniper/xslices"
9 | "github.com/gin-gonic/gin"
10 | )
11 |
12 | func (s *Server) handleGetMailLabels() gin.HandlerFunc {
13 | return func(c *gin.Context) {
14 | types := xslices.Map(c.QueryArray("Type"), func(val string) proton.LabelType {
15 | labelType, err := strconv.Atoi(val)
16 | if err != nil {
17 | panic(err)
18 | }
19 |
20 | return proton.LabelType(labelType)
21 | })
22 |
23 | labels, err := s.b.GetLabels(c.GetString("UserID"), types...)
24 | if err != nil {
25 | c.AbortWithStatus(http.StatusInternalServerError)
26 | return
27 | }
28 |
29 | c.JSON(http.StatusOK, gin.H{
30 | "Labels": labels,
31 | })
32 | }
33 | }
34 |
35 | func (s *Server) handlePostMailLabels() gin.HandlerFunc {
36 | return func(c *gin.Context) {
37 | var req proton.CreateLabelReq
38 |
39 | if err := c.BindJSON(&req); err != nil {
40 | c.AbortWithStatus(http.StatusBadRequest)
41 | return
42 | }
43 |
44 | if _, has, err := s.b.HasLabel(c.GetString("UserID"), req.Name); err != nil {
45 | c.AbortWithStatus(http.StatusInternalServerError)
46 | return
47 | } else if has {
48 | c.AbortWithStatus(http.StatusConflict)
49 | return
50 | }
51 |
52 | label, err := s.b.CreateLabel(c.GetString("UserID"), req.Name, req.ParentID, req.Type)
53 | if err != nil {
54 | c.AbortWithStatus(http.StatusInternalServerError)
55 | return
56 | }
57 |
58 | c.JSON(http.StatusOK, gin.H{
59 | "Label": label,
60 | })
61 | }
62 | }
63 |
64 | func (s *Server) handlePutMailLabel() gin.HandlerFunc {
65 | return func(c *gin.Context) {
66 | var req proton.UpdateLabelReq
67 |
68 | if err := c.BindJSON(&req); err != nil {
69 | c.AbortWithStatus(http.StatusBadRequest)
70 | return
71 | }
72 |
73 | if labelID, has, err := s.b.HasLabel(c.GetString("UserID"), req.Name); err != nil {
74 | c.AbortWithStatus(http.StatusInternalServerError)
75 | return
76 | } else if has && labelID != c.Param("labelID") {
77 | c.AbortWithStatus(http.StatusConflict)
78 | return
79 | }
80 |
81 | label, err := s.b.UpdateLabel(c.GetString("UserID"), c.Param("labelID"), req.Name, req.ParentID)
82 | if err != nil {
83 | c.AbortWithStatus(http.StatusInternalServerError)
84 | return
85 | }
86 |
87 | c.JSON(http.StatusOK, gin.H{
88 | "Label": label,
89 | })
90 | }
91 | }
92 |
93 | func (s *Server) handleDeleteMailLabel() gin.HandlerFunc {
94 | return func(c *gin.Context) {
95 | if err := s.b.DeleteLabel(c.GetString("UserID"), c.Param("labelID")); err != nil {
96 | c.AbortWithStatus(http.StatusBadRequest)
97 | return
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/server/mail_settings.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/ProtonMail/go-proton-api"
7 | "github.com/gin-gonic/gin"
8 | )
9 |
10 | func (s *Server) handleGetMailSettings() gin.HandlerFunc {
11 | return func(c *gin.Context) {
12 | settings, err := s.b.GetMailSettings(c.GetString("UserID"))
13 | if err != nil {
14 | c.AbortWithStatus(http.StatusInternalServerError)
15 | return
16 | }
17 |
18 | c.JSON(http.StatusOK, gin.H{
19 | "MailSettings": settings,
20 | })
21 | }
22 | }
23 |
24 | func (s *Server) handlePutMailSettingsAttachPublicKey() gin.HandlerFunc {
25 | return func(c *gin.Context) {
26 | var req proton.SetAttachPublicKeyReq
27 |
28 | if err := c.ShouldBindJSON(&req); err != nil {
29 | c.AbortWithStatus(http.StatusBadRequest)
30 | return
31 | }
32 |
33 | settings, err := s.b.SetMailSettingsAttachPublicKey(c.GetString("UserID"), bool(req.AttachPublicKey))
34 | if err != nil {
35 | c.AbortWithStatus(http.StatusInternalServerError)
36 | return
37 | }
38 |
39 | c.JSON(http.StatusOK, gin.H{
40 | "MailSettings": settings,
41 | })
42 | }
43 | }
44 |
45 | func (s *Server) handlePutMailSettingsDraftType() gin.HandlerFunc {
46 | return func(c *gin.Context) {
47 | var req proton.SetDraftMIMETypeReq
48 |
49 | if err := c.ShouldBindJSON(&req); err != nil {
50 | c.AbortWithStatus(http.StatusBadRequest)
51 | return
52 | }
53 |
54 | settings, err := s.b.SetMailSettingsDraftMIMEType(c.GetString("UserID"), req.MIMEType)
55 | if err != nil {
56 | c.AbortWithStatus(http.StatusInternalServerError)
57 | return
58 | }
59 |
60 | c.JSON(http.StatusOK, gin.H{
61 | "MailSettings": settings,
62 | })
63 | }
64 | }
65 |
66 | func (s *Server) handlePutMailSettingsSign() gin.HandlerFunc {
67 | return func(c *gin.Context) {
68 | var req proton.SetSignExternalMessagesReq
69 |
70 | if err := c.ShouldBindJSON(&req); err != nil {
71 | c.AbortWithStatus(http.StatusBadRequest)
72 | return
73 | }
74 |
75 | settings, err := s.b.SetMailSettingsSign(c.GetString("UserID"), req.Sign)
76 | if err != nil {
77 | c.AbortWithStatus(http.StatusInternalServerError)
78 | return
79 | }
80 |
81 | c.JSON(http.StatusOK, gin.H{
82 | "MailSettings": settings,
83 | })
84 | }
85 | }
86 |
87 | func (s *Server) handlePutMailSettingsPGPScheme() gin.HandlerFunc {
88 | return func(c *gin.Context) {
89 | var req proton.SetDefaultPGPSchemeReq
90 |
91 | if err := c.ShouldBindJSON(&req); err != nil {
92 | c.AbortWithStatus(http.StatusBadRequest)
93 | return
94 | }
95 |
96 | settings, err := s.b.SetMailSettingsPGPScheme(c.GetString("UserID"), req.PGPScheme)
97 | if err != nil {
98 | c.AbortWithStatus(http.StatusInternalServerError)
99 | return
100 | }
101 |
102 | c.JSON(http.StatusOK, gin.H{
103 | "MailSettings": settings,
104 | })
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/server/main_test.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "testing"
5 |
6 | "go.uber.org/goleak"
7 | )
8 |
9 | func TestMain(m *testing.M) {
10 | goleak.VerifyTestMain(m, goleak.IgnoreCurrent())
11 | }
12 |
--------------------------------------------------------------------------------
/server/ping.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import "github.com/gin-gonic/gin"
4 |
5 | func (s *Server) handleGetPing() gin.HandlerFunc {
6 | return func(c *gin.Context) {}
7 | }
8 |
--------------------------------------------------------------------------------
/server/proto/server.go:
--------------------------------------------------------------------------------
1 | package proto
2 |
3 | //go:generate protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative server.proto
4 |
--------------------------------------------------------------------------------
/server/proto/server.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 |
3 | option go_package = "github.com/ProtonMail/go-proton-api/server/proto";
4 |
5 | package proto;
6 |
7 | //**********************************************************************************************************************
8 | // Service Declaration
9 | //**********************************************************************************************************************
10 | service Server {
11 | rpc GetInfo (GetInfoRequest) returns (GetInfoResponse);
12 |
13 | rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
14 |
15 | rpc RevokeUser(RevokeUserRequest) returns (RevokeUserResponse);
16 |
17 | rpc CreateAddress(CreateAddressRequest) returns (CreateAddressResponse);
18 |
19 | rpc RemoveAddress(RemoveAddressRequest) returns (RemoveAddressResponse);
20 |
21 | rpc CreateLabel(CreateLabelRequest) returns (CreateLabelResponse);
22 | }
23 |
24 | //**********************************************************************************************************************
25 |
26 | message GetInfoRequest {
27 | }
28 |
29 | message GetInfoResponse {
30 | string hostURL = 1;
31 | string proxyURL = 2;
32 | }
33 |
34 | message CreateUserRequest {
35 | string username = 1;
36 | bytes password = 3;
37 | }
38 |
39 | message CreateUserResponse {
40 | string userID = 1;
41 | string addrID = 2;
42 | }
43 |
44 | message RevokeUserRequest {
45 | string userID = 1;
46 | }
47 |
48 | message RevokeUserResponse {
49 | }
50 |
51 | message CreateAddressRequest {
52 | string userID = 1;
53 | string email = 2;
54 | bytes password = 3;
55 | }
56 |
57 | message CreateAddressResponse {
58 | string addrID = 1;
59 | }
60 |
61 | message RemoveAddressRequest {
62 | string userID = 1;
63 | string addrID = 2;
64 | }
65 |
66 | message RemoveAddressResponse {
67 | }
68 |
69 | enum LabelType {
70 | FOLDER = 0;
71 | LABEL = 1;
72 | }
73 |
74 | message CreateLabelRequest {
75 | string userID = 1;
76 | string name = 2;
77 | string parentID = 3;
78 | LabelType type = 4;
79 | }
80 |
81 | message CreateLabelResponse {
82 | string labelID = 1;
83 | }
84 |
--------------------------------------------------------------------------------
/server/quark.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "encoding/json"
5 | "html/template"
6 | "net/http"
7 | "strings"
8 |
9 | "github.com/gin-gonic/gin"
10 | )
11 |
12 | // TODO: This is a disgusting hack to match the output of the internal quark command.
13 | // They should return JSON instead of HTML!
14 | func (s *Server) handleQuarkCommand() gin.HandlerFunc {
15 | return func(c *gin.Context) {
16 | res, err := s.b.RunQuarkCommand(c.Param("command"), strings.Split(c.Query("strInput"), " ")...)
17 | if err != nil {
18 | _ = c.AbortWithError(http.StatusInternalServerError, err)
19 | return
20 | }
21 |
22 | var out string
23 |
24 | switch res := res.(type) {
25 | case string:
26 | out = res
27 |
28 | default:
29 | b, err := json.MarshalIndent(res, "", " ")
30 | if err != nil {
31 | _ = c.AbortWithError(http.StatusInternalServerError, err)
32 | return
33 | }
34 |
35 | out = string(b)
36 | }
37 |
38 | tmp, err := template.New("quarkCommand").Parse(`{{.Content}}
`)
39 | if err != nil {
40 | _ = c.AbortWithError(http.StatusInternalServerError, err)
41 | return
42 | }
43 |
44 | if err := tmp.Execute(c.Writer, map[string]string{
45 | "Content": template.HTMLEscapeString(out),
46 | }); err != nil {
47 | _ = c.AbortWithError(http.StatusInternalServerError, err)
48 | return
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/server/quark_test.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/ProtonMail/go-proton-api"
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func TestServer_Quark_CreateUser(t *testing.T) {
12 | withServer(t, func(ctx context.Context, _ *Server, m *proton.Manager) {
13 | // Create two users, one with keys and one without.
14 | require.NoError(t, m.Quark(ctx, "user:create", "--name", "user-no-keys", "--password", "test", "--create-address"))
15 | require.NoError(t, m.Quark(ctx, "user:create", "--name", "user-keys", "--password", "test", "--gen-keys", "rsa2048"))
16 | require.NoError(t, m.Quark(ctx, "user:create", "--name", "user-disabled", "--password", "test", "--gen-keys", "rsa2048", "--status", "1"))
17 |
18 | {
19 | // The address should be created but should have no keys.
20 | c, _, err := m.NewClientWithLogin(ctx, "user-no-keys", []byte("test"))
21 | require.NoError(t, err)
22 | defer c.Close()
23 |
24 | addr, err := c.GetAddresses(ctx)
25 | require.NoError(t, err)
26 | require.Len(t, addr, 1)
27 | require.Len(t, addr[0].Keys, 0)
28 | }
29 |
30 | {
31 | // The address should be created and should have keys.
32 | c, _, err := m.NewClientWithLogin(ctx, "user-keys", []byte("test"))
33 | require.NoError(t, err)
34 | defer c.Close()
35 |
36 | addr, err := c.GetAddresses(ctx)
37 | require.NoError(t, err)
38 | require.Len(t, addr, 1)
39 | require.Len(t, addr[0].Keys, 1)
40 | }
41 |
42 | {
43 | // The address should be created and should be disabled
44 | c, _, err := m.NewClientWithLogin(ctx, "user-disabled", []byte("test"))
45 | require.NoError(t, err)
46 | defer c.Close()
47 |
48 | addr, err := c.GetAddresses(ctx)
49 | require.NoError(t, err)
50 | require.Len(t, addr, 1)
51 | require.Len(t, addr[0].Keys, 1)
52 | require.Equal(t, addr[0].Status, proton.AddressStatusDisabled)
53 | }
54 | })
55 | }
56 |
57 | func TestServer_Quark_CreateAddress(t *testing.T) {
58 | withServer(t, func(ctx context.Context, _ *Server, m *proton.Manager) {
59 | // Create a user with one address.
60 | require.NoError(t, m.Quark(ctx, "user:create", "--name", "user", "--password", "test", "--gen-keys", "rsa2048"))
61 |
62 | // Login to the user.
63 | c, _, err := m.NewClientWithLogin(ctx, "user", []byte("test"))
64 | require.NoError(t, err)
65 | defer c.Close()
66 |
67 | // Get the user.
68 | user, err := c.GetUser(ctx)
69 | require.NoError(t, err)
70 |
71 | // Initially the user should have one address and it should have keys.
72 | addr, err := c.GetAddresses(ctx)
73 | require.NoError(t, err)
74 | require.Len(t, addr, 1)
75 | require.Len(t, addr[0].Keys, 1)
76 |
77 | // Create a new address.
78 | require.NoError(t, m.Quark(ctx, "user:create:address", "--gen-keys", "rsa2048", user.ID, "test", "alias@proton.local"))
79 |
80 | // Now the user should have two addresses, and they should both have keys.
81 | newAddr, err := c.GetAddresses(ctx)
82 | require.NoError(t, err)
83 | require.Len(t, newAddr, 2)
84 | require.Len(t, newAddr[0].Keys, 1)
85 | require.Len(t, newAddr[1].Keys, 1)
86 | })
87 | }
88 |
--------------------------------------------------------------------------------
/server/rate_limit.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "sync"
5 | "time"
6 | )
7 |
8 | // rateLimiter is a rate limiter for the server.
9 | // If more than limit requests are made in the time window, the server will return 429.
10 | type rateLimiter struct {
11 | // limit is the rate limit to apply to the server.
12 | limit int
13 |
14 | // window is the window in which to apply the rate limit.
15 | window time.Duration
16 |
17 | // nextReset is the time at which the rate limit will reset.
18 | nextReset time.Time
19 |
20 | // count is the number of calls made to the server.
21 | count int
22 |
23 | // countLock is a mutex for the callCount.
24 | countLock sync.Mutex
25 |
26 | // statusCode to reply with
27 | statusCode int
28 | }
29 |
30 | func newRateLimiter(limit int, window time.Duration, statusCode int) *rateLimiter {
31 | return &rateLimiter{
32 | limit: limit,
33 | window: window,
34 | statusCode: statusCode,
35 | }
36 | }
37 |
38 | // exceeded checks the rate limit and returns how long to wait before the next request.
39 | func (r *rateLimiter) exceeded() time.Duration {
40 | r.countLock.Lock()
41 | defer r.countLock.Unlock()
42 |
43 | if time.Now().After(r.nextReset) {
44 | r.count = 0
45 | r.nextReset = time.Now().Add(r.window)
46 | }
47 |
48 | r.count++
49 |
50 | if r.count > r.limit {
51 | return time.Until(r.nextReset)
52 | }
53 |
54 | return 0
55 | }
56 |
--------------------------------------------------------------------------------
/server/reports.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 |
7 | "github.com/gin-gonic/gin"
8 | )
9 |
10 | func (s *Server) handlePostReportBug() gin.HandlerFunc {
11 | return func(c *gin.Context) {
12 | form, err := c.MultipartForm()
13 | if err != nil {
14 | _ = c.AbortWithError(http.StatusBadRequest, err)
15 | return
16 | }
17 |
18 | if form.Value["AsyncAttachments"][0] == "1" {
19 | token := s.b.CreateCSTicket()
20 | c.JSON(http.StatusOK, gin.H{
21 | "Token": token,
22 | })
23 | }
24 | }
25 | }
26 |
27 | func (s *Server) handlePostReportBugAttachments() gin.HandlerFunc {
28 | return func(c *gin.Context) {
29 | form, err := c.MultipartForm()
30 | if err != nil {
31 | _ = c.AbortWithError(http.StatusBadRequest, err)
32 | return
33 | }
34 | if !s.b.GetCSTicket(form.Value["Token"][0]) {
35 | _ = c.AbortWithError(http.StatusBadRequest, errors.New("Token not found in CS Ticket List"))
36 | return
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/server/users.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 | )
8 |
9 | func (s *Server) handleGetUsers() gin.HandlerFunc {
10 | return func(c *gin.Context) {
11 | user, err := s.b.GetUser(c.GetString("UserID"))
12 | if err != nil {
13 | c.AbortWithStatus(http.StatusInternalServerError)
14 | return
15 | }
16 |
17 | c.JSON(http.StatusOK, gin.H{
18 | "User": user,
19 | })
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/share.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | )
8 |
9 | func (c *Client) ListShares(ctx context.Context, all bool) ([]ShareMetadata, error) {
10 | var res struct {
11 | Shares []ShareMetadata
12 | }
13 |
14 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
15 | if all {
16 | r.SetQueryParam("ShowAll", "1")
17 | }
18 |
19 | return r.SetResult(&res).Get("/drive/shares")
20 | }); err != nil {
21 | return nil, err
22 | }
23 |
24 | return res.Shares, nil
25 | }
26 |
27 | func (c *Client) GetShare(ctx context.Context, shareID string) (Share, error) {
28 | var res struct {
29 | Share
30 | }
31 |
32 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
33 | return r.SetResult(&res).Get("/drive/shares/" + shareID)
34 | }); err != nil {
35 | return Share{}, err
36 | }
37 |
38 | return res.Share, nil
39 | }
40 |
--------------------------------------------------------------------------------
/share_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import "github.com/ProtonMail/gopenpgp/v2/crypto"
4 |
5 | type ShareMetadata struct {
6 | ShareID string // Encrypted share ID
7 | LinkID string // Encrypted link ID to which the share points (root of share).
8 | VolumeID string // Encrypted volume ID on which the share is mounted
9 |
10 | Type ShareType // Type of share
11 | State ShareState // The state of the share (active, deleted)
12 |
13 | CreationTime int64 // Creation time of the share in Unix time
14 | ModifyTime int64 // Last modification time of the share in Unix time
15 |
16 | Creator string // Creator email address
17 | Flags ShareFlags // The flag bitmap
18 | Locked bool // Whether the share is locked
19 | VolumeSoftDeleted bool // Was the volume soft deleted
20 | }
21 |
22 | // Share is an entry point to a location in the file structure (Volume).
23 | // It points to a file or folder anywhere in the tree and holds a key called the ShareKey.
24 | // To access a file or folder in Drive, a user must be a member of a share.
25 | // The membership information is tied to a specific address, and key.
26 | // This key then allows the user to decrypt the share key, giving access to the file system rooted at that share.
27 | type Share struct {
28 | ShareMetadata
29 |
30 | AddressID string // Encrypted address ID
31 | AddressKeyID string // Encrypted address key ID
32 |
33 | Key string // The private ShareKey, encrypted with a passphrase
34 | Passphrase string // The encrypted passphrase
35 | PassphraseSignature string // The signature of the passphrase
36 | }
37 |
38 | func (s Share) GetKeyRing(addrKR *crypto.KeyRing) (*crypto.KeyRing, error) {
39 | enc, err := crypto.NewPGPMessageFromArmored(s.Passphrase)
40 | if err != nil {
41 | return nil, err
42 | }
43 |
44 | dec, err := addrKR.Decrypt(enc, nil, crypto.GetUnixTime())
45 | if err != nil {
46 | return nil, err
47 | }
48 |
49 | sig, err := crypto.NewPGPSignatureFromArmored(s.PassphraseSignature)
50 | if err != nil {
51 | return nil, err
52 | }
53 |
54 | if err := addrKR.VerifyDetached(dec, sig, crypto.GetUnixTime()); err != nil {
55 | return nil, err
56 | }
57 |
58 | lockedKey, err := crypto.NewKeyFromArmored(s.Key)
59 | if err != nil {
60 | return nil, err
61 | }
62 |
63 | unlockedKey, err := lockedKey.Unlock(dec.GetBinary())
64 | if err != nil {
65 | return nil, err
66 | }
67 |
68 | return crypto.NewKeyRing(unlockedKey)
69 | }
70 |
71 | type ShareType int
72 |
73 | const (
74 | ShareTypeMain ShareType = 1
75 | ShareTypeStandard ShareType = 2
76 | ShareTypeDevice ShareType = 3
77 | )
78 |
79 | type ShareState int
80 |
81 | const (
82 | ShareStateActive ShareState = 1
83 | ShareStateDeleted ShareState = 2
84 | )
85 |
86 | type ShareFlags int
87 |
88 | const (
89 | NoFlags ShareFlags = iota
90 | PrimaryShare
91 | )
92 |
--------------------------------------------------------------------------------
/testdata/MultipleAttachments.eml:
--------------------------------------------------------------------------------
1 | Content-Type: multipart/related;boundary="------------pXWj190lQsd0d77xbCjkhoss"
2 | User-Agent: Mozilla Thunderbird
3 | Content-Language: en-GB
4 | To: <[user:to]@[domain]>
5 | From: <[user:user]@[domain]>
6 | Subject: HTML message with multiple inline images
7 |
8 | --------------pXWj190lQsd0d77xbCjkhoss
9 | Content-Type: text/html; charset=UTF-8
10 | Content-Transfer-Encoding: 7bit
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | Inline image 1
20 | 
22 | Inline image 2
23 | 
25 | End
26 |
27 |
28 |
29 |
30 | --------------pXWj190lQsd0d77xbCjkhoss
31 | Content-Type: image/png; name="inlinepart2.png"
32 | Content-Disposition: inline; filename="inlinepart2.png"
33 | Content-Id:
34 | Content-Transfer-Encoding: base64
35 |
36 | iVBORw0KGgoAAAANSUhEUgAAAOEAAADhCAMAAAAJbSJIAAAAh1BMVEX///8AAAABAQGbm5sP
37 | Qm1ljU7fCDEb0EoXQHyzccFedRPEVPtOY91tb9BVtzebhEdH0R1oLSwO3QvtoFl8YgQXV7eL
38 | BYPFOEzNxiFmI0jD8eL1M3D/A1pNGGh1w+TvAAAAAElFTkSuQmCC
39 |
40 | --------------pXWj190lQsd0d77xbCjkhoss
41 | Content-Type: image/png; name="alphabeticalZZZZ.png"
42 | Content-Disposition: attachment; filename="alphabeticalZZZZ.png"
43 | Content-Transfer-Encoding: base64
44 |
45 | iVBORw0KGgoAAAANSUhEUgAAAOEAAADhCAMAAAAJbSJIAAAAh1BMVEX///8AAAABAQGbm5sP
46 | Qm1ljU7fCDEb0EoXQHyzccFedRPEVPtOY91tb9BVtzebhEdH0R1oLSwO3QvtoFl8YgQXV7eL
47 | BYPFOEzNxiFmI0jD8eL1M3D/A1pNGGh1w+TvAAAAAElFTkSuQmCC
48 |
49 | --------------pXWj190lQsd0d77xbCjkhoss
50 | Content-Type: image/png; name="inlinepart1.png"
51 | Content-Disposition: inline; filename="inlinepart1.png"
52 | Content-Id:
53 | Content-Transfer-Encoding: base64
54 |
55 | iVBORw0KGgoAAAANSUhEUgAAAOEAAADhCAMAAAAJbSJIAAAAh1BMVEX///8AAAABAQGbm5sP
56 | Qm1ljU7fCDEb0EoXQHyzccFedRPEVPtOY91tb9BVtzebhEdH0R1oLSwO3QvtoFl8YgQXV7eL
57 | BYPFOEzNxiFmI0jD8eL1M3D/A1pNGGh1w+TvAAAAAElFTkSuQmCC
58 |
59 | --------------pXWj190lQsd0d77xbCjkhoss
60 | Content-Type: image/png; name="alphabeticalAAA.png"
61 | Content-Disposition: attachment; filename="alphabeticalAAA.png"
62 | Content-Transfer-Encoding: base64
63 |
64 | iVBORw0KGgoAAAANSUhEUgAAAOEAAADhCAMAAAAJbSJIAAAAh1BMVEX///8AAAABAQGbm5sP
65 | Qm1ljU7fCDEb0EoXQHyzccFedRPEVPtOY91tb9BVtzebhEdH0R1oLSwO3QvtoFl8YgQXV7eL
66 | BYPFOEzNxiFmI0jD8eL1M3D/A1pNGGh1w+TvAAAAAElFTkSuQmCC
67 |
68 | --------------pXWj190lQsd0d77xbCjkhoss--
--------------------------------------------------------------------------------
/testdata/prv.asc:
--------------------------------------------------------------------------------
1 | -----BEGIN PGP PRIVATE KEY BLOCK-----
2 |
3 | lIYEZlhihxYJKwYBBAHaRw8BAQdAhhmOntnHPGorK2sMHVTvm3xRyOfULXJcJTqd
4 | jJgAmwb+BwMChaJkj2c0O4T8aUe94ClBDjkLezPLZTO98qL/lq82pX7tzGBPqfoq
5 | XixA6L3XHuwsoWH/4bboj3Y/jukwzJCPw5bXV0zPKyatrmiXbC/o+7QdUHJpdiBr
6 | ZXkgPG5vX3JlcGx5QHByb3Rvbi5tZT6ImQQTFgoAQRYhBGVDJy/tgyhe+Ohrq+5C
7 | GvQdWJ70BQJmWGKHAhsDBQld/A8ABQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheA
8 | AAoJEO5CGvQdWJ70yGQA/3bWBV0muZC2nfdmagDNXwECBXEw0pCSGaJKSmwhn52Q
9 | AQDJJi+lWA6PgefM6SReEOxDseswS9htTWRwPa5m7DQ2C5yLBGZYYocSCisGAQQB
10 | l1UBBQEBB0CkelYTuazNwtFUhjO+cc9F1NI+GzgnLUra1vFNju6QDAMBCAf+BwMC
11 | u4oyxxLGntT8QEfP6tbli08A59qIs4H7QyIxFnZ1A8cohM4ZuPA3tZTsHS2+ldRT
12 | jKIzcwhZDkGCXU29FDEJIq4b2XSc2pmZfSr1iCnd84h+BBgWCgAmFiEEZUMnL+2D
13 | KF746Gur7kIa9B1YnvQFAmZYYocCGwwFCV38DwAACgkQ7kIa9B1YnvS0VwD9EcmP
14 | EzgHj6m9qtglsGRFy1jODHu6iuAEcygK2QSQHdoA/ick71YS2s+FXLR+MFfKaTov
15 | wdBhd8sM0xk0zUb+rkwP
16 | =uXpu
17 | -----END PGP PRIVATE KEY BLOCK-----
18 |
--------------------------------------------------------------------------------
/testdata/pub.asc:
--------------------------------------------------------------------------------
1 | -----BEGIN PGP PUBLIC KEY BLOCK-----
2 |
3 | mDMEZlhh9xYJKwYBBAHaRw8BAQdASSaT1qy3wtVz/bp0l4AdClywXDpNV5dKS3xh
4 | vPYExwC0HFB1YiBrZXkgPG5vLXJlcGx5QHByb3Rvbi5tZT6ImQQTFgoAQRYhBNkK
5 | ah8BcVrtZxmkb64t4XbfWausBQJmWGH3AhsDBQld/A8ABQsJCAcCAiICBhUKCQgL
6 | AgQWAgMBAh4HAheAAAoJEK4t4XbfWausQNkBALa2hY2qDH3JIi2+yV9D6Zd/U6ZZ
7 | gwwN74ydF5oUBuQgAPwI1ppX+tMHlgoEYan08j+dOF2dRxjfEjtHpl7ir1BBArg4
8 | BGZYYfcSCisGAQQBl1UBBQEBB0CCMq9Cag+cBMz82AMZIFnz5spHoFCxrot4P2/4
9 | jA8+eQMBCAeIfgQYFgoAJhYhBNkKah8BcVrtZxmkb64t4XbfWausBQJmWGH3AhsM
10 | BQld/A8AAAoJEK4t4XbfWaus/ioA/j0csn5y+ir2czuJcZggOsEoYbQ6vF2yVid5
11 | XqaibNwWAQCLHr7meggPyXX8u8qQD+TFMM+xr1YyZmvD0JnyHA22AQ==
12 | =WPm3
13 | -----END PGP PUBLIC KEY BLOCK-----
14 |
--------------------------------------------------------------------------------
/ticker.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "math/rand"
5 | "time"
6 |
7 | "github.com/ProtonMail/gluon/async"
8 | )
9 |
10 | type Ticker struct {
11 | C chan time.Time
12 |
13 | stopCh chan struct{}
14 | doneCh chan struct{}
15 | }
16 |
17 | // NewTicker returns a new ticker that ticks at a random time between period and period+jitter.
18 | // It can be stopped by closing calling Stop().
19 | func NewTicker(period, jitter time.Duration, panicHandler async.PanicHandler) *Ticker {
20 | t := &Ticker{
21 | C: make(chan time.Time),
22 | stopCh: make(chan struct{}),
23 | doneCh: make(chan struct{}),
24 | }
25 |
26 | go func() {
27 | defer async.HandlePanic(panicHandler)
28 |
29 | defer close(t.doneCh)
30 |
31 | for {
32 | select {
33 | case <-t.stopCh:
34 | return
35 |
36 | case <-time.After(withJitter(period, jitter)):
37 | select {
38 | case <-t.stopCh:
39 | return
40 |
41 | case t.C <- time.Now():
42 | // ...
43 | }
44 | }
45 | }
46 | }()
47 |
48 | return t
49 | }
50 |
51 | func (t *Ticker) Stop() {
52 | close(t.stopCh)
53 | <-t.doneCh
54 | }
55 |
56 | func withJitter(period, jitter time.Duration) time.Duration {
57 | if jitter == 0 {
58 | return period
59 | }
60 |
61 | return period + time.Duration(rand.Int63n(int64(jitter)))
62 | }
63 |
--------------------------------------------------------------------------------
/undo.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 | "runtime"
6 | "time"
7 |
8 | "github.com/bradenaw/juniper/parallel"
9 | "github.com/go-resty/resty/v2"
10 | )
11 |
12 | func (c *Client) UndoActions(ctx context.Context, tokens ...UndoToken) ([]UndoRes, error) {
13 | return parallel.MapContext(ctx, runtime.NumCPU(), tokens, func(ctx context.Context, token UndoToken) (UndoRes, error) {
14 | if time.Unix(token.ValidUntil, 0).Before(time.Now()) {
15 | return UndoRes{}, ErrUndoTokenExpired
16 | }
17 |
18 | var res UndoRes
19 |
20 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
21 | return r.SetBody(token).SetResult(&res).Post("/mail/v4/undoactions")
22 | }); err != nil {
23 | return UndoRes{}, err
24 | }
25 |
26 | return res, nil
27 | })
28 | }
29 |
--------------------------------------------------------------------------------
/undo_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import "errors"
4 |
5 | var ErrUndoTokenExpired = errors.New("undo token expired")
6 |
7 | type UndoToken struct {
8 | Token string
9 | ValidUntil int64
10 | }
11 |
12 | type UndoRes struct {
13 | Messages []Message
14 | }
15 |
--------------------------------------------------------------------------------
/unlock.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "fmt"
5 | "runtime"
6 |
7 | "github.com/ProtonMail/gluon/async"
8 | "github.com/ProtonMail/gopenpgp/v2/crypto"
9 | "github.com/bradenaw/juniper/parallel"
10 | )
11 |
12 | func Unlock(user User, addresses []Address, saltedKeyPass []byte, panicHandler async.PanicHandler) (*crypto.KeyRing, map[string]*crypto.KeyRing, error) {
13 | userKR, err := user.Keys.Unlock(saltedKeyPass, nil)
14 | if err != nil {
15 | return nil, nil, fmt.Errorf("failed to unlock user keys: %w", err)
16 | } else if userKR.CountDecryptionEntities() == 0 {
17 | return nil, nil, fmt.Errorf("failed to unlock any user keys")
18 | }
19 |
20 | addrKRs := make(map[string]*crypto.KeyRing)
21 |
22 | for idx, addrKR := range parallel.Map(runtime.NumCPU(), addresses, func(addr Address) *crypto.KeyRing {
23 | defer async.HandlePanic(panicHandler)
24 |
25 | return addr.Keys.TryUnlock(saltedKeyPass, userKR)
26 | }) {
27 | if addrKR == nil {
28 | continue
29 | } else if addrKR.CountDecryptionEntities() == 0 {
30 | continue
31 | }
32 |
33 | addrKRs[addresses[idx].ID] = addrKR
34 | }
35 |
36 | if len(addrKRs) == 0 {
37 | return nil, nil, fmt.Errorf("failed to unlock any address keys")
38 | }
39 |
40 | return userKR, addrKRs, nil
41 | }
42 |
--------------------------------------------------------------------------------
/user.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 | "encoding/base64"
6 |
7 | "github.com/ProtonMail/go-srp"
8 | "github.com/go-resty/resty/v2"
9 | )
10 |
11 | func (c *Client) GetUser(ctx context.Context) (User, error) {
12 | return c.GetUserWithHV(ctx, nil)
13 | }
14 |
15 | func (c *Client) GetUserWithHV(ctx context.Context, hv *APIHVDetails) (User, error) {
16 | var res struct {
17 | User User
18 | }
19 |
20 | if _, err := c.doRes(ctx, func(r *resty.Request) (*resty.Response, error) {
21 | return addHVToRequest(r, hv).SetResult(&res).Get("/core/v4/users")
22 | }); err != nil {
23 | return User{}, err
24 | }
25 |
26 | return res.User, nil
27 | }
28 |
29 | func (c *Client) DeleteUser(ctx context.Context, password []byte, req DeleteUserReq) error {
30 | user, err := c.GetUser(ctx)
31 | if err != nil {
32 | return err
33 | }
34 |
35 | info, err := c.m.AuthInfo(ctx, AuthInfoReq{Username: user.Name})
36 | if err != nil {
37 | return err
38 | }
39 |
40 | srpAuth, err := srp.NewAuth(info.Version, user.Name, password, info.Salt, info.Modulus, info.ServerEphemeral)
41 | if err != nil {
42 | return err
43 | }
44 |
45 | proofs, err := srpAuth.GenerateProofs(2048)
46 | if err != nil {
47 | return err
48 | }
49 |
50 | return c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
51 | return r.SetBody(struct {
52 | DeleteUserReq
53 | AuthReq
54 | }{
55 | DeleteUserReq: req,
56 | AuthReq: AuthReq{
57 | ClientProof: base64.StdEncoding.EncodeToString(proofs.ClientProof),
58 | ClientEphemeral: base64.StdEncoding.EncodeToString(proofs.ClientEphemeral),
59 | SRPSession: info.SRPSession,
60 | },
61 | }).Delete("/core/v4/users/delete")
62 | })
63 | }
64 |
--------------------------------------------------------------------------------
/user_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | type User struct {
4 | ID string
5 | Name string
6 | DisplayName string
7 | Email string
8 | Keys Keys
9 |
10 | UsedSpace uint64
11 | MaxSpace uint64
12 | MaxUpload uint64
13 |
14 | Credit int
15 | Currency string
16 |
17 | ProductUsedSpace ProductUsedSpace
18 | }
19 |
20 | type DeleteUserReq struct {
21 | Reason string
22 | Feedback string
23 | Email string
24 | }
25 |
26 | type ProductUsedSpace struct {
27 | Calendar uint64
28 | Contact uint64
29 | Drive uint64
30 | Mail uint64
31 | Pass uint64
32 | }
33 |
--------------------------------------------------------------------------------
/utils/dependency_license.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -eo pipefail
4 |
5 | src=go.mod
6 | tgt=COPYING_NOTES.md
7 |
8 | STARTAUTOGEN=""
9 | ENDAUTOGEN=""
10 | RE_STARTAUTOGEN="^${STARTAUTOGEN}$"
11 | RE_ENDAUTOGEN="^${ENDAUTOGEN}$"
12 | tmpDepLicenses=""
13 |
14 | error(){
15 | echo "Error: $*"
16 | exit 1
17 | }
18 |
19 | generate_dep_licenses(){
20 | [ -r $src ] || error "Cannot read file '$src'"
21 |
22 |
23 | tmpDepLicenses="$(mktemp)"
24 |
25 | # Collect all go.mod lines beginig with tab:
26 | # * which no replace
27 | # * which have replace
28 | grep -E $'^\t[^=>]*$' $src | sed -r 's/\t([^ ]*) v.*/\1/g' > "$tmpDepLicenses"
29 | # Replace each line with formated link
30 | sed -i -r '/^github.com\/therecipe\/qt\/internal\/binding\/files\/docs\//d;' "$tmpDepLicenses"
31 | sed -i -r 's|^(.*)/([[:alnum:]-]+)/(v[[:digit:]]+)$|* [\2](https://\1/\2/\3)|g' "$tmpDepLicenses"
32 | sed -i -r 's|^(.*)/([[:alnum:]-]+)$|* [\2](https://\1/\2)|g' "$tmpDepLicenses"
33 | sed -i -r 's|^(.*)/([[:alnum:]-]+).(v[[:digit:]]+)$|* [\2](https://\1/\2.\3)|g' "$tmpDepLicenses"
34 |
35 | ## add license file to github links, and others
36 | sed -i -r '/github.com/s|^(.*(https://[^)]+).*)$|\1 available under [license](\2/blob/master/LICENSE) |g' "$tmpDepLicenses"
37 | sed -i -r '/golang.org\/x/s|^(.*golang.org/x/([^)]+).*)$|\1 available under [license](https://cs.opensource.google/go/x/\2/+/master:LICENSE) |g' "$tmpDepLicenses"
38 | sed -i -r '/google.golang.org\/grpc/s|^(.*)$|\1 available under [license](https://github.com/grpc/grpc-go/blob/master/LICENSE) |g' "$tmpDepLicenses"
39 | sed -i -r '/google.golang.org\/protobuf/s|^(.*)$|\1 available under [license](https://github.com/protocolbuffers/protobuf/blob/main/LICENSE) |g' "$tmpDepLicenses"
40 | sed -i -r '/google.golang.org\/genproto/s|^(.*)$|\1 available under [license](https://pkg.go.dev/google.golang.org/genproto?tab=licenses) |g' "$tmpDepLicenses"
41 | sed -i -r '/go.uber.org\/goleak/s|^(.*)$|\1 available under [license](https://pkg.go.dev/go.uber.org/goleak?tab=licenses) |g' "$tmpDepLicenses"
42 | sed -i -r '/gopkg.in\/yaml\.v3/s|^(.*)$|\1 available under [license](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE) |g' "$tmpDepLicenses"
43 |
44 | }
45 |
46 |
47 | check_dependecies(){
48 | generate_dep_licenses
49 |
50 | tmpHaveLicenses=$(mktemp)
51 | sed "/${RE_STARTAUTOGEN}/,/${RE_ENDAUTOGEN}/!d;//d" $tgt > "$tmpHaveLicenses"
52 |
53 | diffOK=0
54 | if ! diff "$tmpHaveLicenses" "$tmpDepLicenses"; then diffOK=1; fi
55 |
56 | rm "$tmpDepLicenses" || echo "Failed to clean tmp file"
57 | rm "$tmpHaveLicenses" || echo "Failed to clean tmp file"
58 |
59 | [ $diffOK -eq 0 ] || error "Dependency licenses are not up-to-date"
60 | exit 0
61 | }
62 |
63 | update_dependecies(){
64 | generate_dep_licenses
65 |
66 | sed -i -e "/${RE_STARTAUTOGEN}/,/${RE_ENDAUTOGEN}/!b" \
67 | -e "/${RE_ENDAUTOGEN}/i ${STARTAUTOGEN}" \
68 | -e "/${RE_ENDAUTOGEN}/r $tmpDepLicenses" \
69 | -e "/${RE_ENDAUTOGEN}/a ${ENDAUTOGEN}" \
70 | -e "d" \
71 | $tgt
72 |
73 |
74 | rm "$tmpDepLicenses" || echo "Failed to clean tmp file"
75 |
76 | exit 0
77 | }
78 |
79 | case $1 in
80 | "check") check_dependecies;;
81 | "update") update_dependecies;;
82 | *) error "One of actions needed: check update" ;;
83 | esac
84 |
85 |
--------------------------------------------------------------------------------
/volume.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/go-resty/resty/v2"
7 | )
8 |
9 | func (c *Client) ListVolumes(ctx context.Context) ([]Volume, error) {
10 | var res struct {
11 | Volumes []Volume
12 | }
13 |
14 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
15 | return r.SetResult(&res).Get("/drive/volumes")
16 | }); err != nil {
17 | return nil, err
18 | }
19 |
20 | return res.Volumes, nil
21 | }
22 |
23 | func (c *Client) GetVolume(ctx context.Context, volumeID string) (Volume, error) {
24 | var res struct {
25 | Volume Volume
26 | }
27 |
28 | if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) {
29 | return r.SetResult(&res).Get("/drive/volumes/" + volumeID)
30 | }); err != nil {
31 | return Volume{}, err
32 | }
33 |
34 | return res.Volume, nil
35 | }
36 |
--------------------------------------------------------------------------------
/volume_types.go:
--------------------------------------------------------------------------------
1 | package proton
2 |
3 | // Volume is a Proton Drive volume.
4 | type Volume struct {
5 | VolumeID string // Encrypted volume ID
6 |
7 | CreationTime int64 // Creation time of the volume in Unix time
8 | ModifyTime int64 // Last modification time of the volume in Unix time
9 | MaxSpace *int64 // Space limit for the volume in bytes, null if unlimited.
10 | UsedSpace int64 // Space used by files in the volume in bytes
11 | DownloadedBytes int64 // The amount of downloaded data since last reset
12 | UploadedBytes int64 // The amount of uploaded data since the last reset
13 |
14 | State VolumeState // The state of the volume (active, locked, maybe more in the future)
15 | Share VolumeShare // The main share of the volume
16 | RestoreStatus *VolumeRestoreStatus // The status of the restore task. Null if not applicable
17 | }
18 |
19 | // VolumeShare is the main share of a volume.
20 | type VolumeShare struct {
21 | ShareID string // Encrypted share ID
22 | LinkID string // Encrypted link ID
23 | }
24 |
25 | // VolumeState is the state of a volume.
26 | type VolumeState int
27 |
28 | const (
29 | VolumeStateActive VolumeState = 1
30 | VolumeStateLocked VolumeState = 3
31 | )
32 |
33 | // VolumeRestoreStatus is the status of the restore task.
34 | type VolumeRestoreStatus int
35 |
36 | const (
37 | RestoreStatusDone VolumeRestoreStatus = 0
38 | RestoreStatusInProgress VolumeRestoreStatus = 1
39 | RestoreStatusFailed VolumeRestoreStatus = -1
40 | )
41 |
--------------------------------------------------------------------------------