├── .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 | CI Status 4 | GoDoc 5 | Go Report Card 6 | License 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 | --------------------------------------------------------------------------------