├── .gitignore ├── cmd ├── imapdump │ ├── .gitignore │ └── imapdump.go ├── graph-proxy │ ├── README.md │ ├── message_test.go │ ├── hash_test.go │ ├── main.go │ ├── proxy.go │ ├── uidmap.go │ └── message.go └── get-outlook-access-token │ └── main.go ├── .golangci.yml ├── gen-selfsigned-cert.sh ├── graph ├── gen.go └── graph.go ├── README.md ├── cram.go ├── v2 ├── cram.go ├── o365 │ ├── o365_test.go │ ├── adapter.go │ ├── graph.go │ └── o365.go ├── loop.go └── client.go ├── o365 ├── o365_test.go ├── adapter.go └── o365.go ├── xoauth2 ├── sasl.go └── xoauth2.go ├── go.mod ├── loop.go ├── LICENSE └── client.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | c 3 | -------------------------------------------------------------------------------- /cmd/imapdump/.gitignore: -------------------------------------------------------------------------------- 1 | *.pem 2 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | maligned: 3 | suggest-new: true 4 | 5 | linters: 6 | disable: 7 | - errcheck 8 | - typecheck 9 | - megacheck 10 | 11 | issues: 12 | exclude: 13 | - "`?encodeFixed(32|64)Pb`? is unused" 14 | 15 | # vim: set et shiftwidth=2: 16 | -------------------------------------------------------------------------------- /gen-selfsigned-cert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | # https://www.baeldung.com/openssl-self-signed-cert 4 | 5 | domain="$1" 6 | # Create a private key 7 | if [[ ! -s "${domain}.key" ]]; then openssl genrsa -out "${domain}.key"; fi 8 | # Create a Certificate Signing Request 9 | if [[ ! -s "${domain}.csr" ]]; then openssl req -key "${domain}.key" -new -out "${domain}.csr"; fi 10 | # Create a self-signed certificate 11 | if [[ ! -s "${domain}.crt" ]]; then openssl x509 -signkey "${domain}.key" -in "${domain}.csr" -req -days 3650 -out "${domain}.crt"; fi 12 | 13 | -------------------------------------------------------------------------------- /graph/gen.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | // https://aka.ms/get/kiota/latest/linux-x64.zip 4 | 5 | // Does not help, the generated code won't be smaller... 6 | // 7 | //go:generate go tool openapi-generator-cli kiota generate --openapi openapi.yaml --language Go -o msgraph -b --clean-output -n github.com/tgulacsi/imapclient/graph/msgraph -i /me -i /users -i /users/*/messages/* -i /me/mailFolders/* -i /users/*/mailFolders/* -i /me/messages -i /users/*/messages 8 | // go : generate go tool openapi-generator-cli kiota generate --openapi https://aka.ms/graph/v1.0/openapi.yaml --language Go -o msgraph -b --clean-output -n github.com/tgulacsi/imapclient/graph/msgraph -i /me -i /users -i /users/*/messages/* -i /me/mailFolders/* -i /users/*/mailFolders/* -i /me/messages -i /users/*/messages 9 | -------------------------------------------------------------------------------- /cmd/graph-proxy/README.md: -------------------------------------------------------------------------------- 1 | # MSGraph-IMAP proxy 2 | ``` 3 | go install github.com/tgulacsi/imapclient/cmd/graph-proxy@latest 4 | graph-proxy -client-id=xxx :1143 5 | ```` 6 | 7 | ## Authentication, Authorization 8 | Right now, this app uses *Application permission* with the OAuth 2.0 *credentials flow*. 9 | 10 | Maybe a *Delegated permission* with the *implicit grant flow* would be better (though it must communicate with the user). 11 | 12 | For description, see https://laurakokkarinen.com/how-to-set-up-an-azure-ad-application-registration-for-calling-microsoft-graph/ . 13 | 14 | It now works for me with the "msgraph-proxy" app (34f2c0c1-b509-43c5-aae8-56c10fa19ed7 ClientID). 15 | 16 | ### OAuth 2.0 authorization code flow 17 | https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow 18 | 19 | ### Azure CLI 20 | https://learn.microsoft.com/en-us/cli/azure/install-azure-cli-linux?pivots=apt 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # imapclient 2 | imapclient is a helper library for speaking with IMAP4rev1 servers: 3 | list and select mailboxes, search for mails and download them. 4 | 5 | # imapdump 6 | ./cmd/imapdump is a usable example program for listing mailboxes and downloading mail in tar format. 7 | 8 | ## Install 9 | 10 | go get github.com/tgulacsi/imapclient/cmd/imapdump 11 | 12 | ## Usage 13 | 14 | imapdump -H imap.gmail.com -p 993 -U myloginname -P mysecretpassword tree % 15 | 16 | will list all mailboxes 17 | 18 | imapdump -H imap.gmail.com -p 993 -U myloginname -P mysecretpassword list -a Trash 19 | 20 | will list the contents (UID, size and subject) of the Trash folder, even seen mails, 21 | 22 | imapdump -H imap.gmail.com -p 993 -U myloginname -P mysecretpassword save -m Trash 3602 23 | 24 | will dump the message from Trash folder with UID of 3602, 25 | 26 | imapdump -H imap.gmail.com -p 993 -U myloginname -P mysecretpassword save -m Trash -o trash.tar 27 | 28 | will save all the mails under Trash into trash.tar. 29 | 30 | -------------------------------------------------------------------------------- /cram.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014, 2021 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package imapclient 6 | 7 | import ( 8 | "crypto/hmac" 9 | "crypto/md5" //nolint:gas 10 | "encoding/hex" 11 | 12 | "github.com/emersion/go-sasl" 13 | ) 14 | 15 | type cramAuth struct { 16 | username, password string 17 | } 18 | 19 | // CramAuth returns an sasl.Client usable for CRAM-MD5 authentication. 20 | func CramAuth(username, password string) sasl.Client { 21 | return cramAuth{username: username, password: password} 22 | } 23 | 24 | func (a cramAuth) Start() (mech string, ir []byte, err error) { 25 | return "CRAM-MD5", ir, nil 26 | } 27 | 28 | func (a cramAuth) Next(challenge []byte) (response []byte, err error) { 29 | h := hmac.New(md5.New, []byte(a.password)) 30 | h.Write(challenge) 31 | n := len(a.username) 32 | response = make([]byte, 0, len(a.username)+1+hex.EncodedLen(h.Size())) 33 | for i := range n { 34 | response[i] = byte(a.username[i]) 35 | } 36 | response[n] = ' ' 37 | hex.Encode(response[n+1:], h.Sum(nil)) 38 | return response, nil 39 | } 40 | -------------------------------------------------------------------------------- /v2/cram.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014, 2021 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package imapclient 6 | 7 | import ( 8 | "crypto/hmac" 9 | "crypto/md5" //nolint:gas 10 | "encoding/hex" 11 | 12 | "github.com/emersion/go-sasl" 13 | ) 14 | 15 | type cramAuth struct { 16 | username, password string 17 | } 18 | 19 | // CramAuth returns an sasl.Client usable for CRAM-MD5 authentication. 20 | func CramAuth(username, password string) sasl.Client { 21 | return cramAuth{username: username, password: password} 22 | } 23 | 24 | func (a cramAuth) Start() (mech string, ir []byte, err error) { 25 | return "CRAM-MD5", ir, nil 26 | } 27 | 28 | func (a cramAuth) Next(challenge []byte) (response []byte, err error) { 29 | h := hmac.New(md5.New, []byte(a.password)) 30 | h.Write(challenge) 31 | n := len(a.username) 32 | response = make([]byte, 0, len(a.username)+1+hex.EncodedLen(h.Size())) 33 | for i := range n { 34 | response[i] = byte(a.username[i]) 35 | } 36 | response[n] = ' ' 37 | hex.Encode(response[n+1:], h.Sum(nil)) 38 | return response, nil 39 | } 40 | -------------------------------------------------------------------------------- /cmd/get-outlook-access-token/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package main 6 | 7 | import ( 8 | "flag" 9 | "log" 10 | "os" 11 | "strings" 12 | 13 | "github.com/tgulacsi/oauth2client" 14 | "golang.org/x/oauth2" 15 | ) 16 | 17 | func main() { 18 | flagID := flag.String("id", os.Getenv("CLIENT_ID"), "CLIENT_ID") 19 | flagSecret := flag.String("secret", os.Getenv("CLIENT_SECRET"), "CLIENT_SECRET") 20 | flagRedirURL := flag.String("redirect", os.Getenv("REDIRECT_URL"), "REDIRECT_URL") 21 | flagScopes := flag.String("scopes", "https://outlook.office.com/mail.read", "scopes to apply for, space separated") 22 | if *flagRedirURL == "" { 23 | *flagRedirURL = "http://localhost:8123" 24 | } 25 | flag.Parse() 26 | 27 | toks := oauth2client.NewTokenSource(&oauth2.Config{ 28 | ClientID: *flagID, 29 | ClientSecret: *flagSecret, 30 | RedirectURL: *flagRedirURL, 31 | Scopes: strings.Split(*flagScopes, " "), 32 | Endpoint: oauth2client.AzureV2Endpoint, 33 | }, 34 | "o365.conf", 35 | ) 36 | tok, err := toks.Token() 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | log.Println(tok) 41 | } 42 | -------------------------------------------------------------------------------- /o365/o365_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package o365 6 | 7 | import ( 8 | "context" 9 | "os" 10 | "strings" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | var clientID, clientSecret = os.Getenv("CLIENT_ID"), os.Getenv("CLIENT_SECRET") 16 | 17 | func TestList(t *testing.T) { 18 | cl := NewClient(clientID, clientSecret, "") 19 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 20 | defer cancel() 21 | messages, err := cl.List(ctx, "", "", false) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | var id string 26 | for i, m := range messages { 27 | t.Logf("%d. %#v", i, m) 28 | id = m.ID 29 | } 30 | 31 | msg, err := cl.Get(ctx, id) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | t.Logf("%q: %#v", id, msg) 36 | } 37 | 38 | func TestSend(t *testing.T) { 39 | cl := NewClient(clientID, clientSecret, "") 40 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 41 | defer cancel() 42 | 43 | if err := cl.Send(ctx, 44 | Message{ 45 | Subject: "test", 46 | Body: ItemBody{ContentType: "Text", Content: "test"}, 47 | To: []Recipient{Recipient{EmailAddress{Address: "tgulacsi78@gmail.com"}}}, 48 | }, 49 | ); err != nil { 50 | if strings.Contains(err.Error(), "Forbidden") { 51 | t.Skip(err) 52 | } 53 | t.Fatal(err) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /v2/o365/o365_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package o365 6 | 7 | import ( 8 | "context" 9 | "os" 10 | "strings" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | var clientID, clientSecret, tenantID = os.Getenv("CLIENT_ID"), os.Getenv("CLIENT_SECRET"), os.Getenv("TENANT_ID") 16 | 17 | func TestList(t *testing.T) { 18 | cl := NewClient(clientID, clientSecret, "", TenantID(tenantID)) 19 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 20 | defer cancel() 21 | messages, err := cl.List(ctx, "", "", false) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | var id string 26 | for i, m := range messages { 27 | t.Logf("%d. %#v", i, m) 28 | id = m.ID 29 | } 30 | 31 | msg, err := cl.Get(ctx, id) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | t.Logf("%q: %#v", id, msg) 36 | } 37 | 38 | func TestSend(t *testing.T) { 39 | cl := NewClient(clientID, clientSecret, "") 40 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 41 | defer cancel() 42 | 43 | if err := cl.Send(ctx, 44 | Message{ 45 | Subject: "test", 46 | Body: ItemBody{ContentType: "Text", Content: "test"}, 47 | To: []Recipient{Recipient{EmailAddress{Address: "tgulacsi78@gmail.com"}}}, 48 | }, 49 | ); err != nil { 50 | if strings.Contains(err.Error(), "Forbidden") { 51 | t.Skip(err) 52 | } 53 | t.Fatal(err) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /xoauth2/sasl.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package xoauth2 6 | 7 | import ( 8 | "encoding/json" 9 | "fmt" 10 | ) 11 | 12 | // The XOAUTH2 mechanism name. 13 | const XOAuth2 = "XOAUTH2" 14 | 15 | type XOAuth2Error struct { 16 | Status string `json:"status"` 17 | Schemes string `json:"schemes"` 18 | Scope string `json:"scope"` 19 | } 20 | 21 | type XOAuth2Options struct { 22 | Username string 23 | AccessToken string 24 | RefreshToken string 25 | } 26 | 27 | // Implements error 28 | func (err *XOAuth2Error) Error() string { 29 | return fmt.Sprintf(XOAuth2+" authentication error (%v)", err.Status) 30 | } 31 | 32 | type xoauth2Client struct { 33 | XOAuth2Options 34 | } 35 | 36 | func (a *xoauth2Client) Start() (mech string, ir []byte, err error) { 37 | mech = XOAuth2 38 | ir = []byte(XOAuth2String(a.Username, a.AccessToken)) 39 | return mech, ir, nil 40 | } 41 | 42 | func (a *xoauth2Client) Next(challenge []byte) ([]byte, error) { 43 | authBearerErr := &XOAuth2Error{} 44 | if err := json.Unmarshal(challenge, authBearerErr); err != nil { 45 | return nil, fmt.Errorf("unmarshal %s: %w", challenge, err) 46 | } 47 | return nil, fmt.Errorf("%s: %w", challenge, authBearerErr) 48 | } 49 | 50 | // An implementation of the OAUTHBEARER authentication mechanism, as 51 | // described in RFC 7628. 52 | func NewXOAuth2Client(opt *XOAuth2Options) *xoauth2Client { 53 | return &xoauth2Client{*opt} 54 | } 55 | -------------------------------------------------------------------------------- /cmd/graph-proxy/message_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Tamás Gulácsi. 2 | // 3 | // SPDX-License-Identifier: MIT 4 | 5 | package main 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/emersion/go-imap/v2" 11 | "github.com/emersion/go-message/mail" 12 | ) 13 | 14 | func TestParseAddressList(t *testing.T) { 15 | for nm, elt := range map[string]struct { 16 | In string 17 | Want []imap.Address 18 | }{ 19 | "missing@": { 20 | In: `"a.k@g.h" , "P A" , "N B" , "B.G@a.h" , "B P" , "B D" , "B.Z@a.h" , "B.Z@a.h" , "C K" , "C A" , "H-C E" , "C S" , "E J" , "F G" , "F G" , "H G" , "T G" , "V G" , "g.t@a.h" , "V G" , "H.I@a.h" , "I H" , "d. P I" , "j.m@g.h" , "C J" , "j.s@m.h" , "S-H J" , "K.A@a.h" , "S K" , "V L" , "l.c@g.h" , "L K" , "L.S@g.h" , "M Z" , "M J" , "O M" , "R M" , "D. S M" , "M.G@a.h" , "M.P@a.h" , "S N" , "E N S" , "P-T.L@a.h" , "V P" , "P Z" , "R M" , "S G" , "S.A@a.h" , "S G" , "S.T@a.h" , "G T" , "T T" , "V F" , "Z B" , "H Z" , "Z Z" `, 21 | Want: []imap.Address{}, 22 | }, 23 | } { 24 | t.Run(nm, func(t *testing.T) { 25 | const k = "To" 26 | mh := mail.HeaderFromMap(map[string][]string{ 27 | k: []string{elt.In}, 28 | }) 29 | // t.Log(mh.Header.Header.Raw(k)) 30 | aa := parseAddressList(mh, k) 31 | if len(aa) == 0 { 32 | t.Error("parse fail") 33 | } 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /cmd/graph-proxy/hash_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Tamás Gulácsi. All rights reserved. 2 | 3 | package main_test 4 | 5 | import ( 6 | "crypto/rand" 7 | "encoding/base64" 8 | "fmt" 9 | "hash/fnv" 10 | "testing" 11 | "time" 12 | 13 | "github.com/dchest/siphash" 14 | ) 15 | 16 | func TestHashCollision(t *testing.T) { 17 | hfnv := fnv.New32() 18 | hfnva := fnv.New32a() 19 | collisions := make(map[string]int64, 4) 20 | for range 10 { 21 | datas := make([][]byte, 0, 128<<10) 22 | for name, f := range map[string]func([]byte) uint32{ 23 | "FNV": func(data []byte) uint32 { hfnv.Reset(); hfnv.Write(data); return hfnv.Sum32() }, 24 | "FNVa": func(data []byte) uint32 { hfnva.Reset(); hfnva.Write(data); return hfnva.Sum32() }, 25 | "SIM": func(data []byte) uint32 { return uint32(siphash.Hash(0, 0, data)) }, 26 | } { 27 | t.Run(name, func(t *testing.T) { 28 | datas := datas[:] 29 | seen := make(map[uint32]struct{}) 30 | start := time.Now() 31 | var collided bool 32 | for _, d := range datas { 33 | v := f(d[:]) 34 | if _, ok := seen[v]; ok { 35 | t.Logf("%q: first collision after %d hashes", name, len(seen)) 36 | collisions[name] += int64(len(seen)) 37 | collided = true 38 | break 39 | } 40 | seen[v] = struct{}{} 41 | } 42 | if !collided { 43 | for { 44 | var a [120 * 3 / 4]byte 45 | var d [120]byte 46 | n, _ := rand.Read(a[:]) 47 | base64.URLEncoding.Encode(d[:], a[:n]) 48 | // copy(d[:], a[:n]) 49 | datas = append(datas, append([]byte(nil), d[:]...)) 50 | v := f(d[:]) 51 | if _, ok := seen[v]; ok { 52 | t.Logf("%q: first collision after %d hashes", name, len(seen)) 53 | collisions[name] += int64(len(seen)) 54 | collided = true 55 | break 56 | } 57 | seen[v] = struct{}{} 58 | } 59 | } 60 | t.Logf("speed: %f", float64(len(seen))/float64(time.Since(start))) 61 | }) 62 | } 63 | } 64 | fmt.Println(collisions) 65 | } 66 | -------------------------------------------------------------------------------- /xoauth2/xoauth2.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2012, Quinn Slack 2 | // All rights reserved. 3 | // 4 | // Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | // 6 | // Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | // Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 9 | 10 | // Package xoauth2 is Go library for generating XOAuth2 strings (for use in XOAUTH2 SASL auth schemes for IMAP/SMTP) 11 | // 12 | // Copied from https://github.com/sqs/go-xoauth2 13 | // 14 | package xoauth2 15 | 16 | import "encoding/base64" 17 | 18 | // OAuth2String generates an unencoded XOAuth2 string of the form 19 | // "user=" {User} "^Aauth=Bearer " {Access Token} "^A^A" 20 | // as defined at https://developers.google.com/google-apps/gmail/xoauth2_protocol#the_sasl_xoauth2_mechanism 21 | // (^A represents a Control+A (\001)). 22 | // 23 | // The function XOAuth2String in this package returns the base64 encoding of this string. 24 | func OAuth2String(user, accessToken string) string { 25 | return "user=" + user + "\001auth=Bearer " + accessToken + "\001\001" 26 | } 27 | 28 | // XOAuth2String generates a base64-encoded XOAuth2 string suitable for use in SASL XOAUTH2, as 29 | // defined at https://developers.google.com/google-apps/gmail/xoauth2_protocol#the_sasl_xoauth2_mechanism. 30 | // 31 | // (Use the base64 encoding mechanism defined in RFC 4648.) 32 | func XOAuth2String(user, accessToken string) string { 33 | s := OAuth2String(user, accessToken) 34 | if false { 35 | return base64.StdEncoding.EncodeToString([]byte(s)) 36 | } 37 | return s 38 | } 39 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tgulacsi/imapclient 2 | 3 | require ( 4 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 5 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 6 | github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 7 | github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 8 | github.com/UNO-SOFT/filecache v0.4.0 9 | github.com/UNO-SOFT/zlog v0.8.6 10 | github.com/dchest/siphash v1.2.3 11 | github.com/emersion/go-imap v1.2.1 12 | github.com/emersion/go-imap/v2 v2.0.0-beta.7 13 | github.com/emersion/go-message v0.18.2 14 | github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 15 | github.com/google/uuid v1.6.0 16 | github.com/microsoft/kiota-abstractions-go v1.9.3 17 | github.com/microsoft/kiota-serialization-form-go v1.1.2 18 | github.com/microsoft/kiota-serialization-json-go v1.1.2 19 | github.com/microsoft/kiota-serialization-multipart-go v1.1.2 20 | github.com/microsoft/kiota-serialization-text-go v1.1.3 21 | github.com/microsoftgraph/msgraph-sdk-go v1.89.0 22 | github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0 23 | github.com/peterbourgon/ff/v4 v4.0.0-beta.1 24 | github.com/tgulacsi/go v0.28.8-0.20251018153338-d2421125b113 25 | github.com/tgulacsi/oauth2client v0.1.0 26 | go.etcd.io/bbolt v1.4.0 27 | golang.org/x/crypto v0.44.0 28 | golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 29 | golang.org/x/net v0.47.0 30 | golang.org/x/oauth2 v0.30.0 31 | golang.org/x/sync v0.18.0 32 | golang.org/x/text v0.31.0 33 | golang.org/x/time v0.14.0 34 | ) 35 | 36 | require ( 37 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect 38 | github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 // indirect 39 | github.com/dgryski/go-linebreak v0.0.0-20180812204043-d8f37254e7d3 // indirect 40 | github.com/go-logr/logr v1.4.3 // indirect 41 | github.com/go-logr/stdr v1.2.2 // indirect 42 | github.com/golang-jwt/jwt/v5 v5.3.0 // indirect 43 | github.com/keybase/go-keychain v0.0.1 // indirect 44 | github.com/kylelemons/godebug v1.1.0 // indirect 45 | github.com/mattn/go-colorable v0.1.14 // indirect 46 | github.com/microsoft/kiota-authentication-azure-go v1.3.1 // indirect 47 | github.com/microsoft/kiota-http-go v1.5.4 // indirect 48 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 49 | github.com/rogpeppe/go-internal v1.14.1 // indirect 50 | github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8 // indirect 51 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect 52 | go.opentelemetry.io/otel v1.38.0 // indirect 53 | go.opentelemetry.io/otel/metric v1.38.0 // indirect 54 | go.opentelemetry.io/otel/trace v1.38.0 // indirect 55 | golang.org/x/sys v0.38.0 // indirect 56 | golang.org/x/term v0.37.0 // indirect 57 | ) 58 | 59 | // The last version supporting Xoauth2: github.com/emersion/go-sasl v0.0.0-20200509202850-4132e15e133d 60 | 61 | go 1.24.1 62 | 63 | // replace github.com/emersion/go-imap/v2 => ../../emersion/go-imap/v2 64 | 65 | tool github.com/tgulacsi/go/openapi-generator-cli 66 | -------------------------------------------------------------------------------- /cmd/graph-proxy/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024, 2025 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "errors" 10 | "log/slog" 11 | "net/http" 12 | _ "net/http/pprof" 13 | "os" 14 | "os/signal" 15 | "path/filepath" 16 | "syscall" 17 | 18 | "github.com/UNO-SOFT/zlog/v2" 19 | 20 | "github.com/peterbourgon/ff/v4" 21 | "github.com/peterbourgon/ff/v4/ffhelp" 22 | ) 23 | 24 | var concurrency int = 8 25 | 26 | func main() { 27 | if err := Main(); err != nil { 28 | slog.Error("Main", "error", err) 29 | os.Exit(1) 30 | } 31 | } 32 | 33 | func Main() error { 34 | var verbose zlog.VerboseVar 35 | logger := zlog.NewLogger(zlog.MaybeConsoleHandler(&verbose, os.Stderr)).SLog() 36 | cd, err := os.UserCacheDir() 37 | if err != nil { 38 | logger.Error("UserCacheDir", "error", err) 39 | } else { 40 | cd = filepath.Join(cd, "graph-proxy") 41 | } 42 | FS := ff.NewFlagSet("graph-proxy") 43 | FS.IntVar(&concurrency, 0, "concurrency", concurrency, "concurrency") 44 | flagRateLimit := FS.Float64Long("rate-limit", 10, "mas number of http calls per second") 45 | flagCacheDir := FS.StringLong("cache-dir", cd, "cache directory") 46 | flagCacheSize := FS.IntLong("cache-max-mb", 512, "cache max size in MiB") 47 | flagClientID := FS.StringLong("client-id", nvl(os.Getenv("AZURE_CLIENT_ID"), "34f2c0c1-b509-43c5-aae8-56c10fa19ed7"), "ClientID") 48 | flagClientCert := FS.StringLong("client-cert", "", "client certificate .pem (key)") 49 | // flagRedirectURI := flag.String("redirect-uri", "http://localhost:19414/auth-repsonse", "The redirect URI you send in the request to the login server") 50 | // flagClientSecret := flag.String("client-secret", "", "ClientSecret") 51 | // flagTenantID := flag.String("tenant-id", "", "TenantID") 52 | // flagUserID := flag.String("user-id", "", "UserID") 53 | flagRedirectURI := FS.StringLong("redirect-uri", "http://localhost", "redirectURI (if client secret is empty)") 54 | FS.Value(0, "verbose", &verbose, "verbosity") 55 | flagPprofURL := FS.StringLong("pprof", "", "pprof URL to listen on") 56 | app := ff.Command{Name: "graph-proxy", Flags: FS, 57 | Exec: func(ctx context.Context, args []string) error { 58 | if *flagPprofURL != "" { 59 | go http.ListenAndServe(*flagPprofURL, nil) 60 | } 61 | 62 | addr := ":1143" 63 | if len(args) != 0 { 64 | addr = args[0] 65 | } 66 | logger.Info("Listen", "addr", addr) 67 | p, err := NewProxy( 68 | zlog.NewSContext(ctx, logger), 69 | *flagClientID, *flagClientCert, *flagRedirectURI, 70 | *flagCacheDir, *flagCacheSize, *flagRateLimit, 71 | ) 72 | if err != nil { 73 | return err 74 | } 75 | defer p.Close() 76 | return p.ListenAndServe(addr) 77 | }, 78 | } 79 | 80 | if err := app.Parse(os.Args[1:]); err != nil { 81 | if errors.Is(err, ff.ErrHelp) { 82 | ffhelp.Command(&app).WriteTo(os.Stderr) 83 | return nil 84 | } 85 | return err 86 | } 87 | 88 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGKILL) 89 | defer cancel() 90 | 91 | return app.Run(ctx) 92 | } 93 | 94 | func nvl[T comparable](a T, b ...T) T { 95 | var zero T 96 | if a != zero { 97 | return a 98 | } 99 | for _, a := range b { 100 | if a != zero { 101 | return a 102 | } 103 | } 104 | return a 105 | } 106 | -------------------------------------------------------------------------------- /o365/adapter.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package o365 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "io" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "golang.org/x/net/context" 16 | 17 | "github.com/tgulacsi/imapclient" 18 | ) 19 | 20 | var _ = imapclient.MinClient((*oClient)(nil)) 21 | 22 | type oClient struct { 23 | *client 24 | u2s map[uint32]string 25 | s2u map[string]uint32 26 | selected string 27 | mu sync.Mutex 28 | } 29 | 30 | func NewIMAPClient(c *client) imapclient.Client { 31 | return imapclient.MaxClient{MinClient: &oClient{ 32 | client: c, 33 | u2s: make(map[uint32]string), 34 | s2u: make(map[string]uint32), 35 | }} 36 | } 37 | 38 | var ErrNotSupported = errors.New("not supported") 39 | 40 | func (c *oClient) Watch(context.Context) ([]uint32, error) { return nil, ErrNotSupported } 41 | func (c *oClient) ConnectC(context.Context) error { return nil } 42 | func (c *oClient) Close(commit bool) error { return nil } 43 | func (c *oClient) ListC(ctx context.Context, mbox, pattern string, all bool) ([]uint32, error) { 44 | ids, err := c.client.List(ctx, mbox, pattern, all) 45 | c.mu.Lock() 46 | defer c.mu.Unlock() 47 | uids := make([]uint32, len(ids)) 48 | for i, msg := range ids { 49 | s := msg.ID 50 | if u := c.s2u[s]; u != 0 { 51 | uids[i] = u 52 | continue 53 | } 54 | u := uint32(i + 1) 55 | c.u2s[u] = s 56 | c.s2u[s] = u 57 | uids[i] = u 58 | } 59 | return uids, err 60 | } 61 | func (c *oClient) ReadToC(ctx context.Context, w io.Writer, msgID uint32) (int64, error) { 62 | s, err := c.uidToStr(msgID) 63 | if err != nil { 64 | return 0, err 65 | } 66 | msg, err := c.client.Get(ctx, s) 67 | if err != nil { 68 | return 0, err 69 | } 70 | var n int64 71 | hdr := [][2]string{ 72 | {"From", rcpt(msg.Sender)}, 73 | {"Categories", strings.Join(msg.Categories, ", ")}, 74 | {"Change-Key", msg.ChangeKey}, 75 | {"Conversation-Id", msg.ConversationID}, 76 | {"Id", msg.ID}, 77 | {"Importance", string(msg.Importance)}, 78 | {"Inference-Classification", string(msg.InferenceClassification)}, 79 | //{"Delivery-Receipt-Requested", msg.IsDeliveryReceiptRequested}, 80 | {"Subject", msg.Subject}, 81 | {"Web-Link", msg.WebLink}, 82 | } 83 | T := func(key string, tim *time.Time) { 84 | if tim != nil && !tim.IsZero() { 85 | hdr = append(hdr, [2]string{key, tim.Format(time.RFC3339)}) 86 | } 87 | } 88 | T("Created", msg.Created) 89 | T("LastModified", msg.LastModified) 90 | T("Received", msg.Received) 91 | T("Sent", msg.Sent) 92 | 93 | A := func(key string, rcpts []Recipient) { 94 | for _, rcp := range rcpts { 95 | if s := rcpt(&rcp); s != "" { 96 | hdr = append(hdr, [2]string{key, s}) 97 | } 98 | } 99 | } 100 | A("Bcc", msg.Bcc) 101 | A("Cc", msg.Cc) 102 | A("Reply-To", msg.ReplyTo) 103 | A("To", msg.To) 104 | 105 | for _, kv := range hdr { 106 | i, _ := fmt.Fprintf(w, `%s: %s\n`, kv[0], kv[1]) 107 | n += int64(i) 108 | } 109 | i, err := io.WriteString(w, msg.Body.Content) 110 | return n + int64(i), err 111 | } 112 | func rcpt(r *Recipient) string { 113 | if r == nil { 114 | return "" 115 | } 116 | if r.EmailAddress.Name == "" { 117 | return "<" + r.EmailAddress.Address + ">" 118 | } 119 | return fmt.Sprintf(`%q <%s>`, r.EmailAddress.Name, r.EmailAddress.Address) 120 | } 121 | func (c *oClient) Peek(ctx context.Context, w io.Writer, msgID uint32, what string) (int64, error) { 122 | return c.ReadToC(ctx, w, msgID) 123 | } 124 | func (c *oClient) Delete(msgID uint32) error { 125 | s, err := c.uidToStr(msgID) 126 | if err != nil { 127 | return err 128 | } 129 | return c.client.Delete(context.Background(), s) 130 | } 131 | func (c *oClient) Move(msgID uint32, mbox string) error { 132 | s, err := c.uidToStr(msgID) 133 | if err != nil { 134 | return err 135 | } 136 | return c.client.Move(context.Background(), s, mbox) 137 | } 138 | 139 | func (c *oClient) uidToStr(msgID uint32) (string, error) { 140 | c.mu.Lock() 141 | s := c.u2s[msgID] 142 | c.mu.Unlock() 143 | if s == "" { 144 | return "", fmt.Errorf("unknown msgID %d", msgID) 145 | } 146 | return s, nil 147 | } 148 | func (c *oClient) SetLogMask(mask imapclient.LogMask) imapclient.LogMask { return false } 149 | func (c *oClient) SetLoggerC(ctx context.Context) {} 150 | func (c *oClient) Select(ctx context.Context, mbox string) error { 151 | c.mu.Lock() 152 | c.selected = mbox 153 | c.mu.Unlock() 154 | return nil 155 | } 156 | func (c *oClient) FetchArgs(ctx context.Context, what string, msgIDs ...uint32) (map[uint32]map[string][]string, error) { 157 | return nil, ErrNotImplemented 158 | } 159 | func (c *oClient) Mark(msgID uint32, seen bool) error { 160 | s, err := c.uidToStr(msgID) 161 | if err != nil { 162 | return err 163 | } 164 | return c.client.Update(context.Background(), s, map[string]any{ 165 | "IsRead": seen, 166 | }) 167 | } 168 | func (c *oClient) Mailboxes(ctx context.Context, root string) ([]string, error) { 169 | folders, err := c.client.ListFolders(ctx, root) 170 | names := make([]string, len(folders)) 171 | for i, f := range folders { 172 | names[i] = f.Name 173 | } 174 | return names, err 175 | } 176 | 177 | func (c *oClient) WriteTo(ctx context.Context, mbox string, msg []byte, date time.Time) error { 178 | return ErrNotImplemented 179 | } 180 | 181 | var ErrNotImplemented = errors.New("not implemented") 182 | -------------------------------------------------------------------------------- /v2/loop.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017, 2025 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package imapclient 6 | 7 | import ( 8 | "context" 9 | "crypto/sha512" 10 | "encoding/base64" 11 | "errors" 12 | "fmt" 13 | "hash" 14 | "io" 15 | "log/slog" 16 | "math/rand/v2" 17 | "sync" 18 | "time" 19 | 20 | "github.com/tgulacsi/go/iohlp" 21 | ) 22 | 23 | var ( 24 | // ShortSleep is the duration which ised for sleep after successful delivery. 25 | ShortSleep = 1 * time.Second 26 | // LongSleep is the duration which used for sleep between errors and if the inbox is empty. 27 | LongSleep = 5 * time.Minute 28 | 29 | // ErrSkip from DeliverFunc means leave the message as is. 30 | ErrSkip = errors.New("skip move") 31 | ) 32 | 33 | // DeliveryLoop periodically checks the inbox for mails with the specified pattern 34 | // in the subject (or for any unseen mail if pattern == ""), tries to parse the 35 | // message, and call the deliver function with the parsed message. 36 | // 37 | // If deliver did not returned error, the message is marked as Seen, and if outbox 38 | // is not empty, then moved to outbox. 39 | // Except when the error is ErrSkip - then the message is left there as is. 40 | // 41 | // deliver is called with the message, UID and hsh. 42 | func DeliveryLoop(ctx context.Context, c Client, inbox, pattern string, deliver DeliverFunc, outbox, errbox string, logger *slog.Logger, delete bool) error { 43 | if inbox == "" { 44 | inbox = "INBOX" 45 | } 46 | for { 47 | // nosemgrep: trailofbits.go.invalid-usage-of-modified-variable.invalid-usage-of-modified-variable 48 | n, err := one(ctx, c, inbox, pattern, deliver, outbox, errbox, logger, delete) 49 | if err != nil { 50 | logger.Error("DeliveryLoop one round", "count", n, "error", err) 51 | } else { 52 | logger.Info("DeliveryLoop one round", "count", n) 53 | } 54 | select { 55 | case <-ctx.Done(): 56 | return nil 57 | default: 58 | } 59 | 60 | dur := ShortSleep 61 | if n == 0 || err != nil { 62 | dur = LongSleep 63 | } 64 | 65 | delay := time.NewTimer(dur) 66 | select { 67 | case <-delay.C: 68 | case <-ctx.Done(): 69 | if !delay.Stop() { 70 | <-delay.C 71 | } 72 | return nil 73 | } 74 | } 75 | } 76 | 77 | func NewHash() *Hash { return &Hash{Hash: sha512.New512_224()} } 78 | 79 | type HashArray [sha512.Size224]byte 80 | 81 | func (h HashArray) String() string { return base64.URLEncoding.EncodeToString(h[:]) } 82 | 83 | type Hash struct{ hash.Hash } 84 | 85 | func (h Hash) Array() HashArray { var a HashArray; h.Hash.Sum(a[:0]); return a } 86 | 87 | func MkDeliverFunc(ctx context.Context, deliver DeliverFunc) DeliverFunc { 88 | return func(ctx context.Context, r io.ReadSeeker, uid uint32, hsh HashArray) error { 89 | return deliver(ctx, r, uid, hsh) 90 | } 91 | } 92 | 93 | // DeliverOne does one round of message reading and delivery. Does not loop. 94 | // Returns the number of messages delivered. 95 | func DeliverOne(ctx context.Context, c Client, inbox, pattern string, deliver DeliverFunc, outbox, errbox string, logger *slog.Logger, delete bool) (int, error) { 96 | if inbox == "" { 97 | inbox = "INBOX" 98 | } 99 | return one(ctx, c, inbox, pattern, deliver, outbox, errbox, logger, delete) 100 | } 101 | 102 | // DeliverFunc is the type for message delivery. 103 | // 104 | // r is the message data, uid is the IMAP server sent message UID, hsh is the message's hash. 105 | type DeliverFunc func(ctx context.Context, r io.ReadSeeker, uid uint32, hsh HashArray) error 106 | 107 | func one(ctx context.Context, c Client, inbox, pattern string, deliver DeliverFunc, outbox, errbox string, logger *slog.Logger, delete bool) (int, error) { 108 | logger = logger.With("inbox", inbox) 109 | if err := c.Connect(ctx); err != nil { 110 | logger.Error("Connecting", "error", err) 111 | return 0, fmt.Errorf("connect: %w", err) 112 | } 113 | defer c.Close(ctx, true) 114 | 115 | uids, err := c.List(ctx, inbox, pattern, outbox != "" && errbox != "") 116 | logger.Info("List", "uids", uids, "error", err) 117 | if err != nil { 118 | return 0, fmt.Errorf("list %v/%v: %w", c, inbox, err) 119 | } 120 | 121 | var n int 122 | hsh := NewHash() 123 | rand.Shuffle(len(uids), func(i, j int) { uids[i], uids[j] = uids[j], uids[i] }) 124 | for _, uid := range uids { 125 | if err = ctx.Err(); err != nil { 126 | return n, err 127 | } 128 | logger := logger.With("uid", uid) 129 | 130 | hsh.Reset() 131 | pr, pw := io.Pipe() 132 | var wg sync.WaitGroup 133 | wg.Add(1) 134 | go func() { 135 | _, err = c.ReadTo(ctx, io.MultiWriter(pw, hsh), uid) 136 | pw.CloseWithError(err) 137 | wg.Done() 138 | }() 139 | sr, err := iohlp.MakeSectionReader(pr, 1<<20) 140 | pr.CloseWithError(err) 141 | wg.Wait() 142 | if err != nil || sr.Size() == 0 { 143 | logger.Error("Read", "error", err) 144 | continue 145 | } 146 | 147 | if err = deliver(ctx, sr, uid, hsh.Array()); err != nil { 148 | logger.Error("deliver", "error", err) 149 | if errbox != "" && !errors.Is(err, ErrSkip) { 150 | if err = c.Move(ctx, uid, errbox); err != nil { 151 | logger.Error("move to", "errbox", errbox, "error", err) 152 | } 153 | } 154 | return n, err 155 | } 156 | n++ 157 | logger.Info("delivered") 158 | 159 | if err = c.Mark(ctx, uid, true); err != nil { 160 | logger.Error("mark seen", "error", err) 161 | } 162 | 163 | if delete { 164 | if err = c.Delete(ctx, uid); err != nil { 165 | logger.Error("delete", "error", err) 166 | } else { 167 | continue 168 | } 169 | } 170 | if outbox != "" { 171 | if err = c.Move(ctx, uid, outbox); err != nil { 172 | logger.Error("move to", "outbox", outbox, "error", err) 173 | continue 174 | } 175 | } 176 | } 177 | 178 | return n, nil 179 | } 180 | -------------------------------------------------------------------------------- /loop.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017, 2024 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package imapclient 6 | 7 | import ( 8 | "context" 9 | "crypto/sha512" 10 | "errors" 11 | "fmt" 12 | "io" 13 | "strconv" 14 | "time" 15 | 16 | "github.com/UNO-SOFT/zlog/v2" 17 | "github.com/tgulacsi/go/temp" 18 | ) 19 | 20 | var ( 21 | // ShortSleep is the duration which ised for sleep after successful delivery. 22 | ShortSleep = 1 * time.Second 23 | // LongSleep is the duration which used for sleep between errors and if the inbox is empty. 24 | LongSleep = 5 * time.Minute 25 | 26 | // ErrSkip from DeliverFunc means leave the message as is. 27 | ErrSkip = errors.New("skip move") 28 | ) 29 | 30 | // DeliveryLoop periodically checks the inbox for mails with the specified pattern 31 | // in the subject (or for any unseen mail if pattern == ""), tries to parse the 32 | // message, and call the deliver function with the parsed message. 33 | // 34 | // If deliver did not returned error, the message is marked as Seen, and if outbox 35 | // is not empty, then moved to outbox. 36 | // Except when the error is ErrSkip - then the message is left there as is. 37 | // 38 | // deliver is called with the message, UID and hsh. 39 | func DeliveryLoop(c Client, inbox, pattern string, deliver DeliverFunc, outbox, errbox string, closeCh <-chan struct{}) { 40 | ctx, cancel := context.WithCancel(context.Background()) 41 | go func() { 42 | <-closeCh 43 | cancel() 44 | }() 45 | _ = DeliveryLoopC(ctx, c, inbox, pattern, MkDeliverFuncC(ctx, deliver), outbox, errbox) 46 | } 47 | 48 | // DeliveryLoopC periodically checks the inbox for mails with the specified pattern 49 | // in the subject (or for any unseen mail if pattern == ""), tries to parse the 50 | // message, and call the deliver function with the parsed message. 51 | // 52 | // If deliver did not returned error, the message is marked as Seen, and if outbox 53 | // is not empty, then moved to outbox. 54 | // Except when the error is ErrSkip - then the message is left there as is. 55 | // 56 | // deliver is called with the message, UID and hsh. 57 | func DeliveryLoopC(ctx context.Context, c Client, inbox, pattern string, deliver DeliverFuncC, outbox, errbox string) error { 58 | if inbox == "" { 59 | inbox = "INBOX" 60 | } 61 | for { 62 | // nosemgrep: trailofbits.go.invalid-usage-of-modified-variable.invalid-usage-of-modified-variable 63 | n, err := one(ctx, c, inbox, pattern, deliver, outbox, errbox) 64 | if err != nil { 65 | logger.Error("DeliveryLoop one round", "count", n, "error", err) 66 | } else { 67 | logger.Info("DeliveryLoop one round", "count", n) 68 | } 69 | select { 70 | case <-ctx.Done(): 71 | return nil 72 | default: 73 | } 74 | 75 | dur := ShortSleep 76 | if n == 0 || err != nil { 77 | dur = LongSleep 78 | } 79 | 80 | delay := time.NewTimer(dur) 81 | select { 82 | case <-delay.C: 83 | case <-ctx.Done(): 84 | if !delay.Stop() { 85 | <-delay.C 86 | } 87 | return nil 88 | } 89 | } 90 | } 91 | 92 | // DeliverOne does one round of message reading and delivery. Does not loop. 93 | // Returns the number of messages delivered. 94 | func DeliverOne(c Client, inbox, pattern string, deliver DeliverFunc, outbox, errbox string) (int, error) { 95 | ctx, cancel := context.WithCancel(context.Background()) 96 | defer cancel() 97 | return DeliverOneC(ctx, c, inbox, pattern, MkDeliverFuncC(ctx, deliver), outbox, errbox) 98 | } 99 | 100 | func MkDeliverFuncC(ctx context.Context, deliver DeliverFunc) DeliverFuncC { 101 | return func(ctx context.Context, r io.ReadSeeker, uid uint32, hsh []byte) error { 102 | return deliver(r, uid, hsh) 103 | } 104 | } 105 | 106 | // DeliverOneC does one round of message reading and delivery. Does not loop. 107 | // Returns the number of messages delivered. 108 | func DeliverOneC(ctx context.Context, c Client, inbox, pattern string, deliver DeliverFuncC, outbox, errbox string) (int, error) { 109 | if inbox == "" { 110 | inbox = "INBOX" 111 | } 112 | return one(ctx, c, inbox, pattern, deliver, outbox, errbox) 113 | } 114 | 115 | // DeliverFunc is the type for message delivery. 116 | // 117 | // r is the message data, uid is the IMAP server sent message UID, hsh is the message's hash. 118 | type DeliverFunc func(r io.ReadSeeker, uid uint32, hsh []byte) error 119 | 120 | // DeliverFuncC is the type for message delivery. 121 | // 122 | // r is the message data, uid is the IMAP server sent message UID, hsh is the message's hash. 123 | type DeliverFuncC func(ctx context.Context, r io.ReadSeeker, uid uint32, hsh []byte) error 124 | 125 | func one(ctx context.Context, c Client, inbox, pattern string, deliver DeliverFuncC, outbox, errbox string) (int, error) { 126 | logger := GetLogger(ctx).With("c", c, "inbox", inbox) 127 | if err := c.ConnectC(ctx); err != nil { 128 | logger.Error("Connecting", "error", err) 129 | return 0, fmt.Errorf("connect to %v: %w", c, err) 130 | } 131 | defer c.Close(true) 132 | 133 | uids, err := c.ListC(ctx, inbox, pattern, outbox != "" && errbox != "") 134 | logger.Info("List", "uids", uids, "error", err) 135 | if err != nil { 136 | return 0, fmt.Errorf("list %v/%v: %w", c, inbox, err) 137 | } 138 | 139 | var n int 140 | hsh := sha512.New384() 141 | for _, uid := range uids { 142 | if err = ctx.Err(); err != nil { 143 | return n, err 144 | } 145 | logger := logger.With("uid", uid) 146 | ctx := zlog.NewSContext(ctx, logger) 147 | hsh.Reset() 148 | body := temp.NewMemorySlurper(strconv.FormatUint(uint64(uid), 10)) 149 | if _, err = c.ReadToC(ctx, io.MultiWriter(body, hsh), uid); err != nil { 150 | body.Close() 151 | logger.Error("Read", "error", err) 152 | continue 153 | } 154 | 155 | err = deliver(ctx, body, uid, hsh.Sum(nil)) 156 | body.Close() 157 | if err != nil { 158 | logger.Error("deliver", "error", err) 159 | if errbox != "" && !errors.Is(err, ErrSkip) { 160 | if err = c.MoveC(ctx, uid, errbox); err != nil { 161 | logger.Error("move to", "errbox", errbox, "error", err) 162 | } 163 | } 164 | continue 165 | } 166 | n++ 167 | 168 | if err = c.MarkC(ctx, uid, true); err != nil { 169 | logger.Error("mark seen", "error", err) 170 | } 171 | 172 | if outbox != "" { 173 | if err = c.MoveC(ctx, uid, outbox); err != nil { 174 | logger.Error("move to", "outbox", outbox, "error", err) 175 | continue 176 | } 177 | } 178 | } 179 | 180 | return n, nil 181 | } 182 | -------------------------------------------------------------------------------- /cmd/graph-proxy/proxy.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "log/slog" 11 | "net" 12 | "os" 13 | "sync" 14 | "time" 15 | 16 | "golang.org/x/time/rate" 17 | 18 | "github.com/UNO-SOFT/filecache" 19 | "github.com/UNO-SOFT/zlog/v2" 20 | "github.com/emersion/go-imap/v2" 21 | "github.com/emersion/go-imap/v2/imapserver" 22 | "github.com/tgulacsi/imapclient/graph" 23 | ) 24 | 25 | func NewProxy(ctx context.Context, 26 | clientID, clientCert, redirectURI, 27 | cacheDir string, cacheSizeMiB int, 28 | rateLimit float64, 29 | ) (*proxy, error) { 30 | credOpts := graph.CredentialOptions{RedirectURL: redirectURI} 31 | if clientCert != "" { 32 | fh, err := os.Open(clientCert) 33 | if err != nil { 34 | return nil, err 35 | } 36 | credOpts.Certs, credOpts.Key, err = graph.ParseCertificates(fh, "") 37 | fh.Close() 38 | if err != nil { 39 | return nil, err 40 | } 41 | } 42 | P := proxy{ 43 | ctx: ctx, clientID: clientID, credOpts: credOpts, 44 | folders: make(map[string]map[string]*Folder), 45 | limit: rate.Limit(rateLimit), 46 | } 47 | logger := P.logger() 48 | os.MkdirAll(cacheDir, 0750) 49 | if cacheSizeMiB < 1 { 50 | cacheSizeMiB = 512 51 | } 52 | var err error 53 | if P.cache, err = filecache.Open( 54 | cacheDir, 55 | filecache.WithMaxSize(int64(cacheSizeMiB)<<20), 56 | filecache.WithLogger(slog.New( 57 | zlog.NewLevelHandler(slog.LevelError, logger.Handler()))), 58 | ); err != nil { 59 | return nil, fmt.Errorf("open cache %q: %w", cacheDir, err) 60 | } 61 | 62 | if P.idm, err = newUIDMap(ctx, cacheDir+".db"); err != nil { 63 | return nil, fmt.Errorf("open uidMap %q: %w", cacheDir+".db", err) 64 | } 65 | 66 | var token struct{} 67 | opts := imapserver.Options{ 68 | // NewSession is called when a client connects. 69 | NewSession: P.newSession, 70 | // Supported capabilities. If nil, only IMAP4rev1 is advertised. This set 71 | // must contain at least IMAP4rev1 or IMAP4rev2. 72 | // 73 | // the following capabilities are part of IMAP4rev2 and need to be 74 | // explicitly enabled by IMAP4rev1-only servers: 75 | // 76 | // - NAMESPACE 77 | // - UIDPLUS 78 | // - ESEARCH 79 | // - LIST-EXTENDED 80 | // - LIST-STATUS 81 | // - MOVE 82 | // - STATUS=SIZE 83 | Caps: imap.CapSet{ 84 | imap.CapIMAP4rev1: token, //imap.CapIMAP4rev2: token, 85 | imap.CapNamespace: token, imap.CapUIDPlus: token, 86 | imap.CapESearch: token, //imap.CapListExtended: token, 87 | //imap.CapListStatus: token, 88 | //imap.CapMove: token, imap.CapStatusSize: token, 89 | }, 90 | // Logger is a logger to print error messages. If nil, log.Default is used. 91 | Logger: slog.NewLogLogger(logger.With("lib", "imapserver").Handler(), slog.LevelError), 92 | // TLSConfig is a TLS configuration for STARTTLS. If nil, STARTTLS is 93 | // disabled. 94 | TLSConfig: nil, 95 | // InsecureAuth allows clients to authenticate without TLS. In this mode, 96 | // the server is susceptible to man-in-the-middle attacks. 97 | InsecureAuth: true, 98 | } 99 | if logger.Enabled(ctx, slog.LevelDebug) { 100 | // Raw ingress and egress data will be written to this writer, if any. 101 | // Note, this may include sensitive information such as credentials used 102 | // during authentication. 103 | opts.DebugWriter = slogDebugWriter{logger} 104 | } 105 | 106 | P.srv = imapserver.New(&opts) 107 | return &P, nil 108 | } 109 | 110 | func (P *proxy) ListenAndServe(addr string) error { 111 | // if P.client == nil && P.clientSecret != "" { 112 | // if err := P.connect( 113 | // P.ctx, P.tenantID, P.clientID, P.clientSecret, 114 | // ); err != nil { 115 | // return err 116 | // } 117 | // } 118 | 119 | if addr == "" { 120 | addr = ":143" 121 | } 122 | ln, err := net.Listen("tcp", addr) 123 | if err != nil { 124 | return err 125 | } 126 | go func() { 127 | <-P.ctx.Done() 128 | ln.Close() 129 | }() 130 | return P.srv.Serve(ln) 131 | } 132 | 133 | const ( 134 | delim = '/' 135 | delimS = "/" 136 | ) 137 | 138 | type Folder struct { 139 | graph.Folder 140 | Mailbox string 141 | } 142 | 143 | type proxy struct { 144 | ctx context.Context 145 | srv *imapserver.Server 146 | cache *filecache.Cache 147 | clients map[string]clientUsers 148 | folders map[string]map[string]*Folder 149 | // client *graph.GraphMailClient 150 | //tenantID string 151 | idm *uidMap 152 | clientID string 153 | credOpts graph.CredentialOptions 154 | limit rate.Limit 155 | 156 | mu sync.RWMutex 157 | } 158 | 159 | func (P *proxy) Close() error { 160 | um := P.idm 161 | P.idm = nil 162 | if um != nil { 163 | return um.Close() 164 | } 165 | return nil 166 | } 167 | 168 | func (P *proxy) logger() *slog.Logger { 169 | if lgr := zlog.SFromContext(P.ctx); lgr != nil { 170 | return lgr 171 | } 172 | return slog.Default() 173 | } 174 | 175 | func (P *proxy) connect(ctx context.Context, tenantID, clientSecret string) (graph.GraphMailClient, []graph.User, map[string]*Folder, error) { 176 | logger := P.logger().With("tenantID", tenantID, "clientID", P.clientID, "clientSecretLen", len(clientSecret)) 177 | P.mu.Lock() 178 | defer P.mu.Unlock() 179 | key := tenantID + "\t" + clientSecret 180 | if P.folders == nil { 181 | P.folders = make(map[string]map[string]*Folder) 182 | } 183 | if P.folders[key] == nil { 184 | P.folders[key] = make(map[string]*Folder) 185 | } 186 | if clu, ok := P.clients[key]; ok { 187 | logger.Debug("client cached") 188 | return clu.Client, clu.Users, P.folders[key], nil 189 | } 190 | start := time.Now() 191 | credOpts := P.credOpts 192 | credOpts.Secret = clientSecret 193 | cl, users, err := graph.NewGraphMailClient(ctx, tenantID, P.clientID, credOpts) 194 | if err != nil { 195 | logger.Error("NewGraphMailClient", "dur", time.Since(start).String(), "error", err) 196 | return graph.GraphMailClient{}, nil, nil, err 197 | } 198 | cl.SetLimit(P.limit) 199 | logger.Debug("NewGraphMailClient", "users", users, "dur", time.Since(start).String()) 200 | if P.clients == nil { 201 | P.clients = make(map[string]clientUsers) 202 | } 203 | P.clients[key] = clientUsers{Client: cl, Users: users} 204 | return cl, users, P.folders[key], err 205 | } 206 | 207 | type clientUsers struct { 208 | Client graph.GraphMailClient 209 | Users []graph.User 210 | } 211 | 212 | type slogDebugWriter struct{ *slog.Logger } 213 | 214 | func (s slogDebugWriter) Write(p []byte) (int, error) { 215 | s.Logger.Debug(string(p)) 216 | return len(p), nil 217 | } 218 | -------------------------------------------------------------------------------- /v2/o365/adapter.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021, 2023 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package o365 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "errors" 11 | "fmt" 12 | "io" 13 | "log/slog" 14 | "mime" 15 | "net/mail" 16 | "strings" 17 | "sync" 18 | "time" 19 | 20 | "github.com/emersion/go-message" 21 | "github.com/tgulacsi/imapclient/v2" 22 | ) 23 | 24 | var _ = imapclient.Client((*oClient)(nil)) 25 | 26 | type oClient struct { 27 | *client 28 | u2s map[uint32]string 29 | s2u map[string]uint32 30 | selected string 31 | mu sync.Mutex 32 | } 33 | 34 | func NewIMAPClient(c *client) imapclient.Client { 35 | return &oClient{ 36 | client: c, 37 | u2s: make(map[uint32]string), 38 | s2u: make(map[string]uint32), 39 | } 40 | } 41 | 42 | var ErrNotSupported = errors.New("not supported") 43 | 44 | func (c *oClient) Watch(context.Context) ([]uint32, error) { return nil, ErrNotSupported } 45 | func (c *oClient) Connect(context.Context) error { return nil } 46 | func (c *oClient) Close(ctx context.Context, commit bool) error { return nil } 47 | func (c *oClient) List(ctx context.Context, mbox, pattern string, all bool) ([]uint32, error) { 48 | ids, err := c.client.List(ctx, mbox, pattern, all) 49 | c.mu.Lock() 50 | defer c.mu.Unlock() 51 | uids := make([]uint32, len(ids)) 52 | for i, msg := range ids { 53 | s := msg.ID 54 | if u := c.s2u[s]; u != 0 { 55 | uids[i] = u 56 | continue 57 | } 58 | u := uint32(i + 1) 59 | c.u2s[u] = s 60 | c.s2u[s] = u 61 | uids[i] = u 62 | } 63 | return uids, err 64 | } 65 | func (c *oClient) ReadTo(ctx context.Context, w io.Writer, msgID uint32) (int64, error) { 66 | s, err := c.uidToStr(msgID) 67 | if err != nil { 68 | return 0, err 69 | } 70 | msg, err := c.client.Get(ctx, s) 71 | if err != nil { 72 | return 0, err 73 | } 74 | var n int64 75 | hdr := [][2]string{ 76 | {"From", rcpt(msg.Sender)}, 77 | {"Categories", strings.Join(msg.Categories, ", ")}, 78 | {"Change-Key", msg.ChangeKey}, 79 | {"Conversation-Id", msg.ConversationID}, 80 | {"Id", msg.ID}, 81 | {"Importance", string(msg.Importance)}, 82 | {"Inference-Classification", string(msg.InferenceClassification)}, 83 | //{"Delivery-Receipt-Requested", msg.IsDeliveryReceiptRequested}, 84 | {"Subject", msg.Subject}, 85 | {"Web-Link", msg.WebLink}, 86 | } 87 | T := func(key string, tim *time.Time) { 88 | if tim != nil && !tim.IsZero() { 89 | hdr = append(hdr, [2]string{key, tim.Format(time.RFC3339)}) 90 | } 91 | } 92 | T("Created", msg.Created) 93 | T("LastModified", msg.LastModified) 94 | T("Received", msg.Received) 95 | T("Sent", msg.Sent) 96 | 97 | A := func(key string, rcpts []Recipient) { 98 | for _, rcp := range rcpts { 99 | if s := rcpt(&rcp); s != "" { 100 | hdr = append(hdr, [2]string{key, s}) 101 | } 102 | } 103 | } 104 | A("Bcc", msg.Bcc) 105 | A("Cc", msg.Cc) 106 | A("Reply-To", msg.ReplyTo) 107 | A("To", msg.To) 108 | 109 | for _, kv := range hdr { 110 | i, _ := fmt.Fprintf(w, `%s: %s\n`, kv[0], kv[1]) 111 | n += int64(i) 112 | } 113 | i, err := io.WriteString(w, msg.Body.Content) 114 | return n + int64(i), err 115 | } 116 | func rcpt(r *Recipient) string { 117 | if r == nil { 118 | return "" 119 | } 120 | if r.EmailAddress.Name == "" { 121 | return "<" + r.EmailAddress.Address + ">" 122 | } 123 | return fmt.Sprintf(`%q <%s>`, r.EmailAddress.Name, r.EmailAddress.Address) 124 | } 125 | func (c *oClient) Peek(ctx context.Context, w io.Writer, msgID uint32, what string) (int64, error) { 126 | return c.ReadTo(ctx, w, msgID) 127 | } 128 | func (c *oClient) Delete(ctx context.Context, msgID uint32) error { 129 | s, err := c.uidToStr(msgID) 130 | if err != nil { 131 | return err 132 | } 133 | return c.client.Delete(ctx, s) 134 | } 135 | func (c *oClient) Move(ctx context.Context, msgID uint32, mbox string) error { 136 | s, err := c.uidToStr(msgID) 137 | if err != nil { 138 | return err 139 | } 140 | return c.client.Move(ctx, s, mbox) 141 | } 142 | 143 | func (c *oClient) uidToStr(msgID uint32) (string, error) { 144 | c.mu.Lock() 145 | s := c.u2s[msgID] 146 | c.mu.Unlock() 147 | if s == "" { 148 | return "", fmt.Errorf("unknown msgID %d", msgID) 149 | } 150 | return s, nil 151 | } 152 | func (c *oClient) SetLogMask(mask imapclient.LogMask) imapclient.LogMask { return false } 153 | func (c *oClient) SetLogger(lgr *slog.Logger) { c.logger = lgr } 154 | func (c *oClient) Select(ctx context.Context, mbox string) error { 155 | c.mu.Lock() 156 | c.selected = mbox 157 | c.mu.Unlock() 158 | return nil 159 | } 160 | func (c *oClient) FetchArgs(ctx context.Context, what string, msgIDs ...uint32) (map[uint32]map[string][]string, error) { 161 | return nil, ErrNotImplemented 162 | } 163 | func (c *oClient) Mark(ctx context.Context, msgID uint32, seen bool) error { 164 | s, err := c.uidToStr(msgID) 165 | if err != nil { 166 | return err 167 | } 168 | return c.client.Update(ctx, s, map[string]any{ 169 | "IsRead": seen, 170 | }) 171 | } 172 | func (c *oClient) Mailboxes(ctx context.Context, root string) ([]string, error) { 173 | folders, err := c.client.ListFolders(ctx, root) 174 | names := make([]string, len(folders)) 175 | for i, f := range folders { 176 | names[i] = f.Name 177 | } 178 | return names, err 179 | } 180 | 181 | func (c *oClient) WriteTo(ctx context.Context, mbox string, p []byte, date time.Time) error { 182 | m, err := message.Read(bytes.NewReader(p)) 183 | if err != nil { 184 | return err 185 | } 186 | from, _ := mail.ParseAddress(m.Header.Get("From")) 187 | to, _ := parseAddressList(m.Header.Get("To")) 188 | cc, _ := parseAddressList(m.Header.Get("Cc")) 189 | bcc, _ := parseAddressList(m.Header.Get("Bcc")) 190 | replyTo, _ := parseAddressList(m.Header.Get("Reply-To")) 191 | dt, _ := mail.ParseDate(m.Header.Get("Date")) 192 | msg := Message{ 193 | Created: &dt, 194 | From: &Recipient{EmailAddress: EmailAddress{Name: from.Name, Address: from.Address}}, 195 | To: to, Cc: cc, Bcc: bcc, 196 | Subject: m.Header.Get("Subject"), 197 | ID: m.Header.Get("Message-ID"), 198 | ReplyTo: replyTo, 199 | } 200 | var buf bytes.Buffer 201 | m.Walk(func(path []int, ent *message.Entity, err error) error { 202 | if err != nil { 203 | if c.logger != nil { 204 | c.logger.Error("walk", "error", err) 205 | } 206 | return nil 207 | } 208 | buf.Reset() 209 | if _, err = io.Copy(&buf, ent.Body); err != nil && c.logger != nil { 210 | c.logger.Error("read body: %w", err) 211 | } 212 | if msg.Body.Content == "" { 213 | msg.Body.Content = buf.String() 214 | msg.Body.ContentType = ent.Header.Get("Content-Type") 215 | } else { 216 | _, params, _ := mime.ParseMediaType(ent.Header.Get("Content-Disposition")) 217 | _, isInline := params["inline"] 218 | msg.Attachments = append(msg.Attachments, Attachment{ 219 | ContentType: ent.Header.Get("Content-Type"), 220 | Name: nvl(params["filename"], params["name"]), 221 | Size: int32(buf.Len()), 222 | IsInline: isInline, 223 | }) 224 | } 225 | return nil 226 | }) 227 | return c.client.Send(ctx, msg) 228 | } 229 | 230 | func parseAddressList(s string) ([]Recipient, error) { 231 | aa, err := mail.ParseAddressList(s) 232 | rr := make([]Recipient, 0, len(aa)) 233 | for _, a := range aa { 234 | rr = append(rr, Recipient{EmailAddress: EmailAddress{Name: a.Name, Address: a.Address}}) 235 | } 236 | return rr, err 237 | } 238 | 239 | var ErrNotImplemented = errors.New("not implemented") 240 | 241 | func nvl[T comparable](a T, b ...T) T { 242 | var z T 243 | if a != z { 244 | return a 245 | } 246 | for _, a := range b { 247 | if a != z { 248 | return a 249 | } 250 | } 251 | return a 252 | } 253 | -------------------------------------------------------------------------------- /cmd/graph-proxy/uidmap.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "encoding/binary" 10 | "fmt" 11 | "log/slog" 12 | "sync" 13 | "time" 14 | 15 | "github.com/UNO-SOFT/zlog/v2" 16 | "github.com/emersion/go-imap/v2" 17 | "go.etcd.io/bbolt" 18 | "golang.org/x/sync/errgroup" 19 | ) 20 | 21 | var endian = binary.BigEndian 22 | 23 | func newUIDMap(ctx context.Context, fileName string) (*uidMap, error) { 24 | logger := zlog.SFromContext(ctx) 25 | db, err := bbolt.Open(fileName, 0600, nil) 26 | if err != nil { 27 | return nil, fmt.Errorf("open uidMap %q: %w", fileName, err) 28 | } 29 | m := uidMap{db: db} 30 | tx, err := db.Begin(true) 31 | if err != nil { 32 | return nil, err 33 | } 34 | defer tx.Rollback() 35 | key := []byte("V:") 36 | bucket, err := tx.CreateBucketIfNotExists(key) 37 | if err != nil { 38 | return nil, err 39 | } 40 | if b := bucket.Get(key); len(b) != 0 { 41 | m.uidValidity = endian.Uint32(b) 42 | } else { 43 | m.uidValidity = uint32(time.Now().Unix() >> 4) 44 | if err = bucket.Put(key, endian.AppendUint32(nil, m.uidValidity)); err != nil { 45 | return nil, err 46 | } 47 | } 48 | if logger.Enabled(ctx, slog.LevelDebug) { 49 | if err = tx.ForEach(func(name []byte, b *bbolt.Bucket) error { 50 | bName := string(name) 51 | logger := logger.With("bucket", bName) 52 | cur := b.Cursor() 53 | for k, v := cur.First(); len(k) != 0; k, v = cur.Next() { 54 | switch bName[:2] { 55 | case "U:": 56 | logger.Debug("uid2id", "uid", endian.Uint32(k), "id", string(v)) 57 | case "M:": 58 | logger.Debug("id2uid", "id", string(k), "uid", endian.Uint32(v)) 59 | case "V:": 60 | logger.Debug("uidValidity", "k", string(k), "uidValidity", endian.Uint32(v)) 61 | default: 62 | logger.Debug("UNKNOWN", "k", string(k), "v", string(v)) 63 | } 64 | } 65 | return nil 66 | }); err != nil { 67 | return nil, err 68 | } 69 | } 70 | return &m, tx.Commit() 71 | } 72 | 73 | func (m *uidMap) Close() error { 74 | m.mu.Lock() 75 | defer m.mu.Unlock() 76 | db := m.db 77 | m.db = nil 78 | if db != nil { 79 | return db.Close() 80 | } 81 | return nil 82 | } 83 | 84 | // uidMap is a per-folder UID->msgID map 85 | // 86 | // The other way (msgID->UID) is the fnv1 hash of the msgID. 87 | // So the UIDs won't change, but may collide - that's why 88 | // we use a per-folder map, to minimize this risk. 89 | // 90 | // No collision for under 32k mailboxes. 91 | type uidMap struct { 92 | // uid2id map[string]map[imap.UID]string 93 | db *bbolt.DB 94 | mu sync.RWMutex 95 | uidValidity uint32 96 | } 97 | 98 | func (m *uidMap) uidNext(folderID string) imap.UID { 99 | m.mu.RLock() 100 | tx, err := m.db.Begin(false) 101 | if err != nil { 102 | panic(err) 103 | } 104 | defer tx.Rollback() 105 | var n uint32 106 | if U := tx.Bucket([]byte("U:" + folderID)); U != nil { 107 | if k, _ := U.Cursor().Last(); len(k) != 0 { 108 | n = endian.Uint32(k) 109 | } 110 | } 111 | m.mu.RUnlock() 112 | return imap.UID(n + 1) 113 | } 114 | 115 | func (m *uidMap) idOf(folderID string, uid imap.UID) string { 116 | m.mu.RLock() 117 | tx, err := m.db.Begin(false) 118 | if err != nil { 119 | panic(err) 120 | } 121 | defer tx.Rollback() 122 | bucket := tx.Bucket([]byte("U:" + folderID)) 123 | if bucket == nil { 124 | panic("no folderID=" + folderID + "seen yet") 125 | } 126 | s := bucket.Get(endian.AppendUint32(nil, uint32(uid))) 127 | m.mu.RUnlock() 128 | return string(s) 129 | } 130 | 131 | func (m *uidMap) uidOf(folderID, msgID string) imap.UID { 132 | folderM := []byte("M:" + folderID) 133 | { 134 | m.mu.RLock() 135 | tx, err := m.db.Begin(false) 136 | if err != nil { 137 | panic(err) 138 | } 139 | M := tx.Bucket(folderM) 140 | var b []byte 141 | if M != nil { 142 | b = M.Get([]byte(msgID)) 143 | } 144 | tx.Rollback() 145 | m.mu.RUnlock() 146 | if len(b) != 0 { 147 | return imap.UID(endian.Uint32(b)) 148 | } 149 | } 150 | m.mu.Lock() 151 | defer m.mu.Unlock() 152 | tx, err := m.db.Begin(true) 153 | if err != nil { 154 | panic(err) 155 | } 156 | defer tx.Rollback() 157 | M, err := tx.CreateBucketIfNotExists(folderM) 158 | if err != nil { 159 | panic(err) 160 | } 161 | if b := M.Get([]byte(msgID)); len(b) != 0 { 162 | return imap.UID(endian.Uint32(b)) 163 | } 164 | folderU := []byte("U:" + folderID) 165 | U, err := tx.CreateBucketIfNotExists(folderU) 166 | if err != nil { 167 | panic(err) 168 | } 169 | var uid uint32 170 | if k, _ := U.Cursor().Last(); len(k) != 0 { 171 | uid = endian.Uint32(k) 172 | } 173 | uid++ 174 | k := endian.AppendUint32(nil, uid) 175 | if err = M.Put([]byte(msgID), k); err != nil { 176 | panic(err) 177 | } 178 | if err = U.Put(k, []byte(msgID)); err != nil { 179 | panic(err) 180 | } 181 | if err = tx.Commit(); err != nil { 182 | panic(err) 183 | } 184 | return imap.UID(uid) 185 | } 186 | 187 | func (m *uidMap) forNumSet(ctx context.Context, 188 | folderID string, numSet imap.NumSet, full bool, 189 | fetchFolder func(context.Context) error, 190 | f func(context.Context, string) error, 191 | ) error { 192 | if fetchFolder != nil { 193 | if fetched, err := func() (bool, error) { 194 | m.mu.RLock() 195 | defer m.mu.RUnlock() 196 | tx, err := m.db.Begin(false) 197 | if err != nil { 198 | return false, err 199 | } 200 | defer tx.Rollback() 201 | return tx.Bucket([]byte("U:"+folderID)) != nil, nil 202 | }(); err != nil { 203 | return err 204 | } else if !fetched { 205 | if err := fetchFolder(ctx); err != nil { 206 | return err 207 | } 208 | } 209 | } 210 | type pair struct { 211 | MsgID string 212 | UID uint32 213 | } 214 | var ids []pair 215 | if err := func() error { 216 | m.mu.RLock() 217 | defer m.mu.RUnlock() 218 | tx, err := m.db.Begin(false) 219 | if err != nil { 220 | return err 221 | } 222 | defer tx.Rollback() 223 | bucket := tx.Bucket([]byte("U:" + folderID)) 224 | if bucket == nil { 225 | return nil 226 | } 227 | cur := bucket.Cursor() 228 | for k, v := cur.First(); len(k) != 0 && len(v) != 0; k, v = cur.Next() { 229 | if len(k) == 0 || len(v) == 0 { 230 | break 231 | } 232 | ids = append(ids, pair{ 233 | UID: endian.Uint32(k), MsgID: string(v), 234 | }) 235 | } 236 | return nil 237 | }(); err != nil { 238 | return err 239 | } 240 | 241 | var next func() (string, bool) 242 | if ids != nil { 243 | var Contains func(imap.UID) bool 244 | if ss, ok := numSet.(imap.SeqSet); ok { 245 | Contains = func(uid imap.UID) bool { return ss.Contains(uint32(uid)) } 246 | } else if us, ok := numSet.(imap.UIDSet); ok { 247 | Contains = us.Contains 248 | } 249 | next = func() (string, bool) { 250 | for len(ids) != 0 { 251 | p := ids[0] 252 | ids = ids[1:] 253 | if Contains(imap.UID(p.UID)) { 254 | return p.MsgID, len(ids) != 0 255 | } 256 | } 257 | return "", false 258 | } 259 | } else { 260 | if ss, ok := numSet.(imap.SeqSet); ok { 261 | nums, _ := ss.Nums() 262 | next = func() (string, bool) { 263 | for len(nums) != 0 { 264 | n := nums[0] 265 | nums = nums[1:] 266 | if id := m.idOf(folderID, imap.UID(n)); id != "" { 267 | return id, len(nums) != 0 268 | } 269 | } 270 | return "", false 271 | } 272 | } else if us, ok := numSet.(imap.UIDSet); ok { 273 | ur := us[0] 274 | us = us[1:] 275 | first := true 276 | var msgID imap.UID 277 | next = func() (string, bool) { 278 | cont := true 279 | for cont { 280 | if first { 281 | msgID = ur.Start 282 | first = false 283 | } else if msgID >= ur.Stop { 284 | if len(us) == 0 { 285 | cont = false 286 | } else { 287 | ur = us[0] 288 | us = us[1:] 289 | first = true 290 | } 291 | } else { 292 | msgID++ 293 | } 294 | if id := m.idOf(folderID, msgID); id != "" { 295 | return id, cont 296 | } 297 | } 298 | return "", false 299 | } 300 | } 301 | 302 | } 303 | 304 | grp := new(errgroup.Group) 305 | if !full { 306 | grp, ctx = errgroup.WithContext(ctx) 307 | } 308 | grp.SetLimit(16) 309 | for id, cont := next(); cont; id, cont = next() { 310 | if id != "" { 311 | grp.Go(func() error { 312 | return f(ctx, id) 313 | }) 314 | } 315 | if !cont { 316 | break 317 | } 318 | } 319 | return grp.Wait() 320 | } 321 | -------------------------------------------------------------------------------- /v2/o365/graph.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022, 2025 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package o365 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "io" 11 | "log/slog" 12 | "net/textproto" 13 | "strconv" 14 | "strings" 15 | "sync/atomic" 16 | "time" 17 | 18 | "github.com/tgulacsi/imapclient/graph" 19 | "github.com/tgulacsi/imapclient/v2" 20 | ) 21 | 22 | type graphMailClient struct { 23 | graph.GraphMailClient `json:"-"` 24 | 25 | userID string 26 | folders map[string]graph.Folder 27 | u2s map[uint32]string 28 | s2u map[string]uint32 29 | 30 | logger *slog.Logger 31 | 32 | seq uint32 33 | } 34 | 35 | func NewGraphMailClient(ctx context.Context, clientID, tenantID, userID string, credOpts graph.CredentialOptions) (*graphMailClient, error) { 36 | gmc, users, err := graph.NewGraphMailClient(ctx, 37 | tenantID, clientID, credOpts) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | logger := slog.Default() 43 | if strings.IndexByte(userID, '@') >= 0 { 44 | var found bool 45 | for _, u := range users { 46 | logger.Debug("users", "name", u.GetDisplayName(), "mail", u.GetMail()) 47 | if m := u.GetMail(); m != nil && string(*m) == userID { 48 | userID, found = *u.GetId(), true 49 | break 50 | } 51 | } 52 | if !found { 53 | return nil, fmt.Errorf("no user found with %q mail address", userID) 54 | } 55 | } 56 | logger.Debug("NewGraphMailClient", "userID", userID) 57 | return &graphMailClient{ 58 | GraphMailClient: gmc, userID: userID, 59 | logger: logger, 60 | }, nil 61 | } 62 | 63 | var _ imapclient.Client = (*graphMailClient)(nil) 64 | 65 | func (g *graphMailClient) init(ctx context.Context, mbox string) error { 66 | if g.u2s == nil { 67 | g.u2s = make(map[uint32]string) 68 | g.s2u = make(map[string]uint32) 69 | } 70 | if g.folders == nil { 71 | g.folders = make(map[string]graph.Folder) 72 | if folders, err := g.GraphMailClient.ListMailFolders(ctx, g.userID, graph.Query{}); err != nil && len(folders) == 0 { 73 | return err 74 | } else { 75 | for _, f := range folders { 76 | g.folders[strings.ToLower(*f.GetDisplayName())] = f 77 | g.folders["{"+*f.GetId()+"}"] = f 78 | } 79 | } 80 | } 81 | if mbox == "" { 82 | return nil 83 | } 84 | if i := strings.IndexByte(mbox, '/'); i >= 0 { 85 | mbox = mbox[:i] 86 | } 87 | mbox = strings.ToLower(mbox) 88 | 89 | fID, err := g.m2s(mbox) 90 | if err != nil { 91 | return err 92 | } 93 | folders, err := g.GraphMailClient.ListChildFolders(ctx, g.userID, fID, true, graph.Query{}) 94 | if err != nil { 95 | g.logger.Error("ListChildFolders", "userID", g.userID, "folder", fID, "folders", folders, "error", err) 96 | if len(folders) == 0 { 97 | return err 98 | } 99 | } 100 | for _, f := range folders { 101 | g.folders["{"+*f.GetId()+"}"] = f 102 | } 103 | var path []string 104 | for _, f := range folders { 105 | var prefix string 106 | path = path[:0] 107 | if pID := f.GetParentFolderId(); pID != nil && *pID != "" { 108 | for p := g.folders["{"+*pID+"}"]; p.GetParentFolderId() != nil && *p.GetParentFolderId() != ""; p = g.folders["{"+*p.GetParentFolderId()+"}"] { 109 | path = append(path, strings.ToLower(*p.GetDisplayName())) 110 | } 111 | if len(path) != 0 { 112 | // reverse 113 | for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 { 114 | path[i], path[j] = path[j], path[i] 115 | } 116 | prefix = strings.Join(path, "/") + "/" 117 | } 118 | } 119 | g.folders[prefix+strings.ToLower(*f.GetDisplayName())] = f 120 | } 121 | if err != nil { 122 | g.logger.Error("MailFolders.Get", "userID", g.userID, "folder", fID, "error", err) 123 | return fmt.Errorf("MailFolders.Get(%q): %w", g.userID, err) 124 | } 125 | 126 | // https://www.c-sharpcorner.com/article/read-email-from-mailbox-folders-using-microsoft-graph-api/ 127 | return nil 128 | } 129 | func (g *graphMailClient) SetLogger(lgr *slog.Logger) { g.logger = lgr } 130 | func (g *graphMailClient) SetLogMask(imapclient.LogMask) imapclient.LogMask { return false } 131 | func (g *graphMailClient) Close(ctx context.Context, commit bool) error { return ErrNotSupported } 132 | func (g *graphMailClient) Mailboxes(ctx context.Context, root string) ([]string, error) { 133 | if err := g.init(ctx, root); err != nil { 134 | return nil, err 135 | } 136 | folders := make([]string, 0, len(g.folders)) 137 | for k := range g.folders { 138 | if !(len(k) > 2 && k[0] == '{' && k[len(k)-1] == '}') { 139 | folders = append(folders, k) 140 | } 141 | } 142 | return folders, nil 143 | } 144 | func (g *graphMailClient) FetchArgs(ctx context.Context, what string, msgIDs ...uint32) (map[uint32]map[string][]string, error) { 145 | m := make(map[uint32]map[string][]string, len(msgIDs)) 146 | var wantMIME bool 147 | for f := range strings.FieldsSeq(what) { 148 | wantMIME = wantMIME || f == "RFC822.HEADER" || f == "RFC822.SIZE" || f == "RFC822.BODY" 149 | } 150 | var firstErr error 151 | for _, mID := range msgIDs { 152 | s := g.u2s[mID] 153 | if wantMIME { 154 | b, err := g.GraphMailClient.GetMIMEMessage(ctx, g.userID, s) 155 | if err != nil { 156 | return m, fmt.Errorf("GetMIMEMessage(%q): %w", s, err) 157 | } 158 | s := string(b) 159 | i := strings.Index(s, "\r\n\r\n") 160 | m[mID] = map[string][]string{ 161 | "RFC822.HEADER": []string{s[:i+4]}, 162 | "RFC822.BODY": []string{s[i+4:]}, 163 | "RFC822.SIZE": []string{strconv.Itoa(len(s))}, 164 | } 165 | } else { 166 | hdrs, err := g.GraphMailClient.GetMessageHeaders(ctx, g.userID, s) 167 | if err != nil { 168 | g.logger.Error("GetMessageHeaders", "msgID", s, "error", err) 169 | if firstErr == nil { 170 | firstErr = err 171 | } 172 | continue 173 | } 174 | 175 | m[mID] = hdrs 176 | if strings.Contains(what, "RFC822.SIZE") { 177 | m[mID]["RFC822.SIZE"] = []string{"0"} 178 | } 179 | if strings.Contains(what, "RFC822.HEADER") { 180 | var buf strings.Builder 181 | for k, vv := range hdrs { 182 | k = textproto.CanonicalMIMEHeaderKey(k) 183 | for _, v := range vv { 184 | buf.WriteString(k) 185 | buf.WriteString(":\t") 186 | buf.WriteString(v) 187 | buf.WriteString("\r\n") 188 | } 189 | } 190 | buf.WriteString("\r\n") 191 | m[mID]["RFC822.HEADER"] = []string{buf.String()} 192 | } 193 | } 194 | } 195 | if len(m) == 0 { 196 | return nil, firstErr 197 | } 198 | return m, nil 199 | } 200 | func (g *graphMailClient) Peek(ctx context.Context, w io.Writer, msgID uint32, what string) (int64, error) { 201 | return 0, ErrNotImplemented 202 | } 203 | func (g *graphMailClient) Delete(ctx context.Context, msgID uint32) error { 204 | if err := g.init(ctx, ""); err != nil { 205 | return err 206 | } 207 | mID, err := g.m2s("deleted items") 208 | if err != nil { 209 | return err 210 | } 211 | _, err = g.GraphMailClient.MoveMessage(ctx, g.userID, "", g.u2s[msgID], mID) 212 | return err 213 | } 214 | func (g *graphMailClient) Select(ctx context.Context, mbox string) error { 215 | return nil 216 | } 217 | func (g *graphMailClient) Watch(ctx context.Context) ([]uint32, error) { 218 | return nil, ErrNotSupported 219 | } 220 | func (g *graphMailClient) WriteTo(ctx context.Context, mbox string, msg []byte, date time.Time) error { 221 | return ErrNotImplemented 222 | } 223 | func (g *graphMailClient) Connect(ctx context.Context) error { 224 | return g.init(ctx, "") 225 | } 226 | func (g *graphMailClient) Move(ctx context.Context, msgID uint32, mbox string) error { 227 | if err := g.init(ctx, mbox); err != nil { 228 | return err 229 | } 230 | mID, err := g.m2s(mbox) 231 | if err != nil { 232 | return nil 233 | } 234 | _, err = g.GraphMailClient.MoveMessage(ctx, g.userID, "", g.u2s[msgID], mID) 235 | return err 236 | } 237 | func (g *graphMailClient) Mark(ctx context.Context, msgID uint32, seen bool) error { 238 | m := graph.NewMessage() 239 | m.SetIsRead(&seen) 240 | _, err := g.GraphMailClient.UpdateMessage(ctx, g.userID, g.u2s[msgID], m) 241 | return err 242 | } 243 | func (g *graphMailClient) m2s(mbox string) (string, error) { 244 | mbox = strings.ToLower(mbox) 245 | if mf, ok := g.folders[mbox]; ok { 246 | return *mf.GetId(), nil 247 | } 248 | if mf, ok := g.folders["{"+mbox+"}"]; ok { 249 | return *mf.GetId(), nil 250 | } 251 | for k, mf := range g.folders { 252 | if len(k) > 2 && k[0] == '{' && k[len(k)-1] == '}' { 253 | continue 254 | } 255 | if nm := *mf.GetDisplayName(); strings.EqualFold(nm, mbox) || (mbox == "inbox" && nm == "beérkezett üzenetek") { 256 | return *mf.GetId(), nil 257 | } 258 | } 259 | folders := make([]string, 0, len(g.folders)) 260 | for k := range g.folders { 261 | if !(len(k) > 2 && k[0] == '{' && k[len(k)-1] == '}') { 262 | folders = append(folders, k) 263 | } 264 | } 265 | return "", fmt.Errorf("mbox %q not found (have: %+v)", mbox, folders) 266 | } 267 | func (g *graphMailClient) List(ctx context.Context, mbox, pattern string, all bool) ([]uint32, error) { 268 | if err := g.init(ctx, mbox); err != nil { 269 | g.logger.Error("init", "mbox", mbox, "error", err) 270 | return nil, err 271 | } 272 | mID, err := g.m2s(mbox) 273 | if err != nil { 274 | g.logger.Error("m2s", "mbox", mbox, "error", err) 275 | return nil, err 276 | } 277 | query := graph.Query{Filter: "isRead eq false"} 278 | if pattern != "" { 279 | query.Filter += " and contains(subject, " + strings.ReplaceAll(strconv.Quote(pattern), `"`, "'") + ")" 280 | } 281 | msgs, err := g.GraphMailClient.ListMessages(ctx, g.userID, mID, query) 282 | if err != nil { 283 | g.logger.Error("folder", "id", mID, "name", mbox, "query", query, "error", err) 284 | return nil, err 285 | } 286 | g.logger.Debug("folder", "id", mID, "name", mbox, "query", query, "msgs", len(msgs)) 287 | if len(msgs) == 0 { 288 | return nil, nil 289 | } 290 | ids := make([]uint32, 0, len(msgs)) 291 | for _, m := range msgs { 292 | s := *m.GetId() 293 | u, ok := g.s2u[s] 294 | if !ok { 295 | u = atomic.AddUint32(&g.seq, 1) 296 | g.u2s[u] = s 297 | } 298 | ids = append(ids, u) 299 | } 300 | return ids, nil 301 | } 302 | func (g *graphMailClient) ReadTo(ctx context.Context, w io.Writer, msgID uint32) (int64, error) { 303 | b, err := g.GraphMailClient.GetMIMEMessage(ctx, g.userID, g.u2s[msgID]) 304 | n, wErr := w.Write(b) 305 | if wErr != nil && err != nil { 306 | err = wErr 307 | } 308 | return int64(n), err 309 | } 310 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /cmd/graph-proxy/message.go: -------------------------------------------------------------------------------- 1 | /* 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2013 The Go-IMAP Authors 5 | Copyright (c) 2016 Proton Technologies AG 6 | Copyright (c) 2023 Simon Ser 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | */ 26 | 27 | package main 28 | 29 | import ( 30 | "bufio" 31 | "bytes" 32 | "context" 33 | "errors" 34 | "fmt" 35 | "io" 36 | "log/slog" 37 | "mime" 38 | "regexp" 39 | "strings" 40 | "sync" 41 | "time" 42 | 43 | "github.com/emersion/go-imap/v2" 44 | "github.com/emersion/go-imap/v2/imapserver" 45 | gomessage "github.com/emersion/go-message" 46 | _ "github.com/emersion/go-message/charset" 47 | "github.com/emersion/go-message/mail" 48 | "github.com/emersion/go-message/textproto" 49 | "github.com/tgulacsi/go/iohlp" 50 | ) 51 | 52 | type message struct { 53 | t time.Time 54 | 55 | GetBuf func() ([]byte, error) 56 | Header textproto.Header 57 | Flags map[imap.Flag]struct{} 58 | Buf []byte 59 | UID imap.UID 60 | } 61 | 62 | func (msg *message) getBuf() ([]byte, error) { 63 | var err error 64 | if len(msg.Buf) == 0 && msg.GetBuf != nil { 65 | msg.Buf, err = msg.GetBuf() 66 | msg.GetBuf = nil 67 | } 68 | return msg.Buf, err 69 | } 70 | 71 | func (msg *message) getHeader() (textproto.Header, error) { 72 | if msg.Header.Len() == 0 { 73 | buf, err := msg.getBuf() 74 | if err != nil { 75 | return msg.Header, err 76 | } 77 | br := bufio.NewReader(bytes.NewReader(buf)) 78 | if msg.Header, err = textproto.ReadHeader(br); err != nil { 79 | return msg.Header, err 80 | } 81 | } 82 | return msg.Header, nil 83 | } 84 | 85 | func (msg *message) fetch(w *imapserver.FetchResponseWriter, options *imap.FetchOptions) error { 86 | header, err := msg.getHeader() 87 | if err != nil { 88 | return err 89 | } 90 | 91 | w.WriteUID(msg.UID) 92 | defer w.Close() 93 | 94 | if options.Flags { 95 | w.WriteFlags(msg.flagList()) 96 | } 97 | if options.InternalDate { 98 | if msg.t.IsZero() { 99 | msg.t = parseDate(header.Get("Date")) 100 | } 101 | w.WriteInternalDate(msg.t) 102 | } 103 | if options.RFC822Size { 104 | buf, err := msg.getBuf() 105 | if err != nil { 106 | return err 107 | } 108 | w.WriteRFC822Size(int64(len(buf))) 109 | } 110 | if options.Envelope { 111 | env, err := getEnvelope(header) 112 | if err != nil { 113 | return err 114 | } 115 | w.WriteEnvelope(env) 116 | } 117 | if bs := options.BodyStructure; bs != nil { 118 | bs, err := msg.bodyStructure(bs.Extended) 119 | if err != nil { 120 | return err 121 | } 122 | w.WriteBodyStructure(bs) 123 | } 124 | 125 | for _, bs := range options.BodySection { 126 | buf := msg.bodySection(bs) 127 | wc := w.WriteBodySection(bs, int64(len(buf))) 128 | _, writeErr := wc.Write(buf) 129 | closeErr := wc.Close() 130 | if writeErr != nil { 131 | return writeErr 132 | } 133 | if closeErr != nil { 134 | return closeErr 135 | } 136 | } 137 | 138 | // TODO: BinarySection, BinarySectionSize 139 | 140 | return w.Close() 141 | } 142 | 143 | func (msg *message) bodyStructure(extended bool) (imap.BodyStructure, error) { 144 | header, err := msg.getHeader() 145 | if err != nil { 146 | return nil, err 147 | } 148 | buf, err := msg.getBuf() 149 | if err != nil { 150 | return nil, err 151 | } 152 | return getBodyStructure(header, bytes.NewReader(buf), extended) 153 | } 154 | 155 | func openMessagePart(header textproto.Header, body io.Reader, parentMediaType string) (textproto.Header, io.Reader) { 156 | msgHeader := gomessage.Header{Header: header} 157 | mediaType, _, _ := msgHeader.ContentType() 158 | if !msgHeader.Has("Content-Type") && parentMediaType == "multipart/digest" { 159 | mediaType = "message/rfc822" 160 | } 161 | if mediaType == "message/rfc822" || mediaType == "message/global" { 162 | br := bufio.NewReader(body) 163 | header, _ = textproto.ReadHeader(br) 164 | return header, br 165 | } 166 | return header, body 167 | } 168 | 169 | func (msg *message) bodySection(item *imap.FetchItemBodySection) []byte { 170 | var ( 171 | header textproto.Header 172 | body io.Reader 173 | ) 174 | 175 | b, _ := msg.getBuf() 176 | br := bufio.NewReader(bytes.NewReader(b)) 177 | header, err := textproto.ReadHeader(br) 178 | if err != nil { 179 | return nil 180 | } 181 | body = br 182 | 183 | // First part of non-multipart message refers to the message itself 184 | msgHeader := gomessage.Header{Header: header} 185 | mediaType, _, _ := msgHeader.ContentType() 186 | partPath := item.Part 187 | if !strings.HasPrefix(mediaType, "multipart/") && len(partPath) > 0 && partPath[0] == 1 { 188 | partPath = partPath[1:] 189 | } 190 | 191 | // Find the requested part using the provided path 192 | var parentMediaType string 193 | for i := 0; i < len(partPath); i++ { 194 | partNum := partPath[i] 195 | 196 | header, body = openMessagePart(header, body, parentMediaType) 197 | 198 | msgHeader := gomessage.Header{Header: header} 199 | mediaType, typeParams, _ := msgHeader.ContentType() 200 | if !strings.HasPrefix(mediaType, "multipart/") { 201 | if partNum != 1 { 202 | return nil 203 | } 204 | continue 205 | } 206 | 207 | mr := textproto.NewMultipartReader(body, typeParams["boundary"]) 208 | found := false 209 | for j := 1; j <= partNum; j++ { 210 | p, err := mr.NextPart() 211 | if err != nil { 212 | return nil 213 | } 214 | 215 | if j == partNum { 216 | parentMediaType = mediaType 217 | header = p.Header 218 | body = p 219 | found = true 220 | break 221 | } 222 | } 223 | if !found { 224 | return nil 225 | } 226 | } 227 | 228 | if len(item.Part) > 0 { 229 | switch item.Specifier { 230 | case imap.PartSpecifierHeader, imap.PartSpecifierText: 231 | header, body = openMessagePart(header, body, parentMediaType) 232 | } 233 | } 234 | 235 | // Filter header fields 236 | if len(item.HeaderFields) > 0 { 237 | keep := make(map[string]struct{}) 238 | for _, k := range item.HeaderFields { 239 | keep[strings.ToLower(k)] = struct{}{} 240 | } 241 | for field := header.Fields(); field.Next(); { 242 | if _, ok := keep[strings.ToLower(field.Key())]; !ok { 243 | field.Del() 244 | } 245 | } 246 | } 247 | for _, k := range item.HeaderFieldsNot { 248 | header.Del(k) 249 | } 250 | 251 | // Write the requested data to a buffer 252 | var buf bytes.Buffer 253 | 254 | writeHeader := true 255 | switch item.Specifier { 256 | case imap.PartSpecifierNone: 257 | writeHeader = len(item.Part) == 0 258 | case imap.PartSpecifierText: 259 | writeHeader = false 260 | } 261 | if writeHeader { 262 | if err := textproto.WriteHeader(&buf, header); err != nil { 263 | return nil 264 | } 265 | } 266 | 267 | switch item.Specifier { 268 | case imap.PartSpecifierNone, imap.PartSpecifierText: 269 | if _, err := io.Copy(&buf, body); err != nil { 270 | return nil 271 | } 272 | } 273 | 274 | // Extract partial if any 275 | b = buf.Bytes() 276 | if partial := item.Partial; partial != nil { 277 | end := partial.Offset + partial.Size 278 | if partial.Offset > int64(len(b)) { 279 | return nil 280 | } 281 | if end > int64(len(b)) { 282 | end = int64(len(b)) 283 | } 284 | b = b[partial.Offset:end] 285 | } 286 | return b 287 | } 288 | 289 | func (msg *message) flagList() []imap.Flag { 290 | var flags []imap.Flag 291 | for flag := range msg.Flags { 292 | flags = append(flags, flag) 293 | } 294 | return flags 295 | } 296 | 297 | func getEnvelope(h textproto.Header) (*imap.Envelope, error) { 298 | mh := mail.Header{Header: gomessage.Header{Header: h}} 299 | date, _ := mh.Date() 300 | inReplyTo, _ := mh.MsgIDList("In-Reply-To") 301 | messageID, err := mh.MessageID() 302 | if err != nil { 303 | slog.Warn("MessageID", "msgID", messageID, "error", err, "headers", mh) 304 | messageID, _, _ = strings.Cut(messageID, " ") 305 | err = nil 306 | } 307 | // messageID, _, _ = strings.Cut(messageID, " ") 308 | return &imap.Envelope{ 309 | Date: date, 310 | Subject: h.Get("Subject"), 311 | From: parseAddressList(mh, "From"), 312 | Sender: parseAddressList(mh, "Sender"), 313 | ReplyTo: parseAddressList(mh, "Reply-To"), 314 | To: parseAddressList(mh, "To"), 315 | Cc: parseAddressList(mh, "Cc"), 316 | Bcc: parseAddressList(mh, "Bcc"), 317 | InReplyTo: inReplyTo, 318 | MessageID: messageID, 319 | }, err 320 | } 321 | 322 | var ( 323 | rSpaceInAnglesMu sync.Mutex 324 | rSpaceInAngles *regexp.Regexp 325 | ) 326 | 327 | func parseAddressList(mh mail.Header, k string) []imap.Address { 328 | // TODO: leave the quoted words unchanged 329 | // TODO: handle groups 330 | addrs, err := mh.AddressList(k) 331 | if err != nil { 332 | raw, _ := mh.Header.Header.Raw(k) 333 | if slog.Default().Enabled(context.Background(), slog.LevelDebug) { 334 | slog.Debug("parseAddressList", "k", k, "raw", string(raw), "error", err) 335 | } 336 | rSpaceInAnglesMu.Lock() 337 | if rSpaceInAngles == nil { 338 | rSpaceInAngles = regexp.MustCompile("<[^>]* [^>]*>") 339 | } 340 | raw = rSpaceInAngles.ReplaceAllFunc(raw, func(b []byte) []byte { 341 | return bytes.ReplaceAll(b, []byte{' '}, nil) 342 | }) 343 | rSpaceInAnglesMu.Unlock() 344 | // raw = bytes.ReplaceAll(raw, []byte("\r\n"), nil) 345 | mh2 := mail.Header{} 346 | mh2.AddRaw(raw) 347 | raw, _ = mh2.Header.Header.Raw(k) 348 | if addrs, err = mh2.AddressList(k); err != nil { 349 | slog.Error("parseAddressList2", "k", k, "raw", string(raw), "error", err) 350 | } 351 | } 352 | var l []imap.Address 353 | for _, addr := range addrs { 354 | mailbox, host, ok := strings.Cut(addr.Address, "@") 355 | if !ok { 356 | continue 357 | } 358 | l = append(l, imap.Address{ 359 | Name: mime.QEncoding.Encode("utf-8", addr.Name), 360 | Mailbox: mailbox, 361 | Host: host, 362 | }) 363 | } 364 | return l 365 | } 366 | 367 | func getBodyStructure(rawHeader textproto.Header, r io.Reader, extended bool) (imap.BodyStructure, error) { 368 | header := gomessage.Header{Header: rawHeader} 369 | 370 | mediaType, typeParams, err := header.ContentType() 371 | if err != nil { 372 | return nil, fmt.Errorf("Content-Type of %+v: %w", header, err) 373 | } 374 | primaryType, subType, _ := strings.Cut(mediaType, "/") 375 | 376 | if primaryType == "multipart" && typeParams["boundary"] != "" { 377 | bs := &imap.BodyStructureMultiPart{Subtype: subType} 378 | mr := textproto.NewMultipartReader(r, typeParams["boundary"]) 379 | for { 380 | part, err := mr.NextPart() 381 | if err != nil { 382 | if errors.Is(err, io.EOF) { 383 | break 384 | } 385 | return bs, err 386 | } 387 | child, err := getBodyStructure(part.Header, part, extended) 388 | bs.Children = append(bs.Children, child) 389 | if err != nil { 390 | return bs, err 391 | } 392 | } 393 | if extended { 394 | bs.Extended = &imap.BodyStructureMultiPartExt{ 395 | Params: typeParams, 396 | Disposition: getContentDisposition(header), 397 | Language: getContentLanguage(header), 398 | Location: header.Get("Content-Location"), 399 | } 400 | } 401 | return bs, nil 402 | } 403 | 404 | sr, err := iohlp.MakeSectionReader(r, 1<<20) 405 | if err != nil { 406 | return nil, err 407 | } 408 | bs := &imap.BodyStructureSinglePart{ 409 | Type: primaryType, 410 | Subtype: subType, 411 | Params: typeParams, 412 | ID: header.Get("Content-Id"), 413 | Description: header.Get("Content-Description"), 414 | Encoding: header.Get("Content-Transfer-Encoding"), 415 | Size: uint32(sr.Size()), 416 | } 417 | if mediaType == "message/rfc822" || mediaType == "message/global" { 418 | br := bufio.NewReader(io.NewSectionReader(sr, 0, sr.Size())) 419 | childHeader, _ := textproto.ReadHeader(br) 420 | bs.MessageRFC822 = &imap.BodyStructureMessageRFC822{ 421 | NumLines: countLines(io.NewSectionReader(sr, 0, sr.Size())), 422 | } 423 | if bs.MessageRFC822.Envelope, err = getEnvelope(childHeader); err != nil { 424 | return bs, err 425 | } 426 | if bs.MessageRFC822.BodyStructure, err = getBodyStructure(childHeader, br, extended); err != nil { 427 | return bs, err 428 | } 429 | } 430 | if primaryType == "text" { 431 | bs.Text = &imap.BodyStructureText{ 432 | NumLines: countLines(io.NewSectionReader(sr, 0, sr.Size())), 433 | } 434 | } 435 | if extended { 436 | bs.Extended = &imap.BodyStructureSinglePartExt{ 437 | Disposition: getContentDisposition(header), 438 | Language: getContentLanguage(header), 439 | Location: header.Get("Content-Location"), 440 | } 441 | } 442 | return bs, nil 443 | } 444 | 445 | func countLines(sr *io.SectionReader) int64 { 446 | var count int64 447 | for off := int64(0); off < sr.Size(); { 448 | var a [4096]byte 449 | n, err := sr.ReadAt(a[:], off) 450 | off += int64(n) 451 | count += int64(bytes.Count(a[:n], []byte("\n"))) 452 | if err != nil { 453 | break 454 | } 455 | } 456 | return count 457 | } 458 | 459 | func getContentDisposition(header gomessage.Header) *imap.BodyStructureDisposition { 460 | disp, dispParams, _ := header.ContentDisposition() 461 | if disp == "" { 462 | return nil 463 | } 464 | return &imap.BodyStructureDisposition{ 465 | Value: disp, 466 | Params: dispParams, 467 | } 468 | } 469 | 470 | func getContentLanguage(header gomessage.Header) []string { 471 | v := header.Get("Content-Language") 472 | if v == "" { 473 | return nil 474 | } 475 | // TODO: handle CFWS 476 | l := strings.Split(v, ",") 477 | for i, lang := range l { 478 | l[i] = strings.TrimSpace(lang) 479 | } 480 | return l 481 | } 482 | 483 | func parseDate(s string) time.Time { 484 | if s == "" { 485 | return time.Time{} 486 | } 487 | for _, pat := range []string{time.RFC1123Z, time.RFC1123, time.RFC850, time.RFC822Z, time.RFC822, time.RFC3339} { 488 | if t, err := time.Parse(pat, s); err == nil { 489 | return t 490 | } 491 | } 492 | return time.Time{} 493 | } 494 | -------------------------------------------------------------------------------- /o365/o365.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | // Package o365 implements an imap client, using Office 365 Mail REST API. 6 | package o365 7 | 8 | import ( 9 | "bytes" 10 | "encoding/json" 11 | "fmt" 12 | "io" 13 | "log" 14 | "net/http" 15 | "net/url" 16 | "os" 17 | "path/filepath" 18 | "strings" 19 | "time" 20 | 21 | "golang.org/x/net/context" 22 | "golang.org/x/oauth2" 23 | 24 | "github.com/tgulacsi/oauth2client" 25 | ) 26 | 27 | var Log = func(keyvals ...any) error { 28 | log.Println(keyvals...) 29 | return nil 30 | } 31 | 32 | const baseURL = "https://outlook.office.com/api/v2.0" 33 | 34 | type client struct { 35 | *oauth2.Config 36 | oauth2.TokenSource 37 | Me string 38 | } 39 | 40 | type clientOptions struct { 41 | TokensFile string 42 | TLSCertFile, TLSKeyFile string 43 | Impersonate string 44 | ReadOnly bool 45 | } 46 | type ClientOption func(*clientOptions) 47 | 48 | func ReadOnly(readOnly bool) ClientOption { return func(o *clientOptions) { o.ReadOnly = readOnly } } 49 | func TokensFile(file string) ClientOption { return func(o *clientOptions) { o.TokensFile = file } } 50 | func TLS(certFile, keyFile string) ClientOption { 51 | return func(o *clientOptions) { o.TLSCertFile, o.TLSKeyFile = certFile, keyFile } 52 | } 53 | func Impersonate(email string) ClientOption { return func(o *clientOptions) { o.Impersonate = email } } 54 | 55 | func NewClient(clientID, clientSecret, redirectURL string, options ...ClientOption) *client { 56 | if clientID == "" || clientSecret == "" { 57 | panic("clientID and clientSecret is a must!") 58 | } 59 | if redirectURL == "" { 60 | redirectURL = "http://localhost:8123" 61 | } 62 | var opts clientOptions 63 | for _, f := range options { 64 | f(&opts) 65 | } 66 | var sWrite string 67 | if !opts.ReadOnly { 68 | sWrite = "write" 69 | } 70 | if opts.TLSCertFile != "" && opts.TLSKeyFile != "" && strings.HasPrefix(redirectURL, "http://") { 71 | redirectURL = "https" + redirectURL[4:] 72 | } 73 | 74 | conf := &oauth2.Config{ 75 | ClientID: clientID, 76 | ClientSecret: clientSecret, 77 | RedirectURL: redirectURL, 78 | Scopes: []string{ 79 | "https://outlook.office.com/mail.read" + sWrite, 80 | "offline_access", 81 | }, 82 | Endpoint: oauth2client.AzureV2Endpoint, 83 | } 84 | 85 | tokensFile := opts.TokensFile 86 | if tokensFile == "" { 87 | tokensFile = filepath.Join(os.Getenv("HOME"), ".config", "o365.conf") 88 | } 89 | if opts.Impersonate == "" { 90 | opts.Impersonate = "me" 91 | } 92 | return &client{ 93 | Config: conf, 94 | Me: opts.Impersonate, 95 | TokenSource: oauth2client.NewTokenSource(conf, tokensFile, opts.TLSCertFile, opts.TLSKeyFile), 96 | } 97 | } 98 | 99 | type Attachment struct { 100 | // The date and time when the attachment was last modified. The date and time use ISO 8601 format and is always in UTC time. For example, midnight UTC on Jan 1, 2014 would look like this: '2014-01-01T00:00:00Z' 101 | LastModifiedDateTime time.Time 102 | // The MIME type of the attachment. 103 | ContentType string `json:",omitempty"` 104 | // The display name of the attachment. This does not need to be the actual file name. 105 | Name string `json:",omitempty"` 106 | // The length of the attachment in bytes. 107 | Size int32 `json:",omitempty"` 108 | // true if the attachment is an inline attachment; otherwise, false. 109 | IsInline bool `json:",omitempty"` 110 | } 111 | 112 | type Recipient struct { 113 | EmailAddress EmailAddress 114 | } 115 | 116 | type EmailAddress struct { 117 | Name, Address string `json:",omitempty"` 118 | } 119 | 120 | type ItemBody struct { 121 | // The content type: Text = 0, HTML = 1. 122 | ContentType string `json:",omitempty"` 123 | // The text or HTML content. 124 | Content string `json:",omitempty"` 125 | } 126 | 127 | type Importance string 128 | type InferenceClassificationType string 129 | type SingleValueLegacyExtendedProperty struct { 130 | // The property ID. This is used to identify the property. 131 | PropertyID string `json:"PropertyId,omitempty"` 132 | // A property values. 133 | Value string `json:",omitempty"` 134 | } 135 | type MultiValueLegacyExtendedProperty struct { 136 | // The property ID. This is used to identify the property. 137 | PropertyID string `json:"PropertyId,omitempty"` 138 | // A collection of property values. 139 | Value []string `json:",omitempty"` 140 | } 141 | 142 | // https://msdn.microsoft.com/en-us/office/office365/api/complex-types-for-mail-contacts-calendar#MessageResource 143 | // The fields last word designates the Writable/Filterable/Searchable property of the field. 144 | type Message struct { 145 | // The date and time the message was created. 146 | // -F- 147 | Created *time.Time `json:"CreatedDateTime,omitempty"` 148 | // The date and time the message was last changed. 149 | // -F- 150 | LastModified *time.Time `json:"LastModifiedDateTime,omitempty"` 151 | // The date and time the message was sent. 152 | // -F- 153 | Sent *time.Time `json:"SentDateTime,omitempty"` 154 | // The date and time the message was received. 155 | // -FS 156 | Received *time.Time `json:"ReceivedDateTime,omitempty"` 157 | // A collection of multi-value extended properties of type MultiValueLegacyExtendedProperty. This is a navigation property. Find more information about extended properties. 158 | // WF- 159 | MultiValueExtendedProperties *MultiValueLegacyExtendedProperty `json:",omitempty"` 160 | // A collection of single-value extended properties of type SingleValueLegacyExtendedProperty. This is a navigation property. Find more information about extended properties. 161 | // WF- 162 | SingleValueExtendedProperties *SingleValueLegacyExtendedProperty `json:",omitempty"` 163 | // The mailbox owner and sender of the message. 164 | // WFS 165 | From *Recipient `json:",omitempty"` 166 | // The account that is actually used to generate the message. 167 | // WF- 168 | Sender *Recipient `json:",omitempty"` 169 | // The body of the message that is unique to the conversation. 170 | // --- 171 | UniqueBody *ItemBody `json:",omitempty"` 172 | // The body of the message. 173 | // W-- 174 | Body ItemBody 175 | // The importance of the message: Low = 0, Normal = 1, High = 2. 176 | // WFS 177 | Importance Importance `json:",omitempty"` 178 | 179 | // The classification of this message for the user, based on inferred relevance or importance, or on an explicit override. 180 | // WFS 181 | InferenceClassification InferenceClassificationType `json:",omitempty"` 182 | // The version of the message. 183 | // --- 184 | ChangeKey string `json:",omitempty"` 185 | // The ID of the conversation the email belongs to. 186 | // -F- 187 | ConversationID string `json:"ConversationId,omitempty"` 188 | // The unique identifier of the message. 189 | // --- 190 | ID string `json:"Id,omitempty"` 191 | // The unique identifier for the message's parent folder. 192 | // --- 193 | ParentFolderID string `json:"ParentFolderId,omitempty"` 194 | // The subject of the message. 195 | // WF- 196 | Subject string `json:",omitempty"` 197 | // The URL to open the message in Outlook Web App. 198 | // You can append an ispopout argument to the end of the URL to change how the message is displayed. If ispopout is not present or if it is set to 1, then the message is shown in a popout window. If ispopout is set to 0, then the browser will show the message in the Outlook Web App review pane. 199 | // The message will open in the browser if you are logged in to your mailbox via Outlook Web App. You will be prompted to login if you are not already logged in with the browser. 200 | // This URL can be accessed from within an iFrame. 201 | // -F- 202 | WebLink string `json:",omitempty"` 203 | // The first 255 characters of the message body content. 204 | // --S 205 | BodyPreview string `json:",omitempty"` 206 | 207 | // The Bcc recipients for the message. 208 | // W-S 209 | Bcc []Recipient `json:"BccRecipients,omitempty"` 210 | // The email addresses to use when replying. 211 | // --- 212 | ReplyTo []Recipient `json:",omitempty"` 213 | // The To recipients for the message. 214 | // W-S 215 | To []Recipient `json:"ToRecipients,omitempty"` 216 | // The Cc recipients for the message. 217 | // W-S 218 | Cc []Recipient `json:"CcRecipients,omitempty"` 219 | 220 | // The FileAttachment and ItemAttachment attachments for the message. Navigation property. 221 | // W-S 222 | Attachments []Attachment `json:",omitempty"` 223 | 224 | // The categories associated with the message. 225 | // WFS 226 | Categories []string `json:",omitempty"` 227 | // The collection of open type data extensions defined for the message. Navigation property. 228 | // -F- 229 | Extensions []string `json:",omitempty"` 230 | 231 | // Indicates whether the message has attachments. 232 | // -FS 233 | HasAttachments bool `json:",omitempty"` 234 | // Indicates whether a read receipt is requested for the message. 235 | // WF- 236 | IsDeliveryReceiptRequested bool `json:",omitempty"` 237 | // Indicates whether the message is a draft. A message is a draft if it hasn't been sent yet. 238 | // -F- 239 | IsDraft bool `json:",omitempty"` 240 | // Indicates whether the message has been read. 241 | // WF- 242 | IsRead bool `json:",omitempty"` 243 | // Indicates whether a read receipt is requested for the message. 244 | // WF- 245 | IsReadReceiptRequested bool `json:",omitempty"` 246 | } 247 | 248 | func (c *client) List(ctx context.Context, mbox, pattern string, all bool) ([]Message, error) { 249 | path := "/messages" 250 | if mbox != "" { 251 | path = "/MailFolders/" + mbox + "/messages" 252 | } 253 | 254 | values := url.Values{ 255 | "$select": {"Sender,Subject"}, 256 | } 257 | if pattern != "" { 258 | values.Set("$search", `"subject:`+pattern+`"`) 259 | } 260 | if !all { 261 | values.Set("$filter", "IsRead eq false") 262 | } 263 | 264 | body, err := c.get(ctx, path+"?"+values.Encode()) 265 | if err != nil { 266 | return nil, err 267 | } 268 | defer func() { 269 | io.Copy(io.Discard, body) 270 | body.Close() 271 | }() 272 | 273 | type listResponse struct { 274 | Value []Message `json:"value"` 275 | } 276 | var resp listResponse 277 | err = json.NewDecoder(body).Decode(&resp) 278 | return resp.Value, err 279 | } 280 | 281 | func (c *client) Get(ctx context.Context, msgID string) (Message, error) { 282 | path := "/messages/" + msgID 283 | var msg Message 284 | body, err := c.get(ctx, path) 285 | if err != nil { 286 | return msg, err 287 | } 288 | defer func() { 289 | io.Copy(io.Discard, body) 290 | body.Close() 291 | }() 292 | err = json.NewDecoder(body).Decode(&msg) 293 | return msg, err 294 | } 295 | 296 | func (c *client) Send(ctx context.Context, msg Message) error { 297 | var buf bytes.Buffer 298 | if err := json.NewEncoder(&buf).Encode(struct { 299 | Message Message 300 | }{Message: msg}); err != nil { 301 | return fmt.Errorf("encode %#v: %w", msg, err) 302 | } 303 | path := "/sendmail" 304 | return c.post(ctx, path, bytes.NewReader(buf.Bytes())) 305 | } 306 | 307 | func (c *client) post(ctx context.Context, path string, body io.Reader) error { 308 | rc, err := c.p(ctx, "POST", path, body) 309 | if rc != nil { 310 | rc.Close() 311 | } 312 | return err 313 | } 314 | func (c *client) p(ctx context.Context, method, path string, body io.Reader) (io.ReadCloser, error) { 315 | if method == "" { 316 | method = "POST" 317 | } 318 | var buf bytes.Buffer 319 | req, err := http.NewRequest(method, c.URLFor(path), io.TeeReader(body, &buf)) 320 | req.Header.Set("Content-Type", "application/json") 321 | if err != nil { 322 | return nil, fmt.Errorf("%s: %w", path, err) 323 | } 324 | resp, err := oauth2.NewClient(ctx, c.TokenSource).Do(req) 325 | if err != nil { 326 | return nil, fmt.Errorf("%s: %w", buf.String(), err) 327 | } 328 | if resp.StatusCode > 299 { 329 | defer resp.Body.Close() 330 | io.Copy(&buf, body) 331 | io.WriteString(&buf, "\n\n") 332 | io.Copy(&buf, resp.Body) 333 | return nil, fmt.Errorf("POST %q: %s\n%s", path, resp.Status, buf.Bytes()) 334 | } 335 | return resp.Body, nil 336 | } 337 | 338 | func (c *client) Delete(ctx context.Context, msgID string) error { 339 | return c.delete(ctx, "/messages/"+msgID) 340 | } 341 | 342 | func (c *client) Move(ctx context.Context, msgID, destinationID string) error { 343 | return c.post(ctx, "/messages/"+msgID+"/move", bytes.NewReader(jsonObj("DestinationId", destinationID))) 344 | } 345 | func (c *client) Copy(ctx context.Context, msgID, destinationID string) error { 346 | return c.post(ctx, "/messages/"+msgID+"/copy", bytes.NewReader(jsonObj("DestinationId", destinationID))) 347 | } 348 | 349 | func (c *client) Update(ctx context.Context, msgID string, upd map[string]any) error { 350 | var buf bytes.Buffer 351 | if err := json.NewEncoder(&buf).Encode(upd); err != nil { 352 | return fmt.Errorf("%#v: %w", upd, err) 353 | } 354 | body, err := c.p(ctx, "PATCH", "/messages/"+msgID, bytes.NewReader(buf.Bytes())) 355 | if body != nil { 356 | body.Close() 357 | } 358 | if err != nil { 359 | return fmt.Errorf("%#v: %w", upd, err) 360 | } 361 | return nil 362 | } 363 | 364 | type Folder struct { 365 | ID string `json:"Id"` 366 | Name string `json:"DisplayName"` 367 | ParentID string `json:"ParentFolderId,omitempty"` 368 | ChildCount uint32 `json:"ChildFolderCount,omitempty"` 369 | UnreadCount uint32 `json:"UnreadItemCount,omitempty"` 370 | TotalCount uint32 `json:"TotalItemCount,omitempty"` 371 | } 372 | 373 | func (c *client) ListFolders(ctx context.Context, parent string) ([]Folder, error) { 374 | path := "/MailFolders" 375 | if parent != "" { 376 | path += "/" + parent + "/childfolders" 377 | } 378 | body, err := c.get(ctx, path) 379 | if body != nil { 380 | defer func() { 381 | io.Copy(io.Discard, body) 382 | body.Close() 383 | }() 384 | } 385 | if err != nil { 386 | return nil, err 387 | } 388 | 389 | type folderList struct { 390 | Value []Folder `json:"value"` 391 | } 392 | var resp folderList 393 | err = json.NewDecoder(body).Decode(&resp) 394 | return resp.Value, err 395 | } 396 | 397 | func (c *client) CreateFolder(ctx context.Context, parent, folder string) error { 398 | return c.post(ctx, "/MailFolders/"+parent+"/childfolders", bytes.NewReader(jsonObj("DisplayName", folder))) 399 | } 400 | 401 | func (c *client) RenameFolder(ctx context.Context, folderID, newName string) error { 402 | return c.post(ctx, "/MailFolders/"+folderID, bytes.NewReader(jsonObj("DisplayName", newName))) 403 | } 404 | func (c *client) MoveFolder(ctx context.Context, folderID, destinationID string) error { 405 | return c.post(ctx, "/MailFolders/"+folderID+"/move", bytes.NewReader(jsonObj("DestinationId", destinationID))) 406 | } 407 | func (c *client) CopyFolder(ctx context.Context, folderID, destinationID string) error { 408 | return c.post(ctx, "/MailFolders/"+folderID+"/copy", bytes.NewReader(jsonObj("DestinationId", destinationID))) 409 | } 410 | 411 | func (c *client) DeleteFolder(ctx context.Context, folderID string) error { 412 | return c.delete(ctx, "/MailFolders/"+folderID) 413 | } 414 | 415 | func (c *client) URLFor(path string) string { return baseURL + "/" + c.Me + path } 416 | func (c *client) get(ctx context.Context, path string) (io.ReadCloser, error) { 417 | URL := c.URLFor(path) 418 | Log("get", URL) 419 | resp, err := oauth2.NewClient(ctx, c.TokenSource).Get(URL) 420 | Log("resp", resp, "error", err) 421 | if err != nil { 422 | return nil, fmt.Errorf("%s: %w", path, err) 423 | } 424 | return resp.Body, nil 425 | } 426 | 427 | func (c *client) delete(ctx context.Context, path string) error { 428 | req, err := http.NewRequest("DELETE", c.URLFor(path), nil) 429 | if err != nil { 430 | return fmt.Errorf("%s: %w", path, err) 431 | } 432 | resp, err := oauth2.NewClient(ctx, c.TokenSource).Do(req) 433 | if err != nil { 434 | return fmt.Errorf("%s: %w", req.URL.String(), err) 435 | } 436 | if resp.StatusCode > 299 { 437 | return fmt.Errorf("DELETE %q: %s", path, resp.Status) 438 | } 439 | return nil 440 | } 441 | 442 | func jsonObj(key, value string) []byte { 443 | b, err := json.Marshal(map[string]string{key: value}) 444 | if err != nil { 445 | panic(err) 446 | } 447 | return b 448 | } 449 | -------------------------------------------------------------------------------- /v2/o365/o365.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021, 2023 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | // Package o365 implements an imap client, using Office 365 Mail REST API. 6 | package o365 7 | 8 | import ( 9 | "bytes" 10 | "encoding/json" 11 | "fmt" 12 | "io" 13 | "log/slog" 14 | "net/http" 15 | "net/url" 16 | "os" 17 | "path/filepath" 18 | "strings" 19 | "time" 20 | 21 | "golang.org/x/net/context" 22 | "golang.org/x/oauth2" 23 | 24 | "github.com/AzureAD/microsoft-authentication-library-for-go/apps/confidential" 25 | "github.com/tgulacsi/oauth2client" 26 | ) 27 | 28 | const baseURL = "https://outlook.office.com/api/v2.0" 29 | 30 | type client struct { 31 | *oauth2.Config 32 | oauth2.TokenSource 33 | logger *slog.Logger 34 | Me string 35 | } 36 | 37 | type clientOptions struct { 38 | TokensFile string 39 | TLSCertFile, TLSKeyFile string 40 | Impersonate string 41 | TenantID string 42 | ReadOnly bool 43 | } 44 | type ClientOption func(*clientOptions) 45 | 46 | func ReadOnly(readOnly bool) ClientOption { return func(o *clientOptions) { o.ReadOnly = readOnly } } 47 | func TokensFile(file string) ClientOption { return func(o *clientOptions) { o.TokensFile = file } } 48 | func TenantID(tenantID string) ClientOption { return func(o *clientOptions) { o.TenantID = tenantID } } 49 | func TLS(certFile, keyFile string) ClientOption { 50 | return func(o *clientOptions) { o.TLSCertFile, o.TLSKeyFile = certFile, keyFile } 51 | } 52 | func Impersonate(email string) ClientOption { return func(o *clientOptions) { o.Impersonate = email } } 53 | 54 | func NewClient(clientID, clientSecret, redirectURL string, options ...ClientOption) *client { 55 | if clientID == "" || clientSecret == "" { 56 | panic("clientID and clientSecret is a must!") 57 | } 58 | if redirectURL == "" { 59 | redirectURL = "http://localhost:8123" 60 | } 61 | var opts clientOptions 62 | for _, f := range options { 63 | f(&opts) 64 | } 65 | var sWrite string 66 | if !opts.ReadOnly { 67 | sWrite = "write" 68 | } 69 | if opts.TLSCertFile != "" && opts.TLSKeyFile != "" && strings.HasPrefix(redirectURL, "http://") { 70 | redirectURL = "https" + redirectURL[4:] 71 | } 72 | 73 | conf := &oauth2.Config{ 74 | ClientID: clientID, 75 | ClientSecret: clientSecret, 76 | RedirectURL: redirectURL, 77 | Scopes: []string{ 78 | "https://outlook.office.com/mail.read" + sWrite, 79 | "offline_access", 80 | }, 81 | Endpoint: oauth2client.AzureV2Endpoint, 82 | } 83 | if opts.TenantID != "" { 84 | conf.Endpoint = oauth2client.AzureV2TenantEndpoint(opts.TenantID) 85 | } 86 | 87 | tokensFile := opts.TokensFile 88 | if tokensFile == "" { 89 | tokensFile = filepath.Join(os.Getenv("HOME"), ".config", "o365.conf") 90 | } 91 | if opts.Impersonate == "" { 92 | opts.Impersonate = "me" 93 | } 94 | 95 | return &client{ 96 | Config: conf, 97 | Me: opts.Impersonate, 98 | TokenSource: oauth2client.NewTokenSource(conf, tokensFile, opts.TLSCertFile, opts.TLSKeyFile), 99 | logger: slog.Default(), 100 | } 101 | } 102 | 103 | type confidentialTokenSource struct { 104 | clientID, clientSecret, tenantID string 105 | confidential.Client 106 | Scopes []string 107 | clientOK bool 108 | } 109 | 110 | func NewConfidentialTokenSource(conf *oauth2.Config, tenantID string) *confidentialTokenSource { 111 | return &confidentialTokenSource{ 112 | clientID: conf.ClientID, clientSecret: conf.ClientSecret, 113 | tenantID: tenantID, 114 | Scopes: conf.Scopes, 115 | } 116 | } 117 | 118 | func (cts *confidentialTokenSource) Token() (*oauth2.Token, error) { 119 | if !cts.clientOK { 120 | cred, err := confidential.NewCredFromSecret(cts.clientSecret) 121 | if err != nil { 122 | return nil, fmt.Errorf("could not create a cred from a secret: %w", err) 123 | } 124 | cts.Client, err = confidential.New( 125 | "https://login.microsoftonline.com/"+url.PathEscape(cts.tenantID), 126 | cts.clientID, cred, 127 | ) 128 | if err != nil { 129 | return nil, fmt.Errorf("app: %w", err) 130 | } 131 | cts.clientOK = true 132 | } 133 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) 134 | result, err := cts.Client.AcquireTokenByCredential(ctx, cts.Scopes) 135 | cancel() 136 | if err != nil { 137 | return nil, fmt.Errorf("AcquiretokenByCredentials: %w", err) 138 | } 139 | return authResultToToken(result), nil 140 | } 141 | 142 | func authResultToToken(result confidential.AuthResult) *oauth2.Token { 143 | return &oauth2.Token{ 144 | AccessToken: result.AccessToken, 145 | Expiry: result.ExpiresOn, 146 | } 147 | } 148 | 149 | type Attachment struct { 150 | // The date and time when the attachment was last modified. The date and time use ISO 8601 format and is always in UTC time. For example, midnight UTC on Jan 1, 2014 would look like this: '2014-01-01T00:00:00Z' 151 | LastModifiedDateTime time.Time 152 | // The MIME type of the attachment. 153 | ContentType string `json:",omitempty"` 154 | // The display name of the attachment. This does not need to be the actual file name. 155 | Name string `json:",omitempty"` 156 | // The length of the attachment in bytes. 157 | Size int32 `json:",omitempty"` 158 | // true if the attachment is an inline attachment; otherwise, false. 159 | IsInline bool `json:",omitempty"` 160 | } 161 | 162 | type Recipient struct { 163 | EmailAddress EmailAddress 164 | } 165 | 166 | type EmailAddress struct { 167 | Name string `json:"name"` 168 | Address string `json:"address"` 169 | } 170 | 171 | type ItemBody struct { 172 | // The content type: Text = 0, HTML = 1. 173 | ContentType string `json:",omitempty"` 174 | // The text or HTML content. 175 | Content string `json:",omitempty"` 176 | } 177 | 178 | type Importance string 179 | type InferenceClassificationType string 180 | type SingleValueLegacyExtendedProperty struct { 181 | // The property ID. This is used to identify the property. 182 | PropertyID string `json:"PropertyId,omitempty"` 183 | // A property values. 184 | Value string `json:",omitempty"` 185 | } 186 | type MultiValueLegacyExtendedProperty struct { 187 | // The property ID. This is used to identify the property. 188 | PropertyID string `json:"PropertyId,omitempty"` 189 | // A collection of property values. 190 | Value []string `json:",omitempty"` 191 | } 192 | 193 | // https://msdn.microsoft.com/en-us/office/office365/api/complex-types-for-mail-contacts-calendar#MessageResource 194 | // The fields last word designates the Writable/Filterable/Searchable property of the field. 195 | type Message struct { 196 | // The date and time the message was created. 197 | // -F- 198 | Created *time.Time `json:"CreatedDateTime,omitempty"` 199 | // The date and time the message was last changed. 200 | // -F- 201 | LastModified *time.Time `json:"LastModifiedDateTime,omitempty"` 202 | // The date and time the message was sent. 203 | // -F- 204 | Sent *time.Time `json:"SentDateTime,omitempty"` 205 | // The date and time the message was received. 206 | // -FS 207 | Received *time.Time `json:"ReceivedDateTime,omitempty"` 208 | // A collection of multi-value extended properties of type MultiValueLegacyExtendedProperty. This is a navigation property. Find more information about extended properties. 209 | // WF- 210 | MultiValueExtendedProperties *MultiValueLegacyExtendedProperty `json:",omitempty"` 211 | // A collection of single-value extended properties of type SingleValueLegacyExtendedProperty. This is a navigation property. Find more information about extended properties. 212 | // WF- 213 | SingleValueExtendedProperties *SingleValueLegacyExtendedProperty `json:",omitempty"` 214 | // The mailbox owner and sender of the message. 215 | // WFS 216 | From *Recipient `json:",omitempty"` 217 | // The account that is actually used to generate the message. 218 | // WF- 219 | Sender *Recipient `json:",omitempty"` 220 | // The body of the message that is unique to the conversation. 221 | // --- 222 | UniqueBody *ItemBody `json:",omitempty"` 223 | // The body of the message. 224 | // W-- 225 | Body ItemBody 226 | // The importance of the message: Low = 0, Normal = 1, High = 2. 227 | // WFS 228 | Importance Importance `json:",omitempty"` 229 | 230 | // The classification of this message for the user, based on inferred relevance or importance, or on an explicit override. 231 | // WFS 232 | InferenceClassification InferenceClassificationType `json:",omitempty"` 233 | // The version of the message. 234 | // --- 235 | ChangeKey string `json:",omitempty"` 236 | // The ID of the conversation the email belongs to. 237 | // -F- 238 | ConversationID string `json:"ConversationId,omitempty"` 239 | // The unique identifier of the message. 240 | // --- 241 | ID string `json:"Id,omitempty"` 242 | // The unique identifier for the message's parent folder. 243 | // --- 244 | ParentFolderID string `json:"ParentFolderId,omitempty"` 245 | // The subject of the message. 246 | // WF- 247 | Subject string `json:",omitempty"` 248 | // The URL to open the message in Outlook Web App. 249 | // You can append an ispopout argument to the end of the URL to change how the message is displayed. If ispopout is not present or if it is set to 1, then the message is shown in a popout window. If ispopout is set to 0, then the browser will show the message in the Outlook Web App review pane. 250 | // The message will open in the browser if you are logged in to your mailbox via Outlook Web App. You will be prompted to login if you are not already logged in with the browser. 251 | // This URL can be accessed from within an iFrame. 252 | // -F- 253 | WebLink string `json:",omitempty"` 254 | // The first 255 characters of the message body content. 255 | // --S 256 | BodyPreview string `json:",omitempty"` 257 | 258 | // The Bcc recipients for the message. 259 | // W-S 260 | Bcc []Recipient `json:"BccRecipients,omitempty"` 261 | // The email addresses to use when replying. 262 | // --- 263 | ReplyTo []Recipient `json:",omitempty"` 264 | // The To recipients for the message. 265 | // W-S 266 | To []Recipient `json:"ToRecipients,omitempty"` 267 | // The Cc recipients for the message. 268 | // W-S 269 | Cc []Recipient `json:"CcRecipients,omitempty"` 270 | 271 | // The FileAttachment and ItemAttachment attachments for the message. Navigation property. 272 | // W-S 273 | Attachments []Attachment `json:",omitempty"` 274 | 275 | // The categories associated with the message. 276 | // WFS 277 | Categories []string `json:",omitempty"` 278 | // The collection of open type data extensions defined for the message. Navigation property. 279 | // -F- 280 | Extensions []string `json:",omitempty"` 281 | 282 | // Indicates whether the message has attachments. 283 | // -FS 284 | HasAttachments bool `json:",omitempty"` 285 | // Indicates whether a read receipt is requested for the message. 286 | // WF- 287 | IsDeliveryReceiptRequested bool `json:",omitempty"` 288 | // Indicates whether the message is a draft. A message is a draft if it hasn't been sent yet. 289 | // -F- 290 | IsDraft bool `json:",omitempty"` 291 | // Indicates whether the message has been read. 292 | // WF- 293 | IsRead bool `json:",omitempty"` 294 | // Indicates whether a read receipt is requested for the message. 295 | // WF- 296 | IsReadReceiptRequested bool `json:",omitempty"` 297 | } 298 | 299 | func (c *client) List(ctx context.Context, mbox, pattern string, all bool) ([]Message, error) { 300 | path := "/messages" 301 | if mbox != "" { 302 | path = "/MailFolders/" + mbox + "/messages" 303 | } 304 | 305 | values := url.Values{ 306 | "$select": {"Sender,Subject"}, 307 | } 308 | if pattern != "" { 309 | values.Set("$search", `"subject:`+pattern+`"`) 310 | } 311 | if !all { 312 | values.Set("$filter", "IsRead eq false") 313 | } 314 | 315 | s := path + "?" + values.Encode() 316 | body, err := c.get(ctx, s) 317 | if err != nil { 318 | c.logger.Error("List", "path", s, "error", err) 319 | return nil, err 320 | } 321 | c.logger.Debug("List", "path", s) 322 | defer func() { 323 | io.Copy(io.Discard, body) 324 | body.Close() 325 | }() 326 | 327 | type listResponse struct { 328 | Value []Message `json:"value"` 329 | } 330 | var resp listResponse 331 | var buf bytes.Buffer 332 | if err = json.NewDecoder(io.TeeReader(body, &buf)).Decode(&resp); err != nil { 333 | b, _ := io.ReadAll(io.MultiReader(bytes.NewReader(buf.Bytes()), body)) 334 | c.logger.Error("decode", "listResponse", string(b)) 335 | } 336 | c.logger.Debug("List", "resp", resp) 337 | return resp.Value, nil 338 | } 339 | 340 | func (c *client) Get(ctx context.Context, msgID string) (Message, error) { 341 | path := "/messages/" + msgID 342 | var msg Message 343 | body, err := c.get(ctx, path) 344 | if err != nil { 345 | return msg, err 346 | } 347 | defer func() { 348 | io.Copy(io.Discard, body) 349 | body.Close() 350 | }() 351 | err = json.NewDecoder(body).Decode(&msg) 352 | return msg, err 353 | } 354 | 355 | func (c *client) Send(ctx context.Context, msg Message) error { 356 | var buf bytes.Buffer 357 | if err := json.NewEncoder(&buf).Encode(struct { 358 | Message Message 359 | }{Message: msg}); err != nil { 360 | return fmt.Errorf("encode %#v: %w", msg, err) 361 | } 362 | path := "/sendmail" 363 | return c.post(ctx, path, bytes.NewReader(buf.Bytes())) 364 | } 365 | 366 | func (c *client) post(ctx context.Context, path string, body io.Reader) error { 367 | rc, err := c.p(ctx, "POST", path, body) 368 | if rc != nil { 369 | rc.Close() 370 | } 371 | return err 372 | } 373 | func (c *client) p(ctx context.Context, method, path string, body io.Reader) (io.ReadCloser, error) { 374 | if method == "" { 375 | method = "POST" 376 | } 377 | var buf bytes.Buffer 378 | req, err := http.NewRequest(method, c.URLFor(path), io.TeeReader(body, &buf)) 379 | req.Header.Set("Content-Type", "application/json") 380 | if err != nil { 381 | return nil, fmt.Errorf("%s: %w", path, err) 382 | } 383 | resp, err := oauth2.NewClient(ctx, c.TokenSource).Do(req) 384 | if err != nil { 385 | return nil, fmt.Errorf("%s: %w", buf.String(), err) 386 | } 387 | if resp.StatusCode > 299 { 388 | defer resp.Body.Close() 389 | io.Copy(&buf, body) 390 | io.WriteString(&buf, "\n\n") 391 | io.Copy(&buf, resp.Body) 392 | return nil, fmt.Errorf("POST %q: %s\n%s", path, resp.Status, buf.Bytes()) 393 | } 394 | return resp.Body, nil 395 | } 396 | 397 | func (c *client) Delete(ctx context.Context, msgID string) error { 398 | return c.delete(ctx, "/messages/"+msgID) 399 | } 400 | 401 | func (c *client) Move(ctx context.Context, msgID, destinationID string) error { 402 | return c.post(ctx, "/messages/"+msgID+"/move", bytes.NewReader(jsonObj("DestinationId", destinationID))) 403 | } 404 | func (c *client) Copy(ctx context.Context, msgID, destinationID string) error { 405 | return c.post(ctx, "/messages/"+msgID+"/copy", bytes.NewReader(jsonObj("DestinationId", destinationID))) 406 | } 407 | 408 | func (c *client) Update(ctx context.Context, msgID string, upd map[string]any) error { 409 | var buf bytes.Buffer 410 | if err := json.NewEncoder(&buf).Encode(upd); err != nil { 411 | return fmt.Errorf("%#v: %w", upd, err) 412 | } 413 | body, err := c.p(ctx, "PATCH", "/messages/"+msgID, bytes.NewReader(buf.Bytes())) 414 | if body != nil { 415 | body.Close() 416 | } 417 | if err != nil { 418 | return fmt.Errorf("%#v: %w", upd, err) 419 | } 420 | return nil 421 | } 422 | 423 | type Folder struct { 424 | ID string `json:"Id"` 425 | Name string `json:"DisplayName"` 426 | ParentID string `json:"ParentFolderId,omitempty"` 427 | ChildCount uint32 `json:"ChildFolderCount,omitempty"` 428 | UnreadCount uint32 `json:"UnreadItemCount,omitempty"` 429 | TotalCount uint32 `json:"TotalItemCount,omitempty"` 430 | } 431 | 432 | func (c *client) ListFolders(ctx context.Context, parent string) ([]Folder, error) { 433 | path := "/MailFolders" 434 | if parent != "" { 435 | path += "/" + parent + "/childfolders" 436 | } 437 | body, err := c.get(ctx, path) 438 | if body != nil { 439 | defer func() { 440 | io.Copy(io.Discard, body) 441 | body.Close() 442 | }() 443 | } 444 | if err != nil { 445 | return nil, err 446 | } 447 | 448 | type folderList struct { 449 | Value []Folder `json:"value"` 450 | } 451 | var resp folderList 452 | err = json.NewDecoder(body).Decode(&resp) 453 | return resp.Value, err 454 | } 455 | 456 | func (c *client) CreateFolder(ctx context.Context, parent, folder string) error { 457 | return c.post(ctx, "/MailFolders/"+parent+"/childfolders", bytes.NewReader(jsonObj("DisplayName", folder))) 458 | } 459 | 460 | func (c *client) RenameFolder(ctx context.Context, folderID, newName string) error { 461 | return c.post(ctx, "/MailFolders/"+folderID, bytes.NewReader(jsonObj("DisplayName", newName))) 462 | } 463 | func (c *client) MoveFolder(ctx context.Context, folderID, destinationID string) error { 464 | return c.post(ctx, "/MailFolders/"+folderID+"/move", bytes.NewReader(jsonObj("DestinationId", destinationID))) 465 | } 466 | func (c *client) CopyFolder(ctx context.Context, folderID, destinationID string) error { 467 | return c.post(ctx, "/MailFolders/"+folderID+"/copy", bytes.NewReader(jsonObj("DestinationId", destinationID))) 468 | } 469 | 470 | func (c *client) DeleteFolder(ctx context.Context, folderID string) error { 471 | return c.delete(ctx, "/MailFolders/"+folderID) 472 | } 473 | 474 | func (c *client) URLFor(path string) string { return baseURL + "/" + c.Me + path } 475 | func (c *client) get(ctx context.Context, path string) (io.ReadCloser, error) { 476 | URL := c.URLFor(path) 477 | c.logger.Debug("get", "url", URL) 478 | resp, err := oauth2.NewClient(ctx, c.TokenSource).Get(URL) 479 | c.logger.Info("get", "resp", resp, "error", err) 480 | if err != nil { 481 | return nil, fmt.Errorf("%s: %w", path, err) 482 | } 483 | return resp.Body, nil 484 | } 485 | 486 | func (c *client) delete(ctx context.Context, path string) error { 487 | req, err := http.NewRequest("DELETE", c.URLFor(path), nil) 488 | if err != nil { 489 | return fmt.Errorf("%s: %w", path, err) 490 | } 491 | resp, err := oauth2.NewClient(ctx, c.TokenSource).Do(req) 492 | if err != nil { 493 | return fmt.Errorf("%s: %w", req.URL.String(), err) 494 | } 495 | if resp.StatusCode > 299 { 496 | return fmt.Errorf("DELETE %q: %s", path, resp.Status) 497 | } 498 | return nil 499 | } 500 | 501 | func jsonObj(key, value string) []byte { 502 | b, err := json.Marshal(map[string]string{key: value}) 503 | if err != nil { 504 | panic(err) 505 | } 506 | return b 507 | } 508 | -------------------------------------------------------------------------------- /v2/client.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021, 2022 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | // Package imapclient is for listing folders, reading messages 6 | // and moving them around (delete, unread, move). 7 | package imapclient 8 | 9 | import ( 10 | "bytes" 11 | "context" 12 | "crypto/tls" 13 | "errors" 14 | "fmt" 15 | "io" 16 | "log" 17 | "log/slog" 18 | "net" 19 | "net/url" 20 | "slices" 21 | "strconv" 22 | "strings" 23 | "time" 24 | 25 | "github.com/tgulacsi/imapclient/xoauth2" 26 | 27 | "github.com/emersion/go-imap" 28 | "github.com/emersion/go-imap/client" 29 | "github.com/emersion/go-sasl" 30 | ) 31 | 32 | type LogMask bool 33 | 34 | const LogAll = LogMask(true) 35 | 36 | // TLSConfig is the client's config for DialTLS. 37 | // nosemgrep 38 | var TLSConfig = tls.Config{InsecureSkipVerify: true} //nolint:gas 39 | 40 | // Client interface declares the needed methods for listing messages, 41 | // deleting and moving them around. 42 | type Client interface { 43 | Close(ctx context.Context, commit bool) error 44 | Mailboxes(ctx context.Context, root string) ([]string, error) 45 | FetchArgs(ctx context.Context, what string, msgIDs ...uint32) (map[uint32]map[string][]string, error) 46 | Peek(ctx context.Context, w io.Writer, msgID uint32, what string) (int64, error) 47 | Delete(ctx context.Context, msgID uint32) error 48 | Select(ctx context.Context, mbox string) error 49 | Watch(ctx context.Context) ([]uint32, error) 50 | WriteTo(ctx context.Context, mbox string, msg []byte, date time.Time) error 51 | Connect(ctx context.Context) error 52 | Move(ctx context.Context, msgID uint32, mbox string) error 53 | Mark(ctx context.Context, msgID uint32, seen bool) error 54 | List(ctx context.Context, mbox, pattern string, all bool) ([]uint32, error) 55 | ReadTo(ctx context.Context, w io.Writer, msgID uint32) (int64, error) 56 | SetLogger(*slog.Logger) 57 | SetLogMask(LogMask) LogMask 58 | } 59 | 60 | type tlsPolicy int8 61 | 62 | const ( 63 | NoTLS = tlsPolicy(-1) 64 | MaybeTLS = tlsPolicy(0) 65 | ForceTLS = tlsPolicy(1) 66 | ) 67 | 68 | type imapClient struct { 69 | ServerAddress 70 | //mu sync.Mutex 71 | c *client.Client 72 | logger *slog.Logger 73 | status *imap.MailboxStatus 74 | created []string 75 | logMask LogMask 76 | } 77 | 78 | // NewClient returns a new (not connected) Client, using TLS iff port == 143. 79 | func NewClient(host string, port int, username, password string) Client { 80 | if port == 0 { 81 | port = 143 82 | } 83 | if port == 143 { 84 | return NewClientNoTLS(host, port, username, password) 85 | } 86 | return NewClientTLS(host, port, username, password) 87 | } 88 | 89 | // FromServerAddress returns a new (not connected) Client, using the ServerAddress. 90 | func FromServerAddress(sa ServerAddress) Client { 91 | return &imapClient{ServerAddress: sa, logger: slog.Default()} 92 | } 93 | 94 | // NewClientTLS returns a new (not connected) Client, using TLS. 95 | func NewClientTLS(host string, port int, username, password string) Client { 96 | if port == 0 { 97 | port = 143 98 | } 99 | return FromServerAddress(ServerAddress{ 100 | Host: host, Port: uint32(port), 101 | Username: username, password: password, 102 | TLSPolicy: ForceTLS, 103 | }) 104 | } 105 | 106 | // NewClientNoTLS returns a new (not connected) Client, without TLS. 107 | func NewClientNoTLS(host string, port int, username, password string) Client { 108 | if port == 0 { 109 | port = 143 110 | } 111 | return FromServerAddress(ServerAddress{ 112 | Host: host, Port: uint32(port), 113 | Username: username, password: password, 114 | TLSPolicy: NoTLS, 115 | }) 116 | } 117 | 118 | // ServerAddress represents the server's address. 119 | type ServerAddress struct { 120 | Host string 121 | Username, password string 122 | ClientID, ClientSecret string 123 | Port uint32 124 | TLSPolicy tlsPolicy 125 | } 126 | 127 | func (m ServerAddress) WithPassword(password string) ServerAddress { 128 | m.password = password 129 | return m 130 | } 131 | func (m ServerAddress) Password() string { 132 | return m.password 133 | } 134 | 135 | // URL representation of the server address. 136 | // 137 | // The password is masked is withPassword is false! 138 | func (m ServerAddress) URL() *url.URL { 139 | if m.Port == 0 { 140 | m.Port = 993 141 | } 142 | u := url.URL{ 143 | User: url.UserPassword(m.Username, m.password), 144 | Host: net.JoinHostPort(m.Host, strconv.FormatUint(uint64(m.Port), 10)), 145 | } 146 | if m.Port == 143 { 147 | u.Scheme = "imap" 148 | } else { 149 | u.Scheme = "imaps" 150 | } 151 | if m.ClientID != "" { 152 | u.RawQuery = fmt.Sprintf("clientID=%s&clientSecret=%s", 153 | url.QueryEscape(m.ClientID), url.QueryEscape(m.ClientSecret)) 154 | } 155 | return &u 156 | } 157 | func (m ServerAddress) String() string { 158 | return m.URL().String() 159 | } 160 | 161 | // Mailbox is the ServerAddress with Mailbox info appended. 162 | type Mailbox struct { 163 | Mailbox string 164 | ServerAddress 165 | } 166 | 167 | func (m Mailbox) String() string { 168 | u := m.URL() 169 | u.Path = "/" + m.Mailbox 170 | return u.String() 171 | } 172 | 173 | // ParseMailbox parses an imaps://user:passw@host:port/mailbox URL. 174 | func ParseMailbox(s string) (Mailbox, error) { 175 | var m Mailbox 176 | u, err := url.Parse(s) 177 | if err != nil { 178 | return m, err 179 | } 180 | host, portS, err := net.SplitHostPort(u.Host) 181 | if err != nil { 182 | return m, err 183 | } 184 | m.Host = host 185 | if portS == "" { 186 | m.Port = 993 187 | } else if port, err := strconv.ParseUint(portS, 10, 32); err != nil { 188 | return m, err 189 | } else { 190 | m.Port = uint32(port) 191 | } 192 | if u.Scheme == "imaps" { 193 | m.TLSPolicy = ForceTLS 194 | } else if u.Scheme == "imap" { 195 | m.TLSPolicy = NoTLS 196 | } 197 | if u.User != nil { 198 | m.Username = u.User.Username() 199 | m.password, _ = u.User.Password() 200 | } 201 | m.Mailbox = strings.TrimLeft(u.Path, "/") 202 | q := u.Query() 203 | m.ClientID = q.Get("clientID") 204 | m.ClientSecret = q.Get("clientSecret") 205 | return m, nil 206 | } 207 | 208 | func (m Mailbox) Connect(ctx context.Context) (Client, error) { 209 | c := FromServerAddress(m.ServerAddress).(*imapClient) 210 | if err := c.Connect(ctx); err != nil { 211 | c.Close(ctx, false) 212 | return nil, err 213 | } 214 | if err := c.Select(ctx, m.Mailbox); err == nil { 215 | return c, nil 216 | } 217 | if err := c.c.Create(m.Mailbox); err != nil { 218 | c.Close(ctx, false) 219 | return nil, err 220 | } 221 | return c, c.Select(ctx, m.Mailbox) 222 | } 223 | 224 | // String returns the connection parameters. 225 | func (c *imapClient) String() string { 226 | return c.ServerAddress.String() 227 | } 228 | 229 | // SetLogMask allows setting the underlying imap.LogMask, 230 | // and also sets the standard logger's destination to the ctx's logger. 231 | func (c *imapClient) SetLogMask(mask LogMask) LogMask { 232 | //c.mu.Lock() 233 | //defer c.mu.Unlock() 234 | return c.setLogMask(mask) 235 | } 236 | 237 | func (c *imapClient) setLogMask(mask LogMask) LogMask { 238 | c.logMask = mask 239 | if c.c != nil { 240 | if c.logMask { 241 | c.c.SetDebug(loggerWriter{c.logger}) 242 | } else { 243 | c.c.SetDebug(nil) 244 | } 245 | } 246 | return mask 247 | } 248 | 249 | func (c *imapClient) SetLogger(lgr *slog.Logger) { 250 | c.logger = lgr 251 | //c.mu.Lock() 252 | if c.c != nil { 253 | c.c.ErrorLog = log.New(loggerWriter{c.logger}, "", 0) 254 | } 255 | //c.mu.Unlock() 256 | } 257 | 258 | // Select selects the mailbox to use - it is needed before ReadTo 259 | // (List includes this). 260 | func (c *imapClient) Select(ctx context.Context, mbox string) error { 261 | if err := ctx.Err(); err != nil { 262 | return err 263 | } 264 | //c.mu.Lock() 265 | status, err := c.c.Select(mbox, false) 266 | //c.mu.Unlock() 267 | if err != nil { 268 | c.logger.Error("Select", "mbox", mbox, "error", err) 269 | return fmt.Errorf("SELECT %q: %w", mbox, err) 270 | } 271 | c.logger.Debug("Select", "mbox", mbox, "status", status) 272 | c.status = status 273 | return nil 274 | } 275 | 276 | // ReadTo reads the message identified by the given msgID, into the io.Writer, 277 | // within the given context (deadline). 278 | func (c *imapClient) ReadTo(ctx context.Context, w io.Writer, msgID uint32) (int64, error) { 279 | if err := ctx.Err(); err != nil { 280 | return 0, err 281 | } 282 | return c.Peek(ctx, w, msgID, "") 283 | } 284 | 285 | // Peek into the message. Possible what: HEADER, TEXT, or empty (both) - 286 | // see http://tools.ietf.org/html/rfc3501#section-6.4.5 287 | func (c *imapClient) Peek(ctx context.Context, w io.Writer, msgID uint32, what string) (int64, error) { 288 | section := &imap.BodySectionName{BodyPartName: imap.BodyPartName{Specifier: imap.PartSpecifier(what)}, Peek: true} 289 | set := &imap.SeqSet{} 290 | set.AddNum(msgID) 291 | ch := make(chan *imap.Message, 1) 292 | done := make(chan error, 1) 293 | //c.mu.Lock() 294 | //defer c.mu.Unlock() 295 | go func() { 296 | done <- c.withTimeout(ctx, func() error { 297 | return c.c.UidFetch(set, []imap.FetchItem{section.FetchItem()}, ch) 298 | }) 299 | }() 300 | select { 301 | case <-ctx.Done(): 302 | return 0, ctx.Err() 303 | case err := <-done: 304 | return 0, err 305 | case msg, ok := <-ch: 306 | if !ok { 307 | return 0, io.EOF 308 | } 309 | if msg != nil { 310 | return io.Copy(w, msg.GetBody(section)) 311 | } 312 | } 313 | return 0, nil 314 | } 315 | 316 | // Fetch the message. Possible what: RFC3551 6.5.4 (RFC822.SIZE, ENVELOPE, ...). The default is "RFC822.SIZE ENVELOPE". 317 | func (c *imapClient) FetchArgs(ctx context.Context, what string, msgIDs ...uint32) (map[uint32]map[string][]string, error) { 318 | if err := ctx.Err(); err != nil { 319 | return nil, err 320 | } 321 | result := make(map[uint32]map[string][]string, len(msgIDs)) 322 | set := &imap.SeqSet{} 323 | for _, msgID := range msgIDs { 324 | set.AddNum(msgID) 325 | } 326 | if what == "" { 327 | what = "RFC822.SIZE ENVELOPE" 328 | } 329 | ss := strings.Fields(what) 330 | items := make([]imap.FetchItem, len(ss)) 331 | for i, s := range ss { 332 | items[i] = imap.FetchItem(s) 333 | } 334 | 335 | done := make(chan error, 1) 336 | ch := make(chan *imap.Message, 1) 337 | //c.mu.Lock() 338 | //defer c.mu.Unlock() 339 | go func() { 340 | defer close(ch) 341 | done <- c.withTimeout(ctx, func() error { 342 | return c.c.UidFetch(set, items, ch) 343 | }) 344 | }() 345 | select { 346 | case <-ctx.Done(): 347 | return result, ctx.Err() 348 | case err := <-done: 349 | return result, err 350 | case msg := <-ch: 351 | m := make(map[string][]string) 352 | result[msg.Uid] = m 353 | 354 | if msg.Size != 0 { 355 | m[string(imap.FetchRFC822Size)] = []string{fmt.Sprintf("%d", msg.Size)} 356 | } 357 | if msg.Uid != 0 { 358 | m[string(imap.FetchUid)] = []string{fmt.Sprintf("%d", msg.Uid)} 359 | } 360 | if !msg.InternalDate.IsZero() { 361 | m[string(imap.FetchInternalDate)] = []string{msg.InternalDate.Format(time.RFC3339)} 362 | } 363 | for k, v := range msg.Items { 364 | m[string(k)] = []string{fmt.Sprintf("%v", v)} 365 | } 366 | if b := msg.BodyStructure; b != nil { 367 | m["BODY.MIME-TYPE"] = []string{b.MIMEType + "/" + b.MIMESubType} 368 | m["BODY.CONTENT-ID"] = []string{b.Id} 369 | m["BODY.CONTENT-DESCRIPTION"] = []string{b.Description} 370 | m["BODY.CONTENT-ENCODING"] = []string{b.Encoding} 371 | m["BODY.CONTENT-LENGTH"] = []string{fmt.Sprintf("%d", b.Size)} 372 | m["BODY.CONTENT-DISPOSITION"] = []string{b.Disposition} 373 | m["BODY.CONTENT-LANGUAGE"] = b.Language 374 | m["BODY.LOCATION"] = b.Location 375 | m["BODY.MD5"] = []string{b.MD5} 376 | } 377 | 378 | if env := msg.Envelope; env != nil { 379 | m["ENVELOPE.DATE"] = []string{env.Date.Format(time.RFC3339)} 380 | m["ENVELOPE.SUBJECT"] = []string{env.Subject} 381 | m["ENVELOPE.FROM"] = formatAddressList(nil, env.From) 382 | m["ENVELOPE.SENDER"] = formatAddressList(nil, env.Sender) 383 | m["ENVELOPE.REPLY-TO"] = formatAddressList(nil, env.ReplyTo) 384 | m["ENVELOPE.TO"] = formatAddressList(nil, env.To) 385 | m["ENVELOPE.CC"] = formatAddressList(nil, env.Cc) 386 | m["ENVELOPE.BCC"] = formatAddressList(nil, env.Bcc) 387 | m["ENVELOPE.IN-REPLY-TO"] = []string{env.InReplyTo} 388 | m["ENVELOPE.MESSAGE-ID"] = []string{env.MessageId} 389 | } 390 | } 391 | return result, nil 392 | } 393 | 394 | func formatAddressList(dst []string, addrs []*imap.Address) []string { 395 | for _, addr := range addrs { 396 | dst = append(dst, formatAddress(addr)) 397 | } 398 | return dst 399 | } 400 | 401 | func formatAddress(addr *imap.Address) string { 402 | s := "<" + addr.MailboxName + "@" + addr.HostName + ">" 403 | if addr.PersonalName != "" { 404 | return addr.PersonalName + " " + s 405 | } 406 | return s 407 | } 408 | 409 | // Move moves the msgid to the given mbox, within deadline. 410 | func (c *imapClient) Move(ctx context.Context, msgID uint32, mbox string) error { 411 | if err := ctx.Err(); err != nil { 412 | return err 413 | } 414 | created := slices.Contains(c.created, mbox) 415 | if !created { 416 | c.logger.Info("Create", "box", mbox) 417 | c.created = append(c.created, mbox) 418 | //c.mu.Lock() 419 | err := c.c.Create(mbox) 420 | //c.mu.Unlock() 421 | if err != nil { 422 | c.logger.Error("Create", "box", mbox, "error", err) 423 | } 424 | } 425 | 426 | set := &imap.SeqSet{} 427 | set.AddNum(msgID) 428 | //c.mu.Lock() 429 | err := c.c.UidCopy(set, mbox) 430 | //c.mu.Unlock() 431 | if err != nil { 432 | return fmt.Errorf("copy %s: %w", mbox, err) 433 | } 434 | return c.Delete(ctx, msgID) 435 | } 436 | 437 | // List the messages from the given mbox, matching the pattern. 438 | // Lists only new (UNSEEN) messages iff all is false, 439 | // withing the given context (deadline). 440 | func (c *imapClient) List(ctx context.Context, mbox, pattern string, all bool) ([]uint32, error) { 441 | if err := ctx.Err(); err != nil { 442 | return nil, err 443 | } 444 | if err := c.Select(ctx, mbox); err != nil { 445 | return nil, fmt.Errorf("SELECT %q: %w", mbox, err) 446 | } 447 | 448 | crit := imap.NewSearchCriteria() 449 | crit.WithoutFlags = append(crit.WithoutFlags, imap.DeletedFlag) 450 | if !all { 451 | crit.WithoutFlags = append(crit.WithoutFlags, imap.SeenFlag) 452 | } 453 | if pattern != "" { 454 | crit.Header.Set("Subject", pattern) 455 | } 456 | //c.mu.Lock() 457 | //defer c.mu.Unlock() 458 | // The response contains a list of message sequence IDs 459 | uids, err := c.c.UidSearch(crit) 460 | if err != nil { 461 | c.logger.Error("UidSearch", "crit", crit, "error", err) 462 | return uids, err 463 | } 464 | if c.logger.Enabled(ctx, slog.LevelDebug) { 465 | c.logger.Debug("UidSearch", "crit", crit, "error", err) 466 | } 467 | return uids, err 468 | } 469 | 470 | // Mailboxes returns the list of mailboxes under root 471 | func (c *imapClient) Mailboxes(ctx context.Context, root string) ([]string, error) { 472 | if err := ctx.Err(); err != nil { 473 | return nil, err 474 | } 475 | ch := make(chan *imap.MailboxInfo, 1) 476 | done := make(chan error, 1) 477 | //c.mu.Lock() 478 | //defer c.mu.Unlock() 479 | go func() { 480 | done <- c.withTimeout(ctx, func() error { 481 | return c.c.List(root, "*", ch) 482 | }) 483 | }() 484 | var names []string 485 | select { 486 | case <-ctx.Done(): 487 | return names, ctx.Err() 488 | case err := <-done: 489 | return names, err 490 | case mi := <-ch: 491 | if mi != nil { 492 | names = append(names, mi.Name) 493 | } 494 | } 495 | return names, nil 496 | } 497 | 498 | // Close closes the currently selected mailbox, then logs out. 499 | func (c *imapClient) Close(ctx context.Context, expunge bool) error { 500 | if err := ctx.Err(); err != nil { 501 | return err 502 | } 503 | //c.mu.Lock() 504 | //defer c.mu.Unlock() 505 | if c.c == nil { 506 | return nil 507 | } 508 | ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 509 | defer cancel() 510 | var err error 511 | if expunge { 512 | err = c.withTimeout(ctx, func() error { return c.c.Expunge(nil) }) 513 | } 514 | if closeErr := c.withTimeout(ctx, func() error { return c.c.Close() }); closeErr != nil && err == nil { 515 | err = closeErr 516 | } 517 | if logoutErr := c.withTimeout(ctx, func() error { return c.c.Logout() }); logoutErr != nil && err == nil { 518 | err = logoutErr 519 | } 520 | c.c = nil 521 | return err 522 | } 523 | 524 | // Mark marks the message seen/unseen, within the given context (deadline). 525 | func (c *imapClient) Mark(ctx context.Context, msgID uint32, seen bool) error { 526 | if err := ctx.Err(); err != nil { 527 | return err 528 | } 529 | set := &imap.SeqSet{} 530 | set.AddNum(msgID) 531 | item := imap.FormatFlagsOp(imap.AddFlags, true) 532 | if !seen { 533 | item = imap.FormatFlagsOp(imap.RemoveFlags, true) 534 | } 535 | flags := []any{imap.SeenFlag} 536 | //c.mu.Lock() 537 | //defer c.mu.Unlock() 538 | return c.c.UidStore(set, item, flags, nil) 539 | } 540 | 541 | // Delete deletes the message, within the given context (deadline). 542 | func (c *imapClient) Delete(ctx context.Context, msgID uint32) error { 543 | if err := ctx.Err(); err != nil { 544 | return err 545 | } 546 | set := &imap.SeqSet{} 547 | set.AddNum(msgID) 548 | item := imap.FormatFlagsOp(imap.AddFlags, true) 549 | flags := []any{imap.DeletedFlag} 550 | //c.mu.Lock() 551 | //defer c.mu.Unlock() 552 | return c.c.UidStore(set, item, flags, nil) 553 | } 554 | 555 | // Watch the current mailbox for changes. 556 | // Return on the first server notification. 557 | func (c *imapClient) Watch(ctx context.Context) ([]uint32, error) { 558 | if err := ctx.Err(); err != nil { 559 | return nil, err 560 | } 561 | ch := make(chan client.Update, 1) 562 | var uids []uint32 563 | c.c.Updates = ch 564 | select { 565 | case <-ctx.Done(): 566 | c.c.Updates = nil 567 | return uids, ctx.Err() 568 | case upd := <-ch: 569 | switch x := upd.(type) { 570 | case *client.MessageUpdate: 571 | uids = append(uids, x.Message.Uid) 572 | } 573 | } 574 | c.c.Updates = nil 575 | return uids, nil 576 | } 577 | 578 | // WriteTo appends the message the given mailbox. 579 | func (c *imapClient) WriteTo(ctx context.Context, mbox string, msg []byte, date time.Time) error { 580 | //c.mu.Lock() 581 | //defer c.mu.Unlock() 582 | return c.c.Append(mbox, nil, date, literalBytes(msg)) 583 | } 584 | 585 | // Connect connects to the server, within the given context (deadline). 586 | func (c *imapClient) Connect(ctx context.Context) error { 587 | if err := ctx.Err(); err != nil { 588 | return err 589 | } 590 | //c.mu.Lock() 591 | if c.c != nil { 592 | c.c.Logout() 593 | c.c = nil 594 | } 595 | addr := c.Host + ":" + strconv.Itoa(int(c.Port)) 596 | var cl *client.Client 597 | var err error 598 | noTLS := c.TLSPolicy == NoTLS || c.TLSPolicy == MaybeTLS && c.Port == 143 599 | if noTLS { 600 | cl, err = client.Dial(addr) 601 | } else { 602 | cl, err = client.DialTLS(addr, &TLSConfig) 603 | } 604 | //c.mu.Unlock() 605 | if err != nil { 606 | c.logger.Error("Connect", "addr", addr, "error", err) 607 | return fmt.Errorf("%s: %w", addr, err) 608 | } 609 | c.c = cl 610 | select { 611 | case <-ctx.Done(): 612 | return ctx.Err() 613 | default: 614 | } 615 | c.SetLogMask(c.logMask) 616 | //c.mu.Lock() 617 | //defer c.mu.Unlock() 618 | c.c.Timeout = 0 619 | // Enable encryption, if supported by the server 620 | if ok, _ := c.c.SupportStartTLS(); ok { 621 | c.logger.Info("Starting TLS") 622 | c.c.StartTLS(&TLSConfig) 623 | } 624 | 625 | // Authenticate 626 | return c.login(ctx) 627 | } 628 | 629 | var errNotLoggedIn = errors.New("not logged in") 630 | 631 | func (c *imapClient) login(ctx context.Context) (err error) { 632 | if err = ctx.Err(); err != nil { 633 | return err 634 | } 635 | logger := c.logger.With("username", c.Username) 636 | order := []string{"login", "oauthbearer", "xoauth2", "cram-md5", "plain"} 637 | if len(c.password) > 40 { 638 | order[0], order[1], order[2] = order[1], order[2], order[0] 639 | } 640 | 641 | oLogMask := c.logMask 642 | defer func() { c.setLogMask(oLogMask) }() 643 | c.setLogMask(LogAll) 644 | 645 | for _, method := range order { 646 | logger := logger.With("method", method) 647 | logger.Info("try logging in") 648 | err = errNotLoggedIn 649 | 650 | switch method { 651 | case "login": 652 | err = c.c.Login(c.Username, c.password) 653 | 654 | case "oauthbearer": 655 | if ok, _ := c.c.SupportAuth("OAUTHBEARER"); ok { 656 | err = c.c.Authenticate(sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{ 657 | Username: c.Username, Token: c.password, 658 | })) 659 | } 660 | 661 | case "cram-md5": 662 | if ok, _ := c.c.SupportAuth("CRAM-MD5"); ok { 663 | err = c.c.Authenticate(CramAuth(c.Username, c.password)) 664 | } 665 | 666 | case "plain": 667 | if ok, _ := c.c.SupportAuth("PLAIN"); ok { 668 | username, identity := c.Username, "" 669 | if i := strings.IndexByte(username, '\\'); i >= 0 { 670 | identity, username = strings.TrimPrefix(username[i+1:], "\\"), username[:i] 671 | } 672 | logger = logger.With("method", method, "identity", identity) 673 | 674 | err = c.c.Authenticate(sasl.NewPlainClient(identity, username, c.password)) 675 | } 676 | 677 | case "xoauth2": 678 | if ok, _ := c.c.SupportAuth("XOAUTH2"); ok { 679 | err = c.c.Authenticate(xoauth2.NewXOAuth2Client(&xoauth2.XOAuth2Options{ 680 | Username: c.Username, AccessToken: c.password, 681 | })) 682 | if err != nil { 683 | logger.Info("XOAUTH2", "password", c.password, "error", err) 684 | } 685 | } 686 | } 687 | 688 | if err == nil || strings.Contains(err.Error(), "Already logged in") { 689 | logger.Info("logged in", "method", method, "error", err) 690 | return nil 691 | } 692 | logger.Info("login failed", "method", method, "error", err) 693 | } 694 | if err != nil { 695 | return err 696 | } 697 | return errNotLoggedIn 698 | } 699 | 700 | // withTimeout executes f within the ctx.Deadline(), then resets the timeout. 701 | func (c *imapClient) withTimeout(ctx context.Context, f func() error) error { 702 | if err := ctx.Err(); err != nil { 703 | return err 704 | } 705 | d, ok := ctx.Deadline() 706 | if ok { 707 | c.c.Timeout = time.Until(d) 708 | c.logger.Info("setTimeout", "deadline", d.UTC(), "timeout", c.c.Timeout.String()) 709 | defer func() { c.c.Timeout = 0 }() 710 | } 711 | return f() 712 | } 713 | 714 | func literalBytes(msg []byte) imap.Literal { 715 | return literal{Reader: bytes.NewReader(msg), length: len(msg)} 716 | } 717 | 718 | type literal struct { 719 | io.Reader 720 | length int 721 | } 722 | 723 | func (lit literal) Len() int { return lit.length } 724 | 725 | var _ = io.Writer(loggerWriter{}) 726 | 727 | type loggerWriter struct { 728 | *slog.Logger 729 | } 730 | 731 | func (lg loggerWriter) Write(p []byte) (int, error) { 732 | lg.Logger.Info(string(bytes.TrimSpace(p))) 733 | return len(p), nil 734 | } 735 | -------------------------------------------------------------------------------- /cmd/imapdump/imapdump.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019, 2025 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package main 6 | 7 | import ( 8 | "archive/tar" 9 | "bufio" 10 | "bytes" 11 | "compress/gzip" 12 | "context" 13 | "encoding/base64" 14 | "errors" 15 | "fmt" 16 | "io" 17 | "mime" 18 | _ "net/http/pprof" 19 | "net/textproto" 20 | "os" 21 | "os/signal" 22 | "strconv" 23 | "strings" 24 | "sync" 25 | "syscall" 26 | "time" 27 | 28 | "golang.org/x/oauth2" 29 | "golang.org/x/text/encoding/htmlindex" 30 | "golang.org/x/text/transform" 31 | 32 | "github.com/UNO-SOFT/zlog/v2" 33 | 34 | "github.com/peterbourgon/ff/v4" 35 | "github.com/peterbourgon/ff/v4/ffhelp" 36 | "github.com/tgulacsi/imapclient/graph" 37 | "github.com/tgulacsi/imapclient/v2" 38 | "github.com/tgulacsi/imapclient/v2/o365" 39 | ) 40 | 41 | const fetchBatchLen = 1024 42 | 43 | var verbose zlog.VerboseVar 44 | var logger = zlog.NewLogger(zlog.MaybeConsoleHandler(&verbose, os.Stderr)).SLog() 45 | 46 | func main() { 47 | if err := Main(); err != nil { 48 | fmt.Fprintf(os.Stderr, "%+v", err) 49 | } 50 | } 51 | 52 | func Main() error { 53 | var ( 54 | username, password string 55 | recursive, all, du bool 56 | clientID, clientSecret string 57 | tenantID, userID string 58 | impersonate string 59 | ) 60 | host := os.Getenv("IMAPDUMP_HOST") 61 | port := 143 62 | if s := os.Getenv("IMAPDUMP_PORT"); s != "" { 63 | if i, err := strconv.Atoi(s); err == nil { 64 | port = i 65 | } 66 | } 67 | 68 | FS := ff.NewFlagSet("imapdump") 69 | FS.Value('v', "verbose", &verbose, "log verbose") 70 | FS.StringVar(&username, 'u', "username", os.Getenv("IMAPDUMP_USER"), "username") 71 | FS.StringVar(&password, 'p', "password", os.Getenv("IMAPDUMP_PASS"), "password") 72 | FS.StringVar(&host, 'H', "host", host, "host") 73 | FS.IntVar(&port, 'P', "port", port, "port") 74 | FS.StringVar(&clientID, 0, "client-id", os.Getenv("CLIENT_ID"), "Office 365 CLIENT_ID") 75 | FS.StringVar(&clientSecret, 0, "client-secret", os.Getenv("CLIENT_SECRET"), "Office 365 CLIENT_SECRET") 76 | flagClientCertsFile := FS.StringLong("client-certs", "", "client certificates file") 77 | FS.StringVar(&tenantID, 'T', "tenant-id", os.Getenv("TENANT_ID"), "Office 365 tenant ID") 78 | FS.StringVar(&impersonate, 0, "impersonate", "", "Office 365 impersonate") 79 | FS.StringVar(&userID, 'U', "user-id", os.Getenv("USER_ID"), "Office 365 user ID. Implies Graph API") 80 | flagForceTLS := FS.BoolLong("force-tls", "force use of TLS") 81 | flagForbidTLS := FS.BoolLong("forbid-tls", "forbid (force no TLS)") 82 | 83 | app := ff.Command{Name: "imapdump", Flags: FS, 84 | ShortHelp: "dump/load mail through IMAP", 85 | } 86 | 87 | rootCtx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 88 | defer cancel() 89 | rootCtx = zlog.NewSContext(rootCtx, logger) 90 | 91 | prepare := func(ctx context.Context) (imapclient.Client, error) { 92 | var c imapclient.Client 93 | if clientID != "" { 94 | if userID != "" { 95 | credOpts := graph.CredentialOptions{Secret: clientSecret} 96 | if *flagClientCertsFile != "" { 97 | fh, err := os.Open(*flagClientCertsFile) 98 | if err != nil { 99 | return nil, err 100 | } 101 | if credOpts.Certs, credOpts.Key, err = graph.ParseCertificates(fh, ""); err != nil { 102 | return nil, err 103 | } 104 | } 105 | var err error 106 | c, err = o365.NewGraphMailClient(ctx, clientID, tenantID, userID, 107 | credOpts) 108 | if err != nil { 109 | return nil, err 110 | } 111 | } else { 112 | if false { 113 | conf := &oauth2.Config{ 114 | ClientID: clientID, 115 | ClientSecret: clientSecret, 116 | Scopes: []string{"https://outlook.office365.com/.default"}, 117 | } 118 | ts := o365.NewConfidentialTokenSource(conf, tenantID) 119 | token, err := ts.Token() 120 | if err != nil { 121 | return nil, err 122 | } 123 | sa := imapclient.ServerAddress{ 124 | Host: host, Port: 993, 125 | Username: username, 126 | TLSPolicy: imapclient.ForceTLS, 127 | }.WithPassword(token.AccessToken) 128 | c = imapclient.FromServerAddress(sa) 129 | if verbose > 1 { 130 | c.SetLogger(logger) 131 | c.SetLogMask(imapclient.LogAll) 132 | } 133 | } else { 134 | c = o365.NewIMAPClient(o365.NewClient( 135 | clientID, clientSecret, "http://localhost:8123", 136 | o365.Impersonate(impersonate), 137 | o365.TenantID(tenantID), 138 | )) 139 | } 140 | } 141 | } else { 142 | if port == 0 { 143 | port = 143 144 | if *flagForceTLS { 145 | port = 993 146 | } 147 | } 148 | sa := imapclient.ServerAddress{ 149 | Host: host, Port: uint32(port), 150 | Username: username, 151 | TLSPolicy: imapclient.MaybeTLS, 152 | }.WithPassword(password) 153 | if *flagForceTLS { 154 | sa.TLSPolicy = imapclient.ForceTLS 155 | } else if *flagForbidTLS { 156 | sa.TLSPolicy = imapclient.NoTLS 157 | } 158 | c = imapclient.FromServerAddress(sa) 159 | if verbose > 1 { 160 | c.SetLogger(logger) 161 | c.SetLogMask(imapclient.LogAll) 162 | } 163 | } 164 | if err := c.Connect(ctx); err != nil { 165 | return nil, err 166 | } 167 | return c, nil 168 | } 169 | 170 | cClose := func(c imapclient.Client) { 171 | ctx, cancel := context.WithTimeout(rootCtx, 3*time.Second) 172 | defer cancel() 173 | c.Close(ctx, false) 174 | } 175 | 176 | //dumpCmd := app.Command("dump", "dump mail").Default() 177 | 178 | FS = ff.NewFlagSet("list") 179 | FS.BoolVar(&all, 'a', "all", "list all, not just UNSEEN") 180 | listCmd := ff.Command{Name: "list", ShortHelp: "list mailbox", Flags: FS, 181 | Exec: func(rootCtx context.Context, args []string) error { 182 | c, err := prepare(rootCtx) 183 | if err != nil { 184 | return err 185 | } 186 | defer cClose(c) 187 | if len(args) == 0 { 188 | args = []string{"INBOX"} 189 | } 190 | for _, mbox := range args { 191 | mails, err := listMbox(rootCtx, c, mbox, all) 192 | if err != nil { 193 | logger.Error("Listing", "box", mbox, "error", err) 194 | } 195 | fmt.Fprintln(os.Stdout, "UID\tSIZE\tSUBJECT") 196 | for _, m := range mails { 197 | fmt.Fprintf(os.Stdout, "%d\t%d\t%s\n", m.UID, m.Size, m.Subject) 198 | } 199 | } 200 | return nil 201 | }, 202 | } 203 | app.Subcommands = append(app.Subcommands, &listCmd) 204 | 205 | FS = ff.NewFlagSet("tree") 206 | FS.BoolVar(&du, 0, "du", "print dir sizes, too") 207 | treeCmd := ff.Command{Name: "tree", ShortHelp: "print the tree of mailboxes", Flags: FS, 208 | Usage: "tree [opts] ", 464 | Exec: func(rootCtx context.Context, args []string) error { 465 | syncSrc, syncDst := args[0], args[1] 466 | srcM, err := imapclient.ParseMailbox(syncSrc) 467 | if err != nil { 468 | return err 469 | } 470 | ctx, cancel := context.WithTimeout(rootCtx, 1*time.Minute) 471 | src, err := srcM.Connect(ctx) 472 | cancel() 473 | if err != nil { 474 | return err 475 | } 476 | if verbose > 1 { 477 | src.SetLogger(logger) 478 | src.SetLogMask(imapclient.LogAll) 479 | } 480 | dstM, err := imapclient.ParseMailbox(syncDst) 481 | if err != nil { 482 | return err 483 | } 484 | ctx, cancel = context.WithTimeout(rootCtx, 1*time.Minute) 485 | dst, err := dstM.Connect(ctx) 486 | cancel() 487 | if err != nil { 488 | return err 489 | } 490 | if verbose > 1 { 491 | dst.SetLogMask(imapclient.LogAll) 492 | } 493 | 494 | var wg sync.WaitGroup 495 | var destMails []Mail 496 | var destListErr error 497 | go func() { 498 | ctx, cancel := context.WithTimeout(rootCtx, 3*time.Minute) 499 | destMails, destListErr = listMbox(ctx, dst, dstM.Mailbox, true) 500 | cancel() 501 | }() 502 | 503 | ctx, cancel = context.WithTimeout(rootCtx, 3*time.Minute) 504 | sourceMails, err := listMbox(ctx, src, srcM.Mailbox, true) 505 | cancel() 506 | if err != nil { 507 | return err 508 | } 509 | wg.Wait() 510 | if destListErr != nil { 511 | return destListErr 512 | } 513 | there := make(map[string]*Mail, len(destMails)) 514 | for i, m := range destMails { 515 | there[m.MessageID] = &destMails[i] 516 | } 517 | 518 | var buf bytes.Buffer 519 | for _, m := range sourceMails { 520 | if _, ok := there[m.MessageID]; ok { 521 | continue 522 | } 523 | fmt.Printf("%s\t\t%q\n", m.MessageID, m.Subject) 524 | if err = rootCtx.Err(); err != nil { 525 | return err 526 | } 527 | buf.Reset() 528 | ctx, cancel = context.WithTimeout(rootCtx, 3*time.Minute) 529 | _, err = src.ReadTo(ctx, &buf, m.UID) 530 | cancel() 531 | if err != nil { 532 | return fmt.Errorf("%s: %w", m.Subject, err) 533 | } 534 | if err = rootCtx.Err(); err != nil { 535 | return err 536 | } 537 | ctx, cancel = context.WithTimeout(rootCtx, 3*time.Minute) 538 | err = dst.WriteTo(ctx, dstM.Mailbox, buf.Bytes(), m.Date) 539 | cancel() 540 | if err != nil { 541 | return err 542 | } 543 | } 544 | //fmt.Println("have: ", there) 545 | 546 | return nil 547 | }, 548 | } 549 | app.Subcommands = append(app.Subcommands, &syncCmd) 550 | if err := app.Parse(os.Args[1:]); err != nil { 551 | if errors.Is(err, ff.ErrHelp) { 552 | ffhelp.Command(&app).WriteTo(os.Stderr) 553 | return nil 554 | } 555 | return err 556 | } 557 | 558 | return app.Run(rootCtx) 559 | } 560 | 561 | var bufPool = sync.Pool{New: func() any { return bytes.NewBuffer(make([]byte, 0, 1<<20)) }} 562 | 563 | func dumpMails(rootCtx context.Context, tw *syncTW, c imapclient.Client, mbox string, uids []uint32) error { 564 | ctx, cancel := context.WithTimeout(rootCtx, 1*time.Minute) 565 | err := c.Select(ctx, mbox) 566 | cancel() 567 | if err != nil { 568 | logger.Error("SELECT", "box", mbox, "error", err) 569 | return err 570 | } 571 | 572 | if len(uids) == 0 { 573 | var err error 574 | ctx, cancel := context.WithTimeout(rootCtx, 1*time.Minute) 575 | uids, err = c.List(ctx, mbox, "", true) 576 | cancel() 577 | if err != nil { 578 | logger.Error("list", "box", mbox, "error", err) 579 | return err 580 | } 581 | } 582 | 583 | now := time.Now() 584 | osUID, osGID := os.Getuid(), os.Getgid() 585 | logger.Info("Saving messages", "count", len(uids), "box", mbox) 586 | buf := bufPool.Get().(*bytes.Buffer) 587 | defer bufPool.Put(buf) 588 | seen := make(map[string]struct{}, 1024) 589 | hsh := imapclient.NewHash() 590 | for _, uid := range uids { 591 | buf.Reset() 592 | ctx, cancel := context.WithTimeout(rootCtx, 10*time.Minute) 593 | _, err = c.ReadTo(ctx, buf, uint32(uid)) 594 | cancel() 595 | if err != nil { 596 | logger.Error("read", "uid", uid, "error", err) 597 | } 598 | hsh.Reset() 599 | hsh.Write(buf.Bytes()) 600 | hshS := hsh.Array().String() 601 | if _, ok := seen[hshS]; ok { 602 | logger.Info("Deleting already seen.", "box", mbox, "uid", uid) 603 | if err := c.Delete(ctx, uid); err != nil { 604 | logger.Error("Delete", "box", mbox, "uid", uid, "error", err) 605 | } 606 | continue 607 | } 608 | seen[hshS] = struct{}{} 609 | 610 | hdr, err := textproto.NewReader(bufio.NewReader(bytes.NewReader(buf.Bytes()))).ReadMIMEHeader() 611 | msgID := hdr.Get("Message-ID") 612 | if msgID == "" { 613 | msgID = fmt.Sprintf("%06d", uid) 614 | } 615 | t := now 616 | if err != nil { 617 | logger.Error("parse", "uid", uid, "bytes", buf.Bytes(), "error", err) 618 | } else { 619 | var ok bool 620 | if t, ok = HeadDate(hdr.Get("Date")); !ok { 621 | t = now 622 | } 623 | } 624 | tw.Lock() 625 | if err := tw.WriteHeader(&tar.Header{ 626 | Name: fmt.Sprintf("%s/%s.eml", mbox, base64.URLEncoding.EncodeToString([]byte(msgID))), 627 | Size: int64(buf.Len()), 628 | Mode: 0640, 629 | Typeflag: tar.TypeReg, 630 | ModTime: t, 631 | Uid: osUID, Gid: osGID, 632 | }); err != nil { 633 | tw.Unlock() 634 | return fmt.Errorf("WriteHeader: %w", err) 635 | } 636 | if _, err := tw.Write(buf.Bytes()); err != nil { 637 | tw.Unlock() 638 | return fmt.Errorf("write tar: %w", err) 639 | } 640 | 641 | if err := tw.Flush(); err != nil { 642 | tw.Unlock() 643 | return fmt.Errorf("flush tar: %w", err) 644 | } 645 | tw.Unlock() 646 | } 647 | return nil 648 | } 649 | 650 | type syncTW struct { 651 | *tar.Writer 652 | sync.Mutex 653 | } 654 | 655 | var WordDecoder = &mime.WordDecoder{ 656 | CharsetReader: func(charset string, input io.Reader) (io.Reader, error) { 657 | //enc, err := ianaindex.MIME.Get(charset) 658 | enc, err := htmlindex.Get(charset) 659 | if err != nil { 660 | return input, err 661 | } 662 | return transform.NewReader(input, enc.NewDecoder()), nil 663 | }, 664 | } 665 | 666 | func HeadDecode(head string) string { 667 | res, err := WordDecoder.DecodeHeader(head) 668 | if err == nil { 669 | return res 670 | } 671 | logger.Error("decode", "head", head, "error", err) 672 | return head 673 | } 674 | 675 | type Mail struct { 676 | Date time.Time 677 | MessageID string 678 | Subject string 679 | Size uint32 680 | UID uint32 681 | } 682 | 683 | func listMbox(rootCtx context.Context, c imapclient.Client, mbox string, all bool) ([]Mail, error) { 684 | ctx, cancel := context.WithTimeout(rootCtx, 3*time.Minute) 685 | uids, err := c.List(ctx, mbox, "", all) 686 | cancel() 687 | // logger.Info("listMbox", "uids", uids, "error", err) 688 | if err != nil { 689 | return nil, fmt.Errorf("LIST %q: %w", mbox, err) 690 | } 691 | if len(uids) == 0 { 692 | logger.Info("empty", "mbox", mbox) 693 | return nil, nil 694 | } 695 | 696 | result := make([]Mail, 0, len(uids)) 697 | for len(uids) > 0 { 698 | if err = rootCtx.Err(); err != nil { 699 | return nil, fmt.Errorf("%s: %w", "listMbox", err) 700 | } 701 | n := len(uids) 702 | if n > fetchBatchLen { 703 | n = fetchBatchLen 704 | logger.Info("Fetching.", "n", n, "of", len(uids)) 705 | } 706 | ctx, cancel = context.WithTimeout(rootCtx, 3*time.Minute) 707 | attrs, err := c.FetchArgs(ctx, "RFC822.SIZE RFC822.HEADER", uids[:n]...) 708 | cancel() 709 | if err != nil { 710 | logger.Error("FetchArgs", "uids", uids, "error", err) 711 | return nil, fmt.Errorf("FetchArgs %v: %w", uids, err) 712 | } 713 | uids = uids[n:] 714 | for uid, a := range attrs { 715 | m := Mail{UID: uid} 716 | result = append(result, m) 717 | if h := a["RFC822.HEADER"]; len(h) == 0 || h[0] == "" || h[0] == "" { 718 | continue 719 | } 720 | hdr, err := textproto.NewReader(bufio.NewReader(strings.NewReader(a["RFC822.HEADER"][0]))).ReadMIMEHeader() 721 | if err != nil { 722 | logger.Error("parse", "uid", uid, "bytes", a["RFC822.HEADER"], "error", err) 723 | continue 724 | } 725 | m.Subject = HeadDecode(hdr.Get("Subject")) 726 | m.MessageID = HeadDecode(hdr.Get("Message-ID")) 727 | s := HeadDecode(hdr.Get("Date")) 728 | for _, pat := range []string{time.RFC1123Z, time.RFC1123, time.RFC822Z, time.RFC822, time.RFC850} { 729 | if d, err := time.Parse(pat, s); err == nil { 730 | m.Date = d 731 | break 732 | } 733 | } 734 | if s, err := strconv.ParseUint(a["RFC822.SIZE"][0], 10, 32); err != nil { 735 | logger.Error("size of", "uid", uid, "text", a["RFC822.SIZE"], "error", err) 736 | continue 737 | } else { 738 | m.Size = uint32(s) 739 | result[len(result)-1] = m 740 | } 741 | } 742 | } 743 | return result, nil 744 | } 745 | 746 | func HeadDate(s string) (time.Time, bool) { 747 | if s == "" { 748 | return time.Time{}, false 749 | } 750 | for _, pat := range []string{time.RFC1123Z, time.RFC1123, time.RFC822Z, time.RFC822} { 751 | if t, err := time.Parse(pat, s); err == nil { 752 | return t, true 753 | } 754 | } 755 | return time.Time{}, false 756 | } 757 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021, 2024 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | // Package imapclient is for listing folders, reading messages 6 | // and moving them around (delete, unread, move). 7 | package imapclient 8 | 9 | import ( 10 | "bytes" 11 | "context" 12 | "crypto/tls" 13 | "errors" 14 | "fmt" 15 | "io" 16 | stdlog "log" 17 | "log/slog" 18 | "net" 19 | "net/url" 20 | "slices" 21 | "strconv" 22 | "strings" 23 | "time" 24 | 25 | "github.com/UNO-SOFT/zlog/v2" 26 | "github.com/emersion/go-imap" 27 | "github.com/emersion/go-imap/client" 28 | "github.com/emersion/go-sasl" 29 | ) 30 | 31 | type LogMask bool 32 | 33 | const LogAll = LogMask(true) 34 | 35 | var ( 36 | logger = slog.New(slog.NewTextHandler(io.Discard, nil)) 37 | 38 | // Timeout is the client timeout - 30 seconds by default. 39 | Timeout = 30 * time.Second 40 | 41 | // TLSConfig is the client's config for DialTLS. 42 | // nosemgrep 43 | TLSConfig = tls.Config{InsecureSkipVerify: true} //nolint:gas 44 | ) 45 | 46 | func SetLogger(lgr *slog.Logger) { logger = lgr } 47 | 48 | // Client interface declares the needed methods for listing messages, 49 | // deleting and moving them around. 50 | type Client interface { 51 | MinClient 52 | Connect() error 53 | MoveC(ctx context.Context, msgID uint32, mbox string) error 54 | MarkC(ctx context.Context, msgID uint32, seen bool) error 55 | List(mbox, pattern string, all bool) ([]uint32, error) 56 | ReadTo(w io.Writer, msgID uint32) (int64, error) 57 | SetLogger(*slog.Logger) 58 | SetLogMaskC(context.Context, LogMask) LogMask 59 | } 60 | 61 | // MinClient is the minimal required methods for a client. 62 | // You can make a full Client from it by wrapping in a MaxClient. 63 | type MinClient interface { 64 | ConnectC(context.Context) error 65 | Close(commit bool) error 66 | ListC(ctx context.Context, mbox, pattern string, all bool) ([]uint32, error) 67 | Mailboxes(ctx context.Context, root string) ([]string, error) 68 | ReadToC(ctx context.Context, w io.Writer, msgID uint32) (int64, error) 69 | FetchArgs(ctx context.Context, what string, msgIDs ...uint32) (map[uint32]map[string][]string, error) 70 | Peek(ctx context.Context, w io.Writer, msgID uint32, what string) (int64, error) 71 | Mark(msgID uint32, seen bool) error 72 | Delete(msgID uint32) error 73 | Move(msgID uint32, mbox string) error 74 | SetLogMask(mask LogMask) LogMask 75 | SetLoggerC(ctx context.Context) 76 | Select(ctx context.Context, mbox string) error 77 | Watch(ctx context.Context) ([]uint32, error) 78 | WriteTo(ctx context.Context, mbox string, msg []byte, date time.Time) error 79 | } 80 | 81 | var _ = Client(MaxClient{}) 82 | 83 | type MaxClient struct { 84 | MinClient 85 | } 86 | 87 | func (c MaxClient) Connect() error { 88 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) 89 | defer cancel() 90 | return c.MinClient.ConnectC(ctx) 91 | } 92 | func (c MaxClient) List(mbox, pattern string, all bool) ([]uint32, error) { 93 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) 94 | defer cancel() 95 | return c.MinClient.ListC(ctx, mbox, pattern, all) 96 | } 97 | func (c MaxClient) ReadTo(w io.Writer, msgID uint32) (int64, error) { 98 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) 99 | defer cancel() 100 | return c.MinClient.ReadToC(ctx, w, msgID) 101 | } 102 | func (c MaxClient) SetLogger(logger *slog.Logger) { 103 | c.MinClient.SetLoggerC(zlog.NewSContext(context.Background(), logger)) 104 | } 105 | func (c MaxClient) SetLogMaskC(ctx context.Context, mask LogMask) LogMask { 106 | return c.MinClient.SetLogMask(mask) 107 | } 108 | func (c MaxClient) MoveC(ctx context.Context, msgID uint32, mbox string) error { 109 | return c.MinClient.Move(msgID, mbox) 110 | } 111 | func (c MaxClient) MarkC(ctx context.Context, msgID uint32, seen bool) error { 112 | return c.MinClient.Mark(msgID, seen) 113 | } 114 | 115 | type tlsPolicy int8 116 | 117 | const ( 118 | NoTLS = tlsPolicy(-1) 119 | MaybeTLS = tlsPolicy(0) 120 | ForceTLS = tlsPolicy(1) 121 | ) 122 | 123 | type imapClient struct { 124 | c *client.Client 125 | status *imap.MailboxStatus 126 | logger *slog.Logger 127 | ServerAddress 128 | created []string 129 | logMask LogMask 130 | } 131 | 132 | // NewClient returns a new (not connected) Client, using TLS iff port == 143. 133 | func NewClient(host string, port int, username, password string) Client { 134 | if port == 0 { 135 | port = 143 136 | } 137 | if port == 143 { 138 | return NewClientNoTLS(host, port, username, password) 139 | } 140 | return NewClientTLS(host, port, username, password) 141 | } 142 | 143 | // FromServerAddress returns a new (not connected) Client, using the ServerAddress. 144 | func FromServerAddress(sa ServerAddress) Client { 145 | return &imapClient{ServerAddress: sa} 146 | } 147 | 148 | // NewClientTLS returns a new (not connected) Client, using TLS. 149 | func NewClientTLS(host string, port int, username, password string) Client { 150 | if port == 0 { 151 | port = 143 152 | } 153 | return FromServerAddress(ServerAddress{ 154 | Host: host, Port: uint32(port), 155 | Username: username, Password: password, 156 | TLSPolicy: ForceTLS, 157 | }) 158 | } 159 | 160 | // NewClientNoTLS returns a new (not connected) Client, without TLS. 161 | func NewClientNoTLS(host string, port int, username, password string) Client { 162 | if port == 0 { 163 | port = 143 164 | } 165 | return FromServerAddress(ServerAddress{ 166 | Host: host, Port: uint32(port), 167 | Username: username, Password: password, 168 | TLSPolicy: NoTLS, 169 | }) 170 | } 171 | 172 | // ServerAddress represents the server's address. 173 | type ServerAddress struct { 174 | Host string 175 | Username, Password string 176 | ClientID, ClientSecret string 177 | Port uint32 178 | TLSPolicy tlsPolicy 179 | } 180 | 181 | // URL representation of the server address. 182 | func (m ServerAddress) URL() *url.URL { 183 | if m.Port == 0 { 184 | m.Port = 993 185 | } 186 | u := url.URL{ 187 | User: url.UserPassword(m.Username, m.Password), 188 | Host: net.JoinHostPort(m.Host, strconv.FormatUint(uint64(m.Port), 10)), 189 | } 190 | if m.Port == 143 { 191 | u.Scheme = "imap" 192 | } else { 193 | u.Scheme = "imaps" 194 | } 195 | if m.ClientID != "" { 196 | u.RawQuery = fmt.Sprintf("clientID=%s&clientSecret=%s", 197 | url.QueryEscape(m.ClientID), url.QueryEscape(m.ClientSecret)) 198 | } 199 | return &u 200 | } 201 | func (m ServerAddress) String() string { 202 | return m.URL().String() 203 | } 204 | 205 | // Mailbox is the ServerAddress with Mailbox info appended. 206 | type Mailbox struct { 207 | Mailbox string 208 | ServerAddress 209 | } 210 | 211 | func (m Mailbox) String() string { 212 | u := m.URL() 213 | u.Path = "/" + m.Mailbox 214 | return u.String() 215 | } 216 | 217 | // ParseMailbox parses an imaps://user:passw@host:port/mailbox URL. 218 | func ParseMailbox(s string) (Mailbox, error) { 219 | var m Mailbox 220 | u, err := url.Parse(s) 221 | if err != nil { 222 | return m, err 223 | } 224 | host, portS, err := net.SplitHostPort(u.Host) 225 | if err != nil { 226 | return m, err 227 | } 228 | m.Host = host 229 | if portS == "" { 230 | m.Port = 993 231 | } else if port, err := strconv.ParseUint(portS, 10, 32); err != nil { 232 | return m, err 233 | } else { 234 | m.Port = uint32(port) 235 | } 236 | if u.Scheme == "imaps" { 237 | m.TLSPolicy = ForceTLS 238 | } else if u.Scheme == "imap" { 239 | m.TLSPolicy = NoTLS 240 | } 241 | if u.User != nil { 242 | m.Username = u.User.Username() 243 | m.Password, _ = u.User.Password() 244 | } 245 | m.Mailbox = strings.TrimLeft(u.Path, "/") 246 | q := u.Query() 247 | m.ClientID = q.Get("clientID") 248 | m.ClientSecret = q.Get("clientSecret") 249 | return m, nil 250 | } 251 | 252 | func (m Mailbox) Connect(ctx context.Context) (Client, error) { 253 | c := FromServerAddress(m.ServerAddress).(*imapClient) 254 | if err := c.ConnectC(ctx); err != nil { 255 | c.Close(false) 256 | return nil, err 257 | } 258 | if err := c.Select(ctx, m.Mailbox); err == nil { 259 | return c, nil 260 | } 261 | if err := c.c.Create(m.Mailbox); err != nil { 262 | c.Close(false) 263 | return nil, err 264 | } 265 | return c, c.Select(ctx, m.Mailbox) 266 | } 267 | 268 | // String returns the connection parameters. 269 | func (c *imapClient) String() string { 270 | return c.ServerAddress.String() 271 | } 272 | 273 | // SetLogMaskC allows setting the underlying imap.LogMask, 274 | // and also sets the standard logger's destination to the ctx's logger. 275 | func (c *imapClient) SetLogMaskC(ctx context.Context, mask LogMask) LogMask { 276 | c.logMask = mask 277 | c.SetLoggerC(ctx) 278 | if c.c != nil { 279 | if c.logMask { 280 | c.c.SetDebug(loggerWriter{c.logger}) 281 | } else { 282 | c.c.SetDebug(nil) 283 | } 284 | } 285 | return mask 286 | } 287 | 288 | // SetLogMask allows setting the underlying imap.LogMask. 289 | func (c *imapClient) SetLogMask(mask LogMask) LogMask { 290 | return c.SetLogMaskC(context.Background(), mask) 291 | } 292 | 293 | func (c *imapClient) SetLogger(logger *slog.Logger) { 294 | c.logger = logger 295 | if c.c != nil { 296 | c.c.ErrorLog = stdlog.New(loggerWriter{c.logger}, "ERR ", 0) 297 | } 298 | } 299 | 300 | func (c *imapClient) SetLoggerC(ctx context.Context) { 301 | var ssl string 302 | if c.TLSPolicy == ForceTLS { 303 | ssl = "SSL" 304 | } 305 | logger := GetLogger(ctx).With( 306 | "imap_server", fmt.Sprintf("%s:%s:%d:%s", c.Username, c.Host, c.Port, ssl), 307 | ) 308 | c.SetLogger(logger) 309 | } 310 | 311 | // Select selects the mailbox to use - it is needed before ReadTo 312 | // (List includes this). 313 | func (c *imapClient) Select(ctx context.Context, mbox string) error { 314 | if err := ctx.Err(); err != nil { 315 | return err 316 | } 317 | status, err := c.c.Select(mbox, false) 318 | if err != nil { 319 | return fmt.Errorf("SELECT %q: %w", mbox, err) 320 | } 321 | c.status = status 322 | return nil 323 | } 324 | 325 | // ReadToC reads the message identified by the given msgID, into the io.Writer, 326 | // within the given context (deadline). 327 | func (c *imapClient) ReadToC(ctx context.Context, w io.Writer, msgID uint32) (int64, error) { 328 | if err := ctx.Err(); err != nil { 329 | return 0, err 330 | } 331 | return c.Peek(ctx, w, msgID, "") 332 | } 333 | 334 | // Peek into the message. Possible what: HEADER, TEXT, or empty (both) - 335 | // see http://tools.ietf.org/html/rfc3501#section-6.4.5 336 | func (c *imapClient) Peek(ctx context.Context, w io.Writer, msgID uint32, what string) (int64, error) { 337 | section := &imap.BodySectionName{BodyPartName: imap.BodyPartName{Specifier: imap.PartSpecifier(what)}, Peek: true} 338 | set := &imap.SeqSet{} 339 | set.AddNum(msgID) 340 | ch := make(chan *imap.Message, 1) 341 | done := make(chan error, 1) 342 | c.setTimeout(ctx) 343 | go func() { done <- c.c.UidFetch(set, []imap.FetchItem{section.FetchItem()}, ch) }() 344 | select { 345 | case <-ctx.Done(): 346 | return 0, ctx.Err() 347 | case err := <-done: 348 | return 0, err 349 | case msg, ok := <-ch: 350 | if !ok { 351 | return 0, io.EOF 352 | } 353 | if msg != nil { 354 | return io.Copy(w, msg.GetBody(section)) 355 | } 356 | } 357 | return 0, nil 358 | } 359 | 360 | // Fetch the message. Possible what: RFC3551 6.5.4 (RFC822.SIZE, ENVELOPE, ...). The default is "RFC822.SIZE ENVELOPE". 361 | func (c *imapClient) FetchArgs(ctx context.Context, what string, msgIDs ...uint32) (map[uint32]map[string][]string, error) { 362 | if err := ctx.Err(); err != nil { 363 | return nil, err 364 | } 365 | result := make(map[uint32]map[string][]string, len(msgIDs)) 366 | set := &imap.SeqSet{} 367 | for _, msgID := range msgIDs { 368 | set.AddNum(msgID) 369 | } 370 | if what == "" { 371 | what = "RFC822.SIZE ENVELOPE" 372 | } 373 | ss := strings.Fields(what) 374 | items := make([]imap.FetchItem, len(ss)) 375 | for i, s := range ss { 376 | items[i] = imap.FetchItem(s) 377 | } 378 | 379 | done := make(chan error, 1) 380 | ch := make(chan *imap.Message, 1) 381 | c.setTimeout(ctx) 382 | go func() { defer close(ch); done <- c.c.UidFetch(set, items, ch) }() 383 | select { 384 | case <-ctx.Done(): 385 | return result, ctx.Err() 386 | case err := <-done: 387 | return result, err 388 | case msg := <-ch: 389 | m := make(map[string][]string) 390 | result[msg.Uid] = m 391 | 392 | if msg.Size != 0 { 393 | m[string(imap.FetchRFC822Size)] = []string{fmt.Sprintf("%d", msg.Size)} 394 | } 395 | if msg.Uid != 0 { 396 | m[string(imap.FetchUid)] = []string{fmt.Sprintf("%d", msg.Uid)} 397 | } 398 | if !msg.InternalDate.IsZero() { 399 | m[string(imap.FetchInternalDate)] = []string{msg.InternalDate.Format(time.RFC3339)} 400 | } 401 | for k, v := range msg.Items { 402 | m[string(k)] = []string{fmt.Sprintf("%v", v)} 403 | } 404 | if b := msg.BodyStructure; b != nil { 405 | m["BODY.MIME-TYPE"] = []string{b.MIMEType + "/" + b.MIMESubType} 406 | m["BODY.CONTENT-ID"] = []string{b.Id} 407 | m["BODY.CONTENT-DESCRIPTION"] = []string{b.Description} 408 | m["BODY.CONTENT-ENCODING"] = []string{b.Encoding} 409 | m["BODY.CONTENT-LENGTH"] = []string{fmt.Sprintf("%d", b.Size)} 410 | m["BODY.CONTENT-DISPOSITION"] = []string{b.Disposition} 411 | m["BODY.CONTENT-LANGUAGE"] = b.Language 412 | m["BODY.LOCATION"] = b.Location 413 | m["BODY.MD5"] = []string{b.MD5} 414 | } 415 | 416 | if env := msg.Envelope; env != nil { 417 | m["ENVELOPE.DATE"] = []string{env.Date.Format(time.RFC3339)} 418 | m["ENVELOPE.SUBJECT"] = []string{env.Subject} 419 | m["ENVELOPE.FROM"] = formatAddressList(nil, env.From) 420 | m["ENVELOPE.SENDER"] = formatAddressList(nil, env.Sender) 421 | m["ENVELOPE.REPLY-TO"] = formatAddressList(nil, env.ReplyTo) 422 | m["ENVELOPE.TO"] = formatAddressList(nil, env.To) 423 | m["ENVELOPE.CC"] = formatAddressList(nil, env.Cc) 424 | m["ENVELOPE.BCC"] = formatAddressList(nil, env.Bcc) 425 | m["ENVELOPE.IN-REPLY-TO"] = []string{env.InReplyTo} 426 | m["ENVELOPE.MESSAGE-ID"] = []string{env.MessageId} 427 | } 428 | } 429 | return result, nil 430 | } 431 | 432 | func formatAddressList(dst []string, addrs []*imap.Address) []string { 433 | for _, addr := range addrs { 434 | dst = append(dst, formatAddress(addr)) 435 | } 436 | return dst 437 | } 438 | 439 | func formatAddress(addr *imap.Address) string { 440 | s := "<" + addr.MailboxName + "@" + addr.HostName + ">" 441 | if addr.PersonalName != "" { 442 | return addr.PersonalName + " " + s 443 | } 444 | return s 445 | } 446 | 447 | // ReadTo reads the message identified by the given msgID, into the io.Writer. 448 | func (c *imapClient) ReadTo(w io.Writer, msgID uint32) (int64, error) { 449 | return c.ReadToC(context.Background(), w, msgID) 450 | } 451 | 452 | // Move the msgID to the given mbox. 453 | func (c *imapClient) Move(msgID uint32, mbox string) error { 454 | return c.MoveC(context.Background(), msgID, mbox) 455 | } 456 | 457 | // MoveC moves the msgid to the given mbox, within deadline. 458 | func (c *imapClient) MoveC(ctx context.Context, msgID uint32, mbox string) error { 459 | if err := ctx.Err(); err != nil { 460 | return err 461 | } 462 | created := slices.Contains(c.created, mbox) 463 | if !created { 464 | logger.Info("Create", "box", mbox) 465 | c.created = append(c.created, mbox) 466 | if err := c.c.Create(mbox); err != nil { 467 | logger.Error("Create", "box", mbox, "error", err) 468 | } 469 | } 470 | 471 | set := &imap.SeqSet{} 472 | set.AddNum(msgID) 473 | if err := c.c.UidCopy(set, mbox); err != nil { 474 | return fmt.Errorf("copy %s: %w", mbox, err) 475 | } 476 | return c.DeleteC(ctx, msgID) 477 | } 478 | 479 | // ListC the messages from the given mbox, matching the pattern. 480 | // Lists only new (UNSEEN) messages iff all is false, 481 | // withing the given context (deadline). 482 | func (c *imapClient) ListC(ctx context.Context, mbox, pattern string, all bool) ([]uint32, error) { 483 | if err := ctx.Err(); err != nil { 484 | return nil, err 485 | } 486 | //Log := GetLogger(ctx) 487 | //Log("msg","List", "box",mbox, "pattern",pattern) 488 | if err := c.Select(ctx, mbox); err != nil { 489 | return nil, fmt.Errorf("SELECT %q: %w", mbox, err) 490 | } 491 | 492 | crit := imap.NewSearchCriteria() 493 | crit.WithoutFlags = append(crit.WithoutFlags, imap.DeletedFlag) 494 | if !all { 495 | crit.WithoutFlags = append(crit.WithoutFlags, imap.SeenFlag) 496 | } 497 | if pattern != "" { 498 | crit.Header.Set("Subject", pattern) 499 | } 500 | // The response contains a list of message sequence IDs 501 | return c.c.UidSearch(crit) 502 | } 503 | 504 | // List the mailbox, where subject meets the pattern, and only unseen (when all is false). 505 | func (c *imapClient) List(mbox, pattern string, all bool) ([]uint32, error) { 506 | return c.ListC(context.Background(), mbox, pattern, all) 507 | } 508 | 509 | // Mailboxes returns the list of mailboxes under root 510 | func (c *imapClient) Mailboxes(ctx context.Context, root string) ([]string, error) { 511 | if err := ctx.Err(); err != nil { 512 | return nil, err 513 | } 514 | ch := make(chan *imap.MailboxInfo, 1) 515 | done := make(chan error, 1) 516 | go func() { done <- c.c.List(root, "*", ch) }() 517 | var names []string 518 | select { 519 | case <-ctx.Done(): 520 | return names, ctx.Err() 521 | case err := <-done: 522 | return names, err 523 | case mi := <-ch: 524 | if mi != nil { 525 | names = append(names, mi.Name) 526 | } 527 | } 528 | return names, nil 529 | } 530 | 531 | // Close closes the currently selected mailbox, then logs out. 532 | func (c *imapClient) CloseC(ctx context.Context, expunge bool) error { 533 | if err := ctx.Err(); err != nil { 534 | return err 535 | } 536 | if c.c == nil { 537 | return nil 538 | } 539 | var err error 540 | if expunge { 541 | err = c.c.Expunge(nil) 542 | } 543 | if closeErr := c.c.Close(); closeErr != nil && err == nil { 544 | err = closeErr 545 | } 546 | if logoutErr := c.c.Logout(); logoutErr != nil && err == nil { 547 | err = logoutErr 548 | } 549 | c.c = nil 550 | return err 551 | } 552 | 553 | // Close closes the currently selected mailbox, then logs out. 554 | func (c *imapClient) Close(expunge bool) error { 555 | return c.CloseC(context.Background(), expunge) 556 | } 557 | 558 | // Mark the message seen/unseed 559 | func (c *imapClient) Mark(msgID uint32, seen bool) error { 560 | return c.MarkC(context.Background(), msgID, seen) 561 | } 562 | 563 | // MarkC marks the message seen/unseen, within the given context (deadline). 564 | func (c *imapClient) MarkC(ctx context.Context, msgID uint32, seen bool) error { 565 | if err := ctx.Err(); err != nil { 566 | return err 567 | } 568 | set := &imap.SeqSet{} 569 | set.AddNum(msgID) 570 | item := imap.FormatFlagsOp(imap.AddFlags, true) 571 | if !seen { 572 | item = imap.FormatFlagsOp(imap.RemoveFlags, true) 573 | } 574 | flags := []any{imap.SeenFlag} 575 | return c.c.UidStore(set, item, flags, nil) 576 | } 577 | 578 | // Delete the message 579 | func (c *imapClient) Delete(msgID uint32) error { 580 | return c.DeleteC(context.Background(), msgID) 581 | } 582 | 583 | // DeleteC deletes the message, within the given context (deadline). 584 | func (c *imapClient) DeleteC(ctx context.Context, msgID uint32) error { 585 | if err := ctx.Err(); err != nil { 586 | return err 587 | } 588 | set := &imap.SeqSet{} 589 | set.AddNum(msgID) 590 | item := imap.FormatFlagsOp(imap.AddFlags, true) 591 | flags := []any{imap.DeletedFlag} 592 | return c.c.UidStore(set, item, flags, nil) 593 | } 594 | 595 | // Watch the current mailbox for changes. 596 | // Return on the first server notification. 597 | func (c *imapClient) Watch(ctx context.Context) ([]uint32, error) { 598 | if err := ctx.Err(); err != nil { 599 | return nil, err 600 | } 601 | ch := make(chan client.Update, 1) 602 | var uids []uint32 603 | c.c.Updates = ch 604 | select { 605 | case <-ctx.Done(): 606 | c.c.Updates = nil 607 | return uids, ctx.Err() 608 | case upd := <-ch: 609 | switch x := upd.(type) { 610 | case *client.MessageUpdate: 611 | uids = append(uids, x.Message.Uid) 612 | } 613 | } 614 | c.c.Updates = nil 615 | return uids, nil 616 | } 617 | 618 | // WriteTo appends the message the given mailbox. 619 | func (c *imapClient) WriteTo(ctx context.Context, mbox string, msg []byte, date time.Time) error { 620 | return c.c.Append(mbox, nil, date, literalBytes(msg)) 621 | } 622 | 623 | // Connect to the server. 624 | func (c *imapClient) Connect() error { 625 | return c.ConnectC(context.Background()) 626 | } 627 | 628 | // ConnectC connects to the server, within the given context (deadline). 629 | func (c *imapClient) ConnectC(ctx context.Context) error { 630 | if err := ctx.Err(); err != nil { 631 | return err 632 | } 633 | if c.c != nil { 634 | c.c.Logout() 635 | c.c = nil 636 | } 637 | logger := GetLogger(ctx) 638 | addr := c.Host + ":" + strconv.Itoa(int(c.Port)) 639 | var cl *client.Client 640 | var err error 641 | noTLS := c.TLSPolicy == NoTLS || c.TLSPolicy == MaybeTLS && c.Port == 143 642 | if noTLS { 643 | cl, err = client.Dial(addr) 644 | } else { 645 | cl, err = client.DialTLS(addr, &TLSConfig) 646 | } 647 | if err != nil { 648 | logger.Error("Connect", "addr", addr, "error", err) 649 | return fmt.Errorf("%s: %w", addr, err) 650 | } 651 | c.c = cl 652 | select { 653 | case <-ctx.Done(): 654 | return ctx.Err() 655 | default: 656 | } 657 | c.SetLogMaskC(ctx, c.logMask) 658 | c.c.Timeout = time.Minute 659 | // Enable encryption, if supported by the server 660 | if ok, _ := c.c.SupportStartTLS(); ok { 661 | logger.Info("Starting TLS") 662 | c.c.StartTLS(&TLSConfig) 663 | } 664 | 665 | // Authenticate 666 | return c.login(ctx) 667 | } 668 | 669 | var errNotLoggedIn = errors.New("not logged in") 670 | 671 | func (c *imapClient) login(ctx context.Context) (err error) { 672 | if err = ctx.Err(); err != nil { 673 | return err 674 | } 675 | logger := GetLogger(ctx).With("username", c.Username) 676 | order := []string{"login", "oauthbearer", "xoauth2", "cram-md5", "plain"} 677 | if len(c.Password) > 40 { 678 | order[0], order[1], order[2] = order[1], order[2], order[0] 679 | } 680 | 681 | oLogMask := c.logMask 682 | defer func() { c.SetLogMaskC(ctx, oLogMask) }() 683 | c.SetLogMaskC(ctx, LogAll) 684 | 685 | for _, method := range order { 686 | logger := logger.With("method", method) 687 | logger.Info("try logging in") 688 | err = errNotLoggedIn 689 | 690 | switch method { 691 | case "login": 692 | err = c.c.Login(c.Username, c.Password) 693 | 694 | case "oauthbearer": 695 | if ok, _ := c.c.SupportAuth("OAUTHBEARER"); ok { 696 | err = c.c.Authenticate(sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{ 697 | Username: c.Username, Token: c.Password, 698 | })) 699 | } 700 | 701 | case "cram-md5": 702 | if ok, _ := c.c.SupportAuth("CRAM-MD5"); ok { 703 | err = c.c.Authenticate(CramAuth(c.Username, c.Password)) 704 | } 705 | 706 | case "plain": 707 | if ok, _ := c.c.SupportAuth("PLAIN"); ok { 708 | username, identity := c.Username, "" 709 | if i := strings.IndexByte(username, '\\'); i >= 0 { 710 | identity, username = strings.TrimPrefix(username[i+1:], "\\"), username[:i] 711 | } 712 | logger = logger.With("method", method, "identity", identity) 713 | 714 | err = c.c.Authenticate(sasl.NewPlainClient(identity, username, c.Password)) 715 | } 716 | } 717 | 718 | if err == nil || strings.Contains(err.Error(), "Already logged in") { 719 | logger.Info("logged in", "method", method, "error", err) 720 | return nil 721 | } 722 | logger.Error("login failed", "method", method, "error", err) 723 | } 724 | if err != nil { 725 | return err 726 | } 727 | return errNotLoggedIn 728 | } 729 | 730 | func (c *imapClient) setTimeout(ctx context.Context) { 731 | d, ok := ctx.Deadline() 732 | if !ok { 733 | return 734 | } 735 | c.c.Timeout = time.Until(d) 736 | } 737 | 738 | func literalBytes(msg []byte) imap.Literal { 739 | return literal{Reader: bytes.NewReader(msg), length: len(msg)} 740 | } 741 | 742 | type literal struct { 743 | io.Reader 744 | length int 745 | } 746 | 747 | func (lit literal) Len() int { return lit.length } 748 | 749 | func GetLogger(ctx context.Context) *slog.Logger { 750 | if lgr := zlog.SFromContext(ctx); lgr != nil { 751 | return lgr 752 | } 753 | return logger 754 | } 755 | 756 | var _ = io.Writer(loggerWriter{}) 757 | 758 | type loggerWriter struct { 759 | *slog.Logger 760 | } 761 | 762 | func (lg loggerWriter) Write(p []byte) (int, error) { 763 | lg.Logger.Info(string(p)) 764 | return len(p), nil 765 | } 766 | -------------------------------------------------------------------------------- /graph/graph.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022, 2025 Tamás Gulácsi. All rights reserved. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | package graph 6 | 7 | import ( 8 | "context" 9 | "crypto" 10 | "crypto/x509" 11 | "encoding/pem" 12 | "errors" 13 | "fmt" 14 | "golang.org/x/crypto/pkcs12" 15 | "io" 16 | "strings" 17 | 18 | "github.com/UNO-SOFT/zlog/v2" 19 | "github.com/microsoft/kiota-abstractions-go/serialization" 20 | "golang.org/x/time/rate" 21 | 22 | "github.com/Azure/azure-sdk-for-go/sdk/azcore" 23 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 24 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache" 25 | 26 | msgraph "github.com/microsoftgraph/msgraph-sdk-go" 27 | msgraphcore "github.com/microsoftgraph/msgraph-sdk-go-core" 28 | // msgraph "github.com/tgulacsi/imapclient/graph/msgraph" 29 | "github.com/microsoftgraph/msgraph-sdk-go/models" 30 | // "github.com/tgulacsi/imapclient/graph/msgraph/models" 31 | "github.com/microsoftgraph/msgraph-sdk-go/users" 32 | // "github.com/tgulacsi/imapclient/graph/msgraph/users" 33 | ) 34 | 35 | type ( 36 | User = models.Userable 37 | Recipient = models.Recipientable 38 | Message = models.Messageable 39 | Folder = models.MailFolderable 40 | ) 41 | 42 | func ParseCertificates(r io.Reader, password string) ([]*x509.Certificate, crypto.PrivateKey, error) { 43 | certData, err := io.ReadAll(r) 44 | if err != nil { 45 | return nil, nil, err 46 | } 47 | blocks, _ := pkcs12.ToPEM(certData, password) 48 | if len(blocks) == 0 { 49 | for { 50 | var block *pem.Block 51 | block, certData = pem.Decode(certData) 52 | if block == nil { 53 | break 54 | } 55 | blocks = append(blocks, block) 56 | } 57 | } 58 | 59 | var certs []*x509.Certificate 60 | var pk crypto.PrivateKey 61 | for _, block := range blocks { 62 | switch block.Type { 63 | case "CERTIFICATE": 64 | c, err := x509.ParseCertificate(block.Bytes) 65 | if err != nil { 66 | return nil, nil, err 67 | } 68 | certs = append(certs, c) 69 | case "PRIVATE KEY": 70 | if pk != nil { 71 | return nil, nil, errors.New("certData contains multiple private keys") 72 | } 73 | pk, err = x509.ParsePKCS8PrivateKey(block.Bytes) 74 | if err != nil { 75 | pk, err = x509.ParsePKCS1PrivateKey(block.Bytes) 76 | } 77 | if err != nil { 78 | return nil, nil, err 79 | } 80 | case "RSA PRIVATE KEY": 81 | if pk != nil { 82 | return nil, nil, errors.New("certData contains multiple private keys") 83 | } 84 | pk, err = x509.ParsePKCS1PrivateKey(block.Bytes) 85 | if err != nil { 86 | return nil, nil, err 87 | } 88 | case "EC PRIVATE KEY": 89 | if pk != nil { 90 | return nil, nil, errors.New("certData contains multiple private keys") 91 | } 92 | pk, err = x509.ParseECPrivateKey(block.Bytes) 93 | if err != nil { 94 | return nil, nil, err 95 | } 96 | } 97 | } 98 | return certs, pk, nil 99 | } 100 | 101 | // type ( 102 | // // Query is a re-export of odata.Query to save the users of importing that package, too. 103 | // Query = odata.Query 104 | // OrderBy = odata.OrderBy 105 | // User = msgraph.User 106 | // ) 107 | 108 | // const ( 109 | // Ascending = odata.Ascending 110 | // Descending = odata.Descending 111 | // ) 112 | 113 | // EscapeSingleQuote replaces all occurrences of single quote, with 2 single quotes. 114 | // For requests that use single quotes, if any parameter values also contain single quotes, 115 | // those must be double escaped; otherwise, the request will fail due to invalid syntax. 116 | // https://docs.microsoft.com/en-us/graph/query-parameters#escaping-single-quotes 117 | func EscapeSingleQuote(qparam string) string { 118 | return strings.ReplaceAll(qparam, `'`, `''`) 119 | } 120 | 121 | // WellKnownFolders folder names 122 | var WellKnownFolders = map[string][]string{ 123 | "archive": {"Archive"}, // The archive folder messages are sent to when using the One_Click Archive feature in Outlook clients that support it. Note: this isn't the same as the Archive Mailbox feature of Exchange online. 124 | "clutter": nil, // The clutter folder low-priority messages are moved to when using the Clutter feature. 125 | "conflicts": nil, // The folder that contains conflicting items in the mailbox. 126 | "conversationhistory": nil, // The folder where Skype saves IM conversations (if Skype is configured to do so). 127 | "deleteditems": {"Trash", "Deleted", "Deleted Items"}, // The folder items are moved to when they're deleted. 128 | "drafts": {"Drafts"}, // The folder that contains unsent messages. 129 | "inbox": {"INBOX"}, // The inbox folder. 130 | "junkemail": {"Spam", "Junk", "Junk Email"}, // The junk email folder. 131 | "localfailures": nil, // The folder that contains items that exist on the local client but couldn't be uploaded to the server. 132 | "msgfolderroot": nil, // "The Top of Information Store" folder. This folder is the parent folder for folders that are displayed in normal mail clients, such as the inbox. 133 | "outbox": nil, // The outbox folder. 134 | "recoverableitemsdeletions": nil, // The folder that contains soft-deleted items: deleted either from the Deleted Items folder, or by pressing shift+delete in Outlook. This folder isn't visible in any Outlook email client, but end users can interact with it through the Recover Deleted Items from Server feature in Outlook or Outlook on the web. 135 | "scheduled": nil, // The folder that contains messages that are scheduled to reappear in the inbox using the Schedule feature in Outlook for iOS. 136 | "searchfolders": nil, // The parent folder for all search folders defined in the user's mailbox. 137 | "sentitems": {"Sent", "Sent Items"}, // The sent items folder. 138 | "serverfailures": nil, // The folder that contains items that exist on the server but couldn't be synchronized to the local client. 139 | "syncissues": nil, // The folder that contains synchronization logs created by Outlook. 140 | } 141 | 142 | type GraphMailClient struct { 143 | client *msgraph.GraphServiceClient 144 | limiter *rate.Limiter 145 | isDelegated bool 146 | } 147 | 148 | var ( 149 | applicationScopes = []string{ 150 | "https://graph.microsoft.com/.default", 151 | } 152 | delegatedScopes = []string{ 153 | "https://graph.microsoft.com/Mail.ReadWrite", 154 | // "https://graph.microsoft.com/Mail.Send", 155 | "https://graph.microsoft.com/MailboxFolder.ReadWrite", 156 | "https://graph.microsoft.com/User.ReadBasic.all", 157 | } 158 | ) 159 | 160 | type CredentialOptions struct { 161 | Secret, RedirectURL string 162 | Certs []*x509.Certificate 163 | Key crypto.PrivateKey 164 | } 165 | 166 | func NewGraphMailClient( 167 | ctx context.Context, 168 | tenantID, clientID string, 169 | credOpts CredentialOptions, 170 | 171 | ) (GraphMailClient, []User, error) { 172 | logger := zlog.SFromContext(ctx) 173 | cache, err := cache.New(nil) 174 | if err != nil { 175 | return GraphMailClient{}, nil, err 176 | } 177 | 178 | var cred azcore.TokenCredential 179 | var scopes []string 180 | var isDelegated bool 181 | var users []User 182 | if credOpts.Key != nil { 183 | cred, err = azidentity.NewClientCertificateCredential( 184 | tenantID, clientID, credOpts.Certs, credOpts.Key, 185 | &azidentity.ClientCertificateCredentialOptions{ 186 | AdditionallyAllowedTenants: []string{"*"}, 187 | Cache: cache, 188 | }, 189 | ) 190 | } else if isDelegated = credOpts.Secret == ""; isDelegated { 191 | cred, err = azidentity.NewInteractiveBrowserCredential(&azidentity.InteractiveBrowserCredentialOptions{ 192 | ClientID: clientID, TenantID: tenantID, Cache: cache, 193 | RedirectURL: credOpts.RedirectURL, 194 | }) 195 | scopes = delegatedScopes 196 | } else { 197 | cred, err = azidentity.NewClientSecretCredential( 198 | tenantID, clientID, credOpts.Secret, 199 | &azidentity.ClientSecretCredentialOptions{Cache: cache}, 200 | ) 201 | scopes = applicationScopes 202 | } 203 | if err != nil { 204 | return GraphMailClient{}, nil, fmt.Errorf("azidentity: %w", err) 205 | } 206 | 207 | client, err := msgraph.NewGraphServiceClientWithCredentials( 208 | cred, scopes) 209 | if err != nil { 210 | return GraphMailClient{}, nil, fmt.Errorf("NewGraphServiceClientWithCredentials: %w", err) 211 | } 212 | 213 | if isDelegated { 214 | me, err := client.Me().Get(ctx, nil) 215 | if err != nil { 216 | return GraphMailClient{}, nil, fmt.Errorf("get Me: %w", err) 217 | } 218 | logger.Info("got", "me", JSON{me}) 219 | if len(users) == 0 { 220 | users = []User{me} 221 | } 222 | } else { 223 | if coll, err := client.Users().Get(ctx, nil); err != nil { 224 | return GraphMailClient{}, nil, fmt.Errorf("get Users: %w", err) 225 | } else { 226 | users = coll.GetValue() 227 | } 228 | } 229 | cl := GraphMailClient{ 230 | client: client, 231 | limiter: rate.NewLimiter(24, 1), 232 | isDelegated: isDelegated, 233 | } 234 | if len(users) == 0 { 235 | if _, err := cl.Users(ctx); err != nil { 236 | return GraphMailClient{}, users, fmt.Errorf("Users: %w", err) 237 | } 238 | } 239 | 240 | return cl, users, nil 241 | } 242 | func (g GraphMailClient) SetLimit(limit rate.Limit) { g.limiter.SetLimit(limit) } 243 | 244 | func (g GraphMailClient) Users(ctx context.Context) ([]User, error) { 245 | if err := g.limiter.Wait(ctx); err != nil { 246 | return nil, err 247 | } 248 | coll, err := g.client.Users().Get(ctx, nil) 249 | if err != nil { 250 | logger := zlog.SFromContext(ctx) 251 | logger.Error("get users", "error", err) 252 | panic(err) 253 | if !g.isDelegated { 254 | return nil, err 255 | } 256 | if u, meErr := g.client.Me().Get(ctx, nil); meErr != nil { 257 | logger.Error("users.Me", "error", err) 258 | return nil, fmt.Errorf("Users.Get: %w (me: %w)", err, meErr) 259 | } else { 260 | return []User{u}, nil 261 | } 262 | } 263 | users := make([]User, 0, 10) 264 | it, err := msgraphcore.NewPageIterator[User](coll, g.client.GetAdapter(), 265 | models.CreateUserCollectionResponseFromDiscriminatorValue) 266 | if err != nil { 267 | return coll.GetValue(), err 268 | } 269 | err = it.Iterate(ctx, func(u User) bool { 270 | users = append(users, u) 271 | return true 272 | }) 273 | return users, err 274 | } 275 | 276 | func (g GraphMailClient) UpdateMessage(ctx context.Context, userID, messageID string, update Message) (Message, error) { 277 | if err := g.limiter.Wait(ctx); err != nil { 278 | return nil, err 279 | } 280 | msg, err := g.user(userID). 281 | Messages().ByMessageId(messageID). 282 | Patch(ctx, update, nil) 283 | if err != nil { 284 | return nil, fmt.Errorf("updateMessage(%q): %w", update, err) 285 | } 286 | return msg, err 287 | } 288 | 289 | func (g GraphMailClient) GetMIMEMessage(ctx context.Context, userID, messageID string) ([]byte, error) { 290 | if err := g.limiter.Wait(ctx); err != nil { 291 | return nil, err 292 | } 293 | // https://learn.microsoft.com/en-us/graph/api/message-get?view=graph-rest-1.0&tabs=go#example-4-get-mime-content 294 | msg, err := g.user(userID). 295 | Messages().ByMessageId(messageID). 296 | Content().Get(ctx, nil) 297 | if err != nil { 298 | return nil, fmt.Errorf("getMIMEMessage(%q): %w", messageID, err) 299 | } 300 | return msg, nil 301 | } 302 | 303 | func (g GraphMailClient) GetMessage(ctx context.Context, userID, messageID string, query Query) (models.Messageable, error) { 304 | if err := g.limiter.Wait(ctx); err != nil { 305 | return nil, err 306 | } 307 | var conf *users.ItemMessagesMessageItemRequestBuilderGetRequestConfiguration 308 | if !query.IsZero() { 309 | conf = &users.ItemMessagesMessageItemRequestBuilderGetRequestConfiguration{ 310 | QueryParameters: &users.ItemMessagesMessageItemRequestBuilderGetQueryParameters{Select: query.Select}, 311 | } 312 | } 313 | return g.user(userID).Messages().ByMessageId(messageID).Get(ctx, conf) 314 | } 315 | 316 | func (g GraphMailClient) GetMessageHeaders(ctx context.Context, userID, messageID string) (map[string][]string, error) { 317 | if err := g.limiter.Wait(ctx); err != nil { 318 | return nil, err 319 | } 320 | msg, err := g.user(userID).Messages().ByMessageId(messageID).Get(ctx, 321 | &users.ItemMessagesMessageItemRequestBuilderGetRequestConfiguration{ 322 | QueryParameters: &users.ItemMessagesMessageItemRequestBuilderGetQueryParameters{ 323 | Select: []string{"internetMessageHeaders"}, 324 | }, 325 | }) 326 | if err != nil { 327 | return nil, err 328 | } 329 | hdrs := msg.GetInternetMessageHeaders() 330 | m := make(map[string][]string, len(hdrs)) 331 | for _, kv := range hdrs { 332 | if k, v := kv.GetName(), kv.GetValue(); k != nil && v != nil { 333 | m[*k] = append(m[*k], *v) 334 | } 335 | } 336 | return m, nil 337 | } 338 | 339 | type Query struct { 340 | Filter, Search string 341 | Select, OrderBy []string 342 | } 343 | 344 | func (q Query) IsZero() bool { return len(q.Select) == 0 && q.Search == "" && q.Filter == "" } 345 | 346 | var requestTop = int32(64) 347 | 348 | func (g GraphMailClient) ListMessages(ctx context.Context, userID, folderID string, query Query) ([]Message, error) { 349 | if err := g.limiter.Wait(ctx); err != nil { 350 | return nil, err 351 | } 352 | qp := users.ItemMailFoldersItemMessagesRequestBuilderGetQueryParameters{ 353 | Top: &requestTop, 354 | } 355 | if !query.IsZero() { 356 | qp.Select = query.Select 357 | qp.Orderby = query.OrderBy 358 | if query.Filter != "" { 359 | qp.Filter = &query.Filter 360 | } 361 | if query.Search != "" { 362 | qp.Search = &query.Search 363 | } 364 | } 365 | resp, err := g.user(userID). 366 | MailFolders().ByMailFolderId(folderID). 367 | Messages().Get(ctx, 368 | &users.ItemMailFoldersItemMessagesRequestBuilderGetRequestConfiguration{ 369 | QueryParameters: &qp, 370 | }) 371 | if err != nil { 372 | return nil, err 373 | } 374 | msgs := make([]Message, 0, requestTop) 375 | it, err := msgraphcore.NewPageIterator[Message](resp, g.client.GetAdapter(), 376 | models.CreateMessageCollectionResponseFromDiscriminatorValue) 377 | if err != nil { 378 | return resp.GetValue(), err 379 | } 380 | err = it.Iterate(ctx, func(m Message) bool { 381 | msgs = append(msgs, m) 382 | return true 383 | }) 384 | return msgs, err 385 | } 386 | 387 | func (g GraphMailClient) CreateFolder(ctx context.Context, userID, displayName string) (Folder, error) { 388 | if err := g.limiter.Wait(ctx); err != nil { 389 | return nil, err 390 | } 391 | f := models.NewMailFolder() 392 | f.SetDisplayName(&displayName) 393 | return g.user(userID).MailFolders().Post(ctx, f, nil) 394 | } 395 | 396 | func (g GraphMailClient) CreateChildFolder(ctx context.Context, userID, parentID, displayName string) (Folder, error) { 397 | if err := g.limiter.Wait(ctx); err != nil { 398 | return nil, err 399 | } 400 | f := models.NewMailFolder() 401 | f.SetParentFolderId(&parentID) 402 | f.SetDisplayName(&displayName) 403 | return g.user(userID).MailFolders().Post(ctx, f, nil) 404 | } 405 | 406 | func (g GraphMailClient) CreateMessage(ctx context.Context, userID, folderID string, msg Message) (Message, error) { 407 | msg.SetParentFolderId(&folderID) 408 | if err := g.limiter.Wait(ctx); err != nil { 409 | return nil, err 410 | } 411 | return g.user(userID).Messages().Post(ctx, msg, nil) 412 | } 413 | 414 | func (g GraphMailClient) CopyMessage(ctx context.Context, userID, msgID, destFolderID string) (Message, error) { 415 | return g.copyOrMoveMessage(ctx, userID, msgID, destFolderID, false) 416 | } 417 | func (g GraphMailClient) MoveMessage(ctx context.Context, userID, msgID, destFolderID string) (Message, error) { 418 | return g.copyOrMoveMessage(ctx, userID, msgID, destFolderID, true) 419 | } 420 | func (g GraphMailClient) copyOrMoveMessage(ctx context.Context, userID, msgID, destFolderID string, move bool) (Message, error) { 421 | if err := g.limiter.Wait(ctx); err != nil { 422 | return nil, err 423 | } 424 | if move { 425 | body := users.NewItemMessagesItemMovePostRequestBody() 426 | body.SetDestinationId(&destFolderID) 427 | return g.user(userID).Messages().ByMessageId(msgID).Move().Post(ctx, body, nil) 428 | } 429 | body := users.NewItemMessagesItemCopyPostRequestBody() 430 | body.SetDestinationId(&destFolderID) 431 | return g.user(userID).Messages().ByMessageId(msgID).Copy().Post(ctx, body, nil) 432 | } 433 | 434 | func (g GraphMailClient) RenameFolder(ctx context.Context, userID, folderID, displayName string) error { 435 | return errors.ErrUnsupported 436 | } 437 | 438 | func (g GraphMailClient) DeleteFolder(ctx context.Context, userID, folderID string) error { 439 | if err := g.limiter.Wait(ctx); err != nil { 440 | return err 441 | } 442 | return g.user(userID).MailFolders().ByMailFolderId(folderID).Delete(ctx, nil) 443 | } 444 | 445 | func (g GraphMailClient) DeleteChildFolder(ctx context.Context, userID, parentID, folderID string) error { 446 | if err := g.limiter.Wait(ctx); err != nil { 447 | return err 448 | } 449 | return g.user(userID).MailFolders().ByMailFolderId(parentID).ChildFolders().ByMailFolderId1(folderID).Delete(ctx, nil) 450 | } 451 | 452 | func (g GraphMailClient) DeleteMessage(ctx context.Context, userID, folderID, msgID string) error { 453 | if err := g.limiter.Wait(ctx); err != nil { 454 | return err 455 | } 456 | return g.user(userID).MailFolders().ByMailFolderId(folderID).Messages().ByMessageId(msgID).Delete(ctx, nil) 457 | } 458 | 459 | func NewFlag(flagged bool) models.FollowupFlagable { 460 | f := models.NewFollowupFlag() 461 | if flagged { 462 | i := models.FLAGGED_FOLLOWUPFLAGSTATUS 463 | f.SetFlagStatus(&i) 464 | } 465 | return f 466 | } 467 | func NewRecipient(name, email string) Recipient { 468 | r := models.NewRecipient() 469 | var a models.EmailAddressable 470 | if name != "" { 471 | if a == nil { 472 | a = models.NewEmailAddress() 473 | } 474 | a.SetName(&name) 475 | } 476 | if email != "" { 477 | if a == nil { 478 | a = models.NewEmailAddress() 479 | } 480 | a.SetAddress(&email) 481 | } 482 | if a != nil { 483 | r.SetEmailAddress(a) 484 | } 485 | return r 486 | } 487 | 488 | func NewMessage() Message { return models.NewMessage() } 489 | 490 | var ErrNotFound = errors.New("not found") 491 | 492 | func (g GraphMailClient) GetFolder(ctx context.Context, userID, displayName string) (Folder, error) { 493 | if _, ok := WellKnownFolders[displayName]; ok { 494 | f, err := g.user(userID).MailFolders().ByMailFolderId(displayName).Get(ctx, nil) 495 | if err != nil { 496 | err = fmt.Errorf("%w: byMailFolderId(%s): %w", ErrNotFound, displayName, err) 497 | } else if f.GetId() == nil { 498 | f.SetId(&displayName) 499 | } 500 | return f, err 501 | } 502 | f := models.NewMailFolder() 503 | if displayName != "" { 504 | f.SetDisplayName(&displayName) 505 | } 506 | return f, nil 507 | } 508 | func NewBody(contentType string, content string) models.ItemBodyable { 509 | body := models.NewItemBody() 510 | var bt models.BodyType 511 | if strings.HasPrefix(contentType, "text/html") { 512 | bt = models.HTML_BODYTYPE 513 | } else { 514 | bt = models.TEXT_BODYTYPE 515 | } 516 | body.SetContentType(&bt) 517 | body.SetContent(&content) 518 | return body 519 | } 520 | 521 | // type imh struct { 522 | // Name string `json:"name"` 523 | // Value string `json:"value"` 524 | // } 525 | 526 | // type Message struct { 527 | // Created time.Time `json:"createdDateTime,omitempty"` 528 | // Modified time.Time `json:"lastModifiedDateTime,omitempty"` 529 | // Received time.Time `json:"receivedDateTime,omitempty"` 530 | // Sent time.Time `json:"sentDateTime,omitempty"` 531 | // Body Content `json:"body,omitempty"` 532 | // Sender EmailAddress `json:"sender,omitempty"` 533 | // From EmailAddress `json:"from,omitempty"` 534 | // UniqueBody Content `json:"uniqueBody,omitempty"` 535 | // ReplyTo []EmailAddress `json:"replyTo,omitempty"` 536 | // ID string `json:"id,omitempty"` 537 | // Subject string `json:"subject,omitempty"` 538 | // BodyPreview string `json:"bodyPreview,omitempty"` 539 | // ChangeKey string `json:"changeKey,omitempty"` 540 | // ConversationID string `json:"conversationId,omitempty"` 541 | // Flag struct { 542 | // Status string `json:"flagStatus,omitempty"` 543 | // } `json:"flag,omitempty"` 544 | // Importance string `json:"importance,omitempty"` 545 | // MessageID string `json:"internetMessageId,omitempty"` 546 | // FolderID string `json:"parentFolderId,omitempty"` 547 | // WebLink string `json:"webLink,omitempty"` 548 | // To []EmailAddress `json:"toRecipients,omitempty"` 549 | // Cc []EmailAddress `json:"bccRecipients,omitempty"` 550 | // Bcc []EmailAddress `json:"ccRecipients,omitempty"` 551 | // Headers []imh `json:"internetMessageHeaders,omitempty"` 552 | // HasAttachments bool `json:"hasAttachments,omitempty"` 553 | // Draft bool `json:"isDraft",omitempty` 554 | // Read bool `json:"isRead,omitempty"` 555 | // } 556 | // type Content struct { 557 | // ContentType string `json:"contentType"` 558 | // Content string `json:"content"` 559 | // } 560 | // type EmailAddress struct { 561 | // Name string `json:"name"` 562 | // Address string `json:"address"` 563 | // } 564 | 565 | func (g GraphMailClient) ListChildFolders(ctx context.Context, userID, folderID string, recursive bool, query Query) ([]Folder, error) { 566 | if err := g.limiter.Wait(ctx); err != nil { 567 | return nil, err 568 | } 569 | conf := users.ItemMailFoldersItemChildFoldersRequestBuilderGetRequestConfiguration{ 570 | QueryParameters: &users.ItemMailFoldersItemChildFoldersRequestBuilderGetQueryParameters{ 571 | Top: &requestTop, 572 | }, 573 | } 574 | resp, err := g.user(userID).MailFolders().ByMailFolderId(folderID).ChildFolders().Get(ctx, &conf) 575 | if err != nil { 576 | return nil, err 577 | } 578 | folders := make([]Folder, 0, requestTop) 579 | it, err := msgraphcore.NewPageIterator[Folder](resp, g.client.GetAdapter(), 580 | models.CreateMailFolderCollectionResponseFromDiscriminatorValue) 581 | if err != nil { 582 | return resp.GetValue(), err 583 | } 584 | err = it.Iterate(ctx, func(f Folder) bool { 585 | folders = append(folders, f) 586 | return true 587 | }) 588 | 589 | return folders, err 590 | } 591 | 592 | func (g GraphMailClient) ListMailFolders(ctx context.Context, userID string, query Query) ([]Folder, error) { 593 | if err := g.limiter.Wait(ctx); err != nil { 594 | return nil, err 595 | } 596 | conf := users.ItemMailFoldersRequestBuilderGetRequestConfiguration{ 597 | QueryParameters: &users.ItemMailFoldersRequestBuilderGetQueryParameters{ 598 | Top: &requestTop, 599 | }, 600 | } 601 | if !query.IsZero() { 602 | conf.QueryParameters.Select = query.Select 603 | if query.Filter != "" { 604 | conf.QueryParameters.Filter = &query.Filter 605 | } 606 | } 607 | resp, err := g.user(userID).MailFolders().Get(ctx, &conf) 608 | if err != nil { 609 | return nil, fmt.Errorf("ListMailFolders(%s, %v): %w", userID, query, err) 610 | } 611 | folders := make([]Folder, 0, requestTop) 612 | it, err := msgraphcore.NewPageIterator[Folder](resp, g.client.GetAdapter(), 613 | models.CreateMailFolderCollectionResponseFromDiscriminatorValue) 614 | if err != nil { 615 | return resp.GetValue(), err 616 | } 617 | logger := zlog.SFromContext(ctx) 618 | err = it.Iterate(ctx, func(f Folder) bool { 619 | logger.Debug("Folder", "dn", f.GetDisplayName(), "id", f.GetId()) 620 | folders = append(folders, f) 621 | return true 622 | }) 623 | return folders, err 624 | } 625 | 626 | func (g GraphMailClient) user(userID string) *users.UserItemRequestBuilder { 627 | if userID == "" || g.isDelegated { 628 | return g.client.Me() 629 | } 630 | return g.client.Users().ByUserId(userID) 631 | } 632 | 633 | type JSON struct { 634 | serialization.Parsable 635 | } 636 | 637 | func (j JSON) String() string { v, _ := serialization.SerializeToJson(j.Parsable); return string(v) } 638 | func (j JSON) MarshalJSON() ([]byte, error) { return serialization.SerializeToJson(j.Parsable) } 639 | --------------------------------------------------------------------------------