Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions server/controllers/email/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
}

log.WithContext(ctx).Debugf("插件执行--SendBefore")
for _, hook := range hooks.HookList {
for _, hook := range hooks.AllHooks() {
if hook == nil {
continue
}
Expand Down Expand Up @@ -212,7 +212,7 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
log.WithContext(ctx).Debugf("插件执行--SendAfter")

as2 := async.New(ctx)
for _, hook := range hooks.HookList {
for _, hook := range hooks.AllHooks() {
if hook == nil {
continue
}
Expand Down
4 changes: 2 additions & 2 deletions server/controllers/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (

func GetPluginList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
ret := []string{}
for s, _ := range hooks.HookList {
for _, s := range hooks.HookNames() {
ret = append(ret, s)
}
response.NewSuccessResponse(ret).FPrint(w)
Expand All @@ -26,7 +26,7 @@ func SettingsHtml(ctx *context.Context, w http.ResponseWriter, req *http.Request
}

pluginName := args[4]
if plugin, ok := hooks.HookList[pluginName]; ok {
if plugin, ok := hooks.GetHook(pluginName); ok {
dt, err := io.ReadAll(req.Body)
if err != nil {
response.NewErrorResponse(response.ParamsError, err.Error(), "").FPrint(w)
Expand Down
72 changes: 70 additions & 2 deletions server/hooks/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,61 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
)

// HookList
var HookList map[string]framework.EmailHook

// hookListMu 保护 HookList。插件进程退出时会并发地从 HookList 移除条目,
// 而收发邮件的 goroutine 会并发读取,必须加锁避免数据竞争。
var hookListMu sync.RWMutex

// AllHooks 返回当前已注册插件的快照,供收发邮件时并发安全地遍历。
func AllHooks() []framework.EmailHook {
hookListMu.RLock()
defer hookListMu.RUnlock()
list := make([]framework.EmailHook, 0, len(HookList))
for _, h := range HookList {
list = append(list, h)
}
return list
}

// HookNames 返回已注册插件的名称列表。
func HookNames() []string {
hookListMu.RLock()
defer hookListMu.RUnlock()
names := make([]string, 0, len(HookList))
for name := range HookList {
names = append(names, name)
}
return names
}

// GetHook 返回指定名称的插件。
func GetHook(name string) (framework.EmailHook, bool) {
hookListMu.RLock()
defer hookListMu.RUnlock()
h, ok := HookList[name]
return h, ok
}

// RegisterHook 注册一个插件。
func RegisterHook(name string, h framework.EmailHook) {
hookListMu.Lock()
defer hookListMu.Unlock()
HookList[name] = h
}

// RemoveHook 移除指定名称的插件。
func RemoveHook(name string) {
hookListMu.Lock()
defer hookListMu.Unlock()
delete(HookList, name)
}

type HookSender struct {
httpc http.Client
name string
Expand Down Expand Up @@ -236,10 +285,22 @@ func Init(serverVersion string) {

pluginNo++

// registeredName 记录该插件最终注册进 HookList 使用的键。
// 清理协程在插件进程退出后必须用同一个键去移除,
// 否则会残留指向已死亡 socket 的 HookSender。
var registeredName string
var registeredNameMu sync.Mutex

go func() {
stat, err := p.Wait()
log.Errorf("[%s] Plugin Stop. Error:%v Stat:%v", info.Name(), err, stat.String())
delete(HookList, info.Name())
registeredNameMu.Lock()
name := registeredName
registeredNameMu.Unlock()
if name != "" {
RemoveHook(name)
log.Infof("[%s] Plugin Removed From HookList", name)
}
os.Remove(socketPath)
}()

Expand All @@ -257,7 +318,14 @@ func Init(serverVersion string) {
if loadSucc {
hk := NewHookSender(socketPath, info.Name(), serverVersion)
hkName := hk.GetName(&context.Context{})
HookList[hkName] = hk
if hkName == "" {
// GetName 失败时退回插件文件名作为键,避免出现空键。
hkName = info.Name()
}
RegisterHook(hkName, hk)
registeredNameMu.Lock()
registeredName = hkName
registeredNameMu.Unlock()
log.Infof("[%s] Plugin Load Success!", hkName)
}

Expand Down
145 changes: 145 additions & 0 deletions server/hooks/base_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package hooks

import (
"sync"
"testing"

"github.com/Jinnrry/pmail/dto/parsemail"
"github.com/Jinnrry/pmail/hooks/framework"
"github.com/Jinnrry/pmail/models"
"github.com/Jinnrry/pmail/utils/context"
)

// fakeHook 实现 framework.EmailHook 接口,仅用于测试。
type fakeHook struct {
name string
}

func (f *fakeHook) SendBefore(ctx *context.Context, email *parsemail.Email) {}
func (f *fakeHook) SendAfter(ctx *context.Context, email *parsemail.Email, err map[string]error) {
}
func (f *fakeHook) ReceiveParseBefore(ctx *context.Context, email *[]byte) {}
func (f *fakeHook) ReceiveParseAfter(ctx *context.Context, email *parsemail.Email) {}
func (f *fakeHook) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) {
}
func (f *fakeHook) GetName(ctx *context.Context) string { return f.name }
func (f *fakeHook) SettingsHtml(ctx *context.Context, url string, requestData string) string {
return ""
}

func resetHookList() {
HookList = map[string]framework.EmailHook{}
}

// TestRegisterAndGetHook 验证注册与按键读取使用同一个键。
// 这是历史 bug 的核心:注册用 GetName() 返回值(如 "SpamBlock"),
// 清理协程却用插件文件名(如 "spam_block")去 delete,键不匹配导致
// 死插件永远残留在 HookList 中。
func TestRegisterAndGetHook(t *testing.T) {
resetHookList()

// 模拟真实流程:注册键 = GetName() 返回值。
registeredKey := "SpamBlock"
RegisterHook(registeredKey, &fakeHook{name: registeredKey})

// 用同一个键能取到。
if _, ok := GetHook(registeredKey); !ok {
t.Fatalf("GetHook(%q) = not found, want found", registeredKey)
}

// 用插件文件名(不同键)取不到,说明键语义是正确的。
if _, ok := GetHook("spam_block"); ok {
t.Fatalf("GetHook(%q) = found, want not found (文件名不是注册键)", "spam_block")
}

// RemoveHook 用注册键移除后应取不到。
RemoveHook(registeredKey)
if _, ok := GetHook(registeredKey); ok {
t.Fatalf("GetHook(%q) after RemoveHook = found, want not found", registeredKey)
}
}

// TestAllHooksSnapshot 验证 AllHooks 返回的是当前快照。
func TestAllHooksSnapshot(t *testing.T) {
resetHookList()

RegisterHook("A", &fakeHook{name: "A"})
RegisterHook("B", &fakeHook{name: "B"})

snap := AllHooks()
if len(snap) != 2 {
t.Fatalf("AllHooks() len = %d, want 2", len(snap))
}

// 移除后快照不受影响(已拷贝),但新快照会变。
RemoveHook("A")
if len(snap) != 2 {
t.Fatalf("snapshot should be immutable, got %d", len(snap))
}
if got := len(AllHooks()); got != 1 {
t.Fatalf("AllHooks() after remove = %d, want 1", got)
}
}

// TestHookNames 验证名称列表与注册键一致。
func TestHookNames(t *testing.T) {
resetHookList()
RegisterHook("SpamBlock", &fakeHook{name: "SpamBlock"})
RegisterHook("WeChatPush", &fakeHook{name: "WeChatPush"})

names := HookNames()
if len(names) != 2 {
t.Fatalf("HookNames() len = %d, want 2", len(names))
}
seen := map[string]bool{}
for _, n := range names {
seen[n] = true
}
if !seen["SpamBlock"] || !seen["WeChatPush"] {
t.Fatalf("HookNames() = %v, want contain SpamBlock and WeChatPush", names)
}
}

// TestConcurrentAccess 在 -race 下验证并发读写不会触发数据竞争。
// 模拟插件进程退出(RemoveHook)与收发邮件(AllHooks 遍历)同时进行。
func TestConcurrentAccess(t *testing.T) {
resetHookList()
const plugins = 50
for i := 0; i < plugins; i++ {
key := "hook" + string(rune('A'+i%26))
RegisterHook(key, &fakeHook{name: key})
}

var wg sync.WaitGroup

// 读侧:模拟收/发邮件时遍历插件。
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 200; j++ {
for _, h := range AllHooks() {
if h == nil {
continue
}
_ = h.GetName(nil)
}
}
}()
}

// 写侧:模拟插件进程退出时的移除。
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 50; j++ {
key := "hook" + string(rune('A'+(i+j)%26))
RemoveHook(key)
RegisterHook(key, &fakeHook{name: key})
}
}(i)
}

wg.Wait()
}
2 changes: 1 addition & 1 deletion server/listen/smtp_server/audit_persistence.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func (s *Session) deliverOutgoingEmail(email *parsemail.Email, sender outgoingSe

func runSendAfterHooks(ctx *context.Context, email *parsemail.Email, domainErrors map[string]error) {
process := async.New(ctx)
for _, hook := range hooks.HookList {
for _, hook := range hooks.AllHooks() {
if hook == nil {
continue
}
Expand Down
8 changes: 4 additions & 4 deletions server/listen/smtp_server/read_content.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func (s *Session) Data(r io.Reader) error {
log.WithContext(ctx).Debugf("%s", string(emailData))

log.WithContext(ctx).Debugf("开始执行插件ReceiveParseBefore!")
for _, hook := range hooks.HookList {
for _, hook := range hooks.AllHooks() {
if hook == nil {
continue
}
Expand Down Expand Up @@ -79,7 +79,7 @@ func (s *Session) Data(r io.Reader) error {
}

log.WithContext(ctx).Debugf("开始执行插件SendBefore!")
for _, hook := range hooks.HookList {
for _, hook := range hooks.AllHooks() {
if hook == nil {
continue
}
Expand Down Expand Up @@ -110,7 +110,7 @@ func (s *Session) Data(r io.Reader) error {
email.Authentication = parsemail.NewEmailAuthentication(SPFStatus, dkimStatus)

log.WithContext(ctx).Debugf("开始执行插件ReceiveParseAfter!")
for _, hook := range hooks.HookList {
for _, hook := range hooks.AllHooks() {
if hook == nil {
continue
}
Expand Down Expand Up @@ -150,7 +150,7 @@ func (s *Session) Data(r io.Reader) error {
log.WithContext(ctx).Errorf("sql Error :%+v", err)
}
as3 := async.New(ctx)
for _, hook := range hooks.HookList {
for _, hook := range hooks.AllHooks() {
if hook == nil {
continue
}
Expand Down
Loading