feat: can specify content type and http body field mapping for Custom HTTP Email provider (#3730)

This commit is contained in:
DacongDA 2025-04-17 01:59:11 +08:00 committed by GitHub
parent 2f4180b1b6
commit 0860cbf343
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 168 additions and 44 deletions

View File

@ -15,6 +15,8 @@
package email package email
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@ -27,13 +29,21 @@ type HttpEmailProvider struct {
endpoint string endpoint string
method string method string
httpHeaders map[string]string httpHeaders map[string]string
bodyMapping map[string]string
contentType string
} }
func NewHttpEmailProvider(endpoint string, method string, httpHeaders map[string]string) *HttpEmailProvider { func NewHttpEmailProvider(endpoint string, method string, httpHeaders map[string]string, bodyMapping map[string]string, contentType string) *HttpEmailProvider {
if contentType == "" {
contentType = "application/x-www-form-urlencoded"
}
client := &HttpEmailProvider{ client := &HttpEmailProvider{
endpoint: endpoint, endpoint: endpoint,
method: method, method: method,
httpHeaders: httpHeaders, httpHeaders: httpHeaders,
bodyMapping: bodyMapping,
contentType: contentType,
} }
return client return client
} }
@ -41,18 +51,52 @@ func NewHttpEmailProvider(endpoint string, method string, httpHeaders map[string
func (c *HttpEmailProvider) Send(fromAddress string, fromName string, toAddress string, subject string, content string) error { func (c *HttpEmailProvider) Send(fromAddress string, fromName string, toAddress string, subject string, content string) error {
var req *http.Request var req *http.Request
var err error var err error
fromNameField := "fromName"
toAddressField := "toAddress"
subjectField := "subject"
contentField := "content"
for k, v := range c.bodyMapping {
switch k {
case "fromName":
fromNameField = v
case "toAddress":
toAddressField = v
case "subject":
subjectField = v
case "content":
contentField = v
}
}
if c.method == "POST" || c.method == "PUT" || c.method == "DELETE" { if c.method == "POST" || c.method == "PUT" || c.method == "DELETE" {
formValues := url.Values{} bodyMap := make(map[string]string)
formValues.Set("fromName", fromName) bodyMap[fromNameField] = fromName
formValues.Set("toAddress", toAddress) bodyMap[toAddressField] = toAddress
formValues.Set("subject", subject) bodyMap[subjectField] = subject
formValues.Set("content", content) bodyMap[contentField] = content
req, err = http.NewRequest(c.method, c.endpoint, strings.NewReader(formValues.Encode()))
var fromValueBytes []byte
if c.contentType == "application/json" {
fromValueBytes, err = json.Marshal(bodyMap)
if err != nil {
return err
}
req, err = http.NewRequest(c.method, c.endpoint, bytes.NewBuffer(fromValueBytes))
} else {
formValues := url.Values{}
for k, v := range bodyMap {
formValues.Add(k, v)
}
req, err = http.NewRequest(c.method, c.endpoint, strings.NewReader(formValues.Encode()))
}
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", c.contentType)
} else if c.method == "GET" { } else if c.method == "GET" {
req, err = http.NewRequest(c.method, c.endpoint, nil) req, err = http.NewRequest(c.method, c.endpoint, nil)
if err != nil { if err != nil {
@ -60,10 +104,10 @@ func (c *HttpEmailProvider) Send(fromAddress string, fromName string, toAddress
} }
q := req.URL.Query() q := req.URL.Query()
q.Add("fromName", fromName) q.Add(fromNameField, fromName)
q.Add("toAddress", toAddress) q.Add(toAddressField, toAddress)
q.Add("subject", subject) q.Add(subjectField, subject)
q.Add("content", content) q.Add(contentField, content)
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
} else { } else {
return fmt.Errorf("HttpEmailProvider's Send() error, unsupported method: %s", c.method) return fmt.Errorf("HttpEmailProvider's Send() error, unsupported method: %s", c.method)

View File

@ -18,11 +18,11 @@ type EmailProvider interface {
Send(fromAddress string, fromName, toAddress string, subject string, content string) error Send(fromAddress string, fromName, toAddress string, subject string, content string) error
} }
func GetEmailProvider(typ string, clientId string, clientSecret string, host string, port int, disableSsl bool, endpoint string, method string, httpHeaders map[string]string) EmailProvider { func GetEmailProvider(typ string, clientId string, clientSecret string, host string, port int, disableSsl bool, endpoint string, method string, httpHeaders map[string]string, bodyMapping map[string]string, contentType string) EmailProvider {
if typ == "Azure ACS" { if typ == "Azure ACS" {
return NewAzureACSEmailProvider(clientSecret, host) return NewAzureACSEmailProvider(clientSecret, host)
} else if typ == "Custom HTTP Email" { } else if typ == "Custom HTTP Email" {
return NewHttpEmailProvider(endpoint, method, httpHeaders) return NewHttpEmailProvider(endpoint, method, httpHeaders, bodyMapping, contentType)
} else if typ == "SendGrid" { } else if typ == "SendGrid" {
return NewSendgridEmailProvider(clientSecret, host, endpoint) return NewSendgridEmailProvider(clientSecret, host, endpoint)
} else { } else {

View File

@ -31,7 +31,7 @@ func TestSmtpServer(provider *Provider) error {
} }
func SendEmail(provider *Provider, title string, content string, dest string, sender string) error { func SendEmail(provider *Provider, title string, content string, dest string, sender string) error {
emailProvider := email.GetEmailProvider(provider.Type, provider.ClientId, provider.ClientSecret, provider.Host, provider.Port, provider.DisableSsl, provider.Endpoint, provider.Method, provider.HttpHeaders) emailProvider := email.GetEmailProvider(provider.Type, provider.ClientId, provider.ClientSecret, provider.Host, provider.Port, provider.DisableSsl, provider.Endpoint, provider.Method, provider.HttpHeaders, provider.UserMapping, provider.IssuerUrl)
fromAddress := provider.ClientId2 fromAddress := provider.ClientId2
if fromAddress == "" { if fromAddress == "" {

View File

@ -42,6 +42,13 @@ const defaultUserMapping = {
avatarUrl: "avatarUrl", avatarUrl: "avatarUrl",
}; };
const defaultEmailMapping = {
fromName: "fromName",
toAddress: "toAddress",
subject: "subject",
content: "content",
};
class ProviderEditPage extends React.Component { class ProviderEditPage extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -72,7 +79,16 @@ class ProviderEditPage extends React.Component {
if (res.status === "ok") { if (res.status === "ok") {
const provider = res.data; const provider = res.data;
provider.userMapping = provider.userMapping || defaultUserMapping; if (provider.type === "Custom HTTP Email") {
if (!provider.userMapping) {
provider.userMapping = provider.userMapping || defaultEmailMapping;
}
if (!provider.userMapping?.fromName) {
provider.userMapping = defaultEmailMapping;
}
} else {
provider.userMapping = provider.userMapping || defaultUserMapping;
}
this.setState({ this.setState({
provider: provider, provider: provider,
}); });
@ -146,9 +162,16 @@ class ProviderEditPage extends React.Component {
const requiredKeys = ["id", "username", "displayName"]; const requiredKeys = ["id", "username", "displayName"];
const provider = this.state.provider; const provider = this.state.provider;
if (value === "" && requiredKeys.includes(key)) { if (provider.type === "Custom HTTP Email") {
Setting.showMessage("error", i18next.t("provider:This field is required")); if (value === "") {
return; Setting.showMessage("error", i18next.t("provider:This field is required"));
return;
}
} else {
if (value === "" && requiredKeys.includes(key)) {
Setting.showMessage("error", i18next.t("provider:This field is required"));
return;
}
} }
provider.userMapping[key] = value; provider.userMapping[key] = value;
@ -184,6 +207,30 @@ class ProviderEditPage extends React.Component {
</React.Fragment> </React.Fragment>
); );
} }
renderEmailMappingInput() {
return (
<React.Fragment>
{Setting.getLabel(i18next.t("provider:From name"), i18next.t("provider:From name - Tooltip"))} :
<Input value={this.state.provider.userMapping.fromName} onChange={e => {
this.updateUserMappingField("fromName", e.target.value);
}} />
{Setting.getLabel(i18next.t("provider:From address"), i18next.t("provider:From address - Tooltip"))} :
<Input value={this.state.provider.userMapping.toAddress} onChange={e => {
this.updateUserMappingField("toAddress", e.target.value);
}} />
{Setting.getLabel(i18next.t("provider:Subject"), i18next.t("provider:Subject - Tooltip"))} :
<Input value={this.state.provider.userMapping.subject} onChange={e => {
this.updateUserMappingField("subject", e.target.value);
}} />
{Setting.getLabel(i18next.t("provider:Email content"), i18next.t("provider:Email content - Tooltip"))} :
<Input value={this.state.provider.userMapping.content} onChange={e => {
this.updateUserMappingField("content", e.target.value);
}} />
</React.Fragment>
);
}
getClientIdLabel(provider) { getClientIdLabel(provider) {
switch (provider.category) { switch (provider.category) {
case "OAuth": case "OAuth":
@ -1097,33 +1144,66 @@ class ProviderEditPage extends React.Component {
</Col> </Col>
</Row> </Row>
)} )}
<Row style={{marginTop: "20px"}} > {
<Col style={{marginTop: "5px"}} span={2}> !["Custom HTTP Email"].includes(this.state.provider.type) ? null : (
{Setting.getLabel(i18next.t("general:Method"), i18next.t("provider:Method - Tooltip"))} : <React.Fragment>
</Col> <Row style={{marginTop: "20px"}} >
<Col span={22} > <Col style={{marginTop: "5px"}} span={2}>
<Select virtual={false} style={{width: "100%"}} value={this.state.provider.method} onChange={value => { {Setting.getLabel(i18next.t("general:Method"), i18next.t("provider:Method - Tooltip"))} :
this.updateProviderField("method", value); </Col>
}}> <Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.provider.method} onChange={value => {
this.updateProviderField("method", value);
}}>
{
[
{id: "GET", name: "GET"},
{id: "POST", name: "POST"},
{id: "PUT", name: "PUT"},
{id: "DELETE", name: "DELETE"},
].map((method, index) => <Option key={index} value={method.id}>{method.name}</Option>)
}
</Select>
</Col>
</Row>
{ {
[ this.state.provider.method !== "GET" ? (<Row style={{marginTop: "20px"}} >
{id: "GET", name: "GET"}, <Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{id: "POST", name: "POST"}, {Setting.getLabel(i18next.t("webhook:Content type"), i18next.t("webhook:Content type - Tooltip"))} :
{id: "PUT", name: "PUT"}, </Col>
{id: "DELETE", name: "DELETE"}, <Col span={22} >
].map((method, index) => <Option key={index} value={method.id}>{method.name}</Option>) <Select virtual={false} style={{width: "100%"}} value={this.state.provider.issuerUrl === "" ? "application/x-www-form-urlencoded" : this.state.provider.issuerUrl} onChange={value => {
this.updateProviderField("issuerUrl", value);
}}>
{
[
{id: "application/json", name: "application/json"},
{id: "application/x-www-form-urlencoded", name: "application/x-www-form-urlencoded"},
].map((method, index) => <Option key={index} value={method.id}>{method.name}</Option>)
}
</Select>
</Col>
</Row>) : null
} }
</Select> <Row style={{marginTop: "20px"}} >
</Col> <Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
</Row> {Setting.getLabel(i18next.t("provider:HTTP header"), i18next.t("provider:HTTP header - Tooltip"))} :
<Row style={{marginTop: "20px"}} > </Col>
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}> <Col span={22} >
{Setting.getLabel(i18next.t("provider:HTTP header"), i18next.t("provider:HTTP header - Tooltip"))} : <HttpHeaderTable httpHeaders={this.state.provider.httpHeaders} onUpdateTable={(value) => {this.updateProviderField("httpHeaders", value);}} />
</Col> </Col>
<Col span={22} > </Row>
<HttpHeaderTable httpHeaders={this.state.provider.httpHeaders} onUpdateTable={(value) => {this.updateProviderField("httpHeaders", value);}} /> {this.state.provider.method !== "GET" ? <Row style={{marginTop: "20px"}}>
</Col> <Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
</Row> {Setting.getLabel(i18next.t("provider:HTTP body mapping"), i18next.t("provider:HTTP body mapping - Tooltip"))} :
</Col>
<Col span={22}>
{this.renderEmailMappingInput()}
</Col>
</Row> : null}
</React.Fragment>
)
}
<Row style={{marginTop: "20px"}} > <Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}> <Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Email title"), i18next.t("provider:Email title - Tooltip"))} : {Setting.getLabel(i18next.t("provider:Email title"), i18next.t("provider:Email title - Tooltip"))} :