From 0396d8222edf14a980b7cc6ab35265894a4b9ace Mon Sep 17 00:00:00 2001
From: Leo Chen <Leo@zhangbudademao.com>
Date: Sun, 27 Oct 2024 20:21:34 +0800
Subject: [PATCH 1/2] feat: add mail push
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

新增电子邮箱推送
---
 internal/domain/notify.go                |   1 +
 internal/notify/mail.go                  |  58 +++++
 internal/notify/notify.go                |  10 +
 ui/src/components/notify/Mail.tsx        | 319 +++++++++++++++++++++++
 ui/src/domain/settings.ts                |  13 +-
 ui/src/i18n/locales/en/nls.common.json   |   3 +-
 ui/src/i18n/locales/en/nls.settings.json |   6 +
 ui/src/i18n/locales/zh/nls.common.json   |   3 +-
 ui/src/i18n/locales/zh/nls.settings.json |   6 +
 ui/src/pages/setting/Notify.tsx          |   7 +
 10 files changed, 423 insertions(+), 3 deletions(-)
 create mode 100644 internal/notify/mail.go
 create mode 100644 ui/src/components/notify/Mail.tsx

diff --git a/internal/domain/notify.go b/internal/domain/notify.go
index de9cf6cf..534d8b02 100644
--- a/internal/domain/notify.go
+++ b/internal/domain/notify.go
@@ -6,6 +6,7 @@ const (
 	NotifyChannelTelegram = "telegram"
 	NotifyChannelLark     = "lark"
 	NotifyChannelServerChan = "serverchan"
+	NotifyChannelMail = "mail"
 )
 
 type NotifyTestPushReq struct {
diff --git a/internal/notify/mail.go b/internal/notify/mail.go
new file mode 100644
index 00000000..ece00ad2
--- /dev/null
+++ b/internal/notify/mail.go
@@ -0,0 +1,58 @@
+package notify
+
+import (
+	"context"
+	"net/smtp"
+)
+
+type Mail struct {
+	senderAddress     string
+	smtpHostAddr      string
+	smtpHostPort	  string
+	smtpAuth          smtp.Auth
+	receiverAddresses string
+}
+
+func NewMail(senderAddress, receiverAddresses, smtpHostAddr, smtpHostPort string) *Mail {
+	if(smtpHostPort == "") {
+		smtpHostPort = "25"
+	}
+
+	return &Mail{
+		senderAddress:     senderAddress,
+		smtpHostAddr:      smtpHostAddr,
+		smtpHostPort:      smtpHostPort,
+		receiverAddresses: receiverAddresses,
+	}
+}
+
+func (m *Mail) SetAuth(username, password string) {
+	m.smtpAuth = smtp.PlainAuth("", username, password, m.smtpHostAddr)
+}
+
+func (m *Mail) Send(ctx context.Context, subject, message string) error {
+	// 构建邮件
+    from := m.senderAddress
+    to := []string{m.receiverAddresses}
+    msg := []byte(
+		"From: " + from + "\r\n" +
+		"To: " + m.receiverAddresses + "\r\n" +
+        "Subject: " + subject + "\r\n" +
+        "\r\n" +
+        message + "\r\n")
+	
+	var smtpAddress string
+	// 组装邮箱服务器地址
+	if(m.smtpHostPort == "25"){
+		smtpAddress = m.smtpHostAddr
+	}else{
+		smtpAddress = m.smtpHostAddr + ":" + m.smtpHostPort
+	}
+
+    err := smtp.SendMail(smtpAddress, m.smtpAuth, from, to, msg)
+    if err != nil {
+        return err
+    }
+
+	return nil
+}
\ No newline at end of file
diff --git a/internal/notify/notify.go b/internal/notify/notify.go
index 3dfa643b..4b91cdbd 100644
--- a/internal/notify/notify.go
+++ b/internal/notify/notify.go
@@ -106,6 +106,8 @@ func getNotifier(channel string, conf map[string]any) (notifyPackage.Notifier, e
 		return getWebhookNotifier(conf), nil
 	case domain.NotifyChannelServerChan:
 		return getServerChanNotifier(conf), nil
+	case domain.NotifyChannelMail:
+		return getMailNotifier(conf), nil
 	}
 
 	return nil, fmt.Errorf("notifier not found")
@@ -166,6 +168,14 @@ func getLarkNotifier(conf map[string]any) notifyPackage.Notifier {
 	return lark.NewWebhookService(getString(conf, "webhookUrl"))
 }
 
+func getMailNotifier(conf map[string]any) notifyPackage.Notifier {
+	rs := NewMail(getString(conf, "senderAddress"),getString(conf,"receiverAddress"), getString(conf, "smtpHostAddr"), getString(conf, "smtpHostPort"))
+
+	rs.SetAuth(getString(conf, "username"), getString(conf, "password"))
+
+	return rs
+}
+
 func getString(conf map[string]any, key string) string {
 	if _, ok := conf[key]; !ok {
 		return ""
diff --git a/ui/src/components/notify/Mail.tsx b/ui/src/components/notify/Mail.tsx
new file mode 100644
index 00000000..9161ba09
--- /dev/null
+++ b/ui/src/components/notify/Mail.tsx
@@ -0,0 +1,319 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { useToast } from "@/components/ui/use-toast";
+import { getErrMessage } from "@/lib/error";
+import { NotifyChannelMail, NotifyChannels } from "@/domain/settings";
+import { useNotifyContext } from "@/providers/notify";
+import { update } from "@/repository/settings";
+import Show from "@/components/Show";
+import { notifyTest } from "@/api/notify";
+
+type MailSetting = {
+  id: string;
+  name: string;
+  data: NotifyChannelMail;
+};
+
+const Mail = () => {
+  const { config, setChannels } = useNotifyContext();
+  const { t } = useTranslation();
+
+  const [changed, setChanged] = useState<boolean>(false);
+
+  const [mail, setmail] = useState<MailSetting>({
+    id: config.id ?? "",
+    name: "notifyChannels",
+    data: {
+      senderAddress: "",
+      receiverAddresses: "",
+      smtpHostAddr: "",
+      smtpHostPort: "25",
+      username: "",
+      password: "",
+      enabled: false,
+    },
+  });
+
+  const [originMail, setoriginMail] = useState<MailSetting>({
+    id: config.id ?? "",
+    name: "notifyChannels",
+    data: {
+        senderAddress: "",
+        receiverAddresses: "",
+        smtpHostAddr: "",
+        smtpHostPort: "25",
+        username: "",
+        password: "",
+        enabled: false,
+    },
+  });
+
+  useEffect(() => {
+    setChanged(false);
+  }, [config]);
+
+  useEffect(() => {
+    const data = getDetailMail();
+    setoriginMail({
+      id: config.id ?? "",
+      name: "mail",
+      data,
+    });
+  }, [config]);
+
+  useEffect(() => {
+    const data = getDetailMail();
+    setmail({
+      id: config.id ?? "",
+      name: "mail",
+      data,
+    });
+  }, [config]);
+
+  const { toast } = useToast();
+
+  const getDetailMail = () => {
+    const df: NotifyChannelMail = {
+        senderAddress: "",
+        receiverAddresses: "",
+        smtpHostAddr: "",
+        smtpHostPort: "25",
+        username: "",
+        password: "",
+        enabled: false,
+    };
+    if (!config.content) {
+      return df;
+    }
+    const chanels = config.content as NotifyChannels;
+    if (!chanels.mail) {
+      return df;
+    }
+
+    return chanels.mail as NotifyChannelMail;
+  };
+
+  const checkChanged = (data: NotifyChannelMail) => {
+    if (data.senderAddress !== originMail.data.senderAddress || data.receiverAddresses !== originMail.data.receiverAddresses || data.smtpHostAddr !== originMail.data.smtpHostAddr || data.smtpHostPort !== originMail.data.smtpHostPort || data.username !== originMail.data.username || data.password !== originMail.data.password) {
+      setChanged(true);
+    } else {
+      setChanged(false);
+    }
+  };
+
+  const handleSaveClick = async () => {
+    try {
+      const resp = await update({
+        ...config,
+        name: "notifyChannels",
+        content: {
+          ...config.content,
+          mail: {
+            ...mail.data,
+          },
+        },
+      });
+
+      setChannels(resp);
+      toast({
+        title: t("common.save.succeeded.message"),
+        description: t("settings.notification.config.saved.message"),
+      });
+    } catch (e) {
+      const msg = getErrMessage(e);
+
+      toast({
+        title: t("common.save.failed.message"),
+        description: `${t("settings.notification.config.failed.message")}: ${msg}`,
+        variant: "destructive",
+      });
+    }
+  };
+
+  const handlePushTestClick = async () => {
+    try {
+      await notifyTest("mail");
+
+      toast({
+        title: t("settings.notification.config.push.test.message.success.message"),
+        description: t("settings.notification.config.push.test.message.success.message"),
+      });
+    } catch (e) {
+      const msg = getErrMessage(e);
+
+      toast({
+        title: t("settings.notification.config.push.test.message.failed.message"),
+        description: `${t("settings.notification.config.push.test.message.failed.message")}: ${msg}`,
+        variant: "destructive",
+      });
+    }
+  };
+
+  const handleSwitchChange = async () => {
+    const newData = {
+      ...mail,
+      data: {
+        ...mail.data,
+        enabled: !mail.data.enabled,
+      },
+    };
+    setmail(newData);
+
+    try {
+      const resp = await update({
+        ...config,
+        name: "notifyChannels",
+        content: {
+          ...config.content,
+          mail: {
+            ...newData.data,
+          },
+        },
+      });
+
+      setChannels(resp);
+    } catch (e) {
+      const msg = getErrMessage(e);
+
+      toast({
+        title: t("common.save.failed.message"),
+        description: `${t("settings.notification.config.failed.message")}: ${msg}`,
+        variant: "destructive",
+      });
+    }
+  };
+
+  return (
+    <div>
+      <Input
+        placeholder={t("settings.notification.mail.sender_address.placeholder")}
+        value={mail.data.senderAddress}
+        onChange={(e) => {
+          const newData = {
+            ...mail,
+            data: {
+              ...mail.data,
+              senderAddress: e.target.value,
+            },
+          };
+          checkChanged(newData.data);
+          setmail(newData);
+        }}
+      />
+      <Input
+        placeholder={t("settings.notification.mail.receiver_address.placeholder")}
+        className="mt-2"
+        value={mail.data.receiverAddresses}
+        onChange={(e) => {
+          const newData = {
+            ...mail,
+            data: {
+              ...mail.data,
+              secret: e.target.value,
+            },
+          };
+          checkChanged(newData.data);
+          setmail(newData);
+        }}
+      />
+      <Input
+        placeholder={t("settings.notification.mail.smtp_host.placeholder")}
+        className="mt-2"
+        value={mail.data.smtpHostAddr}
+        onChange={(e) => {
+          const newData = {
+            ...mail,
+            data: {
+              ...mail.data,
+              secret: e.target.value,
+            },
+          };
+          checkChanged(newData.data);
+          setmail(newData);
+        }}
+        />
+    <Input
+        placeholder={t("settings.notification.mail.smtp_port.placeholder")}
+        className="mt-2"
+        value={mail.data.smtpHostPort}
+        onChange={(e) => {
+        const newData = {
+            ...mail,
+            data: {
+            ...mail.data,
+            secret: e.target.value,
+            },
+        };
+        checkChanged(newData.data);
+        setmail(newData);
+        }}
+    />
+    <Input
+        placeholder={t("settings.notification.mail.username.placeholder")}
+        className="mt-2"
+        value={mail.data.username}
+        onChange={(e) => {
+        const newData = {
+            ...mail,
+            data: {
+            ...mail.data,
+            secret: e.target.value,
+            },
+        };
+        checkChanged(newData.data);
+        setmail(newData);
+        }}
+        />
+    <Input
+        placeholder={t("settings.notification.mail.password.placeholder")}
+        className="mt-2"
+        value={mail.data.password}
+        onChange={(e) => {
+        const newData = {
+            ...mail,
+            data: {
+            ...mail.data,
+            secret: e.target.value,
+            },
+        };
+        checkChanged(newData.data);
+        setmail(newData);
+        }}
+        />
+      <div className="flex items-center space-x-1 mt-2">
+        <Switch id="airplane-mode" checked={mail.data.enabled} onCheckedChange={handleSwitchChange} />
+        <Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
+      </div>
+
+      <div className="flex justify-end mt-2">
+        <Show when={changed}>
+          <Button
+            onClick={() => {
+              handleSaveClick();
+            }}
+          >
+            {t("common.save")}
+          </Button>
+        </Show>
+
+        <Show when={!changed && mail.id != ""}>
+          <Button
+            variant="secondary"
+            onClick={() => {
+              handlePushTestClick();
+            }}
+          >
+            {t("settings.notification.config.push.test.message")}
+          </Button>
+        </Show>
+      </div>
+    </div>
+  );
+};
+
+export default Mail;
diff --git a/ui/src/domain/settings.ts b/ui/src/domain/settings.ts
index af77ea98..798c4e86 100644
--- a/ui/src/domain/settings.ts
+++ b/ui/src/domain/settings.ts
@@ -23,9 +23,10 @@ export type NotifyChannels = {
   telegram?: NotifyChannel;
   webhook?: NotifyChannel;
   serverchan?: NotifyChannel;
+  mail?: NotifyChannelMail;
 };
 
-export type NotifyChannel = NotifyChannelDingTalk | NotifyChannelLark | NotifyChannelTelegram | NotifyChannelWebhook | NotifyChannelServerChan;
+export type NotifyChannel = NotifyChannelDingTalk | NotifyChannelLark | NotifyChannelTelegram | NotifyChannelWebhook | NotifyChannelServerChan | NotifyChannelMail;
 
 export type NotifyChannelDingTalk = {
   accessToken: string;
@@ -54,6 +55,16 @@ export type NotifyChannelServerChan = {
   enabled: boolean;
 };
 
+export type NotifyChannelMail = {
+  senderAddress: string;
+  receiverAddresses: string;
+  smtpHostAddr: string;
+  smtpHostPort: string;
+  username:string;
+  password:string;
+  enabled: boolean;
+};
+
 export const defaultNotifyTemplate: NotifyTemplate = {
   title: "您有 {COUNT} 张证书即将过期",
   content: "有 {COUNT} 张证书即将过期,域名分别为 {DOMAINS},请保持关注!",
diff --git a/ui/src/i18n/locales/en/nls.common.json b/ui/src/i18n/locales/en/nls.common.json
index d90008ac..6870c616 100644
--- a/ui/src/i18n/locales/en/nls.common.json
+++ b/ui/src/i18n/locales/en/nls.common.json
@@ -85,5 +85,6 @@
   "common.provider.kubernetes.secret": "Kubernetes - Secret",
   "common.provider.dingtalk": "DingTalk",
   "common.provider.telegram": "Telegram",
-  "common.provider.lark": "Lark"
+  "common.provider.lark": "Lark",
+  "common.provider.mail": "Mail"
 }
diff --git a/ui/src/i18n/locales/en/nls.settings.json b/ui/src/i18n/locales/en/nls.settings.json
index b23f6ee0..fd84b888 100644
--- a/ui/src/i18n/locales/en/nls.settings.json
+++ b/ui/src/i18n/locales/en/nls.settings.json
@@ -36,6 +36,12 @@
   "settings.notification.dingtalk.secret.placeholder": "Signature for signed addition",
   "settings.notification.url.errmsg.invalid": "Invalid Url format",
   "settings.notification.serverchan.url.placeholder": "Url, e.g. https://sctapi.ftqq.com/****************.send",
+  "settings.notification.mail.sender_address.placeholder": "Sender email address",
+  "settings.notification.mail.receiver_address.placeholder": "Receiver email address",
+  "settings.notification.mail.smtp_host.placeholder": "SMTP server address",
+  "settings.notification.mail.smtp_port.placeholder": "SMTP server port, if not set, default is 25",
+  "settings.notification.mail.username.placeholder": "username",
+  "settings.notification.mail.password.placeholder": "password",
 
   "settings.ca.tab": "Certificate Authority",
   "settings.ca.provider.errmsg.empty": "Please select a Certificate Authority",
diff --git a/ui/src/i18n/locales/zh/nls.common.json b/ui/src/i18n/locales/zh/nls.common.json
index d1144d79..12fcd58a 100644
--- a/ui/src/i18n/locales/zh/nls.common.json
+++ b/ui/src/i18n/locales/zh/nls.common.json
@@ -85,6 +85,7 @@
   "common.provider.kubernetes.secret": "Kubernetes - Secret",
   "common.provider.dingtalk": "钉钉",
   "common.provider.telegram": "Telegram",
-  "common.provider.lark": "飞书"
+  "common.provider.lark": "飞书",
+  "common.provider.mail": "电子邮件"
 }
 
diff --git a/ui/src/i18n/locales/zh/nls.settings.json b/ui/src/i18n/locales/zh/nls.settings.json
index f3dedaa9..2c0a807f 100644
--- a/ui/src/i18n/locales/zh/nls.settings.json
+++ b/ui/src/i18n/locales/zh/nls.settings.json
@@ -36,6 +36,12 @@
   "settings.notification.dingtalk.secret.placeholder": "加签的签名",
   "settings.notification.url.errmsg.invalid": "URL 格式不正确",
   "settings.notification.serverchan.url.placeholder": "Url, 形如: https://sctapi.ftqq.com/****************.send",
+  "settings.notification.mail.sender_address.placeholder": "发送邮箱地址",
+  "settings.notification.mail.receiver_address.placeholder": "接收邮箱地址",
+  "settings.notification.mail.smtp_host.placeholder": "SMTP服务器地址",
+  "settings.notification.mail.smtp_port.placeholder": "SMTP服务器端口, 如果未设置, 默认为25",
+  "settings.notification.mail.username.placeholder": "用于登录到邮件服务器的用户名",
+  "settings.notification.mail.password.placeholder": "用于登录到邮件服务器的密码",
 
   "settings.ca.tab": "证书颁发机构(CA)",
   "settings.ca.provider.errmsg.empty": "请选择证书分发机构",
diff --git a/ui/src/pages/setting/Notify.tsx b/ui/src/pages/setting/Notify.tsx
index ad06a082..350e4b8b 100644
--- a/ui/src/pages/setting/Notify.tsx
+++ b/ui/src/pages/setting/Notify.tsx
@@ -7,6 +7,7 @@ import NotifyTemplate from "@/components/notify/NotifyTemplate";
 import Telegram from "@/components/notify/Telegram";
 import Webhook from "@/components/notify/Webhook";
 import ServerChan from "@/components/notify/ServerChan";
+import Mail from "@/components/notify/Mail";
 import { NotifyProvider } from "@/providers/notify";
 
 const Notify = () => {
@@ -61,6 +62,12 @@ const Notify = () => {
                 <ServerChan />
               </AccordionContent>
             </AccordionItem>
+            <AccordionItem value="item-7" className="dark:border-stone-200">
+              <AccordionTrigger>{t("common.provider.mail")}</AccordionTrigger>
+              <AccordionContent>
+                <Mail />
+              </AccordionContent>
+            </AccordionItem>
           </Accordion>
         </div>
       </NotifyProvider>

From c9f347f77af04aeb41b125afdde0a82f382bc445 Mon Sep 17 00:00:00 2001
From: Leo Chen <Leo@zhangbudademao.com>
Date: Sun, 27 Oct 2024 20:27:46 +0800
Subject: [PATCH 2/2] fix mail push onchange

---
 ui/src/components/notify/Mail.tsx | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/ui/src/components/notify/Mail.tsx b/ui/src/components/notify/Mail.tsx
index 9161ba09..7b6195fb 100644
--- a/ui/src/components/notify/Mail.tsx
+++ b/ui/src/components/notify/Mail.tsx
@@ -214,7 +214,7 @@ const Mail = () => {
             ...mail,
             data: {
               ...mail.data,
-              secret: e.target.value,
+              receiverAddresses: e.target.value,
             },
           };
           checkChanged(newData.data);
@@ -230,7 +230,7 @@ const Mail = () => {
             ...mail,
             data: {
               ...mail.data,
-              secret: e.target.value,
+              smtpHostAddr: e.target.value,
             },
           };
           checkChanged(newData.data);
@@ -246,7 +246,7 @@ const Mail = () => {
             ...mail,
             data: {
             ...mail.data,
-            secret: e.target.value,
+            smtpHostPort: e.target.value,
             },
         };
         checkChanged(newData.data);
@@ -262,7 +262,7 @@ const Mail = () => {
             ...mail,
             data: {
             ...mail.data,
-            secret: e.target.value,
+            username: e.target.value,
             },
         };
         checkChanged(newData.data);
@@ -278,7 +278,7 @@ const Mail = () => {
             ...mail,
             data: {
             ...mail.data,
-            secret: e.target.value,
+            password: e.target.value,
             },
         };
         checkChanged(newData.data);