mirror of
https://github.com/songquanpeng/one-api.git
synced 2025-11-16 13:13:41 +08:00
✨ feat: add notifier (#144)
* ♻️ refactor: email refactor * ✨ feat: add notifier
This commit is contained in:
@@ -1,86 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func SendEmail(subject string, receiver string, content string) error {
|
||||
if SMTPFrom == "" { // for compatibility
|
||||
SMTPFrom = SMTPAccount
|
||||
}
|
||||
encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
|
||||
|
||||
// Extract domain from SMTPFrom
|
||||
parts := strings.Split(SMTPFrom, "@")
|
||||
var domain string
|
||||
if len(parts) > 1 {
|
||||
domain = parts[1]
|
||||
}
|
||||
// Generate a unique Message-ID
|
||||
buf := make([]byte, 16)
|
||||
_, err := rand.Read(buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messageId := fmt.Sprintf("<%x@%s>", buf, domain)
|
||||
|
||||
mail := []byte(fmt.Sprintf("To: %s\r\n"+
|
||||
"From: %s<%s>\r\n"+
|
||||
"Subject: %s\r\n"+
|
||||
"Message-ID: %s\r\n"+ // add Message-ID header to avoid being treated as spam, RFC 5322
|
||||
"Date: %s\r\n"+
|
||||
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
|
||||
receiver, SystemName, SMTPFrom, encodedSubject, messageId, time.Now().Format(time.RFC1123Z), content))
|
||||
auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
|
||||
addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
|
||||
to := strings.Split(receiver, ";")
|
||||
|
||||
if SMTPPort == 465 {
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: SMTPServer,
|
||||
}
|
||||
conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", SMTPServer, SMTPPort), tlsConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := smtp.NewClient(conn, SMTPServer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = client.Mail(SMTPFrom); err != nil {
|
||||
return err
|
||||
}
|
||||
receiverEmails := strings.Split(receiver, ";")
|
||||
for _, receiver := range receiverEmails {
|
||||
if err = client.Rcpt(receiver); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(mail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
err = smtp.SendMail(addr, auth, SMTPAccount, to, mail)
|
||||
}
|
||||
return err
|
||||
}
|
||||
128
common/notify/channel/channel_test.go
Normal file
128
common/notify/channel/channel_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package channel_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"one-api/common/notify/channel"
|
||||
"one-api/common/requester"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func InitConfig() {
|
||||
viper.AddConfigPath("/one-api")
|
||||
viper.SetConfigName("config")
|
||||
viper.ReadInConfig()
|
||||
requester.InitHttpClient()
|
||||
}
|
||||
|
||||
func TestDingTalkSend(t *testing.T) {
|
||||
InitConfig()
|
||||
access_token := viper.GetString("notify.dingtalk.token")
|
||||
secret := viper.GetString("notify.dingtalk.secret")
|
||||
dingTalk := channel.NewDingTalk(access_token, secret)
|
||||
|
||||
err := dingTalk.Send(context.Background(), "Test Title", "*Test Message*")
|
||||
fmt.Println(err)
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestDingTalkSendWithKeyWord(t *testing.T) {
|
||||
InitConfig()
|
||||
access_token := viper.GetString("notify.dingtalk.token")
|
||||
keyWord := viper.GetString("notify.dingtalk.keyWord")
|
||||
|
||||
dingTalk := channel.NewDingTalkWithKeyWord(access_token, keyWord)
|
||||
|
||||
err := dingTalk.Send(context.Background(), "Test Title", "Test Message")
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestDingTalkSendError(t *testing.T) {
|
||||
InitConfig()
|
||||
access_token := viper.GetString("notify.dingtalk.token")
|
||||
secret := "test"
|
||||
dingTalk := channel.NewDingTalk(access_token, secret)
|
||||
|
||||
err := dingTalk.Send(context.Background(), "Test Title", "*Test Message*")
|
||||
fmt.Println(err)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestLarkSend(t *testing.T) {
|
||||
InitConfig()
|
||||
access_token := viper.GetString("notify.lark.token")
|
||||
secret := viper.GetString("notify.lark.secret")
|
||||
dingTalk := channel.NewLark(access_token, secret)
|
||||
|
||||
err := dingTalk.Send(context.Background(), "Test Title", "*Test Message*")
|
||||
fmt.Println(err)
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestLarkSendWithKeyWord(t *testing.T) {
|
||||
InitConfig()
|
||||
access_token := viper.GetString("notify.lark.token")
|
||||
keyWord := viper.GetString("notify.lark.keyWord")
|
||||
|
||||
dingTalk := channel.NewLarkWithKeyWord(access_token, keyWord)
|
||||
|
||||
err := dingTalk.Send(context.Background(), "Test Title", "Test Message\n\n- 111\n- 222")
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestLarkSendError(t *testing.T) {
|
||||
InitConfig()
|
||||
access_token := viper.GetString("notify.lark.token")
|
||||
secret := "test"
|
||||
dingTalk := channel.NewLark(access_token, secret)
|
||||
|
||||
err := dingTalk.Send(context.Background(), "Title", "*Message*")
|
||||
fmt.Println(err)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPushdeerSend(t *testing.T) {
|
||||
InitConfig()
|
||||
pushkey := viper.GetString("notify.pushdeer.pushkey")
|
||||
dingTalk := channel.NewPushdeer(pushkey, "")
|
||||
|
||||
err := dingTalk.Send(context.Background(), "Test Title", "*Test Message*")
|
||||
fmt.Println(err)
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestPushdeerSendError(t *testing.T) {
|
||||
InitConfig()
|
||||
pushkey := "test"
|
||||
dingTalk := channel.NewPushdeer(pushkey, "")
|
||||
|
||||
err := dingTalk.Send(context.Background(), "Test Title", "*Test Message*")
|
||||
fmt.Println(err)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestTelegramSend(t *testing.T) {
|
||||
InitConfig()
|
||||
secret := viper.GetString("notify.telegram.bot_api_key")
|
||||
chatID := viper.GetString("notify.telegram.chat_id")
|
||||
dingTalk := channel.NewTelegram(secret, chatID)
|
||||
|
||||
err := dingTalk.Send(context.Background(), "Test Title", "*Test Message*")
|
||||
fmt.Println(err)
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestTelegramSendError(t *testing.T) {
|
||||
InitConfig()
|
||||
secret := "test"
|
||||
chatID := viper.GetString("notify.telegram.chat_id")
|
||||
dingTalk := channel.NewTelegram(secret, chatID)
|
||||
|
||||
err := dingTalk.Send(context.Background(), "Test Title", "*Test Message*")
|
||||
fmt.Println(err)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
127
common/notify/channel/dingTalk.go
Normal file
127
common/notify/channel/dingTalk.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"one-api/common/requester"
|
||||
"one-api/types"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dingTalkURL = "https://oapi.dingtalk.com/robot/send?"
|
||||
|
||||
type DingTalk struct {
|
||||
token string
|
||||
secret string
|
||||
keyWord string
|
||||
}
|
||||
|
||||
type dingTalkMessage struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Markdown struct {
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
} `json:"markdown"`
|
||||
}
|
||||
|
||||
type dingTalkResponse struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
func NewDingTalk(token string, secret string) *DingTalk {
|
||||
return &DingTalk{
|
||||
token: token,
|
||||
secret: secret,
|
||||
}
|
||||
}
|
||||
|
||||
func NewDingTalkWithKeyWord(token string, keyWord string) *DingTalk {
|
||||
return &DingTalk{
|
||||
token: token,
|
||||
keyWord: keyWord,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DingTalk) Name() string {
|
||||
return "DingTalk"
|
||||
}
|
||||
|
||||
func (d *DingTalk) Send(ctx context.Context, title, message string) error {
|
||||
msg := dingTalkMessage{
|
||||
MsgType: "markdown",
|
||||
}
|
||||
msg.Markdown.Title = title
|
||||
msg.Markdown.Text = message
|
||||
|
||||
if d.keyWord != "" {
|
||||
msg.Markdown.Text = fmt.Sprintf("%s\n%s", d.keyWord, msg.Markdown.Text)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("access_token", d.token)
|
||||
if d.secret != "" {
|
||||
t := time.Now().UnixMilli()
|
||||
query.Set("timestamp", fmt.Sprintf("%d", t))
|
||||
query.Set("sign", d.sign(t))
|
||||
}
|
||||
uri := dingTalkURL + query.Encode()
|
||||
|
||||
client := requester.NewHTTPRequester("", dingtalkErrFunc)
|
||||
client.Context = ctx
|
||||
client.IsOpenAI = false
|
||||
|
||||
req, err := client.NewRequest(http.MethodPost, uri, client.WithHeader(requester.GetJsonHeaders()), client.WithBody(msg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, errWithOP := client.SendRequestRaw(req)
|
||||
if errWithOP != nil {
|
||||
return fmt.Errorf("%s", errWithOP.Message)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
dingtalkErr := dingtalkErrFunc(resp)
|
||||
if dingtalkErr != nil {
|
||||
return fmt.Errorf("%s", dingtalkErr.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DingTalk) sign(timestamp int64) string {
|
||||
stringToHash := fmt.Sprintf("%d\n%s", timestamp, d.secret)
|
||||
hmac256 := hmac.New(sha256.New, []byte(d.secret))
|
||||
hmac256.Write([]byte(stringToHash))
|
||||
data := hmac256.Sum(nil)
|
||||
signature := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
return url.QueryEscape(signature)
|
||||
}
|
||||
|
||||
func dingtalkErrFunc(resp *http.Response) *types.OpenAIError {
|
||||
respMsg := &dingTalkResponse{}
|
||||
|
||||
err := json.NewDecoder(resp.Body).Decode(respMsg)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if respMsg.ErrCode == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &types.OpenAIError{
|
||||
Message: fmt.Sprintf("send msg err. err msg: %s", respMsg.ErrMsg),
|
||||
Type: "dingtalk_error",
|
||||
Code: fmt.Sprintf("%d", respMsg.ErrCode),
|
||||
}
|
||||
}
|
||||
50
common/notify/channel/email.go
Normal file
50
common/notify/channel/email.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"one-api/common"
|
||||
"one-api/common/stmp"
|
||||
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
)
|
||||
|
||||
type Email struct {
|
||||
To string
|
||||
}
|
||||
|
||||
func NewEmail(to string) *Email {
|
||||
return &Email{
|
||||
To: to,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Email) Name() string {
|
||||
return "Email"
|
||||
}
|
||||
|
||||
func (e *Email) Send(ctx context.Context, title, message string) error {
|
||||
to := e.To
|
||||
if to == "" {
|
||||
to = common.RootUserEmail
|
||||
}
|
||||
|
||||
if common.SMTPServer == "" || common.SMTPAccount == "" || common.SMTPToken == "" || to == "" {
|
||||
return errors.New("smtp config is not set, skip send email notifier")
|
||||
}
|
||||
|
||||
p := parser.NewWithExtensions(parser.CommonExtensions | parser.DefinitionLists | parser.OrderedListStart)
|
||||
doc := p.Parse([]byte(message))
|
||||
|
||||
htmlFlags := html.CommonFlags | html.HrefTargetBlank
|
||||
opts := html.RendererOptions{Flags: htmlFlags}
|
||||
renderer := html.NewRenderer(opts)
|
||||
|
||||
body := markdown.Render(doc, renderer)
|
||||
|
||||
emailClient := stmp.NewStmp(common.SMTPServer, common.SMTPPort, common.SMTPAccount, common.SMTPToken, common.SMTPFrom)
|
||||
|
||||
return emailClient.Send(to, title, string(body))
|
||||
}
|
||||
149
common/notify/channel/lark.go
Normal file
149
common/notify/channel/lark.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"one-api/common/requester"
|
||||
"one-api/types"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const larkURL = "https://open.feishu.cn/open-apis/bot/v2/hook/"
|
||||
|
||||
type Lark struct {
|
||||
token string
|
||||
secret string
|
||||
keyWord string
|
||||
}
|
||||
|
||||
type larkMessage struct {
|
||||
MessageType string `json:"msg_type"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
Sign string `json:"sign,omitempty"`
|
||||
Card larkCardContent `json:"card"`
|
||||
}
|
||||
|
||||
type larkCardContent struct {
|
||||
Config struct {
|
||||
WideScreenMode bool `json:"wide_screen_mode"`
|
||||
EnableForward bool `json:"enable_forward"`
|
||||
}
|
||||
Elements []larkMessageRequestCardElement `json:"elements"`
|
||||
}
|
||||
|
||||
type larkMessageRequestCardElementText struct {
|
||||
Content string `json:"content"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
type larkMessageRequestCardElement struct {
|
||||
Tag string `json:"tag"`
|
||||
Text larkMessageRequestCardElementText `json:"text"`
|
||||
}
|
||||
|
||||
type larkResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"msg"`
|
||||
}
|
||||
|
||||
func NewLark(token, secret string) *Lark {
|
||||
return &Lark{
|
||||
token: token,
|
||||
secret: secret,
|
||||
}
|
||||
}
|
||||
|
||||
func NewLarkWithKeyWord(token, keyWord string) *Lark {
|
||||
return &Lark{
|
||||
token: token,
|
||||
keyWord: keyWord,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Lark) Name() string {
|
||||
return "Lark"
|
||||
}
|
||||
|
||||
func (l *Lark) Send(ctx context.Context, title, message string) error {
|
||||
msg := larkMessage{
|
||||
MessageType: "interactive",
|
||||
}
|
||||
|
||||
if l.keyWord != "" {
|
||||
title = fmt.Sprintf("%s(%s)", title, l.keyWord)
|
||||
}
|
||||
|
||||
msg.Card.Config.WideScreenMode = true
|
||||
msg.Card.Config.EnableForward = true
|
||||
msg.Card.Elements = append(msg.Card.Elements, larkMessageRequestCardElement{
|
||||
Tag: "div",
|
||||
Text: larkMessageRequestCardElementText{
|
||||
Content: fmt.Sprintf("**%s**\n%s", title, message),
|
||||
Tag: "lark_md",
|
||||
},
|
||||
})
|
||||
|
||||
if l.secret != "" {
|
||||
t := time.Now().Unix()
|
||||
msg.Timestamp = strconv.FormatInt(t, 10)
|
||||
msg.Sign = l.sign(t)
|
||||
}
|
||||
|
||||
uri := larkURL + l.token
|
||||
client := requester.NewHTTPRequester("", larkErrFunc)
|
||||
client.Context = ctx
|
||||
client.IsOpenAI = false
|
||||
|
||||
req, err := client.NewRequest(http.MethodPost, uri, client.WithHeader(requester.GetJsonHeaders()), client.WithBody(msg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, errWithOP := client.SendRequestRaw(req)
|
||||
if errWithOP != nil {
|
||||
return fmt.Errorf("%s", errWithOP.Message)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
larkErr := larkErrFunc(resp)
|
||||
if larkErr != nil {
|
||||
return fmt.Errorf("%s", larkErr.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func (l *Lark) sign(timestamp int64) string {
|
||||
//timestamp + key 做sha256, 再进行base64 encode
|
||||
stringToSign := fmt.Sprintf("%v", timestamp) + "\n" + l.secret
|
||||
var data []byte
|
||||
h := hmac.New(sha256.New, []byte(stringToSign))
|
||||
h.Write(data)
|
||||
|
||||
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func larkErrFunc(resp *http.Response) *types.OpenAIError {
|
||||
respMsg := &larkResponse{}
|
||||
err := json.NewDecoder(resp.Body).Decode(respMsg)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if respMsg.Code == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &types.OpenAIError{
|
||||
Message: fmt.Sprintf("send msg err. err msg: %s", respMsg.Message),
|
||||
Type: "lark_error",
|
||||
Code: respMsg.Code,
|
||||
}
|
||||
}
|
||||
96
common/notify/channel/pushdeer.go
Normal file
96
common/notify/channel/pushdeer.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"one-api/common/requester"
|
||||
"one-api/types"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const pushdeerURL = "https://api2.pushdeer.com"
|
||||
|
||||
type Pushdeer struct {
|
||||
url string
|
||||
pushkey string
|
||||
}
|
||||
|
||||
type pushdeerMessage struct {
|
||||
Text string `json:"text"`
|
||||
Desp string `json:"desp"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type pushdeerResponse struct {
|
||||
Code int `json:"code,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func NewPushdeer(pushkey, url string) *Pushdeer {
|
||||
return &Pushdeer{
|
||||
url: url,
|
||||
pushkey: pushkey,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pushdeer) Name() string {
|
||||
return "Pushdeer"
|
||||
}
|
||||
|
||||
func (p *Pushdeer) Send(ctx context.Context, title, message string) error {
|
||||
msg := pushdeerMessage{
|
||||
Text: title,
|
||||
Desp: message,
|
||||
Type: "markdown",
|
||||
}
|
||||
|
||||
url := p.url
|
||||
if url == "" {
|
||||
url = pushdeerURL
|
||||
}
|
||||
|
||||
// 去除最后一个/
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
uri := fmt.Sprintf("%s/message/push?pushkey=%s", url, p.pushkey)
|
||||
|
||||
client := requester.NewHTTPRequester("", pushdeerErrFunc)
|
||||
client.Context = ctx
|
||||
client.IsOpenAI = false
|
||||
|
||||
req, err := client.NewRequest(http.MethodPost, uri, client.WithHeader(requester.GetJsonHeaders()), client.WithBody(msg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
respMsg := &pushdeerResponse{}
|
||||
_, errWithOP := client.SendRequest(req, respMsg, false)
|
||||
if errWithOP != nil {
|
||||
return fmt.Errorf("%s", errWithOP.Message)
|
||||
}
|
||||
|
||||
if respMsg.Code != 0 {
|
||||
return fmt.Errorf("send msg err. err msg: %s", respMsg.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func pushdeerErrFunc(resp *http.Response) *types.OpenAIError {
|
||||
respMsg := &pushdeerResponse{}
|
||||
err := json.NewDecoder(resp.Body).Decode(respMsg)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if respMsg.Message == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &types.OpenAIError{
|
||||
Message: fmt.Sprintf("send msg err. err msg: %s", respMsg.Message),
|
||||
Type: "pushdeer_error",
|
||||
}
|
||||
}
|
||||
114
common/notify/channel/telegram.go
Normal file
114
common/notify/channel/telegram.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"one-api/common/requester"
|
||||
"one-api/types"
|
||||
)
|
||||
|
||||
const telegramURL = "https://api.telegram.org/bot"
|
||||
|
||||
type Telegram struct {
|
||||
secret string
|
||||
chatID string
|
||||
}
|
||||
|
||||
type telegramMessage struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
ParseMode string `json:"parse_mode"`
|
||||
}
|
||||
|
||||
type telegramResponse struct {
|
||||
Ok bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func NewTelegram(secret string, chatID string) *Telegram {
|
||||
return &Telegram{
|
||||
secret: secret,
|
||||
chatID: chatID,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Telegram) Name() string {
|
||||
return "Telegram"
|
||||
}
|
||||
|
||||
func (t *Telegram) Send(ctx context.Context, title, message string) error {
|
||||
const maxMessageLength = 4096
|
||||
message = fmt.Sprintf("*%s*\n%s", title, message)
|
||||
messages := splitTelegramMessageIntoParts(message, maxMessageLength)
|
||||
|
||||
client := requester.NewHTTPRequester("", telegramErrFunc)
|
||||
client.Context = ctx
|
||||
client.IsOpenAI = false
|
||||
|
||||
for _, msg := range messages {
|
||||
err := t.sendMessage(msg, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Telegram) sendMessage(message string, client *requester.HTTPRequester) error {
|
||||
msg := telegramMessage{
|
||||
ChatID: t.chatID,
|
||||
Text: message,
|
||||
ParseMode: "Markdown",
|
||||
}
|
||||
|
||||
uri := telegramURL + t.secret + "/sendMessage"
|
||||
|
||||
req, err := client.NewRequest(http.MethodPost, uri, client.WithHeader(requester.GetJsonHeaders()), client.WithBody(msg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, errWithOP := client.SendRequestRaw(req)
|
||||
if errWithOP != nil {
|
||||
return fmt.Errorf("%s", errWithOP.Message)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
telegramErr := telegramErrFunc(resp)
|
||||
if telegramErr != nil {
|
||||
return fmt.Errorf("%s", telegramErr.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitTelegramMessageIntoParts(message string, partSize int) []string {
|
||||
var parts []string
|
||||
for len(message) > partSize {
|
||||
parts = append(parts, message[:partSize])
|
||||
message = message[partSize:]
|
||||
}
|
||||
parts = append(parts, message)
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
func telegramErrFunc(resp *http.Response) *types.OpenAIError {
|
||||
respMsg := &telegramResponse{}
|
||||
err := json.NewDecoder(resp.Body).Decode(respMsg)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if respMsg.Ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &types.OpenAIError{
|
||||
Message: fmt.Sprintf("send msg err. err msg: %s", respMsg.Description),
|
||||
Type: "telegram_error",
|
||||
}
|
||||
}
|
||||
98
common/notify/notifier.go
Normal file
98
common/notify/notifier.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"one-api/common"
|
||||
"one-api/common/notify/channel"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Notifier interface {
|
||||
Send(context.Context, string, string) error
|
||||
Name() string
|
||||
}
|
||||
|
||||
func InitNotifier() {
|
||||
InitEmailNotifier()
|
||||
InitDingTalkNotifier()
|
||||
InitLarkNotifier()
|
||||
InitPushdeerNotifier()
|
||||
InitTelegramNotifier()
|
||||
}
|
||||
|
||||
func InitEmailNotifier() {
|
||||
if viper.GetBool("notify.email.disable") {
|
||||
common.SysLog("email notifier disabled")
|
||||
return
|
||||
}
|
||||
smtp_to := viper.GetString("notify.email.smtp_to")
|
||||
emailNotifier := channel.NewEmail(smtp_to)
|
||||
AddNotifiers(emailNotifier)
|
||||
common.SysLog("email notifier enable")
|
||||
}
|
||||
|
||||
func InitDingTalkNotifier() {
|
||||
access_token := viper.GetString("notify.dingtalk.token")
|
||||
secret := viper.GetString("notify.dingtalk.secret")
|
||||
keyWord := viper.GetString("notify.dingtalk.keyWord")
|
||||
if access_token == "" || (secret == "" && keyWord == "") {
|
||||
return
|
||||
}
|
||||
|
||||
var dingTalkNotifier Notifier
|
||||
|
||||
if secret != "" {
|
||||
dingTalkNotifier = channel.NewDingTalk(access_token, secret)
|
||||
} else {
|
||||
dingTalkNotifier = channel.NewDingTalkWithKeyWord(access_token, keyWord)
|
||||
}
|
||||
|
||||
AddNotifiers(dingTalkNotifier)
|
||||
common.SysLog("dingtalk notifier enable")
|
||||
}
|
||||
|
||||
func InitLarkNotifier() {
|
||||
access_token := viper.GetString("notify.lark.token")
|
||||
secret := viper.GetString("notify.lark.secret")
|
||||
keyWord := viper.GetString("notify.lark.keyWord")
|
||||
if access_token == "" || (secret == "" && keyWord == "") {
|
||||
return
|
||||
}
|
||||
|
||||
var larkNotifier Notifier
|
||||
|
||||
if secret != "" {
|
||||
larkNotifier = channel.NewLark(access_token, secret)
|
||||
} else {
|
||||
larkNotifier = channel.NewLarkWithKeyWord(access_token, keyWord)
|
||||
}
|
||||
|
||||
AddNotifiers(larkNotifier)
|
||||
common.SysLog("lark notifier enable")
|
||||
}
|
||||
|
||||
func InitPushdeerNotifier() {
|
||||
pushkey := viper.GetString("notify.pushdeer.pushkey")
|
||||
if pushkey == "" {
|
||||
return
|
||||
}
|
||||
|
||||
pushdeerNotifier := channel.NewPushdeer(pushkey, viper.GetString("notify.pushdeer.url"))
|
||||
|
||||
AddNotifiers(pushdeerNotifier)
|
||||
common.SysLog("pushdeer notifier enable")
|
||||
}
|
||||
|
||||
func InitTelegramNotifier() {
|
||||
bot_token := viper.GetString("notify.telegram.bot_api_key")
|
||||
chat_id := viper.GetString("notify.telegram.chat_id")
|
||||
if bot_token == "" || chat_id == "" {
|
||||
return
|
||||
}
|
||||
|
||||
telegramNotifier := channel.NewTelegram(bot_token, chat_id)
|
||||
|
||||
AddNotifiers(telegramNotifier)
|
||||
common.SysLog("telegram notifier enable")
|
||||
}
|
||||
35
common/notify/notify.go
Normal file
35
common/notify/notify.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package notify
|
||||
|
||||
var notifyChannels = New()
|
||||
|
||||
type Notify struct {
|
||||
notifiers map[string]Notifier
|
||||
}
|
||||
|
||||
func (n *Notify) addChannel(channel Notifier) {
|
||||
if channel != nil {
|
||||
channelName := channel.Name()
|
||||
if _, ok := n.notifiers[channelName]; ok {
|
||||
return
|
||||
}
|
||||
n.notifiers[channelName] = channel
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notify) addChannels(channel ...Notifier) {
|
||||
for _, s := range channel {
|
||||
n.addChannel(s)
|
||||
}
|
||||
}
|
||||
|
||||
func New() *Notify {
|
||||
notify := &Notify{
|
||||
notifiers: make(map[string]Notifier, 0),
|
||||
}
|
||||
|
||||
return notify
|
||||
}
|
||||
|
||||
func AddNotifiers(channel ...Notifier) {
|
||||
notifyChannels.addChannels(channel...)
|
||||
}
|
||||
30
common/notify/send.go
Normal file
30
common/notify/send.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"one-api/common"
|
||||
)
|
||||
|
||||
func (n *Notify) Send(ctx context.Context, title, message string) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
for channelName, channel := range n.notifiers {
|
||||
if channel == nil {
|
||||
continue
|
||||
}
|
||||
err := channel.Send(ctx, title, message)
|
||||
if err != nil {
|
||||
common.LogError(ctx, fmt.Sprintf("%s err: %s", channelName, err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Send(title, message string) {
|
||||
//lint:ignore SA1029 reason: 需要使用该类型作为错误处理
|
||||
ctx := context.WithValue(context.Background(), common.RequestIdKey, "NotifyTask")
|
||||
|
||||
notifyChannels.Send(ctx, title, message)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type HTTPRequester struct {
|
||||
ErrorHandler HttpErrorHandler
|
||||
proxyAddr string
|
||||
Context context.Context
|
||||
IsOpenAI bool
|
||||
}
|
||||
|
||||
// NewHTTPRequester 创建一个新的 HTTPRequester 实例。
|
||||
@@ -39,6 +40,7 @@ func NewHTTPRequester(proxyAddr string, errorHandler HttpErrorHandler) *HTTPRequ
|
||||
ErrorHandler: errorHandler,
|
||||
proxyAddr: proxyAddr,
|
||||
Context: context.Background(),
|
||||
IsOpenAI: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,10 +96,14 @@ func (r *HTTPRequester) SendRequest(req *http.Request, response any, outputResp
|
||||
|
||||
// 处理响应
|
||||
if r.IsFailureStatusCode(resp) {
|
||||
return nil, HandleErrorResp(resp, r.ErrorHandler)
|
||||
return nil, HandleErrorResp(resp, r.ErrorHandler, r.IsOpenAI)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
if response == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if outputResp {
|
||||
var buf bytes.Buffer
|
||||
tee := io.TeeReader(resp.Body, &buf)
|
||||
@@ -126,7 +132,7 @@ func (r *HTTPRequester) SendRequestRaw(req *http.Request) (*http.Response, *type
|
||||
|
||||
// 处理响应
|
||||
if r.IsFailureStatusCode(resp) {
|
||||
return nil, HandleErrorResp(resp, r.ErrorHandler)
|
||||
return nil, HandleErrorResp(resp, r.ErrorHandler, r.IsOpenAI)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
@@ -136,7 +142,7 @@ func (r *HTTPRequester) SendRequestRaw(req *http.Request) (*http.Response, *type
|
||||
func RequestStream[T streamable](requester *HTTPRequester, resp *http.Response, handlerPrefix HandlerPrefix[T]) (*streamReader[T], *types.OpenAIErrorWithStatusCode) {
|
||||
// 如果返回的头是json格式 说明有错误
|
||||
if strings.Contains(resp.Header.Get("Content-Type"), "application/json") {
|
||||
return nil, HandleErrorResp(resp, requester.ErrorHandler)
|
||||
return nil, HandleErrorResp(resp, requester.ErrorHandler, requester.IsOpenAI)
|
||||
}
|
||||
|
||||
stream := &streamReader[T]{
|
||||
@@ -180,7 +186,7 @@ func (r *HTTPRequester) IsFailureStatusCode(resp *http.Response) bool {
|
||||
}
|
||||
|
||||
// 处理错误响应
|
||||
func HandleErrorResp(resp *http.Response, toOpenAIError HttpErrorHandler) *types.OpenAIErrorWithStatusCode {
|
||||
func HandleErrorResp(resp *http.Response, toOpenAIError HttpErrorHandler, isPrefix bool) *types.OpenAIErrorWithStatusCode {
|
||||
|
||||
openAIErrorWithStatusCode := &types.OpenAIErrorWithStatusCode{
|
||||
StatusCode: resp.StatusCode,
|
||||
@@ -199,12 +205,19 @@ func HandleErrorResp(resp *http.Response, toOpenAIError HttpErrorHandler) *types
|
||||
|
||||
if errorResponse != nil && errorResponse.Message != "" {
|
||||
openAIErrorWithStatusCode.OpenAIError = *errorResponse
|
||||
openAIErrorWithStatusCode.OpenAIError.Message = fmt.Sprintf("Provider API error: %s", openAIErrorWithStatusCode.OpenAIError.Message)
|
||||
if isPrefix {
|
||||
openAIErrorWithStatusCode.OpenAIError.Message = fmt.Sprintf("Provider API error: %s", openAIErrorWithStatusCode.OpenAIError.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if openAIErrorWithStatusCode.OpenAIError.Message == "" {
|
||||
openAIErrorWithStatusCode.OpenAIError.Message = fmt.Sprintf("Provider API error: bad response status code %d", resp.StatusCode)
|
||||
if isPrefix {
|
||||
openAIErrorWithStatusCode.OpenAIError.Message = fmt.Sprintf("Provider API error: bad response status code %d", resp.StatusCode)
|
||||
} else {
|
||||
openAIErrorWithStatusCode.OpenAIError.Message = fmt.Sprintf("bad response status code %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
return openAIErrorWithStatusCode
|
||||
@@ -218,6 +231,12 @@ func SetEventStreamHeaders(c *gin.Context) {
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
}
|
||||
|
||||
func GetJsonHeaders() map[string]string {
|
||||
return map[string]string{
|
||||
"Content-type": "application/json",
|
||||
}
|
||||
}
|
||||
|
||||
type Stringer interface {
|
||||
GetString() *string
|
||||
}
|
||||
|
||||
168
common/stmp/email.go
Normal file
168
common/stmp/email.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package stmp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"one-api/common"
|
||||
"strings"
|
||||
|
||||
"github.com/wneessen/go-mail"
|
||||
)
|
||||
|
||||
type StmpConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
func NewStmp(host string, port int, username string, password string, from string) *StmpConfig {
|
||||
if from == "" {
|
||||
from = username
|
||||
}
|
||||
|
||||
return &StmpConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
From: from,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StmpConfig) Send(to, subject, body string) error {
|
||||
message := mail.NewMsg()
|
||||
message.From(s.From)
|
||||
message.To(to)
|
||||
message.Subject(subject)
|
||||
message.SetGenHeader("References", s.getReferences())
|
||||
message.SetBodyString(mail.TypeTextHTML, body)
|
||||
message.SetUserAgent(fmt.Sprintf("One API %s // https://github.com/MartialBE/one-api", common.Version))
|
||||
|
||||
client, err := mail.NewClient(
|
||||
s.Host,
|
||||
mail.WithPort(s.Port),
|
||||
mail.WithUsername(s.Username),
|
||||
mail.WithPassword(s.Password),
|
||||
mail.WithSMTPAuth(mail.SMTPAuthPlain))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch s.Port {
|
||||
case 465:
|
||||
client.SetSSL(true)
|
||||
case 587:
|
||||
client.SetTLSPolicy(mail.TLSMandatory)
|
||||
}
|
||||
|
||||
if err := client.DialAndSend(message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StmpConfig) getReferences() string {
|
||||
froms := strings.Split(s.From, "@")
|
||||
return fmt.Sprintf("<%s.%s@%s>", froms[0], common.GetUUID(), froms[1])
|
||||
}
|
||||
|
||||
func (s *StmpConfig) Render(to, subject, content string) error {
|
||||
body := getDefaultTemplate(content)
|
||||
|
||||
return s.Send(to, subject, body)
|
||||
}
|
||||
|
||||
func GetSystemStmp() (*StmpConfig, error) {
|
||||
if common.SMTPServer == "" || common.SMTPPort == 0 || common.SMTPAccount == "" || common.SMTPToken == "" {
|
||||
return nil, fmt.Errorf("SMTP 信息未配置")
|
||||
}
|
||||
|
||||
return NewStmp(common.SMTPServer, common.SMTPPort, common.SMTPAccount, common.SMTPToken, common.SMTPFrom), nil
|
||||
}
|
||||
|
||||
func SendPasswordResetEmail(userName, email, link string) error {
|
||||
stmp, err := GetSystemStmp()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contentTemp := `<p style="font-size: 30px">Hi <strong>%s,</strong></p>
|
||||
<p>
|
||||
您正在进行密码重置。点击下方按钮以重置密码。
|
||||
</p>
|
||||
|
||||
<p style="text-align: center; font-size: 13px;">
|
||||
<a target="__blank" href="%s" class="button" style="color: #ffffff;">重置密码</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #858585; padding-top: 15px;">
|
||||
如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开<br> %s
|
||||
</p>
|
||||
<p style="color: #858585;">重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>`
|
||||
|
||||
subject := fmt.Sprintf("%s密码重置", common.SystemName)
|
||||
content := fmt.Sprintf(contentTemp, userName, link, link, common.VerificationValidMinutes)
|
||||
|
||||
return stmp.Render(email, subject, content)
|
||||
}
|
||||
|
||||
func SendVerificationCodeEmail(email, code string) error {
|
||||
stmp, err := GetSystemStmp()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contentTemp := `
|
||||
<p>
|
||||
您正在进行邮箱验证。您的验证码为:
|
||||
</p>
|
||||
|
||||
<p style="text-align: center; font-size: 30px; color: #58a6ff;">
|
||||
<strong>%s</strong>
|
||||
</p>
|
||||
|
||||
<p style="color: #858585; padding-top: 15px;">
|
||||
验证码 %d 分钟内有效,如果不是本人操作,请忽略。
|
||||
</p>`
|
||||
|
||||
subject := fmt.Sprintf("%s邮箱验证邮件", common.SystemName)
|
||||
content := fmt.Sprintf(contentTemp, code, common.VerificationValidMinutes)
|
||||
|
||||
return stmp.Render(email, subject, content)
|
||||
}
|
||||
|
||||
func SendQuotaWarningCodeEmail(userName, email string, quota int, noMoreQuota bool) error {
|
||||
stmp, err := GetSystemStmp()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contentTemp := `<p style="font-size: 30px">Hi <strong>%s,</strong></p>
|
||||
<p>
|
||||
%s,当前剩余额度为 %d,为了不影响您的使用,请及时充值。
|
||||
</p>
|
||||
|
||||
<p style="text-align: center; font-size: 13px;">
|
||||
<a target="__blank" href="%s" class="button" style="color: #ffffff;">点击充值</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #858585; padding-top: 15px;">
|
||||
如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开<br> %s
|
||||
</p>`
|
||||
|
||||
subject := "您的额度即将用尽"
|
||||
if noMoreQuota {
|
||||
subject = "您的额度已用尽"
|
||||
}
|
||||
topUpLink := fmt.Sprintf("%s/topup", common.ServerAddress)
|
||||
|
||||
content := fmt.Sprintf(contentTemp, userName, subject, quota, topUpLink, topUpLink)
|
||||
|
||||
return stmp.Render(email, subject, content)
|
||||
}
|
||||
110
common/stmp/template.go
Normal file
110
common/stmp/template.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package stmp
|
||||
|
||||
import (
|
||||
"one-api/common"
|
||||
)
|
||||
|
||||
func getLogo() string {
|
||||
if common.Logo == "" {
|
||||
return ""
|
||||
}
|
||||
return `<table class="logo" width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<img src="` + common.Logo + `" width="130" style="max-width: 100%"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`
|
||||
}
|
||||
|
||||
func getSystemName() string {
|
||||
if common.SystemName == "" {
|
||||
return "One API"
|
||||
}
|
||||
|
||||
return common.SystemName
|
||||
}
|
||||
|
||||
func getDefaultTemplate(content string) string {
|
||||
return `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<style type="text/css">
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #eceff1;
|
||||
font-family: Helvetica, sans-serif;
|
||||
}
|
||||
table {
|
||||
border-spacing: 0;
|
||||
}
|
||||
td {
|
||||
padding: 0;
|
||||
}
|
||||
img {
|
||||
border: 0;
|
||||
}
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
background-color: #eceff1;
|
||||
padding-bottom: 60px;
|
||||
padding-top: 60px;
|
||||
}
|
||||
.main {
|
||||
background-color: #ffffff;
|
||||
border-spacing: 0;
|
||||
color: #000000;
|
||||
border-radius: 10px;
|
||||
border-color: #ebebeb;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
padding: 10px 30px;
|
||||
line-height: 25px;
|
||||
font-size: 16px;
|
||||
text-align: start;
|
||||
width: 600px;
|
||||
}
|
||||
.button {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
padding: 12px 20px;
|
||||
font-weight: bold;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.logo {
|
||||
text-align: center;
|
||||
margin: 10px auto;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #858585
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<center class="wrapper">
|
||||
` + getLogo() + `
|
||||
<table class="main" width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
` + content + `
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table class="footer" width="100%">
|
||||
<tr>
|
||||
<td width="100%">
|
||||
<p>© ` + getSystemName() + `</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
Reference in New Issue
Block a user