写完了 PMail 插件总感觉还差了点什么,又翻了翻原仓库的 issues,发现有人提到没有多用户推送的功能,刚好设置页面放一个说明文档好像有点鸡肋,就想着改改。但是我要先搞明白这个前后端是怎么交互的,继续翻看源码,刚好有一个插件可以参考。

阅读源码

// server/hooks/spam_block/spam_block.go
​
//go:embed static/index.html
var index string
​
//go:embed static/jquery.js
var jquery string
​
// SettingsHtml 插件页面
func (s *SpamBlock) SettingsHtml(ctx *context.Context, url string, requestData string) string {
​
    if strings.Contains(url, "jquery.js") {
        return jquery
    }
​
    if strings.Contains(url, "index.html") {
        if !ctx.IsAdmin {
            return fmt.Sprintf(`
<div>
    Please contact the administrator for configuration.
</div>
`)
        }
        return fmt.Sprintf(index, s.cfg.ApiURL, s.cfg.ApiTimeout, s.cfg.Threshold)
    }
 ​
    var cfg SpamBlockConfig
    var tempCfg map[string]string
    err := json.Unmarshal([]byte(requestData), &tempCfg)
    if err != nil {
        return err.Error()
    }
    cfg.ApiURL = tempCfg["url"]
    cfg.Threshold = cast.ToFloat64(tempCfg["threshold"])
    cfg.ApiTimeout = cast.ToInt(tempCfg["timeout"])
    err = saveConfig(cfg)
    if err != nil {
        return err.Error()
    }
​
    s.cfg = cfg
​
    return "success"
​
}

大致是看明白了,通过 url 路径不同返回不同的内容,通过 requestData 获取前端发送的数据,那就可以做 api 接口,get 请求返回用户的配置信息,post 请求设置用户的配置信息,但是怎么绑定是哪个用户发起的请求呢?继续看源码!

// server/hooks/spam_block/spam_block.go
func (s *SpamBlock) SettingsHtml(ctx *context.Context, url string, requestData string) string {}
​
// server/utils/context/context.go
type Context struct {
    context.Context `json:"-"`
    UserID          int
    UserAccount     string
    UserName        string
    Values          map[string]any
    Lang            string
    IsAdmin         bool
}

context 刚好携带了用户 ID 信息,那就创建一个用户表,唯一键是用户ID,其它字段存储用户配置信息,收到邮件时通过用户 ID 判断应该发给哪个 ChatID,计划通!

创建表结构

// model/telegram_push.go
package model

import (
    "xorm.io/xorm"
)
​
// 表结构
type TelegramPushSetting struct {
    ID                 int    `xorm:"id pk autoincr comment('主键')" json:"-"`
    UserID             int    `xorm:"user_id int index('idx_uid') comment('用户id') unique('idx_uid')" json:"-"`
    ChatID             string `xorm:"chat_id varchar(255) index('idx_cid') index comment('聊天id')" json:"chat_id"`
    ShowContent        bool   `xorm:"show_content bool not null default 1 comment('是否显示邮件内容')" json:"show_content"`
    SpoilerContent     bool   `xorm:"spoiler_content bool not null default 1 comment('是否 spoiler 显示邮件内容')" json:"spoiler_content"`
    SendAttachments    bool   `xorm:"send_attachments bool not null default 1 comment('是否发送附件')" json:"send_attachments"`
    DisableLinkPreview bool   `xorm:"disable_link_preview bool not null default 1 comment('是否禁用链接预览')" json:"disable_link_preview"`
}
​
// 表名
func (u *TelegramPushSetting) TableName() string {
    return "plugin_telegram_push_setting"
}
​
// 获取配置
func GetSetting(db *xorm.Engine, userID int) (setting *TelegramPushSetting, err error) {
    setting = &TelegramPushSetting{}
    has, err := db.Where("user_id = ?", userID).Get(setting)
    if err != nil {
        return nil, err
    }
    if !has {
        // 不存在时创建默认配置,ChatID 空时,不发送消息
        setting = &TelegramPushSetting{
            UserID:             userID,
            ChatID:             "",
            ShowContent:        true,
            SpoilerContent:     true,
            SendAttachments:    true,
            DisableLinkPreview: true,
        }
        _, err = db.Insert(setting)
        if err != nil {
            return nil, err
        }
    }
    return setting, nil
}
​
// 创建或更新配置,哦,这里好像不应该创建了,上边创建过了,回去改改
func CreateOrUpdateSetting(db *xorm.Engine, setting *TelegramPushSetting) (err error) {
    has, err := db.Exist(&TelegramPushSetting{UserID: setting.UserID})
    if err != nil {
        return err
    }
    if has {
        _, err = db.Where("user_id = ?", setting.UserID).AllCols().Update(setting)
    } else {
        _, err = db.Insert(setting)
    }
    return err
}
// db/db.go
​
package db
​
import (
    "time"
​
    "github.com/Jinnrry/pmail/config"
    "github.com/Jinnrry/pmail/utils/errors"
    _ "github.com/go-sql-driver/mysql"
    _ "github.com/lib/pq"
    log "github.com/sirupsen/logrus"
    "github.com/ydzydzydz/pmail_telegram_push/model"
    _ "modernc.org/sqlite"
    "xorm.io/xorm"
)
​
var Instance *xorm.Engine
​
// 复制粘贴原项目初始化 DB,稍作修改
func Init(config *config.Config) error {
    dsn := config.DbDSN
    var err error
​
    switch config.DbType {
    case "mysql":
        Instance, err = xorm.NewEngine("mysql", dsn)
        Instance.SetMaxOpenConns(100)
        Instance.SetMaxIdleConns(10)
    case "sqlite":
        Instance, err = xorm.NewEngine("sqlite", dsn)
        Instance.SetMaxOpenConns(1)
        Instance.SetMaxIdleConns(1)
    case "postgres":
        Instance, err = xorm.NewEngine("postgres", dsn)
        Instance.SetMaxOpenConns(100)
        Instance.SetMaxIdleConns(10)
    default:
        return errors.New("Database Type Error!")
    }
    if err != nil {
        log.Errorf("DB init Error! %s", err.Error())
        return errors.Wrap(err)
    }
​
    Instance.SetConnMaxLifetime(30 * time.Minute)
    Instance.ShowSQL(false)
    // 创建表结构
    Instance.Sync2(new(model.TelegramPushSetting))
    return nil
}
.header on                            -- 开启表头
.mode column                          -- 左对齐的列
.output stdout                        -- 发送输出到屏幕
.tables plugin_telegram_push_setting  -- 列出表
.schema  plugin_telegram_push_setting -- 列出建表语句
CREATE TABLE `plugin_telegram_push_setting` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` INTEGER NULL,  `chat_id` TEXT NULL, `show_content` INTEGER DEFAULT 1 NOT NULL, `spoiler_content` INTEGER DEFAULT 1 NOT NULL,  `send_attachments` INTEGER DEFAULT 1 NOT NULL, `disable_link_preview` INTEGER DEFAULT 1 NOT NULL);
CREATE UNIQUE INDEX `UQE_plugin_telegram_push_setting_'idx_uid'` ON `plugin_telegram_push_setting` (`user_id`);
CREATE INDEX `IDX_plugin_telegram_push_setting_chat_id` ON `plugin_telegram_push_setting` (`chat_id`);
CREATE INDEX `IDX_plugin_telegram_push_setting_'idx_cid'` ON `plugin_telegram_push_setting` (`chat_id`);
 ​
SELECT * FROM sqlite_master WHERE type='table' AND name='plugin_telegram_push_setting';  -- 查看建表语句
 ​
PRAGMA table_info(plugin_telegram_push_setting);  -- 获取表的详细列信息
cid  name                  type     notnull  dflt_value  pk
---  --------------------  -------  -------  ----------  --
0    id                    INTEGER  1                    1
1    user_id               INTEGER  0                    0
2    chat_id               TEXT     0                    0
3    show_content          INTEGER  1        1           0
4    spoiler_content       INTEGER  1        1           0
5    send_attachments      INTEGER  1        1           0
6    disable_link_preview  INTEGER  1        1           0

修改配置

// config/config.go
// 管理员用户只需要配置这些就够了,其它配置由普通用户自行配置
type PluginConfig struct {
    TelegramBotToken string `json:"telegram_bot_token"`
    Proxy            string `json:"proxy" default:""`
    Timeout          int    `json:"timeout" default:"30"`
    Debug            bool   `json:"debug" default:"false"`
}
// hook/hook.go
// 相应的这里也可简化了
type PmailTelegramPushHook struct {
    bot          *bot.Bot                // 通过 bot 发送消息
    mainConfig   *pconfig.Config         // 通过主配置文件获取网页版地址
    pluginConfig *config.PluginConfig    // 通过插件配置文件创建 bot
}

接口设计

// hook/hook.go
func (h *PmailTelegramPushHook) SettingsHtml(ctx *context.Context, url string, requestData string) string {
    switch {
    // GET /api/plugin/settings/pmail_telegram_push/setting 返回 用户自定义配置
    case strings.Contains(url, "setting"):
        return h.getSetting(ctx.UserID)
    // GET /api/plugin/settings/pmail_telegram_push/bot 返回机器人信息,用户绑定 bot
    case strings.Contains(url, "bot"):
        return h.getBotInfo()
    // POST /api/plugin/settings/pmail_telegram_push/submit 保存用户的配置
    case strings.Contains(url, "submit"):
        return h.submitSetting(ctx.UserID, requestData)
    default:
        return SettingHtml
    }
}
// 这里应该没有限定请求方法,好像也限制不了
// hook/setting.go
​
var (
    //go:embed dist/index.html
    SettingHtml string
)
​
// 接口统一响应,返回前端处理
type Response struct {
    // 0 成功、非 0 失败
    Code    int    `json:"code"`
    // 返回消息
    Message string `json:"message"`
    // 返回数据
    Data    any    `json:"data"`
}
​
func (r *Response) Json() string {
    json, err := json.Marshal(r)
    if err != nil {
        log.Errorf("marshal response failed, err: %v", err)
        return ""
    }
    return string(json)
}
 ​
// 通过用户 ID 获取配置
func (h *PmailTelegramPushHook) getSetting(id int) string {
    result, err := model.GetSetting(db.Instance, id)
    if err != nil {
        response := Response{
            Code:    -1,
            Message: "get setting failed",
        }
        return response.Json()
    }
    response := Response{
        Code:    0,
        Message: "success",
        Data:    result,
    }
    return response.Json()
}
​
// 通过用户 ID 保存配置
func (h *PmailTelegramPushHook) submitSetting(id int, requestData string) string {
    var setting model.TelegramPushSetting
     if err := json.Unmarshal([]byte(requestData), &setting); err != nil {
        log.Errorf("unmarshal setting request failed, err: %v", err)
        response := Response{
            Code:    -1,
            Message: "unmarshal setting request failed",
        }
        return response.Json()
    }
    setting.UserID = id
    setting.ChatID = strings.TrimSpace(setting.ChatID)
    if err := model.CreateOrUpdateSetting(db.Instance, &setting); err != nil {
        log.Errorf("create or update setting failed, err: %v", err)
        response := Response{
            Code:    -1,
            Message: "create or update setting failed",
        }
        return response.Json()
    }
    response := Response{
        Code:    0,
        Message: "success",
    }
    return response.Json()
}
​
// 机器人信息,用户名,链接等等
type TelegramPushBotInfo struct {
    Username  string `json:"username"`
    FirstName string `json:"first_name"`
    BotLink   string `json:"bot_link"`
}
​
// 获取机器人信息
func (h *PmailTelegramPushHook) getBotInfo() string {
    me, err := h.bot.GetMe(context.Background())
    if err != nil {
        response := Response{
            Code:    -1,
            Message: "get bot me failed",
        }
        return response.Json()
    }
    response := Response{
        Code:    0,
        Message: "success",
        Data: TelegramPushBotInfo{
            Username:  me.Username,
            FirstName: me.FirstName,
            BotLink:   fmt.Sprintf("https://t.me/%s", me.Username),
        },
    }
    fmt.Println(response.Json())
    return response.Json()
}

发送通知

// hook/hook.go
func (h *PmailTelegramPushHook) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) {
    for _, u := range ue {
        if u.IsRead == 0 && u.Status == 0 && email.MessageId > 0 {
            // 根据用户 ID 获取用户配置,ChatID 不为空时发送通知
            setting, err := model.GetSetting(db.Instance, u.UserID)
            if err != nil || setting.ChatID == "" {
                continue
            }
            
            // 后边逻辑保持不变
            msg, err := h.sendNotification(email, setting)
            if err != nil {
                log.Errorf("send notification failed, err: %v", err)
                continue
            }
​
            if setting.SendAttachments && len(email.Attachments) > 0 {
                if _, err = h.sendAttachments(msg.ID, email, setting); err != nil {
                    log.Errorf("send attachments failed, err: %v", err)
                } else {
                    log.Infof("send attachments success, message id: %d", msg.ID)
                }
            }
        }
    }
}

后端部分基本搞定,编写设置页面

设置页面

pnpm install element-plus
pnpm install axios
// main.js
​
import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
​
const app = createApp(App)
app.use(ElementPlus)
app.mount('#app')
<!-- App.vue -->
​
<template>
  <div class="pmail-telegram-push-settings">
    <el-card class="box-card">
      <template #header>
        <div class="card-header">
          <span>Telegram 推送设置</span>
        </div>
      </template>
​
      <el-form
        ref="formRef"
        :model="formData"
        :rules="rules"
        label-width="180px"
        label-position="left"
        v-loading="loading"
      >
        <el-form-item label="Chat ID" prop="chat_id">
          <el-input v-model="formData.chat_id" placeholder="请输入 Telegram Chat ID" />
        </el-form-item>
​
        <el-form-item label="显示邮件内容">
          <el-switch v-model="formData.show_content" />
        </el-form-item>
​
        <el-form-item label="显示邮件内容时添加防剧透">
          <el-switch v-model="formData.spoiler_content" />
        </el-form-item>
​
        <el-form-item label="发送附件">
          <el-switch v-model="formData.send_attachments" />
        </el-form-item>
​
        <el-form-item label="禁用链接预览">
          <el-switch v-model="formData.disable_link_preview" />
        </el-form-item>
​
        <el-form-item>
          <el-button type="primary" :disabled="!botInfo?.bot_link" @click="contactBot">
            推送机器人
          </el-button>
          <el-button type="primary" @click="submitForm" :loading="loading"> 保存设置 </el-button>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>
​
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import axios from 'axios'
import './resize.js'
​
const formRef = ref(null)
const loading = ref(false)
const formData = ref({
  chat_id: '',
  show_content: true,
  spoiler_content: true,
  send_attachments: true,
  disable_link_preview: true,
})
​
// 这里好像有点问题,ChatID 没办法置空,一旦设置了,没法取消,回去再改改
// 这里 ChatID 应该为 int64 类型?前后端都应该限制一下,但好像 golang bot 库用的是 any,就不改了。
const rules = {
  chat_id: [{ required: true, message: '请输入 Chat ID', trigger: 'blur', whitespace: true }],
}
​
const botInfo = ref({
  username: '',
  first_name: '',
  bot_link: '',
})
​
// 获取设置数据
const fetchSettings = async () => {
  try {
    loading.value = true
    const response = await axios.get('/api/plugin/settings/pmail_telegram_push/setting')
    if (response.data && response.data.code === 0) {
      formData.value = {
        ...formData.value,
        ...response.data.data,
      }
    } else {
      ElMessage.error(response.data.message || '获取设置失败')
    }
  } catch (error) {
    console.error('Failed to fetch settings:', error)
    ElMessage.error('获取设置失败')
  } finally {
    loading.value = false
  }
}
​
// 提交表单
const submitForm = async () => {
  try {
    await formRef.value.validate()
    loading.value = true
    const response = await axios.post(
      '/api/plugin/settings/pmail_telegram_push/submit',
      formData.value,
    )
    if (response.data && response.data.code === 0) {
      ElMessage.success('设置保存成功')
    } else {
      ElMessage.error(response.data.message || '保存设置失败')
    }
  } catch (error) {
    console.error('Failed to save settings:', error)
    if (error.response && error.response.data.message) {
      ElMessage.error(error.response.data.message)
    } else {
      ElMessage.error('保存设置失败')
    }
  } finally {
    loading.value = false
  }
}
​
// 获取机器人信息
const getBotInfo = async () => {
  try {
    const response = await axios.get('/api/plugin/settings/pmail_telegram_push/bot')
    if (response.data && response.data.code === 0) {
      botInfo.value = {
        ...botInfo.value,
        ...response.data.data,
      }
    } else {
      ElMessage.error(response.data.message || '获取机器人信息失败')
    }
  } catch (error) {
    console.error('Failed to get bot:', error)
    ElMessage.error('获取机器人信息失败')
   }
}
​
// 联系机器人
const contactBot = () => {
  if (botInfo.value.bot_link) {
    window.open(botInfo.value.bot_link, '_blank')
  } else {
    ElMessage.error('机器人链接不存在')
  }
}
​
onMounted(() => {
  fetchSettings()
  getBotInfo()
})
</script>
​
<style scoped>
.pmail-telegram-push-settings {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}
​
.box-card {
  margin-top: 20px;
}
​
.card-header {
  font-size: 18px;
  font-weight: bold;
}
</style>

编译运行

make
make install
docker-compose down
docker-compose up -d
# 创建不同用户访问网页前端设置
# 向不同用户发送邮件测试
截屏 2025年10月02日 PMail Telegram 插件.png

打包发布

git tag v0.0.2
git push --tags

仓库链接

https://github.com/Jinnrry/PMailhttps://github.com/ydzydzydz/pmail_telegram_push