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] 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, + } +}