最近把邮箱换成 PMail 了,因为足够小巧,虽然各个邮件客户端支持还不够友好,但也足够用了。

搭建 PMail 还废了我不少功夫,起初我以为就写一个 docker-compose.yaml,再配置一下 DNS 就 OK 了

结果都配好了,收也收不到,发也发不出去!!!

我就来回翻 README 文档,检查 SELinux, 检查防火墙,检查安全组,貌似都没问题。

iptables、telnet、nmap 命令都试了一个遍,还是不行,再翻翻 issues,发现必须要放行 25 端口,结果我所有端口都试了,就没试 25 端口,VPS 厂商禁用了 25 端口,最后换了一家 VPS,就 OK 了。

我不是一个合格的运维了。

总体用起来还是很舒服的,项目自带了 telegram 插件,但是很久没有更新了,有时侯看不见邮件正文,就很难受,就想着自己改改,看了看文档,还挺简单的,主要是定义一个结构体,实现 framework.EmailHook 接口就可以了。

type DemoPlugin struct {}
​
var _ framework.EmailHook = (*DemoPlugin)(nil)
​
// GetName 获取插件名称
func (w *DemoPlugin) GetName(ctx *context.Context) string {
    return "DemoPlugin"
}
​
// SettingsHtml 返回插件设置页面的html
func (w *DemoPlugin) SettingsHtml(ctx *context.Context, url string, requestData string) string {
    return ""
}
​
// SendBefore 邮件发送前调用
func (w *DemoPlugin) SendBefore(ctx *context.Context, email *parsemail.Email) {
}
​
// SendAfter 邮件发送后调用
func (w *DemoPlugin) SendAfter(ctx *context.Context, email *parsemail.Email, err map[string]error) {
}
​
// ReceiveParseBefore 收信解析前调用
func (w *DemoPlugin) ReceiveParseBefore(ctx *context.Context, email *[]byte) {
}
​
// ReceiveParseAfter 收信解析后调用
func (w *DemoPlugin) ReceiveParseAfter(ctx *context.Context, email *parsemail.Email) {
}

再看看源码,就可以改了。

初始化配置

// config.go
​
const (
    // PMail 主配置文件
    MAIN_CONFIG_FILE   = "./config/config.json"
    // Telegram 推送插件配置,不想与主配置文件搞混,单独新建一个配置文件
    PLUGIN_CONFIG_FILE = "./config/pmail_telegram_push_config.json"
)
​
// 插件配置文件
type PluginConfig struct {
    TelegramBotToken string `json:"telegram_bot_token"`
    TelegramChatID   string `json:"telegram_chat_id"`
}
​
type Config struct {
    PluginConfig *PluginConfig
    MainConfig   *pconfig.Config // 主要是从主配置文件获取到网页版地址,其实也可以在插件配置文件里写,但少点配置少出错
}
​
// 读取主配置文件
func readMainConfig() *pconfig.Config {
    content, err := os.ReadFile(MAIN_CONFIG_FILE)
    if err != nil {
        panic(err)
    }
    var mainConfig pconfig.Config
    if err := json.Unmarshal(content, &mainConfig); err != nil {
        panic(err)
    }
    return &mainConfig
}

// 读取插件配置文件
func readPluginConfig() *PluginConfig {
    content, err := os.ReadFile(PLUGIN_CONFIG_FILE)
    if err != nil {
        panic(err)
    }
    var pluginConfig PluginConfig
    if err := json.Unmarshal(content, &pluginConfig); err != nil {
        panic(err)
    }
    if pluginConfig.TelegramBotToken == "" || pluginConfig.TelegramChatID == "" {
        panic("telegram bot token or chat id is empty")
    }
    return &pluginConfig
}
​
func ReadConfig() *Config {
    return &Config{
        PluginConfig: readPluginConfig(),
        MainConfig:   readMainConfig(),
    }
}

编写插件

// hook.go
type PmailTelegramPushHook struct {
    chatId         string
    bot            *bot.Bot
    httpsEnabled   int
    webDomain      string
}
​
var _ framework.EmailHook = (*PmailTelegramPushHook)(nil)
​
...
​
func NewPmailTelegramPushHook(cfg *config.Config) *PmailTelegramPushHook {
    bot, err := NewBot(cfg)
    if err != nil {
        panic(err)
    }
    return &PmailTelegramPushHook{
        chatId:         cfg.PluginConfig.TelegramChatID,
        bot:            bot,
        httpsEnabled:   cfg.MainConfig.HttpsEnabled,
        webDomain:      cfg.MainConfig.WebDomain,
    }
}

原项目使用 http post 请求直接发送的消息,我找到了两个库:

https://github.com/go-telegram-bot-api/telegram-bot-api

https://github.com/go-telegram/bot

第一个太久没跟新了,所以选用了第二个,然后我就发现可以加入更多的配置项了

type PluginConfig struct {
    // 机器人 Token
    TelegramBotToken string `json:"telegram_bot_token"`
    // 聊天 ID
    TelegramChatID   string `json:"telegram_chat_id"`
    // 显示正文内容
    ShowContent      bool   `json:"show_content" default:"true"`
    // 开启防剧透模式
    SpoilerContent   bool   `json:"spoiler_content" default:"true"`
    // 发送附件
    SendAttachment   bool   `json:"send_attachment" default:"true"`
    // 开启调试模式
    Debug            bool   `json:"debug" default:"false"`
    // 使用代理,虽然国外不需要,但国内呢,国内不放 25 端口,好吧!
    Proxy            string `json:"proxy" default:""`
    // 超时时间
    Timeout          int    `json:"timeout" default:"30"`
}

接下来开始创建机器人了

package hook
​
import (
    "context"
    "net"
    "net/http"
    "net/url"
    "strings"
    "time"
​
    "github.com/go-telegram/bot"
    "github.com/ydzydzydz/pmail_telegram_push/config"
    "golang.org/x/net/proxy"
)
​
func NewBot(config *config.Config) (*bot.Bot, error) {
    opts := []bot.Option{
        bot.WithCheckInitTimeout(time.Duration(config.PluginConfig.Timeout) * time.Second),
    }
    
    // 开启调试模式
    if config.PluginConfig.Debug {
        opts = append(opts, bot.WithDebug())
    }
​
    if config.PluginConfig.Proxy == "" {
        return newBotWithOutProxy(config, opts...)
    }
    parsedURL, err := url.Parse(config.PluginConfig.Proxy)
    if err != nil {
        panic(err)
    }
    switch strings.ToLower(parsedURL.Scheme) {
    case "socks5":
        return newBotWithSocks5Proxy(config, parsedURL, opts...)
    case "http", "https":
        return newBotWithHTTPProxy(config, parsedURL, opts...)
    default:
        return newBotWithOutProxy(config, opts...)
    }
}
​
// 不使用代理
func newBotWithOutProxy(config *config.Config, options ...bot.Option) (*bot.Bot, error) {
    return bot.New(config.PluginConfig.TelegramBotToken, options...)
}
​
// 使用 Socks5 代理
func newBotWithSocks5Proxy(config *config.Config, proxyURL *url.URL, options ...bot.Option) (*bot.Bot, error) {
    var auth *proxy.Auth
    if proxyURL.User != nil {
        password, _ := proxyURL.User.Password()
        auth = &proxy.Auth{
            User:     proxyURL.User.Username(),
            Password: password,
        }
    }
    dialer, err := proxy.SOCKS5(
        "tcp",
        proxyURL.Host,
        auth,
        proxy.Direct,
    )
    if err != nil {
        return nil, err
    }
    httpClient := &http.Client{
        Transport: &http.Transport{
            DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
                return dialer.Dial(network, addr)
            },
        },
    }
    opts := append(options, bot.WithHTTPClient(time.Duration(config.PluginConfig.Timeout)*time.Second, httpClient))
    return bot.New(config.PluginConfig.TelegramBotToken, opts...)
}
​
// 使用 HTTP 代理
func newBotWithHTTPProxy(config *config.Config, proxyURL *url.URL, options ...bot.Option) (*bot.Bot, error) {
    httpClient := &http.Client{
        Transport: &http.Transport{
            Proxy: http.ProxyURL(proxyURL),
        },
    }
​
   options = append(options, bot.WithHTTPClient(time.Duration(config.PluginConfig.Timeout)*time.Second, httpClient))
    return bot.New(config.PluginConfig.TelegramBotToken, options...)
}

使用机器人发送消息

// send.go
​
// 消息底部按钮
func (h *PmailTelegramPushHook) getWebButton() *models.InlineKeyboardMarkup {
    var url string
    if h.httpsEnabled > 1 {
        url = "http://" + h.webDomain
    } else {
        url = "https://" + h.webDomain
    }
​
    return &models.InlineKeyboardMarkup{
        InlineKeyboard: [][]models.InlineKeyboardButton{
            {
                {
                    Text: "查收邮件",
                    URL:  url,
                },
            },
        },
    }
}
​
// 使用正则删除 HTML 标签,虽然 Telegram 支持一部分比 HTML 标签,
// 但是要写一个超级复杂的正则也太麻烦了,干脆全部替换了,
// 注意要替换成空格,不然内容就粘连在一起了,不易读,如果是多个链接,都没法点
func removeHTMLTags(text string) string {
    re := regexp.MustCompile("<.*?>")
    return re.ReplaceAllString(text, " ")
}
​
// Telegram 消息有长度限制,防止溢出可能需要截一下
const TEXT_MAX_SIZE = 4096
​
// 生成获取需要发送的文本消息
func (h *PmailTelegramPushHook) getText(email *parsemail.Email) (text string) {
    text = "📧 有新邮件\n"
    text += fmt.Sprintf("🔖 主题:<b>%s</b>\n", email.Subject)
    text += fmt.Sprintf("📤 发件:&#60;%s&#62;\n", email.From.EmailAddress)
    if len(email.To) > 0 {
        text += "📥 收件:"
        for _, to := range email.To {
            text += fmt.Sprintf("&#60;%s&#62; ", to.EmailAddress)
        }
        text += "\n"
    }
    if len(email.Cc) > 0 {
        text += "📋 抄送:"
        for _, cc := range email.Cc {
            text += fmt.Sprintf("&#60;%s&#62; ", cc.EmailAddress)
        }
        text += "\n"
    }
    if len(email.Bcc) > 0 {
        text += "🕵️ 密送:"
        for _, bcc := range email.Bcc {
            text += fmt.Sprintf("&#60;%s&#62; ", bcc.EmailAddress)
        }
        text += "\n"
    }
    if len(email.Attachments) > 0 {
        text += fmt.Sprintf("📎 附件:%d 个\n", len(email.Attachments))
    }
    // 生成邮件正文内容
    if h.pluginConfig.ShowContent {
        size := TEXT_MAX_SIZE - len(text) - 100
        if size <= 0 {
            log.Warnf("text size too large: %s", text)
            return
        }
​
        var emailContent string
        if len(email.Text) > 0 {
             if len(email.Text) > size {
                emailContent = fmt.Sprintf("%s...", string(email.Text[:size]))
            } else {
                emailContent = string(email.Text)
            }
        } else if len(email.HTML) > 0 {
            // 原项目只发送纯文本消息,所以有时看不到正文,这里对 HTML 正文做了简单处理
            if len(email.HTML) > size {
                emailContent = fmt.Sprintf("%s...", removeHTMLTags(string(email.HTML))[:size])
            } else {
                emailContent = removeHTMLTags(string(email.HTML))
            }
        }
        // 开启防剧透模式就是添加一对标签
        if len(emailContent) > 0 && h.pluginConfig.SpoilerContent {
            emailContent = fmt.Sprintf("<tg-spoiler>%s</tg-spoiler>", emailContent)
        }
        if len(emailContent) > 0 {
            text += fmt.Sprintf("%s\n", emailContent)
        }
    }
​
    return
}
​
// 发送消息,返回响应
func (h *PmailTelegramPushHook) sendNotification(email *parsemail.Email) (msg *models.Message, err error) {
    ctx, cancel := context.WithTimeout(context.Background(), time.Duration(h.pluginConfig.Timeout)*time.Second)
    defer cancel()
​
    parmas := &bot.SendMessageParams{
        ChatID:      h.pluginConfig.TelegramChatID,
        Text:        h.getText(email),
        ParseMode:   models.ParseModeHTML,
        ReplyMarkup: h.getWebButton(),
        // 配置是否显示链接预览,有时候挺讨厌的,我喜欢关掉
        LinkPreviewOptions: &models.LinkPreviewOptions{
            IsDisabled: &h.pluginConfig.DisableLinkPreview,
        },
    }
 
    return h.bot.SendMessage(ctx, parmas)
}
​
// 发送附件,引用上一条消息,传入上一条消息 ID 即可
func (h *PmailTelegramPushHook) sendAttachments(id int, email *parsemail.Email) (msg *models.Message, err error) {
    ctx, cancel := context.WithTimeout(context.Background(), time.Duration(h.pluginConfig.Timeout)*time.Second)
    defer cancel()
​
    params := &bot.SendDocumentParams{
        ChatID: h.pluginConfig.TelegramChatID,
        ReplyParameters: &models.ReplyParameters{
            MessageID: id,
            // 这里只引用上一条消息的关键字,而非整条消息
            Quote:     fmt.Sprintf("📎 附件:%d 个", len(email.Attachments)),
        },
    }
    // 这里遍历附件发送消息,不够优雅,但是合并发送我没写明白,我需要大佬支持一下
    for i, attachment := range email.Attachments {
        params.Caption = fmt.Sprintf("📎 附件 %d", i+1)
        params.Document = &models.InputFileUpload{
            Filename: filepath.Base(attachment.Filename),
            Data:     bytes.NewReader(attachment.Content),
        }
​
        if msg, err = h.bot.SendDocument(ctx, params); err != nil {
            err = errors.Join(err, fmt.Errorf("send document failed, err: %w", err))
            continue
        }
    }
    return
}
​
// TODO: 合并多个附件为一个消息发送
// 没想明白,为啥不行?
// func (h *PmailTelegramPushHook) sendAttachmentsCombine(id int, email *parsemail.Email) (msg []*models.Message, err error) {
//  ctx, cancel := context.WithTimeout(context.Background(), time.Duration(h.pluginConfig.Timeout)*time.Second)
//  defer cancel()
//  params := &bot.SendMediaGroupParams{
//      ChatID: h.pluginConfig.TelegramChatID,
//      ReplyParameters: &models.ReplyParameters{
//          MessageID: id,
//          Quote:     fmt.Sprintf("📎 附件:%d 个", len(email.Attachments)),
//      },
//  }
//  for i, attachment := range email.Attachments {
//      params.Media = append(params.Media, &models.InputMediaDocument{
//          Media:           filepath.Base(attachment.Filename),
//          Caption:         fmt.Sprintf("📎 附件 %d", i+1),
//          MediaAttachment: bytes.NewReader(attachment.Content),
//      })
//  }
//  return h.bot.SendMediaGroup(ctx, params)
// }

调用 send.go 发送邮件通知

// hook.go
​
const (
    PLUGIN_NAME = "pmail_telegram_push"
)
​
type PmailTelegramPushHook struct {
    chatId         string
    bot            *bot.Bot
    httpsEnabled   int
    webDomain      string
    showContent    bool
    sendAttachment bool
    spoilerContent bool
    timeout        time.Duration
}
​
var _ framework.EmailHook = (*PmailTelegramPushHook)(nil)
​
func (h *PmailTelegramPushHook) GetName(ctx *context.Context) string {
    return PLUGIN_NAME
}
​
func (h *PmailTelegramPushHook) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) {
    for _, u := range ue {
        // 管理员,未读消息,发送通知
        if u.UserID == 1 && u.IsRead == 0 && u.Status == 0 && email.MessageId > 0 {
            msg, err := h.sendNotification(email)
            if err != nil {
                log.Errorf("send notification failed, err: %v", err)
                continue
            }
            // 有附件,引用上一条消息发送附件
            if h.sendAttachment && len(email.Attachments) > 0 {
                if _, err = h.sendAttachments(msg.ID, email); err != nil {
                    log.Errorf("send attachments failed, err: %v", err)
                } else {
                    log.Infof("send attachments success, message id: %d", msg.ID)
                }
            }
        }
    }
}
 ​
func (h *PmailTelegramPushHook) ReceiveParseBefore(ctx *context.Context, email *[]byte) {}
func (h *PmailTelegramPushHook) ReceiveParseAfter(ctx *context.Context, email *parsemail.Email) {}
func (h *PmailTelegramPushHook) SendAfter(ctx *context.Context, email *parsemail.Email, err map[string]error) {}
func (h *PmailTelegramPushHook) SendBefore(ctx *context.Context, email *parsemail.Email) {}
func (h *PmailTelegramPushHook) SettingsHtml(ctx *context.Context, url string, requestData string) string {return ""}
​
func NewPmailTelegramPushHook(cfg *config.Config) *PmailTelegramPushHook {
    bot, err := NewBot(cfg)
    if err != nil {
        panic(err)
    }
    return &PmailTelegramPushHook{
        chatId:         cfg.PluginConfig.TelegramChatID,
        bot:            bot,
        httpsEnabled:   cfg.MainConfig.HttpsEnabled,
        webDomain:      cfg.MainConfig.WebDomain,
        showContent:    cfg.PluginConfig.ShowContent,
        sendAttachment: cfg.PluginConfig.SendAttachment,
        spoilerContent: cfg.PluginConfig.SpoilerContent,
        timeout:        time.Duration(cfg.PluginConfig.Timeout) * time.Second,
    }
}

好了,最后一步

// main.go
func main() {
    config := config.ReadConfig()
    framework.CreatePlugin(
        hook.PLUGIN_NAME,
        hook.NewPmailTelegramPushHook(config),
    ).Run()
}

运行插件

GOOS=linux GOARCH=amd64 go build -o pmail_telegram_push
scp pmail_telegram_push [email protected]:/root/docker/pmail/plugins
ssh [email protected] 
cd /root/docker/pmail
vim config/pmail_telegram_push_config.json
docker-compose down
docker-compose up -d
# 发一封邮件测试一下(带附件的,嗯,不带也可以)
// config/pmail_telegram_push_config.json
{
    "telegram_bot_token": "YOUR_TELEGRAM_BOT_TOKEN",
    "telegram_chat_id": "YOUR_TELEGRAM_CHAT_ID",
    "show_content": true,
    "spoiler_content": true,
    "send_attachment": true,
    "debug": false,
    "proxy": "",
    "timeout": 30
}

截屏 2025年10月02 来自壮壮博客.png

设置页面

其实现在主要功能都 OK 了, 也没有必要又设置页面,就放一个说明文档吧

// hook.go
​
import (
    _ "embed"
)
​
//go:embed setting.html
var SettingHtml string

func (h *PmailTelegramPushHook) SettingsHtml(ctx *context.Context, url string, requestData string) string {
    return SettingHtml
}

重新编译运行 OK 了,emmm,这画的也太丑了,而且还有点问题,Pmail 设置页面是通过 iframe 嵌入插件设置页面的,高度不够,展示不全,不会前端,问问 AI 吧

截屏 2025年10月02日 PMail Telegram 插件 (1).png
function adjustIframeHeight() {
    try {
        const iframe = window.frameElement;
        if (!iframe) return; 
        const height = Math.max(
            document.body.scrollHeight,
            document.body.offsetHeight,
            document.documentElement.clientHeight,
            document.documentElement.scrollHeight,
            document.documentElement.offsetHeight
        );
        iframe.style.height = height + 'px';
    } catch (e) {
        console.error('调整 iframe 高度失败:', e);
    }
}
window.addEventListener('load', adjustIframeHeight);
​
const observer = new MutationObserver(adjustIframeHeight);
observer.observe(document.body, {
    attributes: true,
    childList: true,
    subtree: true,
    characterData: true
});
window.addEventListener('resize', adjustIframeHeight);

重新编译,运行,现在 OK 了,还是丑

pnpm create vue@latest
pnpm install vite-plugin-singlefile // 打包成单个文件
pnpm install unplugin-vue-markdown markdown-it-prism prism @unhead/vue -D
pnpm install highlight.js -D
pnpm install github-markdown-css
// vite.config.js
import { fileURLToPath, URL } from 'node:url'
​
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import { viteSingleFile } from 'vite-plugin-singlefile'
import Markdown from 'unplugin-vue-markdown/vite'
​
// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue({
      include: [/\.vue$/, /\.md$/],
    }),
    vueDevTools(),
    viteSingleFile(),
    Markdown(),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
  build: {
    outDir: 'hook/dist', // 打包输出文件为 hook/dist/index.html
  },
})
<!-- src/App.vue -->
​
<script setup>
import Readme from '../README.md'
import 'github-markdown-css'
import './resize.js'
</script>
​
<template>
  <div class="markdown-body container">
    <Readme />
  </div>
</template>
​
<style scoped>
.container {
  max-width: 800px;
  margin: 0 auto;
}
</style>
// hook.go
​
//go:embed dist/index.html
var SettingHtml string
pnpm build
go build
截屏 2025年10月2日 PMail Telegram 插件.png

自动化

# .github/workflows/release.yaml
name: Release Binary
​
on:
  push:
    tags:
      - 'v*'
​
jobs:
  build:
    strategy:
      matrix:
        goos: ['linux', 'darwin', 'windows']
        goarch: ['amd64', 'arm64']
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '>=1.25'
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '24'
      - name: Set up pnpm
        uses: pnpm/action-setup@v4
        with:
          version: '10'
          run_install: true
          standalone: true
      - name: Download dependencies
        run: |
          go mod tidy
          go mod download
      - name: Build binary
        run: |
          pnpm build
          if [[ "${{ matrix.goos }}" = "windows" ]]; then
            go build -o pmail_telegram_push_${{ matrix.goos }}_${{ matrix.goarch }}.exe -ldflags="-s -w"
          else
            go build -o pmail_telegram_push_${{ matrix.goos }}_${{ matrix.goarch }} -ldflags="-s -w"
          fi
        env:
          GOOS: ${{ matrix.goos }}
          GOARCH: ${{ matrix.goarch }}
          CGO_ENABLED: 0  # 这里有坑,不关闭 CGO 是动态链接的...
      - name: Publish Release
        uses: ncipollo/release-action@v1
        with:
          allowUpdates: true
          artifactErrorsFailBuild: true
          artifacts: 'pmail_telegram_push_${{ matrix.goos }}_${{ matrix.goarch }}*'
          commit: ${{ github.sha }}
          draft: false
          prerelease: false
          token: ${{ secrets.GITHUB_TOKEN }}
git tag v0.0.1    # 打标签
git push --tags   # 触发 workflow 自动编译 release

编写 Makefile

.PHONY: build
​
build:
        pnpm build
        go build -o pmail_telegram_push -ldflags "-s -w"
​
install:
        cp -v pmail_telegram_push ./plugins
​
clean:
        rm -vf pmail_telegram_push ./plugins/pmail_telegram_push
make && make install

好了,该有的功能都有了,麻雀虽小,五脏俱全 🤣

项目链接

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