diff --git a/exerr/data.go b/exerr/data.go index a5c2002..5d6b0b7 100644 --- a/exerr/data.go +++ b/exerr/data.go @@ -59,6 +59,9 @@ var ( TypeMarshalEntityID = NewType("MARSHAL_ENTITY_ID", langext.Ptr(400)) TypeInvalidCSID = NewType("INVALID_CSID", langext.Ptr(400)) + TypeGoogleStatuscode = NewType("GOOGLE_STATUSCODE", langext.Ptr(400)) + TypeGoogleResponse = NewType("GOOGLE_RESPONSE", langext.Ptr(400)) + TypeUnauthorized = NewType("UNAUTHORIZED", langext.Ptr(401)) TypeAuthFailed = NewType("AUTH_FAILED", langext.Ptr(401)) diff --git a/goextVersion.go b/goextVersion.go index 87c78f9..f906494 100644 --- a/goextVersion.go +++ b/goextVersion.go @@ -1,5 +1,5 @@ package goext -const GoextVersion = "0.0.329" +const GoextVersion = "0.0.333" -const GoextVersionTimestamp = "2023-12-02T13:38:17+0100" +const GoextVersionTimestamp = "2023-12-02T13:56:00+0100" diff --git a/googleapi/README.md b/googleapi/README.md new file mode 100644 index 0000000..ad54bc8 --- /dev/null +++ b/googleapi/README.md @@ -0,0 +1,54 @@ + +Google OAuth Setup (to send mails) +================================== + + + - Login @ https://console.cloud.google.com + + - GMail API akivieren: https://console.cloud.google.com/apis/library/gmail.googleapis.com? + + - Create new Project (aka 'BackendMailAPI') @ https://console.cloud.google.com/projectcreate + User Type: Intern + Anwendungsname: 'BackendMailAPI' + Support-Email: ... + Authorisierte Domains: 'heydyno.de' (or project domain) + Kontakt-Email: ... + + + - Unter "Anmeldedaten" neuer OAuth Client erstellen @ https://console.cloud.google.com/apis/credentials + Anwendungstyp: Web + Name: 'BackendMailOAuth' + Redirect-Uri: 'http://localhost/oauth' + Client-ID und Client-Key merken + + - Open in Browser: + https://accounts.google.com/o/oauth2/v2/auth?redirect_uri=http://localhost/oauth&prompt=consent&response_type=code&client_id={...}&scope=https://www.googleapis.com/auth/gmail.send&access_type=offline + Code aus redirected URI merken + + - Code via request einlösen (und refresh_roken merken): + +``` +curl --request POST \ + --url https://oauth2.googleapis.com/token \ + --data code={...} \ + --data redirect_uri=http://localhost/oauth \ + --data client_id={...} \ + --data client_secret={...} \ + --data grant_type=authorization_code \ + --data scope=https://www.googleapis.com/auth/gmail.send +``` + + - Fertig, mit `client_id`, `client_secret` und `refresh_token` kann das package benutzt werden + + + + + + + + + + + + + diff --git a/googleapi/attachment.go b/googleapi/attachment.go new file mode 100644 index 0000000..0c4ca45 --- /dev/null +++ b/googleapi/attachment.go @@ -0,0 +1,46 @@ +package googleapi + +import ( + "encoding/base64" + "fmt" +) + +type MailAttachment struct { + IsInline bool + ContentType string + Filename string + Data []byte +} + +func (a MailAttachment) dump() []string { + res := make([]string, 0, 4) + + if a.ContentType != "" { + res = append(res, "Content-Type: "+a.ContentType+"; charset=UTF-8") + } + + res = append(res, "Content-Transfer-Encoding: base64") + + if a.IsInline { + if a.Filename != "" { + res = append(res, fmt.Sprintf("Content-Disposition: inline;filename=\"%s\"", a.Filename)) + } else { + res = append(res, "Content-Disposition: inline") + } + } else { + if a.Filename != "" { + res = append(res, fmt.Sprintf("Content-Disposition: attachment;filename=\"%s\"", a.Filename)) + } else { + res = append(res, "Content-Disposition: attachment") + } + } + + b64 := base64.StdEncoding.EncodeToString(a.Data) + for i := 0; i < len(b64); i += 80 { + res = append(res, b64[i:min(i+80, len(b64))]) + } + + res = append(res) + + return res +} diff --git a/googleapi/body.go b/googleapi/body.go new file mode 100644 index 0000000..c927965 --- /dev/null +++ b/googleapi/body.go @@ -0,0 +1,6 @@ +package googleapi + +type MailBody struct { + Plain string + HTML string +} diff --git a/googleapi/mimeMessage.go b/googleapi/mimeMessage.go new file mode 100644 index 0000000..a5a4d7b --- /dev/null +++ b/googleapi/mimeMessage.go @@ -0,0 +1,224 @@ +package googleapi + +import ( + "gogs.mikescher.com/BlackForestBytes/goext/langext" + "mime" + "strings" + "time" +) + +// https://datatracker.ietf.org/doc/html/rfc2822 +func encodeMimeMail(from string, recipients []string, cc []string, bcc []string, subject string, body MailBody, attachments []MailAttachment) string { + + data := make([]string, 0, 32) + + data = append(data, "Date: "+time.Now().Format(time.RFC1123Z)) + data = append(data, "MIME-Version: 1.0") + data = append(data, "From: "+mime.QEncoding.Encode("UTF-8", from)) + data = append(data, "To: "+strings.Join(langext.ArrMap(recipients, func(v string) string { return mime.QEncoding.Encode("UTF-8", v) }), ", ")) + if len(cc) > 0 { + data = append(data, "To: "+strings.Join(langext.ArrMap(cc, func(v string) string { return mime.QEncoding.Encode("UTF-8", v) }), ", ")) + } + if len(bcc) > 0 { + data = append(data, "Bcc: "+strings.Join(langext.ArrMap(bcc, func(v string) string { return mime.QEncoding.Encode("UTF-8", v) }), ", ")) + } + data = append(data, "Subject: "+mime.QEncoding.Encode("UTF-8", subject)) + + hasInlineAttachments := langext.ArrAny(attachments, func(v MailAttachment) bool { return v.IsInline }) + hasNormalAttachments := langext.ArrAny(attachments, func(v MailAttachment) bool { return !v.IsInline }) + hasPlain := body.Plain != "" + hasHTML := body.HTML != "" + + mixedBoundary := langext.MustRawHexUUID() + relatedBoundary := langext.MustRawHexUUID() + altBoundary := langext.MustRawHexUUID() + + inlineAttachments := langext.ArrFilter(attachments, func(v MailAttachment) bool { return v.IsInline }) + normalAttachments := langext.ArrFilter(attachments, func(v MailAttachment) bool { return !v.IsInline }) + + if hasInlineAttachments && hasNormalAttachments { + // "mixed+related" + + data = append(data, "Content-Type: multipart/mixed; boundary="+mixedBoundary) + data = append(data, "") + data = append(data, "--"+mixedBoundary) + + data = append(data, "Content-Type: multipart/related; boundary="+relatedBoundary) + data = append(data, "") + + data = append(data, dumpMailBody(body, hasInlineAttachments, hasNormalAttachments, relatedBoundary, altBoundary)...) + data = append(data, "") + + for i, attachment := range inlineAttachments { + data = append(data, "--"+relatedBoundary) + data = append(data, attachment.dump()...) + + if i < len(inlineAttachments)-1 { + data = append(data, "") + } + } + + data = append(data, "--"+relatedBoundary+"--") + + for i, attachment := range normalAttachments { + data = append(data, "--"+mixedBoundary) + data = append(data, attachment.dump()...) + + if i < len(normalAttachments)-1 { + data = append(data, "") + } + } + + data = append(data, "--"+mixedBoundary+"--") + + } else if hasNormalAttachments { + // "mixed" + + data = append(data, "Content-Type: multipart/mixed; boundary="+mixedBoundary) + data = append(data, "") + + data = append(data, dumpMailBody(body, hasInlineAttachments, hasNormalAttachments, mixedBoundary, altBoundary)...) + if hasPlain && hasHTML { + data = append(data, "") + } + + for i, attachment := range normalAttachments { + data = append(data, "--"+mixedBoundary) + data = append(data, attachment.dump()...) + + if i < len(normalAttachments)-1 { + data = append(data, "") + } + } + + data = append(data, "--"+mixedBoundary+"--") + + } else if hasInlineAttachments { + // "related" + + data = append(data, "Content-Type: multipart/related; boundary="+relatedBoundary) + data = append(data, "") + + data = append(data, dumpMailBody(body, hasInlineAttachments, hasNormalAttachments, relatedBoundary, altBoundary)...) + data = append(data, "") + + for i, attachment := range inlineAttachments { + data = append(data, "--"+relatedBoundary) + data = append(data, attachment.dump()...) + + if i < len(inlineAttachments)-1 { + data = append(data, "") + } + } + + data = append(data, "--"+relatedBoundary+"--") + + } else if hasPlain && hasHTML { + // "alternative" + + data = append(data, "Content-Type: multipart/alternative; boundary="+altBoundary) + data = append(data, "") + + data = append(data, dumpMailBody(body, hasInlineAttachments, hasNormalAttachments, altBoundary, altBoundary)...) + data = append(data, "") + + data = append(data, "--"+altBoundary+"--") + + } else if hasPlain { + // "plain" + + data = append(data, "Content-Type: text/plain; charset=UTF-8") + data = append(data, "Content-Transfer-Encoding: 7bit") + data = append(data, "") + data = append(data, body.Plain) + + } else if hasHTML { + // "plain" + + data = append(data, "Content-Type: text/html; charset=UTF-8") + data = append(data, "Content-Transfer-Encoding: 7bit") + data = append(data, "") + data = append(data, body.HTML) + + } else { + // "empty??" + + } + + return strings.Join(data, "\r\n") +} + +func dumpMailBody(body MailBody, hasInlineAttachments bool, hasNormalAttachments bool, boundary string, boundaryAlt string) []string { + + if body.HTML != "" && body.Plain != "" && !hasInlineAttachments && hasNormalAttachments { + data := make([]string, 0, 16) + data = append(data, "--"+boundary) + data = append(data, "Content-Type: multipart/alternative; boundary="+boundaryAlt) + data = append(data, "") + data = append(data, "--"+boundaryAlt) + data = append(data, "Content-Type: text/plain; charset=UTF-8") + data = append(data, "Content-Transfer-Encoding: 7bit") + data = append(data, "") + data = append(data, body.Plain) + data = append(data, "") + data = append(data, "--"+boundaryAlt) + data = append(data, "Content-Type: text/html; charset=UTF-8") + data = append(data, "Content-Transfer-Encoding: 7bit") + data = append(data, "") + data = append(data, body.HTML) + data = append(data, "") + data = append(data, "--"+boundaryAlt+"--") + return data + } + + if body.HTML != "" && body.Plain != "" && hasInlineAttachments { + data := make([]string, 0, 2) + data = append(data, "--"+boundary) + data = append(data, body.HTML) + return data + } + + if body.HTML != "" && body.Plain != "" { + data := make([]string, 0, 8) + data = append(data, "--"+boundary) + data = append(data, "Content-Type: text/plain; charset=UTF-8") + data = append(data, "Content-Transfer-Encoding: 7bit") + data = append(data, "") + data = append(data, body.Plain) + data = append(data, "") + data = append(data, "--"+boundary) + data = append(data, "Content-Type: text/html; charset=UTF-8") + data = append(data, "Content-Transfer-Encoding: 7bit") + data = append(data, "") + data = append(data, body.HTML) + return data + } + + if body.HTML != "" { + data := make([]string, 0, 2) + data = append(data, "--"+boundary) + data = append(data, "Content-Type: text/html; charset=UTF-8") + data = append(data, "Content-Transfer-Encoding: 7bit") + data = append(data, "") + data = append(data, body.HTML) + return data + } + + if body.Plain != "" { + data := make([]string, 0, 2) + data = append(data, "--"+boundary) + data = append(data, "Content-Type: text/plain; charset=UTF-8") + data = append(data, "Content-Transfer-Encoding: 7bit") + data = append(data, "") + data = append(data, body.Plain) + return data + } + + data := make([]string, 0, 16) + data = append(data, "--"+boundary) + data = append(data, "Content-Type: text/plain; charset=UTF-8") + data = append(data, "Content-Transfer-Encoding: 7bit") + data = append(data, "") + data = append(data, "") // no content ?!? + return data +} diff --git a/googleapi/mimeMessage_test.go b/googleapi/mimeMessage_test.go new file mode 100644 index 0000000..9ed1ca7 --- /dev/null +++ b/googleapi/mimeMessage_test.go @@ -0,0 +1,77 @@ +package googleapi + +import ( + "fmt" + "gogs.mikescher.com/BlackForestBytes/goext/tst" + "os" + "testing" +) + +func TestEncodeMimeMail(t *testing.T) { + + mail := encodeMimeMail( + "noreply@heydyno.de", + []string{"trash@mikescher.de"}, + nil, + nil, + "Hello Test Mail", + MailBody{Plain: "Plain Text"}, + nil) + + fmt.Printf("%s\n\n", mail) +} + +func TestEncodeMimeMail2(t *testing.T) { + + mail := encodeMimeMail( + "noreply@heydyno.de", + []string{"trash@mikescher.de"}, + nil, + nil, + "Hello Test Mail (alternative)", + MailBody{ + Plain: "Plain Text", + HTML: "
Non Plain Text", + }, + nil) + + fmt.Printf("%s\n\n", mail) +} + +func TestEncodeMimeMail3(t *testing.T) { + + mail := encodeMimeMail( + "noreply@heydyno.de", + []string{"trash@mikescher.de"}, + nil, + nil, + "Hello Test Mail (alternative)", + MailBody{ + HTML: "Non Plain Text", + }, + []MailAttachment{ + {Data: []byte("HelloWorld"), Filename: "test.txt", IsInline: false, ContentType: "text/plain"}, + }) + + fmt.Printf("%s\n\n", mail) +} + +func TestEncodeMimeMail4(t *testing.T) { + + b := tst.Must(os.ReadFile("/home/mike/Pictures/Screenshot_20220706_190205.png"))(t) + + mail := encodeMimeMail( + "noreply@heydyno.de", + []string{"trash@mikescher.de"}, + nil, + nil, + "Hello Test Mail (inline)", + MailBody{ + HTML: "Non Plain Text", + }, + []MailAttachment{ + {Data: b, Filename: "img.png", IsInline: true, ContentType: "image/png"}, + }) + + fmt.Printf("%s\n\n", mail) +} diff --git a/googleapi/oAuth.go b/googleapi/oAuth.go new file mode 100644 index 0000000..fc3c051 --- /dev/null +++ b/googleapi/oAuth.go @@ -0,0 +1,91 @@ +package googleapi + +import ( + "encoding/json" + "fmt" + "gogs.mikescher.com/BlackForestBytes/goext/exerr" + "gogs.mikescher.com/BlackForestBytes/goext/langext" + "gogs.mikescher.com/BlackForestBytes/goext/timeext" + "io" + "net/http" + "sync" + "time" +) + +type GoogleOAuth interface { + AccessToken() (string, error) +} + +type oauth struct { + clientID string + clientSecret string + refreshToken string + + lock sync.RWMutex + accessToken *string + expiryDate *time.Time +} + +func NewGoogleOAuth(clientid string, clientsecret, refreshtoken string) GoogleOAuth { + return &oauth{ + clientID: clientid, + clientSecret: clientsecret, + refreshToken: refreshtoken, + } +} + +func (c *oauth) AccessToken() (string, error) { + c.lock.RLock() + if c.accessToken != nil && c.expiryDate != nil && (*c.expiryDate).After(time.Now()) { + c.lock.RUnlock() + return *c.accessToken, nil // still valid + } + c.lock.RUnlock() + + httpclient := http.Client{} + + url := fmt.Sprintf("https://oauth2.googleapis.com/token?client_id=%s&client_secret=%s&grant_type=%s&refresh_token=%s", + c.clientID, + c.clientSecret, + "refresh_token", + c.refreshToken) + + req, err := http.NewRequest(http.MethodPost, url, nil) + if err != nil { + return "", err + } + + reqStartTime := time.Now() + + res, err := httpclient.Do(req) + + type response struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` + } + + var r response + + data, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + + err = json.Unmarshal(data, &r) + if err != nil { + return "", err + } + + if r.ExpiresIn == 0 || r.AccessToken == "" { + return "", exerr.New(exerr.TypeGoogleResponse, "google oauth returned no response").Str("body", string(data)).Build() + } + + c.lock.Lock() + c.expiryDate = langext.Ptr(reqStartTime.Add(timeext.FromSeconds(r.ExpiresIn - 10))) + c.accessToken = langext.Ptr(r.AccessToken) + c.lock.Unlock() + + return r.AccessToken, nil +} diff --git a/googleapi/sendMail.go b/googleapi/sendMail.go new file mode 100644 index 0000000..db03601 --- /dev/null +++ b/googleapi/sendMail.go @@ -0,0 +1,69 @@ +package googleapi + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "gogs.mikescher.com/BlackForestBytes/goext" + "gogs.mikescher.com/BlackForestBytes/goext/exerr" + "gogs.mikescher.com/BlackForestBytes/goext/langext" + "io" + "net/http" +) + +type MailRef struct { + ID string `json:"id"` + ThreadID string `json:"threadId"` + LabelIDs []string `json:"labelIds"` +} + +func (c *client) SendMail(ctx context.Context, from string, recipients []string, cc []string, bcc []string, subject string, body MailBody, attachments []MailAttachment) (MailRef, error) { + + mm := encodeMimeMail(from, recipients, cc, bcc, subject, body, attachments) + + tok, err := c.oauth.AccessToken() + if err != nil { + return MailRef{}, exerr.Wrap(err, "").Build() + } + + url := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/%s/messages/send?alt=json&prettyPrint=false", "me") + + msgbody, err := json.Marshal(langext.H{"raw": base64.URLEncoding.EncodeToString([]byte(mm))}) + if err != nil { + return MailRef{}, exerr.Wrap(err, "").Build() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(msgbody)) + if err != nil { + return MailRef{}, exerr.Wrap(err, "").Build() + } + + req.Header.Add("Authorization", "Bearer "+tok) + req.Header.Add("X-Goog-Api-Client", "blackforestbytes-goext/"+goext.GoextVersion) + req.Header.Add("User-Agent", "blackforestbytes-goext/"+goext.GoextVersion) + req.Header.Add("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return MailRef{}, exerr.Wrap(err, "").Build() + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return MailRef{}, exerr.Wrap(err, "").Build() + } + + if resp.StatusCode != 200 { + return MailRef{}, exerr.New(exerr.TypeGoogleStatuscode, "gmail returned non-200 statuscode").Int("sc", resp.StatusCode).Str("body", string(respBody)).Build() + } + + var respObj MailRef + err = json.Unmarshal(respBody, &respObj) + if err != nil { + return MailRef{}, exerr.Wrap(err, "").Str("body", string(respBody)).Build() + } + + return respObj, nil +} diff --git a/googleapi/sendMail_test.go b/googleapi/sendMail_test.go new file mode 100644 index 0000000..2a4b8c7 --- /dev/null +++ b/googleapi/sendMail_test.go @@ -0,0 +1,151 @@ +package googleapi + +import ( + "context" + "fmt" + "gogs.mikescher.com/BlackForestBytes/goext/exerr" + "gogs.mikescher.com/BlackForestBytes/goext/langext" + "gogs.mikescher.com/BlackForestBytes/goext/tst" + "os" + "testing" +) + +func TestMain(m *testing.M) { + if !exerr.Initialized() { + exerr.Init(exerr.ErrorPackageConfigInit{ZeroLogErrTraces: langext.PFalse, ZeroLogAllTraces: langext.PFalse}) + } + os.Exit(m.Run()) +} + +func TestSendMail1(t *testing.T) { + t.Skip() + return + + auth := NewGoogleOAuth( + "554617284247-8di0j6s5dcmlk4lmk4hdf9kdn8scss54.apps.googleusercontent.com", + "TODO", + "TODO") + + ctx := context.Background() + + gclient := NewGoogleClient(auth) + + mail, err := gclient.SendMail( + ctx, + "noreply@heydyno.de", + []string{"trash@mikescher.de"}, + nil, + nil, + "Hello Test Mail", + MailBody{Plain: "Plain Text"}, + nil) + + tst.AssertNoErr(t, err) + + fmt.Printf("mail.ID := %s\n", mail.ID) + fmt.Printf("mail.ThreadID := %s\n", mail.ThreadID) + fmt.Printf("mail.LabelIDs := %v\n", mail.LabelIDs) +} + +func TestSendMail2(t *testing.T) { + t.Skip() + return + + auth := NewGoogleOAuth( + "554617284247-8di0j6s5dcmlk4lmk4hdf9kdn8scss54.apps.googleusercontent.com", + "TODO", + "TODO") + + ctx := context.Background() + + gclient := NewGoogleClient(auth) + + mail, err := gclient.SendMail( + ctx, + "noreply@heydyno.de", + []string{"trash@mikescher.de"}, + nil, + nil, + "Hello Test Mail (alternative)", + MailBody{ + Plain: "Plain Text", + HTML: "Non Plain Text", + }, + nil) + + tst.AssertNoErr(t, err) + + fmt.Printf("mail.ID := %s\n", mail.ID) + fmt.Printf("mail.ThreadID := %s\n", mail.ThreadID) + fmt.Printf("mail.LabelIDs := %v\n", mail.LabelIDs) +} + +func TestSendMail3(t *testing.T) { + t.Skip() + return + + auth := NewGoogleOAuth( + "554617284247-8di0j6s5dcmlk4lmk4hdf9kdn8scss54.apps.googleusercontent.com", + "TODO", + "TODO") + + ctx := context.Background() + + gclient := NewGoogleClient(auth) + + mail, err := gclient.SendMail( + ctx, + "noreply@heydyno.de", + []string{"trash@mikescher.de"}, + nil, + nil, + "Hello Test Mail (attach)", + MailBody{ + HTML: "Non Plain Text", + }, + []MailAttachment{ + {Data: []byte("HelloWorld"), Filename: "test.txt", IsInline: false, ContentType: "text/plain"}, + }) + + tst.AssertNoErr(t, err) + + fmt.Printf("mail.ID := %s\n", mail.ID) + fmt.Printf("mail.ThreadID := %s\n", mail.ThreadID) + fmt.Printf("mail.LabelIDs := %v\n", mail.LabelIDs) +} + +func TestSendMail4(t *testing.T) { + t.Skip() + return + + auth := NewGoogleOAuth( + "554617284247-8di0j6s5dcmlk4lmk4hdf9kdn8scss54.apps.googleusercontent.com", + "TODO", + "TODO") + + ctx := context.Background() + + gclient := NewGoogleClient(auth) + + b := tst.Must(os.ReadFile("/home/mike/Pictures/Screenshot_20220706_190205.png"))(t) + + mail, err := gclient.SendMail( + ctx, + "noreply@heydyno.de", + []string{"trash@mikescher.de"}, + nil, + nil, + "Hello Test Mail (inline)", + MailBody{ + HTML: "Non Plain Text", + }, + []MailAttachment{ + {Data: b, Filename: "img.png", IsInline: true, ContentType: "image/png"}, + }) + + tst.AssertNoErr(t, err) + + fmt.Printf("mail.ID := %s\n", mail.ID) + fmt.Printf("mail.ThreadID := %s\n", mail.ThreadID) + fmt.Printf("mail.LabelIDs := %v\n", mail.LabelIDs) +} diff --git a/googleapi/service.go b/googleapi/service.go new file mode 100644 index 0000000..c4501a7 --- /dev/null +++ b/googleapi/service.go @@ -0,0 +1,22 @@ +package googleapi + +import ( + "context" + "net/http" +) + +type GoogleClient interface { + SendMail(ctx context.Context, from string, recipients []string, cc []string, bcc []string, subject string, body MailBody, attachments []MailAttachment) (MailRef, error) +} + +type client struct { + oauth GoogleOAuth + http http.Client +} + +func NewGoogleClient(oauth GoogleOAuth) GoogleClient { + return &client{ + oauth: oauth, + http: http.Client{}, + } +}