From 8f13eb2f167e3f52bf2676d97bb8991f72e837db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Fri, 1 Dec 2023 18:33:04 +0100 Subject: [PATCH 1/2] google mail API [[[WIP]]] --- googleapi/README.md | 54 +++++++++++++++++ googleapi/attachment.go | 41 +++++++++++++ googleapi/body.go | 6 ++ googleapi/oAuth.go | 83 ++++++++++++++++++++++++++ googleapi/sendMail.go | 127 ++++++++++++++++++++++++++++++++++++++++ googleapi/service.go | 14 +++++ 6 files changed, 325 insertions(+) create mode 100644 googleapi/README.md create mode 100644 googleapi/attachment.go create mode 100644 googleapi/body.go create mode 100644 googleapi/oAuth.go create mode 100644 googleapi/sendMail.go create mode 100644 googleapi/service.go 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..344a4bb --- /dev/null +++ b/googleapi/attachment.go @@ -0,0 +1,41 @@ +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 != nil { + res = append(res, "Content-Type: "+*a.ContentType+"; charset=UTF-8") + } + + res = append(res, "Content-Transfer-Encoding: base64") + + if a.IsInline { + if a.Filename != nil { + res = append(res, fmt.Sprintf("Content-Disposition: inline;filename=\"%s\"", *a.Filename)) + } else { + res = append(res, "Content-Disposition: inline") + } + } else { + if a.Filename != nil { + res = append(res, fmt.Sprintf("Content-Disposition: attachment;filename=\"%s\"", *a.Filename)) + } else { + res = append(res, "Content-Disposition: attachment") + } + } + + res = append(res, base64.URLEncoding.EncodeToString(a.Data)) + + return res +} diff --git a/googleapi/body.go b/googleapi/body.go new file mode 100644 index 0000000..c6b48a2 --- /dev/null +++ b/googleapi/body.go @@ -0,0 +1,6 @@ +package googleapi + +type MailBody struct { + Plain *string + HTML *string +} diff --git a/googleapi/oAuth.go b/googleapi/oAuth.go new file mode 100644 index 0000000..96dc2d6 --- /dev/null +++ b/googleapi/oAuth.go @@ -0,0 +1,83 @@ +package googleapi + +import ( + "encoding/json" + "gogs.mikescher.com/BlackForestBytes/goext/langext" + "gogs.mikescher.com/BlackForestBytes/goext/timeext" + "io" + "net/http" + "sync" + "time" +) + +type GoogleOAuth interface { +} + +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{} + + req, err := http.NewRequest(http.MethodPost, "https://oauth2.googleapis.com/token", nil) + if err != nil { + return "", err + } + + req.URL.Query().Add("client_id", c.clientID) + req.URL.Query().Add("client_secret", c.clientSecret) + req.URL.Query().Add("grant_type", "refresh_token") + req.URL.Query().Add("refresh_token", c.refreshToken) + + 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 + } + + 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..73de315 --- /dev/null +++ b/googleapi/sendMail.go @@ -0,0 +1,127 @@ +package googleapi + +import ( + "gogs.mikescher.com/BlackForestBytes/goext/langext" + "mime" + "strings" + "time" +) + +func (c *client) SendMail(from string, recipients []string, cc []string, bcc []string, subject string, body MailBody, attachments []MailAttachment) error { + + data := make([]string, 0, 32) + + data = append(data, "Date: "+time.Now().Format(time.RFC1123Z)) + 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)) + + hasAttachments := len(attachments) > 0 + hasInlineAttachments := langext.ArrAny(attachments, func(v MailAttachment) bool { return v.IsInline }) + hasPlain := body.Plain != nil + hasHTML := body.HTML != nil + + 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 { + // "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, c.dumpHTMLBody(body, hasInlineAttachments, hasAttachments, 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(inlineAttachments)-1 { + data = append(data, "") + } + } + + data = append(data, mixedBoundary) + + } else if hasAttachments { + // "mixed" + + //TODO https://github.dev/muratgozel/MIMEText/blob/master/src/MIMEMessage.ts + + } else if hasPlain && hasHTML { + // "alternative" + + //TODO https://github.dev/muratgozel/MIMEText/blob/master/src/MIMEMessage.ts + + } else { + // "plain" + + //TODO https://github.dev/muratgozel/MIMEText/blob/master/src/MIMEMessage.ts + + } + +} + +func (c *client) dumpHTMLBody(body MailBody, hasInlineAttachments bool, hasAttachments bool, boundary string, boundaryAlt string) []string { + + data := make([]string, 0, 16) + + if body.HTML != nil && body.Plain != nil && hasInlineAttachments { + data = append(data, boundary) + data = append(data, *body.HTML) + } else if body.HTML != nil && body.Plain != nil && hasAttachments { + data = append(data, boundary) + data = append(data, "Content-Type: multipart/alternative; boundary="+boundaryAlt) + data = append(data, "") + data = append(data, boundaryAlt) + data = append(data, *body.Plain) + data = append(data, "") + data = append(data, boundaryAlt) + data = append(data, *body.HTML) + data = append(data, "") + data = append(data, boundaryAlt) + } else if body.HTML != nil && body.Plain != nil { + data = append(data, boundary) + data = append(data, *body.Plain) + data = append(data, "") + data = append(data, boundary) + data = append(data, *body.HTML) + } else if body.HTML != nil { + data = append(data, boundary) + data = append(data, *body.HTML) + } else if body.Plain != nil { + data = append(data, boundary) + data = append(data, *body.Plain) + } else { + data = append(data, boundary) + data = append(data, "") // no content ?!? + } + + return data +} diff --git a/googleapi/service.go b/googleapi/service.go new file mode 100644 index 0000000..b3a90bb --- /dev/null +++ b/googleapi/service.go @@ -0,0 +1,14 @@ +package googleapi + +type GoogleClient interface { +} + +type client struct { + oauth GoogleOAuth +} + +func NewGoogleClient(oauth GoogleOAuth) GoogleClient { + return &client{ + oauth: oauth, + } +} From 358c238f3d99e317eed013e96c16dfbf81f2d003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Mon, 4 Dec 2023 13:48:11 +0100 Subject: [PATCH 2/2] google mail API [[FIN]] --- exerr/data.go | 3 + googleapi/attachment.go | 23 ++-- googleapi/body.go | 4 +- googleapi/mimeMessage.go | 224 ++++++++++++++++++++++++++++++++++ googleapi/mimeMessage_test.go | 77 ++++++++++++ googleapi/oAuth.go | 20 ++- googleapi/sendMail.go | 170 +++++++++----------------- googleapi/sendMail_test.go | 151 +++++++++++++++++++++++ googleapi/service.go | 8 ++ 9 files changed, 549 insertions(+), 131 deletions(-) create mode 100644 googleapi/mimeMessage.go create mode 100644 googleapi/mimeMessage_test.go create mode 100644 googleapi/sendMail_test.go 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/googleapi/attachment.go b/googleapi/attachment.go index 344a4bb..0c4ca45 100644 --- a/googleapi/attachment.go +++ b/googleapi/attachment.go @@ -7,35 +7,40 @@ import ( type MailAttachment struct { IsInline bool - ContentType *string - Filename *string + ContentType string + Filename string Data []byte } func (a MailAttachment) dump() []string { res := make([]string, 0, 4) - if a.ContentType != nil { - res = append(res, "Content-Type: "+*a.ContentType+"; charset=UTF-8") + 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 != nil { - res = append(res, fmt.Sprintf("Content-Disposition: inline;filename=\"%s\"", *a.Filename)) + 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 != nil { - res = append(res, fmt.Sprintf("Content-Disposition: attachment;filename=\"%s\"", *a.Filename)) + if a.Filename != "" { + res = append(res, fmt.Sprintf("Content-Disposition: attachment;filename=\"%s\"", a.Filename)) } else { res = append(res, "Content-Disposition: attachment") } } - res = append(res, base64.URLEncoding.EncodeToString(a.Data)) + 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 index c6b48a2..c927965 100644 --- a/googleapi/body.go +++ b/googleapi/body.go @@ -1,6 +1,6 @@ package googleapi type MailBody struct { - Plain *string - HTML *string + 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 index 96dc2d6..fc3c051 100644 --- a/googleapi/oAuth.go +++ b/googleapi/oAuth.go @@ -2,6 +2,8 @@ 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" @@ -11,6 +13,7 @@ import ( ) type GoogleOAuth interface { + AccessToken() (string, error) } type oauth struct { @@ -41,16 +44,17 @@ func (c *oauth) AccessToken() (string, error) { httpclient := http.Client{} - req, err := http.NewRequest(http.MethodPost, "https://oauth2.googleapis.com/token", nil) + 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 } - req.URL.Query().Add("client_id", c.clientID) - req.URL.Query().Add("client_secret", c.clientSecret) - req.URL.Query().Add("grant_type", "refresh_token") - req.URL.Query().Add("refresh_token", c.refreshToken) - reqStartTime := time.Now() res, err := httpclient.Do(req) @@ -74,6 +78,10 @@ func (c *oauth) AccessToken() (string, error) { 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) diff --git a/googleapi/sendMail.go b/googleapi/sendMail.go index 73de315..db03601 100644 --- a/googleapi/sendMail.go +++ b/googleapi/sendMail.go @@ -1,127 +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" - "mime" - "strings" - "time" + "io" + "net/http" ) -func (c *client) SendMail(from string, recipients []string, cc []string, bcc []string, subject string, body MailBody, attachments []MailAttachment) error { - - data := make([]string, 0, 32) - - data = append(data, "Date: "+time.Now().Format(time.RFC1123Z)) - 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)) - - hasAttachments := len(attachments) > 0 - hasInlineAttachments := langext.ArrAny(attachments, func(v MailAttachment) bool { return v.IsInline }) - hasPlain := body.Plain != nil - hasHTML := body.HTML != nil - - 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 { - // "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, c.dumpHTMLBody(body, hasInlineAttachments, hasAttachments, 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(inlineAttachments)-1 { - data = append(data, "") - } - } - - data = append(data, mixedBoundary) - - } else if hasAttachments { - // "mixed" - - //TODO https://github.dev/muratgozel/MIMEText/blob/master/src/MIMEMessage.ts - - } else if hasPlain && hasHTML { - // "alternative" - - //TODO https://github.dev/muratgozel/MIMEText/blob/master/src/MIMEMessage.ts - - } else { - // "plain" - - //TODO https://github.dev/muratgozel/MIMEText/blob/master/src/MIMEMessage.ts - - } - +type MailRef struct { + ID string `json:"id"` + ThreadID string `json:"threadId"` + LabelIDs []string `json:"labelIds"` } -func (c *client) dumpHTMLBody(body MailBody, hasInlineAttachments bool, hasAttachments bool, boundary string, boundaryAlt string) []string { +func (c *client) SendMail(ctx context.Context, from string, recipients []string, cc []string, bcc []string, subject string, body MailBody, attachments []MailAttachment) (MailRef, error) { - data := make([]string, 0, 16) + mm := encodeMimeMail(from, recipients, cc, bcc, subject, body, attachments) - if body.HTML != nil && body.Plain != nil && hasInlineAttachments { - data = append(data, boundary) - data = append(data, *body.HTML) - } else if body.HTML != nil && body.Plain != nil && hasAttachments { - data = append(data, boundary) - data = append(data, "Content-Type: multipart/alternative; boundary="+boundaryAlt) - data = append(data, "") - data = append(data, boundaryAlt) - data = append(data, *body.Plain) - data = append(data, "") - data = append(data, boundaryAlt) - data = append(data, *body.HTML) - data = append(data, "") - data = append(data, boundaryAlt) - } else if body.HTML != nil && body.Plain != nil { - data = append(data, boundary) - data = append(data, *body.Plain) - data = append(data, "") - data = append(data, boundary) - data = append(data, *body.HTML) - } else if body.HTML != nil { - data = append(data, boundary) - data = append(data, *body.HTML) - } else if body.Plain != nil { - data = append(data, boundary) - data = append(data, *body.Plain) - } else { - data = append(data, boundary) - data = append(data, "") // no content ?!? + tok, err := c.oauth.AccessToken() + if err != nil { + return MailRef{}, exerr.Wrap(err, "").Build() } - return data + 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 index b3a90bb..c4501a7 100644 --- a/googleapi/service.go +++ b/googleapi/service.go @@ -1,14 +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{}, } }