diff --git a/email/azure_acs.go b/email/azure_acs.go new file mode 100644 index 00000000..edd1f1c3 --- /dev/null +++ b/email/azure_acs.go @@ -0,0 +1,227 @@ +// Copyright 2023 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package email + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + importanceNormal = "normal" + sendEmailEndpoint = "/emails:send" + apiVersion = "2023-03-31" +) + +type Email struct { + Recipients Recipients `json:"recipients"` + SenderAddress string `json:"senderAddress"` + Content Content `json:"content"` + Headers []CustomHeader `json:"headers"` + Tracking bool `json:"disableUserEngagementTracking"` + Importance string `json:"importance"` + ReplyTo []EmailAddress `json:"replyTo"` + Attachments []Attachment `json:"attachments"` +} + +type Recipients struct { + To []EmailAddress `json:"to"` + CC []EmailAddress `json:"cc"` + BCC []EmailAddress `json:"bcc"` +} + +type EmailAddress struct { + DisplayName string `json:"displayName"` + Address string `json:"address"` +} + +type Content struct { + Subject string `json:"subject"` + HTML string `json:"html"` + PlainText string `json:"plainText"` +} + +type CustomHeader struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type Attachment struct { + Content string `json:"contentBytesBase64"` + AttachmentType string `json:"attachmentType"` + Name string `json:"name"` +} + +type ErrorResponse struct { + Error CommunicationError `json:"error"` +} + +// CommunicationError contains the error code and message +type CommunicationError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type AzureACSEmailProvider struct { + AccessKey string + Endpoint string +} + +func NewAzureACSEmailProvider(accessKey string, endpoint string) *AzureACSEmailProvider { + return &AzureACSEmailProvider{ + AccessKey: accessKey, + Endpoint: endpoint, + } +} + +func newEmail(fromAddress string, toAddress string, subject string, content string) *Email { + return &Email{ + Recipients: Recipients{ + To: []EmailAddress{ + { + DisplayName: toAddress, + Address: toAddress, + }, + }, + }, + SenderAddress: fromAddress, + Content: Content{ + Subject: subject, + HTML: content, + }, + Importance: importanceNormal, + } +} + +func (a *AzureACSEmailProvider) sendEmail(e *Email) error { + postBody, err := json.Marshal(e) + if err != nil { + return fmt.Errorf("email JSON marshall failed: %s", err) + } + + bodyBuffer := bytes.NewBuffer(postBody) + + req, err := http.NewRequest("POST", a.Endpoint+sendEmailEndpoint+"?api-version="+apiVersion, bodyBuffer) + if err != nil { + return fmt.Errorf("error creating AzureACS API request: %s", err) + } + + // Sign the request using the AzureACS access key and HMAC-SHA256 + err = signRequestHMAC(a.AccessKey, req) + if err != nil { + return fmt.Errorf("error signing AzureACS API request: %s", err) + } + + req.Header.Set("Content-Type", "application/json") + + // Some important header + req.Header.Set("repeatability-request-id", uuid.New().String()) + req.Header.Set("repeatability-first-sent", time.Now().UTC().Format(http.TimeFormat)) + + // Send request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("error sending AzureACS API request: %s", err) + } + defer resp.Body.Close() + + // Response error Handling + if resp.StatusCode == http.StatusBadRequest { + commError := ErrorResponse{} + + err = json.NewDecoder(resp.Body).Decode(&commError) + if err != nil { + return err + } + + return fmt.Errorf("error sending email: %s", commError.Error.Message) + } + + if resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("error sending email: status: %d", resp.StatusCode) + } + + return nil +} + +func signRequestHMAC(secret string, req *http.Request) error { + method := req.Method + host := req.URL.Host + pathAndQuery := req.URL.Path + + if req.URL.RawQuery != "" { + pathAndQuery = pathAndQuery + "?" + req.URL.RawQuery + } + + var content []byte + var err error + if req.Body != nil { + content, err = io.ReadAll(req.Body) + if err != nil { + // return err + content = []byte{} + } + } + + req.Body = io.NopCloser(bytes.NewBuffer(content)) + + key, err := base64.StdEncoding.DecodeString(secret) + if err != nil { + return fmt.Errorf("error decoding secret: %s", err) + } + + timestamp := time.Now().UTC().Format(http.TimeFormat) + contentHash := GetContentHashBase64(content) + stringToSign := fmt.Sprintf("%s\n%s\n%s;%s;%s", strings.ToUpper(method), pathAndQuery, timestamp, host, contentHash) + signature := GetHmac(stringToSign, key) + + req.Header.Set("x-ms-content-sha256", contentHash) + req.Header.Set("x-ms-date", timestamp) + + req.Header.Set("Authorization", "HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature="+signature) + + return nil +} + +func GetContentHashBase64(content []byte) string { + hasher := sha256.New() + hasher.Write(content) + + return base64.StdEncoding.EncodeToString(hasher.Sum(nil)) +} + +func GetHmac(content string, key []byte) string { + hmac := hmac.New(sha256.New, key) + hmac.Write([]byte(content)) + + return base64.StdEncoding.EncodeToString(hmac.Sum(nil)) +} + +func (a *AzureACSEmailProvider) Send(fromAddress string, fromName string, toAddress string, subject string, content string) error { + e := newEmail(fromAddress, toAddress, subject, content) + + return a.sendEmail(e) +} diff --git a/email/provider.go b/email/provider.go new file mode 100644 index 00000000..56b5ff7d --- /dev/null +++ b/email/provider.go @@ -0,0 +1,27 @@ +// Copyright 2023 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package email + +type EmailProvider interface { + Send(fromAddress string, fromName, toAddress string, subject string, content string) error +} + +func GetEmailProvider(typ string, clientId string, clientSecret string, appId string, host string, port int, disableSsl bool) EmailProvider { + if typ == "Azure ACS" { + return NewAzureACSEmailProvider(appId, host) + } else { + return NewSmtpEmailProvider(clientId, clientSecret, host, port, typ, disableSsl) + } +} diff --git a/email/smtp.go b/email/smtp.go new file mode 100644 index 00000000..d26d357d --- /dev/null +++ b/email/smtp.go @@ -0,0 +1,49 @@ +// Copyright 2023 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package email + +import ( + "crypto/tls" + + "github.com/casdoor/gomail/v2" +) + +type SmtpEmailProvider struct { + Dialer *gomail.Dialer +} + +func NewSmtpEmailProvider(userName string, password string, host string, port int, typ string, disableSsl bool) *SmtpEmailProvider { + dialer := &gomail.Dialer{} + dialer = gomail.NewDialer(host, port, userName, password) + if typ == "SUBMAIL" { + dialer.TLSConfig = &tls.Config{InsecureSkipVerify: true} + } + + dialer.SSL = !disableSsl + + return &SmtpEmailProvider{Dialer: dialer} +} + +func (s *SmtpEmailProvider) Send(fromAddress string, fromName string, toAddress string, subject string, content string) error { + message := gomail.NewMessage() + + message.SetAddressHeader("From", fromAddress, fromName) + message.SetHeader("To", toAddress) + message.SetHeader("Subject", subject) + message.SetBody("text/html", content) + + message.SkipUsernameCheck = true + return s.Dialer.DialAndSend(message) +} diff --git a/object/email.go b/object/email.go index 55bbb0ff..e393295b 100644 --- a/object/email.go +++ b/object/email.go @@ -19,6 +19,7 @@ package object import ( "crypto/tls" + "github.com/casdoor/casdoor/email" "github.com/casdoor/gomail/v2" ) @@ -35,9 +36,7 @@ func getDialer(provider *Provider) *gomail.Dialer { } func SendEmail(provider *Provider, title string, content string, dest string, sender string) error { - dialer := getDialer(provider) - - message := gomail.NewMessage() + emailProvider := email.GetEmailProvider(provider.Type, provider.ClientId, provider.ClientSecret, provider.AppId, provider.Host, provider.Port, provider.DisableSsl) fromAddress := provider.ClientId2 if fromAddress == "" { @@ -49,14 +48,7 @@ func SendEmail(provider *Provider, title string, content string, dest string, se fromName = sender } - message.SetAddressHeader("From", fromAddress, fromName) - message.SetHeader("To", dest) - message.SetHeader("Subject", title) - message.SetBody("text/html", content) - - message.SkipUsernameCheck = true - - return dialer.DialAndSend(message) + return emailProvider.Send(fromAddress, fromName, dest, title, content) } // DailSmtpServer Dail Smtp server diff --git a/web/src/ProviderEditPage.js b/web/src/ProviderEditPage.js index 72f3b531..801daae6 100644 --- a/web/src/ProviderEditPage.js +++ b/web/src/ProviderEditPage.js @@ -297,7 +297,7 @@ class ProviderEditPage extends React.Component { tooltip = i18next.t("provider:Project Id - Tooltip"); } } else if (provider.category === "Email") { - if (provider.type === "SUBMAIL") { + if (provider.type === "SUBMAIL" || provider.type === "Azure ACS") { text = i18next.t("provider:App ID"); tooltip = i18next.t("provider:App ID - Tooltip"); } @@ -626,6 +626,7 @@ class ProviderEditPage extends React.Component { } { (this.state.provider.category === "Captcha" && this.state.provider.type === "Default") || + (this.state.provider.category === "Email" && this.state.provider.type === "Azure ACS") || (this.state.provider.category === "Web3") || (this.state.provider.category === "Storage" && this.state.provider.type === "Local File System" || (this.state.provider.category === "Notification" && this.state.provider.type !== "Webpush" && this.state.provider.type !== "Line" && this.state.provider.type !== "Matrix" && this.state.provider.type !== "Twitter" && this.state.provider.type !== "Reddit" && this.state.provider.type !== "Rocket Chat" && this.state.provider.type !== "Viber")) ? null : ( @@ -671,7 +672,7 @@ class ProviderEditPage extends React.Component { { - this.state.provider.type === "WeChat Pay" ? null : ( + (this.state.provider.type === "WeChat Pay") || (this.state.provider.category === "Email" && this.state.provider.type === "Azure ACS") ? null : ( {this.getClientSecret2Label(this.state.provider)} : @@ -866,26 +867,30 @@ class ProviderEditPage extends React.Component { }} /> - - - {Setting.getLabel(i18next.t("provider:Port"), i18next.t("provider:Port - Tooltip"))} : - - - { - this.updateProviderField("port", value); - }} /> - - - - - {Setting.getLabel(i18next.t("provider:Disable SSL"), i18next.t("provider:Disable SSL - Tooltip"))} : - - - { - this.updateProviderField("disableSsl", checked); - }} /> - - + {["Azure ACS"].includes(this.state.provider.type) ? null : ( + + + {Setting.getLabel(i18next.t("provider:Port"), i18next.t("provider:Port - Tooltip"))} : + + + { + this.updateProviderField("port", value); + }} /> + + + )} + {["Azure ACS"].includes(this.state.provider.type) ? null : ( + + + {Setting.getLabel(i18next.t("provider:Disable SSL"), i18next.t("provider:Disable SSL - Tooltip"))} : + + + { + this.updateProviderField("disableSsl", checked); + }} /> + + + )} {Setting.getLabel(i18next.t("provider:Email title"), i18next.t("provider:Email title - Tooltip"))} : @@ -915,9 +920,11 @@ class ProviderEditPage extends React.Component { this.updateProviderField("receiver", e.target.value); }} /> - + {["Azure ACS"].includes(this.state.provider.type) ? null : ( + + )}