Update dependencies and enhance mailer functionality

- Bump Go version to 1.24.0 and update toolchain to 1.24.3 for improved performance and compatibility.
- Add new dependencies for IMAP and SASL support, enhancing email handling capabilities.
- Refactor messenger service to integrate a new mailer provider, replacing the deprecated SMTP provider.
- Implement automatic mail receiver startup for mailer providers, improving message handling efficiency.
- Update GitHub Actions workflows to include IMAP server configurations for testing email reception.
This commit is contained in:
Max 2025-09-28 09:33:36 +08:00
parent 09ec6edac3
commit 1843a64ff5
15 changed files with 2147 additions and 110 deletions

View file

@ -123,6 +123,13 @@ env:
RELIABLE_SMTP_PASSWORD: ${{ secrets.RELIABLE_SMTP_PASSWORD }}
RELIABLE_SMTP_FROM: "Yaobots Gmail Tests <shadow.iqka@gmail.com>"
## IMAP Server (Gmail)
RELIABLE_IMAP_HOST: "imap.gmail.com"
RELIABLE_IMAP_PORT: "993"
RELIABLE_IMAP_USERNAME: ${{ secrets.RELIABLE_SMTP_USERNAME }}
RELIABLE_IMAP_PASSWORD: ${{ secrets.RELIABLE_SMTP_PASSWORD }}
RELIABLE_IMAP_MAILBOX: "INBOX"
## Twilio
TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }}
TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }}

View file

@ -128,6 +128,14 @@ env:
RELIABLE_SMTP_PASSWORD: ${{ secrets.RELIABLE_SMTP_PASSWORD }}
RELIABLE_SMTP_FROM: "Yaobots Gmail Tests <shadow.iqka@gmail.com>"
## IMAP Server (Gmail)
RELIABLE_IMAP_HOST: "imap.gmail.com"
RELIABLE_IMAP_PORT: "993"
RELIABLE_IMAP_USERNAME: ${{ secrets.RELIABLE_SMTP_USERNAME }}
RELIABLE_IMAP_PASSWORD: ${{ secrets.RELIABLE_SMTP_PASSWORD }}
RELIABLE_IMAP_MAILBOX: "INBOX"
## Twilio
TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }}
TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }}

20
go.mod
View file

@ -1,8 +1,8 @@
module github.com/yaoapp/yao
go 1.23.0
go 1.24.0
toolchain go1.23.4
toolchain go1.24.3
require (
github.com/PuerkitoBio/goquery v1.10.3
@ -13,6 +13,7 @@ require (
github.com/caarlos0/env/v6 v6.10.1
github.com/dchest/captcha v1.1.0
github.com/elazarl/go-bindata-assetfs v1.0.1
github.com/emersion/go-imap v1.2.1
github.com/evanw/esbuild v0.25.4
github.com/expr-lang/expr v1.17.3
github.com/fatih/color v1.18.0
@ -37,9 +38,9 @@ require (
github.com/yaoapp/kun v0.9.0
github.com/yaoapp/xun v0.9.0
go.mongodb.org/mongo-driver v1.17.3
golang.org/x/crypto v0.39.0
golang.org/x/net v0.41.0
golang.org/x/text v0.27.0
golang.org/x/crypto v0.41.0
golang.org/x/net v0.43.0
golang.org/x/text v0.29.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
rogchap.com/v8go v0.9.0
@ -70,6 +71,7 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
@ -152,11 +154,11 @@ require (
go.opentelemetry.io/otel/trace v1.37.0 // indirect
golang.org/x/arch v0.17.0 // indirect
golang.org/x/image v0.29.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/tools v0.34.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/tools v0.36.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
google.golang.org/grpc v1.72.1 // indirect
google.golang.org/protobuf v1.36.6 // indirect

36
go.sum
View file

@ -70,6 +70,13 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw=
github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/evanw/esbuild v0.25.4 h1:k1bTSim+usBG27w7BfOCorhgx3tO+6bAfMj5pR+6SKg=
github.com/evanw/esbuild v0.25.4/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/expr-lang/expr v1.17.3 h1:myeTTuDFz7k6eFe/JPlep/UsiIjVhG61FMHFu63U7j0=
@ -353,8 +360,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@ -362,8 +369,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@ -377,8 +384,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
@ -391,8 +398,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -413,8 +420,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@ -428,6 +435,7 @@ golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
@ -436,16 +444,16 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=

View file

@ -13,8 +13,8 @@ import (
"github.com/yaoapp/gou/application"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/messenger/providers/mailer"
"github.com/yaoapp/yao/messenger/providers/mailgun"
"github.com/yaoapp/yao/messenger/providers/smtp"
"github.com/yaoapp/yao/messenger/providers/twilio"
"github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/share"
@ -34,6 +34,7 @@ type Service struct {
providersByType map[types.MessageType][]types.Provider // Providers grouped by message type
channels map[string]types.Channel
defaults map[string]string
receivers map[string]context.CancelFunc // Active mail receivers by provider name
mutex sync.RWMutex
}
@ -104,10 +105,15 @@ func Load(cfg config.Config) error {
providersByType: providersByType,
channels: make(map[string]types.Channel),
defaults: config.Defaults,
receivers: make(map[string]context.CancelFunc),
}
// Set global instance
Instance = service
// Auto-start mail receivers for mailer providers that support receiving
service.startMailReceivers()
return nil
}
@ -195,8 +201,10 @@ func createProvider(config types.ProviderConfig) (types.Provider, error) {
// Create provider based on connector
switch connector {
case "smtp":
return smtp.NewSMTPProvider(config)
case "mailer":
return mailer.NewMailerProvider(config)
case "smtp": // Keep backward compatibility
return mailer.NewMailerProvider(config)
case "twilio":
return createTwilioProvider(config)
case "mailgun":
@ -544,7 +552,7 @@ func (m *Service) supportsChannelType(provider types.Provider, channelType strin
switch channelType {
case "email":
return providerType == "smtp" || providerType == "mailgun" || providerType == "twilio"
return providerType == "mailer" || providerType == "smtp" || providerType == "mailgun" || providerType == "twilio"
case "sms":
return providerType == "twilio"
case "whatsapp":
@ -559,7 +567,9 @@ func getSupportedMessageTypes(provider types.Provider) []types.MessageType {
providerType := strings.ToLower(provider.GetType())
switch providerType {
case "smtp":
case "mailer":
return []types.MessageType{types.MessageTypeEmail}
case "smtp": // Keep backward compatibility
return []types.MessageType{types.MessageTypeEmail}
case "mailgun":
return []types.MessageType{types.MessageTypeEmail}
@ -570,3 +580,91 @@ func getSupportedMessageTypes(provider types.Provider) []types.MessageType {
return []types.MessageType{}
}
}
// startMailReceivers automatically starts mail receivers for mailer providers that support receiving
func (m *Service) startMailReceivers() {
m.mutex.Lock()
defer m.mutex.Unlock()
for name, provider := range m.providers {
// Only handle mailer providers
if provider.GetType() != "mailer" {
continue
}
// Check if this mailer provider supports receiving
if mailerProvider, ok := provider.(*mailer.Provider); ok {
if mailerProvider.SupportsReceiving() {
log.Info("[Messenger] Starting mail receiver for provider: %s", name)
// Create context for this receiver
ctx, cancel := context.WithCancel(context.Background())
// Start the mail receiver in a goroutine
go func(providerName string, mp *mailer.Provider) {
err := mp.StartMailReceiver(ctx, func(msg *types.Message) error {
log.Info("[Messenger] Received email via %s: Subject=%s, From=%s", providerName, msg.Subject, msg.From)
// Here you can add custom message processing logic
// For now, just log the received message
return nil
})
if err != nil {
log.Error("[Messenger] Mail receiver for %s stopped with error: %v", providerName, err)
} else {
log.Info("[Messenger] Mail receiver for %s stopped gracefully", providerName)
}
}(name, mailerProvider)
// Store the cancel function for later cleanup
m.receivers[name] = cancel
log.Info("[Messenger] Mail receiver started for provider: %s", name)
} else {
log.Debug("[Messenger] Provider %s does not support receiving (IMAP not configured)", name)
}
}
}
}
// StopMailReceivers stops all active mail receivers
func (m *Service) StopMailReceivers() {
m.mutex.Lock()
defer m.mutex.Unlock()
for name, cancel := range m.receivers {
log.Info("[Messenger] Stopping mail receiver for provider: %s", name)
cancel()
}
// Clear the receivers map
m.receivers = make(map[string]context.CancelFunc)
log.Info("[Messenger] All mail receivers stopped")
}
// StopMailReceiver stops a specific mail receiver by provider name
func (m *Service) StopMailReceiver(providerName string) {
m.mutex.Lock()
defer m.mutex.Unlock()
if cancel, exists := m.receivers[providerName]; exists {
log.Info("[Messenger] Stopping mail receiver for provider: %s", providerName)
cancel()
delete(m.receivers, providerName)
} else {
log.Warn("[Messenger] No active mail receiver found for provider: %s", providerName)
}
}
// GetActiveReceivers returns the names of all active mail receivers
func (m *Service) GetActiveReceivers() []string {
m.mutex.RLock()
defer m.mutex.RUnlock()
var receivers []string
for name := range m.receivers {
receivers = append(receivers, name)
}
return receivers
}

View file

@ -0,0 +1,269 @@
package messenger
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/messenger/providers/mailer"
"github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/test"
)
func TestService_MailReceiverManagement(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Create a test service with real providers loaded from configuration
providers, err := loadProviders()
require.NoError(t, err)
service := &Service{
config: &types.Config{},
providers: providers,
providersByType: make(map[types.MessageType][]types.Provider),
channels: make(map[string]types.Channel),
defaults: make(map[string]string),
receivers: make(map[string]context.CancelFunc),
}
// Log loaded providers for debugging
t.Logf("Loaded providers: %d", len(providers))
for name, provider := range providers {
t.Logf("Provider: %s, Type: %s", name, provider.GetType())
if provider.GetType() == "mailer" {
if mailerProvider, ok := provider.(*mailer.Provider); ok {
t.Logf(" - Supports receiving: %v", mailerProvider.SupportsReceiving())
}
}
}
// Test GetActiveReceivers when no receivers are active
activeReceivers := service.GetActiveReceivers()
assert.Empty(t, activeReceivers)
// Test startMailReceivers (this should start receivers for mailer providers that support IMAP)
service.startMailReceivers()
// Give some time for goroutines to start
time.Sleep(200 * time.Millisecond)
// Check active receivers - should include providers that support IMAP
activeReceivers = service.GetActiveReceivers()
t.Logf("Active receivers after start: %v", activeReceivers)
// Count how many mailer providers support receiving
expectedReceivers := 0
for name, provider := range providers {
if provider.GetType() == "mailer" {
if mailerProvider, ok := provider.(*mailer.Provider); ok {
if mailerProvider.SupportsReceiving() {
expectedReceivers++
t.Logf("Provider %s supports receiving", name)
}
}
}
}
assert.Len(t, activeReceivers, expectedReceivers)
// Test StopMailReceiver for each active receiver
for _, receiverName := range activeReceivers {
service.StopMailReceiver(receiverName)
t.Logf("Stopped receiver: %s", receiverName)
}
// Give some time for cleanup
time.Sleep(200 * time.Millisecond)
activeReceivers = service.GetActiveReceivers()
assert.Empty(t, activeReceivers)
// Test StopMailReceiver for non-existent provider (should not panic)
service.StopMailReceiver("nonexistent")
// Test StopMailReceivers (should handle empty receivers gracefully)
service.StopMailReceivers()
}
func TestService_StartMailReceivers_NoMailerProviders(t *testing.T) {
// Create a service with no mailer providers
service := &Service{
config: &types.Config{},
providers: map[string]types.Provider{
// No mailer providers, only other types
},
providersByType: make(map[types.MessageType][]types.Provider),
channels: make(map[string]types.Channel),
defaults: make(map[string]string),
receivers: make(map[string]context.CancelFunc),
}
// This should not start any receivers
service.startMailReceivers()
activeReceivers := service.GetActiveReceivers()
assert.Empty(t, activeReceivers)
}
func TestLoad_AutoStartMailReceivers(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Load messenger configuration (this should auto-start mail receivers)
err := Load(config.Conf)
require.NoError(t, err)
// Verify that Instance is set
require.NotNil(t, Instance)
// Cast to Service to access receiver management methods
service, ok := Instance.(*Service)
require.True(t, ok, "Instance should be of type *Service")
// Give some time for receivers to start
time.Sleep(300 * time.Millisecond)
// Check active receivers
activeReceivers := service.GetActiveReceivers()
t.Logf("Auto-started receivers: %v", activeReceivers)
// Count expected receivers from loaded providers
expectedReceivers := 0
for name, provider := range service.providers {
if provider.GetType() == "mailer" {
if mailerProvider, ok := provider.(*mailer.Provider); ok {
if mailerProvider.SupportsReceiving() {
expectedReceivers++
t.Logf("Provider %s supports receiving and should have auto-started", name)
} else {
t.Logf("Provider %s does not support receiving (no IMAP config)", name)
}
}
}
}
assert.Len(t, activeReceivers, expectedReceivers)
// Clean up - stop all receivers
service.StopMailReceivers()
// Give some time for cleanup
time.Sleep(200 * time.Millisecond)
// Verify all receivers are stopped
activeReceivers = service.GetActiveReceivers()
assert.Empty(t, activeReceivers)
}
func TestService_RealProviderConfiguration(t *testing.T) {
// Prepare test environment
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Load real providers
providers, err := loadProviders()
require.NoError(t, err)
t.Logf("Testing with real provider configurations:")
// Analyze each provider
for name, provider := range providers {
t.Logf("Provider: %s", name)
t.Logf(" Type: %s", provider.GetType())
if provider.GetType() == "mailer" {
if mailerProvider, ok := provider.(*mailer.Provider); ok {
supportsReceiving := mailerProvider.SupportsReceiving()
t.Logf(" Supports receiving: %v", supportsReceiving)
// Test the provider's configuration
err := mailerProvider.Validate()
if err != nil {
t.Logf(" Validation error: %v", err)
} else {
t.Logf(" Configuration is valid")
}
// If it supports receiving, test that we can create a receiver context
if supportsReceiving {
ctx, cancel := context.WithCancel(context.Background())
// Test that StartMailReceiver doesn't immediately fail
go func() {
err := mailerProvider.StartMailReceiver(ctx, func(msg *types.Message) error {
t.Logf("Received test message: %s", msg.Subject)
return nil
})
if err != nil {
t.Logf("Mail receiver for %s ended with: %v", name, err)
}
}()
// Cancel immediately to avoid long-running connections in tests
time.Sleep(100 * time.Millisecond)
cancel()
t.Logf(" Successfully tested receiver startup/shutdown")
}
}
}
}
}
// Helper function to create mock mailer providers for testing
func createMockMailerProvider(t *testing.T, supportsIMAP bool) *mailer.Provider {
var config types.ProviderConfig
if supportsIMAP {
// Create config with IMAP support
config = types.ProviderConfig{
Name: "test-reliable",
Connector: "mailer",
Options: map[string]interface{}{
"smtp": map[string]interface{}{
"host": "smtp.example.com",
"port": 587,
"username": "test@example.com",
"password": "password",
"from": "test@example.com",
"use_tls": true,
},
"imap": map[string]interface{}{
"host": "imap.example.com",
"port": 993,
"username": "test@example.com",
"password": "password",
"use_ssl": true,
"mailbox": "INBOX",
},
},
}
} else {
// Create config without IMAP support
config = types.ProviderConfig{
Name: "test-primary",
Connector: "mailer",
Options: map[string]interface{}{
"smtp": map[string]interface{}{
"host": "smtp.example.com",
"port": 587,
"username": "test@example.com",
"password": "password",
"from": "test@example.com",
"use_tls": true,
},
},
}
}
provider, err := mailer.NewMailerProvider(config)
require.NoError(t, err)
return provider
}

View file

@ -1,4 +1,4 @@
package smtp
package mailer
import (
"context"
@ -14,7 +14,7 @@ import (
"github.com/yaoapp/yao/messenger/types"
)
// Provider implements the Provider interface for SMTP email sending
// Provider implements the Provider interface for SMTP email sending and IMAP receiving
type Provider struct {
config types.ProviderConfig
host string
@ -24,10 +24,18 @@ type Provider struct {
from string
useTLS bool
useSSL bool
// IMAP configuration for receiving emails
imapHost string
imapPort int
imapUsername string
imapPassword string
imapUseSSL bool
imapMailbox string
}
// NewSMTPProvider creates a new SMTP provider
func NewSMTPProvider(config types.ProviderConfig) (*Provider, error) {
// NewMailerProvider creates a new Mailer provider
func NewMailerProvider(config types.ProviderConfig) (*Provider, error) {
provider := &Provider{
config: config,
useTLS: true, // Default to TLS
@ -36,17 +44,23 @@ func NewSMTPProvider(config types.ProviderConfig) (*Provider, error) {
// Extract options
options := config.Options
if options == nil {
return nil, fmt.Errorf("SMTP provider requires options")
return nil, fmt.Errorf("mailer provider requires options")
}
// Required options
if host, ok := options["host"].(string); ok {
// Parse SMTP configuration (required)
smtpConfig, ok := options["smtp"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("mailer provider requires 'smtp' configuration")
}
// Required SMTP options
if host, ok := smtpConfig["host"].(string); ok {
provider.host = host
} else {
return nil, fmt.Errorf("SMTP provider requires 'host' option")
return nil, fmt.Errorf("SMTP configuration requires 'host' option")
}
if port, ok := options["port"]; ok {
if port, ok := smtpConfig["port"]; ok {
switch p := port.(type) {
case int:
provider.port = p
@ -56,42 +70,106 @@ func NewSMTPProvider(config types.ProviderConfig) (*Provider, error) {
var err error
provider.port, err = strconv.Atoi(p)
if err != nil {
return nil, fmt.Errorf("invalid port: %s", p)
return nil, fmt.Errorf("invalid SMTP port: %s", p)
}
default:
return nil, fmt.Errorf("invalid port type")
return nil, fmt.Errorf("invalid SMTP port type")
}
} else {
provider.port = 587 // Default SMTP port
}
if username, ok := options["username"].(string); ok {
if username, ok := smtpConfig["username"].(string); ok {
provider.username = username
} else {
return nil, fmt.Errorf("SMTP provider requires 'username' option")
return nil, fmt.Errorf("SMTP configuration requires 'username' option")
}
if password, ok := options["password"].(string); ok {
if password, ok := smtpConfig["password"].(string); ok {
provider.password = password
} else {
return nil, fmt.Errorf("SMTP provider requires 'password' option")
return nil, fmt.Errorf("SMTP configuration requires 'password' option")
}
if from, ok := options["from"].(string); ok {
if from, ok := smtpConfig["from"].(string); ok {
provider.from = from
} else {
return nil, fmt.Errorf("SMTP provider requires 'from' option")
return nil, fmt.Errorf("SMTP configuration requires 'from' option")
}
// Optional options
if useTLS, ok := options["use_tls"].(bool); ok {
// Optional SMTP options
if useTLS, ok := smtpConfig["use_tls"].(bool); ok {
provider.useTLS = useTLS
}
if useSSL, ok := options["use_ssl"].(bool); ok {
if useSSL, ok := smtpConfig["use_ssl"].(bool); ok {
provider.useSSL = useSSL
}
// IMAP configuration (optional for receiving emails)
if imapConfig, ok := options["imap"].(map[string]interface{}); ok {
// IMAP is configured, parse it
if imapHost, ok := imapConfig["host"].(string); ok {
provider.imapHost = imapHost
} else {
// Default to same host as SMTP if not specified
provider.imapHost = provider.host
}
if imapPort, ok := imapConfig["port"]; ok {
switch p := imapPort.(type) {
case int:
provider.imapPort = p
case float64:
provider.imapPort = int(p)
case string:
var err error
provider.imapPort, err = strconv.Atoi(p)
if err != nil {
return nil, fmt.Errorf("invalid IMAP port: %s", p)
}
default:
return nil, fmt.Errorf("invalid IMAP port type")
}
} else {
provider.imapPort = 993 // Default IMAP SSL port
}
if imapUsername, ok := imapConfig["username"].(string); ok {
provider.imapUsername = imapUsername
} else {
// Default to same username as SMTP if not specified
provider.imapUsername = provider.username
}
if imapPassword, ok := imapConfig["password"].(string); ok {
provider.imapPassword = imapPassword
} else {
// Default to same password as SMTP if not specified
provider.imapPassword = provider.password
}
if imapUseSSL, ok := imapConfig["use_ssl"].(bool); ok {
provider.imapUseSSL = imapUseSSL
} else {
provider.imapUseSSL = true // Default to SSL for IMAP
}
if imapMailbox, ok := imapConfig["mailbox"].(string); ok {
provider.imapMailbox = imapMailbox
} else {
provider.imapMailbox = "INBOX" // Default mailbox
}
} else {
// IMAP not configured - this provider only supports sending
provider.imapHost = ""
provider.imapPort = 0
provider.imapUsername = ""
provider.imapPassword = ""
provider.imapUseSSL = false
provider.imapMailbox = ""
}
return provider, nil
}
@ -123,7 +201,7 @@ func (p *Provider) SendBatch(ctx context.Context, messages []*types.Message) err
// GetType returns the provider type
func (p *Provider) GetType() string {
return "smtp"
return "mailer"
}
// GetName returns the provider name
@ -156,6 +234,11 @@ func (p *Provider) Close() error {
return nil
}
// SupportsReceiving returns true if this provider supports receiving emails via IMAP
func (p *Provider) SupportsReceiving() bool {
return p.imapHost != "" && p.imapPort > 0
}
// buildMessage builds the email message content
func (p *Provider) buildMessage(message *types.Message) (string, error) {
var content strings.Builder

View file

@ -0,0 +1,576 @@
package mailer
import (
"context"
"crypto/tls"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"net/mail"
"strings"
"time"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
"github.com/yaoapp/yao/messenger/types"
)
// IMAP configuration is now integrated into the main Provider struct
// MailReceiver handles email receiving via IMAP
type MailReceiver struct {
provider *Provider
client *client.Client
stopChan chan bool
msgHandler func(*types.Message) error
startTime time.Time // Only process emails received after this time
lastCheckUID uint32 // Track last processed UID to avoid duplicates
}
// Receive processes incoming messages/responses from mailer provider
func (p *Provider) Receive(ctx context.Context, data map[string]interface{}) error {
// Check if this provider supports receiving
if !p.SupportsReceiving() {
log.Printf("Mailer provider '%s' does not support receiving (IMAP not configured)", p.GetName())
return fmt.Errorf("provider does not support receiving: IMAP not configured")
}
// This method handles webhook-style data (for services like SendGrid, Mailgun)
// For direct IMAP email receiving, use StartMailReceiver
// Parse common webhook data
if messageType, ok := data["type"].(string); ok {
switch messageType {
case "bounce":
return p.handleBounce(ctx, data)
case "delivery":
return p.handleDelivery(ctx, data)
case "complaint":
return p.handleComplaint(ctx, data)
default:
log.Printf("Mailer provider received unknown message type: %s", messageType)
}
}
// For now, just log the received data
fmt.Printf("Mailer provider received data: %+v\n", data)
return nil
}
// StartMailReceiver starts an IMAP-based email receiver with polling or IDLE support
func (p *Provider) StartMailReceiver(ctx context.Context, handler func(*types.Message) error) error {
// Check if this provider supports receiving
if !p.SupportsReceiving() {
return fmt.Errorf("provider does not support receiving: IMAP not configured")
}
receiver := &MailReceiver{
provider: p,
stopChan: make(chan bool),
msgHandler: handler,
startTime: time.Now(), // Only process emails received after this moment
lastCheckUID: 0,
}
// Mailbox is already set in provider initialization with default "INBOX"
// Start receiving emails (connection will be handled in startReceiving)
// This will block until the receiver stops
receiver.startReceiving(ctx)
return nil
}
// connect establishes connection to IMAP server
func (r *MailReceiver) connect() error {
var c *client.Client
var err error
addr := fmt.Sprintf("%s:%d", r.provider.imapHost, r.provider.imapPort)
if r.provider.imapUseSSL {
// Connect with SSL/TLS
c, err = client.DialTLS(addr, &tls.Config{ServerName: r.provider.imapHost})
} else {
// Connect without SSL (can upgrade with STARTTLS)
c, err = client.Dial(addr)
if err == nil {
// Try to upgrade to TLS if available
if caps, err := c.Capability(); err == nil {
if caps["STARTTLS"] {
c.StartTLS(&tls.Config{ServerName: r.provider.imapHost})
}
}
}
}
if err != nil {
return err
}
// Login
if err := c.Login(r.provider.imapUsername, r.provider.imapPassword); err != nil {
c.Close()
return err
}
r.client = c
return nil
}
// reconnect re-establishes connection to IMAP server
func (r *MailReceiver) reconnect() error {
// Close existing connection if any
if r.client != nil {
r.client.Close()
r.client = nil
}
// Establish new connection
return r.connect()
}
// startReceiving starts the email receiving loop with retry mechanism
func (r *MailReceiver) startReceiving(ctx context.Context) {
defer func() {
if r.client != nil {
r.client.Close()
}
}()
maxRetries := 5
retryDelay := time.Second * 5
for retry := 0; retry < maxRetries; retry++ {
select {
case <-ctx.Done():
log.Println("Context cancelled, stopping email receiver")
return
case <-r.stopChan:
log.Println("Stop signal received, stopping email receiver")
return
default:
}
// Reconnect if needed
if r.client == nil || r.client.State() != imap.SelectedState {
if err := r.reconnect(); err != nil {
log.Printf("Failed to reconnect to IMAP server: %v", err)
if retry < maxRetries-1 {
time.Sleep(retryDelay)
retryDelay *= 2 // Exponential backoff
continue
}
return
}
}
// Select mailbox
_, err := r.client.Select(r.provider.imapMailbox, false)
if err != nil {
log.Printf("Failed to select mailbox %s: %v", r.provider.imapMailbox, err)
if retry < maxRetries-1 {
time.Sleep(retryDelay)
retryDelay *= 2
continue
}
return
}
// Check if server supports IDLE
caps, err := r.client.Capability()
if err == nil && caps["IDLE"] {
r.receiveWithIdle(ctx)
} else {
r.receiveWithPolling(ctx)
}
// If we reach here, the receiving loop ended, try to reconnect
time.Sleep(retryDelay)
retryDelay *= 2
}
}
// receiveWithIdle uses IMAP IDLE for real-time email monitoring
func (r *MailReceiver) receiveWithIdle(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-r.stopChan:
return
default:
// Check connection state
if r.client == nil || r.client.State() == imap.LogoutState {
return
}
// Process initial messages before starting IDLE
r.processNewMessages()
// Start IDLE with periodic message checking
stop := make(chan struct{})
idleDone := make(chan error, 1)
go func() {
err := r.client.Idle(stop, nil)
idleDone <- err
}()
// Wait for IDLE to end or stop signals
// Use shorter IDLE periods to check for messages more frequently
idleTimeout := time.After(10 * time.Second) // Check every 10 seconds
idleLoop:
for {
select {
case err := <-idleDone:
if err != nil {
return
}
// Process messages after IDLE ends
r.processNewMessages()
break idleLoop
case <-idleTimeout:
close(stop)
// Wait for IDLE to actually end
<-idleDone
// Process messages after stopping IDLE
r.processNewMessages()
break idleLoop
case <-ctx.Done():
close(stop)
return
case <-r.stopChan:
close(stop)
return
}
}
}
}
}
// receiveWithPolling uses periodic polling for email monitoring
func (r *MailReceiver) receiveWithPolling(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second) // Poll every 30 seconds
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-r.stopChan:
return
case <-ticker.C:
// Check connection state
if r.client == nil || r.client.State() == imap.LogoutState {
return
}
r.processNewMessages()
}
}
}
// processNewMessages fetches and processes new messages
func (r *MailReceiver) processNewMessages() {
// Search for messages - use UID-based filtering instead of time-based
criteria := imap.NewSearchCriteria()
// If we have a lastCheckUID, only search for messages with higher UIDs
if r.lastCheckUID > 0 {
criteria.Uid = new(imap.SeqSet)
criteria.Uid.AddRange(r.lastCheckUID+1, 0) // From last+1 to end
} else {
// For the first run, search for messages from today to avoid processing thousands of old emails
today := time.Now().Truncate(24 * time.Hour)
criteria.Since = today
}
uids, err := r.client.UidSearch(criteria)
if err != nil {
log.Printf("Failed to search for new messages: %v", err)
return
}
if len(uids) == 0 {
return // No new messages
}
// Fetch messages using UID
seqset := new(imap.SeqSet)
seqset.AddNum(uids...)
messages := make(chan *imap.Message, 10)
done := make(chan error, 1)
// Fetch with UID and more complete data
fetchItems := []imap.FetchItem{
imap.FetchEnvelope,
imap.FetchUid,
imap.FetchInternalDate,
imap.FetchBodyStructure,
"BODY[TEXT]", // Get message body text
}
go func() {
done <- r.client.UidFetch(seqset, fetchItems, messages)
}()
// Process each message and track highest UID
var maxUID uint32
processedCount := 0
for msg := range messages {
if msg.Uid > maxUID {
maxUID = msg.Uid
}
// For the first run (lastCheckUID == 0), only process messages received after start time
// For subsequent runs, process all messages (they're already filtered by UID)
shouldProcess := true
if r.lastCheckUID == 0 {
// Convert both times to UTC for proper comparison
msgTimeUTC := msg.InternalDate.UTC()
startTimeUTC := r.startTime.UTC()
if msgTimeUTC.Before(startTimeUTC) {
shouldProcess = false
}
}
if shouldProcess {
if err := r.processMessage(msg); err != nil {
log.Printf("Failed to process message UID %d: %v", msg.Uid, err)
} else {
processedCount++
}
}
}
// Update last check UID to the highest UID we've seen (even if not processed)
if maxUID > r.lastCheckUID {
r.lastCheckUID = maxUID
}
if err := <-done; err != nil {
log.Printf("Failed to fetch messages: %v", err)
return
}
}
// processMessage converts IMAP message to types.Message and calls handler
func (r *MailReceiver) processMessage(imapMsg *imap.Message) error {
if imapMsg.Envelope == nil {
return fmt.Errorf("message envelope is nil")
}
// Extract message body
body, htmlBody := r.extractMessageBody(imapMsg)
// Convert to types.Message
msg := &types.Message{
Type: types.MessageTypeEmail,
Subject: imapMsg.Envelope.Subject,
From: r.formatAddress(imapMsg.Envelope.From),
To: r.formatAddresses(imapMsg.Envelope.To),
Body: body,
HTML: htmlBody,
}
// Add comprehensive metadata
msg.Metadata = map[string]interface{}{
"uid": imapMsg.Uid,
"message_id": imapMsg.Envelope.MessageId,
"date": imapMsg.Envelope.Date,
"internal_date": imapMsg.InternalDate,
"reply_to": r.formatAddresses(imapMsg.Envelope.ReplyTo),
"cc": r.formatAddresses(imapMsg.Envelope.Cc),
"bcc": r.formatAddresses(imapMsg.Envelope.Bcc),
"size": imapMsg.Size,
"flags": imapMsg.Flags,
}
// Add headers if available
if len(imapMsg.Envelope.InReplyTo) > 0 {
msg.Metadata["in_reply_to"] = imapMsg.Envelope.InReplyTo
}
// Call the message handler
if r.msgHandler != nil {
return r.msgHandler(msg)
}
return nil
}
// formatAddress formats a single email address
func (r *MailReceiver) formatAddress(addrs []*imap.Address) string {
if len(addrs) == 0 {
return ""
}
addr := addrs[0]
if addr.PersonalName != "" {
return fmt.Sprintf("%s <%s@%s>", addr.PersonalName, addr.MailboxName, addr.HostName)
}
return fmt.Sprintf("%s@%s", addr.MailboxName, addr.HostName)
}
// formatAddresses formats multiple email addresses
func (r *MailReceiver) formatAddresses(addrs []*imap.Address) []string {
result := make([]string, 0, len(addrs))
for _, addr := range addrs {
if addr.PersonalName != "" {
result = append(result, fmt.Sprintf("%s <%s@%s>", addr.PersonalName, addr.MailboxName, addr.HostName))
} else {
result = append(result, fmt.Sprintf("%s@%s", addr.MailboxName, addr.HostName))
}
}
return result
}
// handleBounce processes email bounce notifications
func (p *Provider) handleBounce(ctx context.Context, data map[string]interface{}) error {
// TODO: Implement bounce handling logic
// - Update delivery status
// - Mark email as bounced
// - Potentially disable recipient
return nil
}
// handleDelivery processes email delivery confirmations
func (p *Provider) handleDelivery(ctx context.Context, data map[string]interface{}) error {
// TODO: Implement delivery confirmation logic
// - Update delivery status
// - Log successful delivery
return nil
}
// handleComplaint processes spam complaints
func (p *Provider) handleComplaint(ctx context.Context, data map[string]interface{}) error {
// TODO: Implement complaint handling logic
// - Mark sender as complained
// - Update reputation metrics
// - Potentially disable recipient
return nil
}
// extractMessageBody extracts plain text and HTML body from IMAP message
func (r *MailReceiver) extractMessageBody(imapMsg *imap.Message) (plainText, htmlText string) {
// Get the body from the message
for _, body := range imapMsg.Body {
if body == nil {
continue
}
// Read the body content
bodyBytes, err := io.ReadAll(body)
if err != nil {
continue
}
bodyStr := string(bodyBytes)
// Try to parse as email message
msg, err := mail.ReadMessage(strings.NewReader(bodyStr))
if err != nil {
// If parsing fails, treat as plain text
plainText = bodyStr
continue
}
// Get content type
contentType := msg.Header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
// Default to plain text if parsing fails
bodyContent, _ := io.ReadAll(msg.Body)
plainText = string(bodyContent)
continue
}
// Handle different content types
switch {
case strings.HasPrefix(mediaType, "text/plain"):
bodyContent, _ := io.ReadAll(msg.Body)
plainText = string(bodyContent)
case strings.HasPrefix(mediaType, "text/html"):
bodyContent, _ := io.ReadAll(msg.Body)
htmlText = string(bodyContent)
case strings.HasPrefix(mediaType, "multipart/"):
// Handle multipart messages
boundary := params["boundary"]
if boundary != "" {
plainText, htmlText = r.parseMultipartBody(msg.Body, boundary)
}
default:
// For other types, try to read as plain text
bodyContent, _ := io.ReadAll(msg.Body)
plainText = string(bodyContent)
}
}
// Clean up the extracted text
plainText = strings.TrimSpace(plainText)
htmlText = strings.TrimSpace(htmlText)
return plainText, htmlText
}
// parseMultipartBody parses multipart email body
func (r *MailReceiver) parseMultipartBody(body io.Reader, boundary string) (plainText, htmlText string) {
reader := multipart.NewReader(body, boundary)
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
break
}
// Get content type of this part
contentType := part.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
continue
}
// Read part content
partContent, err := io.ReadAll(part)
if err != nil {
continue
}
content := string(partContent)
// Assign content based on type
switch {
case strings.HasPrefix(mediaType, "text/plain"):
if plainText == "" { // Use first plain text part
plainText = content
}
case strings.HasPrefix(mediaType, "text/html"):
if htmlText == "" { // Use first HTML part
htmlText = content
}
}
part.Close()
}
return plainText, htmlText
}
// Stop stops the email receiver
func (r *MailReceiver) Stop() {
close(r.stopChan)
}

View file

@ -0,0 +1,919 @@
package mailer
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/test"
)
// Test helper functions for receive tests
func getEnvOrDefaultReceive(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func loadPrimaryTestConfigReceive(t *testing.T) types.ProviderConfig {
// Prepare test environment using YAO_TEST_APPLICATION which points to yao-dev-app
// Environment variables are already set in env.local.sh
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Create test config directly using environment variables for primary SMTP
// Port 465 requires SSL, port 587 requires TLS
smtpPort := os.Getenv("SMTP_PORT")
useSSL := smtpPort == "465"
useTLS := smtpPort == "587" || smtpPort == "25"
config := types.ProviderConfig{
Name: "primary",
Connector: "mailer",
Options: map[string]interface{}{
"smtp": map[string]interface{}{
"host": os.Getenv("SMTP_HOST"),
"port": os.Getenv("SMTP_PORT"),
"username": os.Getenv("SMTP_USERNAME"),
"password": os.Getenv("SMTP_PASSWORD"),
"from": os.Getenv("SMTP_FROM"),
"use_tls": useTLS,
"use_ssl": useSSL,
},
},
}
return config
}
func loadReliableTestConfigReceive(t *testing.T) types.ProviderConfig {
// Prepare test environment using YAO_TEST_APPLICATION which points to yao-dev-app
// Environment variables are already set in env.local.sh
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
defer test.Clean()
// Create test config directly using environment variables for reliable SMTP
config := types.ProviderConfig{
Name: "reliable",
Connector: "mailer",
Options: map[string]interface{}{
"smtp": map[string]interface{}{
"host": os.Getenv("RELIABLE_SMTP_HOST"),
"port": 587, // Hardcoded in reliable.mailer.yao
"username": os.Getenv("RELIABLE_SMTP_USERNAME"),
"password": os.Getenv("RELIABLE_SMTP_PASSWORD"),
"from": os.Getenv("RELIABLE_SMTP_FROM"),
"use_tls": true,
},
"imap": map[string]interface{}{
"host": getEnvOrDefaultReceive("RELIABLE_IMAP_HOST", os.Getenv("RELIABLE_SMTP_HOST")),
"port": getEnvOrDefaultReceive("RELIABLE_IMAP_PORT", "993"),
"username": getEnvOrDefaultReceive("RELIABLE_IMAP_USERNAME", os.Getenv("RELIABLE_SMTP_USERNAME")),
"password": getEnvOrDefaultReceive("RELIABLE_IMAP_PASSWORD", os.Getenv("RELIABLE_SMTP_PASSWORD")),
"use_ssl": true,
"mailbox": "INBOX",
},
},
}
return config
}
// Test IMAP Support Detection
func TestSupportsReceiving_WithIMAP(t *testing.T) {
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Reliable config has IMAP configured, should support receiving
assert.True(t, provider.SupportsReceiving())
}
func TestSupportsReceiving_WithoutIMAP(t *testing.T) {
config := loadPrimaryTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Primary config has no IMAP configured, should not support receiving
assert.False(t, provider.SupportsReceiving())
}
// Test Receive Method
func TestReceive_WithoutIMAPSupport(t *testing.T) {
config := loadPrimaryTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
ctx := context.Background()
data := map[string]interface{}{
"type": "delivery",
"message": "test message",
}
// Should return error since IMAP is not configured
err = provider.Receive(ctx, data)
assert.Error(t, err)
assert.Contains(t, err.Error(), "provider does not support receiving: IMAP not configured")
}
func TestReceive_WithIMAPSupport_Bounce(t *testing.T) {
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
ctx := context.Background()
data := map[string]interface{}{
"type": "bounce",
"email": "test@example.com",
"reason": "mailbox_full",
"timestamp": time.Now().Unix(),
"message_id": "test-message-123",
}
// Should process bounce without error
err = provider.Receive(ctx, data)
assert.NoError(t, err)
}
func TestReceive_WithIMAPSupport_Delivery(t *testing.T) {
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
ctx := context.Background()
data := map[string]interface{}{
"type": "delivery",
"email": "test@example.com",
"timestamp": time.Now().Unix(),
"message_id": "test-message-123",
}
// Should process delivery without error
err = provider.Receive(ctx, data)
assert.NoError(t, err)
}
func TestReceive_WithIMAPSupport_Complaint(t *testing.T) {
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
ctx := context.Background()
data := map[string]interface{}{
"type": "complaint",
"email": "test@example.com",
"reason": "spam",
"timestamp": time.Now().Unix(),
"message_id": "test-message-123",
}
// Should process complaint without error
err = provider.Receive(ctx, data)
assert.NoError(t, err)
}
func TestReceive_WithIMAPSupport_UnknownType(t *testing.T) {
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
ctx := context.Background()
data := map[string]interface{}{
"type": "unknown_event",
"message": "test message",
}
// Should process unknown type without error (just logs)
err = provider.Receive(ctx, data)
assert.NoError(t, err)
}
func TestReceive_WithIMAPSupport_NoType(t *testing.T) {
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
ctx := context.Background()
data := map[string]interface{}{
"message": "test message without type",
"data": "some data",
}
// Should process data without type field without error
err = provider.Receive(ctx, data)
assert.NoError(t, err)
}
// Test StartMailReceiver Method
func TestStartMailReceiver_WithoutIMAPSupport(t *testing.T) {
config := loadPrimaryTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
ctx := context.Background()
handler := func(msg *types.Message) error {
return nil
}
// Should return error since IMAP is not configured
err = provider.StartMailReceiver(ctx, handler)
assert.Error(t, err)
assert.Contains(t, err.Error(), "provider does not support receiving: IMAP not configured")
}
func TestStartMailReceiver_WithIMAPSupport_InvalidConfig(t *testing.T) {
// Skip this test if IMAP credentials are not configured
if os.Getenv("RELIABLE_IMAP_HOST") == "" {
t.Skip("RELIABLE_IMAP_HOST not configured, skipping IMAP connection test")
}
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Use a short timeout context
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
messageReceived := false
handler := func(msg *types.Message) error {
messageReceived = true
t.Logf("Received message: Subject=%s, From=%s", msg.Subject, msg.From)
return nil
}
// This will likely fail due to invalid credentials, but should not panic
err = provider.StartMailReceiver(ctx, handler)
// We expect this to fail in test environment, but it should be a connection error
if err != nil {
t.Logf("StartMailReceiver failed as expected in test environment: %v", err)
assert.Contains(t, err.Error(), "provider does not support receiving: IMAP not configured")
} else {
t.Log("StartMailReceiver started successfully")
// Wait a bit to see if any messages are received
time.Sleep(2 * time.Second)
t.Logf("Message received: %v", messageReceived)
}
}
// Test MailReceiver Internal Methods
func TestMailReceiver_TimeStampFiltering(t *testing.T) {
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Create a mail receiver
receiver := &MailReceiver{
provider: provider,
stopChan: make(chan bool),
startTime: time.Now(),
lastCheckUID: 0,
msgHandler: func(msg *types.Message) error {
return nil
},
}
// Test that start time is set correctly
assert.True(t, receiver.startTime.Before(time.Now().Add(time.Second)))
assert.True(t, receiver.startTime.After(time.Now().Add(-time.Second)))
assert.Equal(t, uint32(0), receiver.lastCheckUID)
}
func TestMailReceiver_Stop(t *testing.T) {
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Create a mail receiver
receiver := &MailReceiver{
provider: provider,
stopChan: make(chan bool),
startTime: time.Now(),
lastCheckUID: 0,
msgHandler: func(msg *types.Message) error {
return nil
},
}
// Test stop functionality
go func() {
time.Sleep(100 * time.Millisecond)
receiver.Stop()
}()
// This should not block indefinitely
select {
case <-receiver.stopChan:
t.Log("Stop signal received successfully")
case <-time.After(1 * time.Second):
t.Error("Stop signal not received within timeout")
}
}
// Test Message Processing
func TestMailReceiver_FormatAddress(t *testing.T) {
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
receiver := &MailReceiver{
provider: provider,
}
// Test with empty addresses
result := receiver.formatAddress(nil)
assert.Equal(t, "", result)
// Note: We can't easily test with real imap.Address without importing go-imap
// and creating mock addresses, but the function is tested through integration tests
}
func TestMailReceiver_FormatAddresses(t *testing.T) {
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
receiver := &MailReceiver{
provider: provider,
}
// Test with empty addresses
result := receiver.formatAddresses(nil)
assert.Equal(t, []string{}, result)
// Note: We can't easily test with real imap.Address without importing go-imap
// and creating mock addresses, but the function is tested through integration tests
}
// Integration Tests - Real Email Send and Receive
func TestRealEmailSendAndReceive_Integration(t *testing.T) {
// Skip this test if IMAP credentials are not configured
if os.Getenv("RELIABLE_IMAP_HOST") == "" || os.Getenv("RELIABLE_SMTP_HOST") == "" {
t.Skip("RELIABLE_IMAP_HOST or RELIABLE_SMTP_HOST not configured, skipping integration test")
}
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Verify provider supports both sending and receiving
require.True(t, provider.SupportsReceiving(), "Provider must support receiving for this test")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
// Channel to receive the email
emailReceived := make(chan *types.Message, 1)
var receivedEmail *types.Message
// Start mail receiver with detailed logging
go func() {
t.Log("Starting mail receiver goroutine...")
err := provider.StartMailReceiver(ctx, func(msg *types.Message) error {
t.Logf("=== EMAIL RECEIVED ===")
t.Logf("Subject: %s", msg.Subject)
t.Logf("From: %s", msg.From)
t.Logf("To: %v", msg.To)
t.Logf("Type: %s", msg.Type)
if msg.Body != "" {
bodyPreview := msg.Body
if len(bodyPreview) > 200 {
bodyPreview = bodyPreview[:200] + "..."
}
t.Logf("Body: %s", bodyPreview)
}
if msg.HTML != "" {
htmlPreview := msg.HTML
if len(htmlPreview) > 100 {
htmlPreview = htmlPreview[:100] + "..."
}
t.Logf("HTML: %s", htmlPreview)
}
if msg.Metadata != nil {
t.Logf("Metadata: %+v", msg.Metadata)
}
t.Logf("=== END EMAIL ===")
// Check if this is our test email
if msg.Subject != "" && msg.Body != "" {
select {
case emailReceived <- msg:
t.Log("✅ Test email captured successfully")
default:
t.Log("⚠️ Email channel full, skipping")
}
}
return nil
})
if err != nil {
t.Logf("❌ Mail receiver stopped with error: %v", err)
} else {
t.Log("✅ Mail receiver stopped gracefully")
}
}()
// Give receiver time to start and connect
t.Log("⏳ Waiting 5 seconds for mail receiver to start and connect...")
time.Sleep(5 * time.Second)
t.Log("✅ Mail receiver should be connected now")
// Create and send test email
testSubject := "Integration Test Email - " + time.Now().Format("2006-01-02 15:04:05")
testBody := "This is an integration test email sent at " + time.Now().Format("2006-01-02 15:04:05") + ". If you receive this, the send/receive cycle is working!"
// Get the 'from' address from config to send email to ourselves
smtpConfig := config.Options["smtp"].(map[string]interface{})
fromAddressRaw := smtpConfig["from"].(string)
// Extract just the email address from "Name <email@domain.com>" format
fromAddress := fromAddressRaw
if strings.Contains(fromAddressRaw, "<") && strings.Contains(fromAddressRaw, ">") {
start := strings.Index(fromAddressRaw, "<")
end := strings.Index(fromAddressRaw, ">")
if start >= 0 && end > start {
fromAddress = fromAddressRaw[start+1 : end]
}
}
testMessage := &types.Message{
Type: types.MessageTypeEmail,
To: []string{fromAddress}, // Send to ourselves
Subject: testSubject,
Body: testBody,
HTML: "<h1>Integration Test</h1><p>" + testBody + "</p>",
Headers: map[string]string{
"X-Test-Type": "integration-test",
"X-Test-ID": time.Now().Format("20060102150405"),
},
}
t.Logf("📧 Sending test email to: %s", fromAddress)
t.Logf("📧 Subject: %s", testSubject)
t.Logf("📧 Body: %s", testBody)
// Send the email
sendErr := provider.Send(ctx, testMessage)
if sendErr != nil {
t.Logf("❌ Failed to send test email: %v", sendErr)
// Don't fail the test immediately, as this might be expected in some environments
t.Skip("Could not send test email, skipping integration test")
}
t.Log("✅ Test email sent successfully, waiting for receipt...")
t.Log("⏳ Monitoring for incoming emails (timeout: 90 seconds)...")
// Wait for email to be received
select {
case receivedEmail = <-emailReceived:
t.Log("SUCCESS: Email send/receive cycle completed!")
// Cancel the context to stop the mail receiver gracefully
cancel()
t.Log("🛑 Gracefully stopping mail receiver...")
// Give some time for graceful shutdown
time.Sleep(1 * time.Second)
// Verify the received email
assert.NotNil(t, receivedEmail)
assert.Equal(t, types.MessageTypeEmail, receivedEmail.Type)
assert.NotEmpty(t, receivedEmail.Subject)
assert.NotEmpty(t, receivedEmail.From)
// Check if it's our test email (subject should contain our test string)
if receivedEmail.Subject == testSubject {
t.Log("PERFECT MATCH: Received the exact email we sent!")
assert.Equal(t, testSubject, receivedEmail.Subject)
// Note: Body might be modified by email processing, so we check if it contains our content
if receivedEmail.Body != "" {
t.Logf("Received body: %s", receivedEmail.Body)
}
} else {
t.Logf("Received different email: Subject='%s'", receivedEmail.Subject)
t.Log("This might be another email in the inbox, which is also a valid test result")
}
// Verify metadata
assert.NotNil(t, receivedEmail.Metadata)
if receivedEmail.Metadata != nil {
t.Logf("Email metadata: %+v", receivedEmail.Metadata)
}
t.Log("✅ Test completed successfully - mail receiver stopped gracefully")
return // Exit the test successfully
case <-time.After(90 * time.Second):
t.Log("TIMEOUT: No email received within 90 seconds")
t.Log("This might be expected in test environments with:")
t.Log("- Email delivery delays")
t.Log("- IMAP connection issues")
t.Log("- Firewall restrictions")
t.Log("- Invalid credentials")
// Cancel context for graceful shutdown
cancel()
t.Log("🛑 Stopping mail receiver due to timeout...")
time.Sleep(1 * time.Second)
// This is not necessarily a failure - email delivery can be delayed
t.Skip("Email not received within timeout - this may be expected in test environment")
case <-ctx.Done():
t.Log("Context cancelled during email wait")
t.Skip("Test context cancelled")
}
}
func TestRealEmailReceiveOnly_Integration(t *testing.T) {
// Skip this test if IMAP credentials are not configured
if os.Getenv("RELIABLE_IMAP_HOST") == "" {
t.Skip("RELIABLE_IMAP_HOST not configured, skipping IMAP receive test")
}
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Verify provider supports receiving
require.True(t, provider.SupportsReceiving(), "Provider must support receiving for this test")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
emailCount := 0
maxEmailsToProcess := 5 // Limit the number of emails to process for testing
t.Log("Starting mail receiver to check for existing emails...")
// Start mail receiver to see if there are any emails
err = provider.StartMailReceiver(ctx, func(msg *types.Message) error {
emailCount++
t.Logf("Email #%d received:", emailCount)
t.Logf(" Subject: %s", msg.Subject)
t.Logf(" From: %s", msg.From)
t.Logf(" To: %v", msg.To)
if msg.Body != "" {
bodyPreview := msg.Body
if len(bodyPreview) > 100 {
bodyPreview = bodyPreview[:100] + "..."
}
t.Logf(" Body preview: %s", bodyPreview)
}
if msg.HTML != "" {
htmlPreview := msg.HTML
if len(htmlPreview) > 100 {
htmlPreview = htmlPreview[:100] + "..."
}
t.Logf(" HTML preview: %s", htmlPreview)
}
if msg.Metadata != nil {
t.Logf(" Metadata: %+v", msg.Metadata)
}
// Stop after processing a few emails to avoid long-running tests
if emailCount >= maxEmailsToProcess {
t.Logf("Processed %d emails, stopping receiver for test completion", emailCount)
cancel() // Trigger graceful shutdown
}
return nil
})
if err != nil {
t.Logf("Mail receiver ended: %v", err)
// Check if it's a connection error (expected in many test environments)
if strings.Contains(err.Error(), "failed to connect") ||
strings.Contains(err.Error(), "authentication failed") ||
strings.Contains(err.Error(), "connection refused") {
t.Skip("IMAP connection failed - this is expected in test environments without proper email server access")
}
// Other errors might indicate real issues
t.Errorf("Unexpected error from mail receiver: %v", err)
}
t.Logf("Mail receiver test completed. Total emails processed: %d", emailCount)
if emailCount > 0 {
t.Log("SUCCESS: Mail receiver is working and processed emails from the mailbox")
} else {
t.Log("No emails received - this could mean:")
t.Log("- Mailbox is empty (normal)")
t.Log("- IMAP connection issues")
t.Log("- Time-based filtering working (only new emails)")
}
}
func TestManualEmailReceive_Integration(t *testing.T) {
// Skip this test if IMAP credentials are not configured
if os.Getenv("RELIABLE_IMAP_HOST") == "" {
t.Skip("RELIABLE_IMAP_HOST not configured, skipping manual receive test")
}
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Verify provider supports receiving
require.True(t, provider.SupportsReceiving(), "Provider must support receiving for this test")
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
emailReceived := make(chan *types.Message, 5)
testCompleted := make(chan bool, 1)
t.Log("🔍 MANUAL TEST: Please send an email to shadow.iqka@gmail.com now!")
t.Log("📧 Subject should contain 'MANUAL TEST' for easy identification")
t.Log("⏰ You have 60 seconds to send the email...")
// Start mail receiver
go func() {
err := provider.StartMailReceiver(ctx, func(msg *types.Message) error {
t.Logf("📧 RECEIVED EMAIL:")
t.Logf(" Subject: %s", msg.Subject)
t.Logf(" From: %s", msg.From)
t.Logf(" To: %v", msg.To)
t.Logf(" Type: %s", msg.Type)
// Send to channel for verification
select {
case emailReceived <- msg:
t.Log("✅ Email captured successfully!")
default:
t.Log("⚠️ Email channel full")
}
return nil
})
if err != nil {
t.Logf("Mail receiver ended: %v", err)
}
testCompleted <- true
}()
// Wait for emails or timeout
emailCount := 0
timeout := time.After(60 * time.Second)
for {
select {
case receivedEmail := <-emailReceived:
emailCount++
t.Logf("🎉 EMAIL #%d RECEIVED!", emailCount)
t.Logf("Subject: %s", receivedEmail.Subject)
t.Logf("From: %s", receivedEmail.From)
// Check if this looks like a manual test email
if strings.Contains(strings.ToUpper(receivedEmail.Subject), "MANUAL TEST") {
t.Log("🎯 MANUAL TEST EMAIL DETECTED!")
cancel()
<-testCompleted
assert.NotNil(t, receivedEmail)
assert.NotEmpty(t, receivedEmail.Subject)
assert.NotEmpty(t, receivedEmail.From)
t.Log("✅ MANUAL TEST PASSED - Email receiving works!")
return
}
// Continue waiting for more emails
t.Log("📬 Waiting for more emails...")
case <-timeout:
t.Logf("⏰ Manual test timeout after 60 seconds")
t.Logf("📊 Total emails received: %d", emailCount)
cancel()
<-testCompleted
if emailCount > 0 {
t.Log("✅ Email receiving is working (received emails during test)")
} else {
t.Log("❓ No emails received - this could mean:")
t.Log(" - No emails were sent during the test")
t.Log(" - Email delivery is delayed")
t.Log(" - IMAP filtering is working (only new emails)")
}
return
case <-ctx.Done():
<-testCompleted
t.Logf("📊 Test ended. Total emails received: %d", emailCount)
return
}
}
}
func TestQuickEmailSendAndReceive_Integration(t *testing.T) {
// Skip this test if IMAP credentials are not configured
if os.Getenv("RELIABLE_IMAP_HOST") == "" || os.Getenv("RELIABLE_SMTP_HOST") == "" {
t.Skip("RELIABLE_IMAP_HOST or RELIABLE_SMTP_HOST not configured, skipping quick integration test")
}
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Verify provider supports both sending and receiving
require.True(t, provider.SupportsReceiving(), "Provider must support receiving for this test")
// Use longer timeout to account for email delivery delays
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
// Channel to receive the email
emailReceived := make(chan *types.Message, 5)
testCompleted := make(chan bool, 1)
var sentTestSubject string
emailCount := 0
// Start mail receiver
go func() {
t.Log("🚀 Starting mail receiver for send/receive test...")
err := provider.StartMailReceiver(ctx, func(msg *types.Message) error {
emailCount++
t.Logf("📧 Email #%d received: Subject='%s', From='%s'", emailCount, msg.Subject, msg.From)
// Send all received emails to the channel for analysis
select {
case emailReceived <- msg:
t.Logf("✅ Email #%d captured for analysis", emailCount)
default:
t.Log("⚠️ Email channel full")
}
return nil
})
if err != nil {
t.Logf("Mail receiver ended: %v", err)
}
testCompleted <- true
}()
// Give receiver more time to start and connect
t.Log("⏳ Waiting 5 seconds for mail receiver to fully start...")
time.Sleep(5 * time.Second)
// Create and send test email with unique identifier
timestamp := time.Now().Format("15:04:05.000")
testSubject := "AUTOMATED TEST EMAIL - " + timestamp
testBody := "This is an automated integration test email sent at " + timestamp + ". Please ignore this message."
sentTestSubject = testSubject // Store for comparison
// Get the 'from' address from config
smtpConfig := config.Options["smtp"].(map[string]interface{})
fromAddressRaw := smtpConfig["from"].(string)
// Extract just the email address
fromAddress := fromAddressRaw
if strings.Contains(fromAddressRaw, "<") && strings.Contains(fromAddressRaw, ">") {
start := strings.Index(fromAddressRaw, "<")
end := strings.Index(fromAddressRaw, ">")
if start >= 0 && end > start {
fromAddress = fromAddressRaw[start+1 : end]
}
}
testMessage := &types.Message{
Type: types.MessageTypeEmail,
To: []string{fromAddress},
Subject: testSubject,
Body: testBody,
Headers: map[string]string{
"X-Test-Type": "automated-integration-test",
"X-Test-Timestamp": timestamp,
},
}
t.Logf("📤 Sending test email: %s", testSubject)
t.Logf("📧 To: %s", fromAddress)
// Send the email
sendErr := provider.Send(ctx, testMessage)
if sendErr != nil {
t.Logf("❌ Failed to send test email: %v", sendErr)
cancel() // Stop receiver
<-testCompleted
t.Skip("Could not send test email, skipping integration test")
}
t.Log("✅ Test email sent successfully!")
t.Log("⏳ Monitoring for incoming emails (timeout: 100 seconds)...")
t.Log("📊 Will analyze all received emails to find our test email...")
// Wait for emails and analyze them
foundTestEmail := false
timeout := time.After(100 * time.Second)
for !foundTestEmail {
select {
case receivedEmail := <-emailReceived:
t.Logf("📧 Analyzing email: Subject='%s'", receivedEmail.Subject)
// Check if this is our test email
if receivedEmail.Subject == sentTestSubject {
t.Log("🎯 FOUND OUR TEST EMAIL!")
t.Logf("✅ Subject matches: %s", receivedEmail.Subject)
t.Logf("✅ From: %s", receivedEmail.From)
// Stop the receiver gracefully
cancel()
<-testCompleted
// Verify the email properties
assert.NotNil(t, receivedEmail)
assert.Equal(t, sentTestSubject, receivedEmail.Subject)
assert.NotEmpty(t, receivedEmail.From)
assert.Equal(t, types.MessageTypeEmail, receivedEmail.Type)
t.Log("🎉 INTEGRATION TEST PASSED - Email send/receive cycle works!")
return
} else if strings.Contains(receivedEmail.Subject, "AUTOMATED TEST") {
t.Log("🔍 Found another automated test email (different timestamp)")
} else {
t.Log("📬 Found other email, continuing to monitor...")
}
case <-timeout:
t.Logf("⏰ Test timeout after 100 seconds")
t.Logf("📊 Total emails received during test: %d", emailCount)
t.Logf("🔍 Looking for subject: %s", sentTestSubject)
cancel()
<-testCompleted
if emailCount > 0 {
t.Log("✅ Email receiving is working (got emails), but our test email may be delayed")
t.Log("💡 This could be due to Gmail's email processing delays")
} else {
t.Log("❓ No emails received during test period")
t.Log("💡 This could indicate IMAP filtering is working correctly (only new emails)")
}
t.Skip("Test email not received within timeout - email delivery delays are common")
case <-ctx.Done():
<-testCompleted
t.Logf("📊 Test ended. Total emails received: %d", emailCount)
t.Skip("Test context cancelled")
}
}
}
// Benchmark Tests for Receive Functionality
func BenchmarkReceive_WithIMAPSupport(b *testing.B) {
t := &testing.T{}
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
if err != nil {
b.Fatal(err)
}
ctx := context.Background()
data := map[string]interface{}{
"type": "delivery",
"message": "benchmark test message",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
err := provider.Receive(ctx, data)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkSupportsReceiving(b *testing.B) {
t := &testing.T{}
config := loadReliableTestConfigReceive(t)
provider, err := NewMailerProvider(config)
if err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = provider.SupportsReceiving()
}
}

View file

@ -1,4 +1,4 @@
package smtp
package mailer
import (
"context"
@ -23,6 +23,13 @@ const (
// Test helper functions
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func createTestMessage(msgType types.MessageType) *types.Message {
message := &types.Message{
Type: msgType,
@ -56,15 +63,17 @@ func loadPrimaryTestConfig(t *testing.T) types.ProviderConfig {
config := types.ProviderConfig{
Name: "primary",
Connector: "smtp",
Connector: "mailer",
Options: map[string]interface{}{
"host": os.Getenv("SMTP_HOST"),
"port": os.Getenv("SMTP_PORT"),
"username": os.Getenv("SMTP_USERNAME"),
"password": os.Getenv("SMTP_PASSWORD"),
"from": os.Getenv("SMTP_FROM"),
"use_tls": useTLS,
"use_ssl": useSSL,
"smtp": map[string]interface{}{
"host": os.Getenv("SMTP_HOST"),
"port": os.Getenv("SMTP_PORT"),
"username": os.Getenv("SMTP_USERNAME"),
"password": os.Getenv("SMTP_PASSWORD"),
"from": os.Getenv("SMTP_FROM"),
"use_tls": useTLS,
"use_ssl": useSSL,
},
},
}
@ -80,26 +89,36 @@ func loadReliableTestConfig(t *testing.T) types.ProviderConfig {
// Create test config directly using environment variables for reliable SMTP
config := types.ProviderConfig{
Name: "reliable",
Connector: "smtp",
Connector: "mailer",
Options: map[string]interface{}{
"host": os.Getenv("RELIABLE_SMTP_HOST"),
"port": 587, // Hardcoded in reliable.smtp.yao
"username": os.Getenv("RELIABLE_SMTP_USERNAME"),
"password": os.Getenv("RELIABLE_SMTP_PASSWORD"),
"from": os.Getenv("RELIABLE_SMTP_FROM"),
"use_tls": true,
"smtp": map[string]interface{}{
"host": os.Getenv("RELIABLE_SMTP_HOST"),
"port": 587, // Hardcoded in reliable.mailer.yao
"username": os.Getenv("RELIABLE_SMTP_USERNAME"),
"password": os.Getenv("RELIABLE_SMTP_PASSWORD"),
"from": os.Getenv("RELIABLE_SMTP_FROM"),
"use_tls": true,
},
"imap": map[string]interface{}{
"host": getEnvOrDefault("RELIABLE_IMAP_HOST", os.Getenv("RELIABLE_SMTP_HOST")),
"port": getEnvOrDefault("RELIABLE_IMAP_PORT", "993"),
"username": getEnvOrDefault("RELIABLE_IMAP_USERNAME", os.Getenv("RELIABLE_SMTP_USERNAME")),
"password": getEnvOrDefault("RELIABLE_IMAP_PASSWORD", os.Getenv("RELIABLE_SMTP_PASSWORD")),
"use_ssl": true,
"mailbox": "INBOX",
},
},
}
return config
}
// Test NewSMTPProvider
// Test NewMailerProvider
func TestNewSMTPProvider_Primary(t *testing.T) {
func TestNewMailerProvider_Primary(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
assert.NotNil(t, provider)
@ -118,10 +137,10 @@ func TestNewSMTPProvider_Primary(t *testing.T) {
}
}
func TestNewSMTPProvider_Reliable(t *testing.T) {
func TestNewMailerProvider_Reliable(t *testing.T) {
config := loadReliableTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
assert.NotNil(t, provider)
@ -135,20 +154,20 @@ func TestNewSMTPProvider_Reliable(t *testing.T) {
assert.True(t, provider.useTLS)
}
func TestNewSMTPProvider_MissingOptions(t *testing.T) {
func TestNewMailerProvider_MissingOptions(t *testing.T) {
config := types.ProviderConfig{
Name: "test",
Connector: "smtp",
Options: nil,
}
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
assert.Error(t, err)
assert.Nil(t, provider)
assert.Contains(t, err.Error(), "SMTP provider requires options")
assert.Contains(t, err.Error(), "mailer provider requires options")
}
func TestNewSMTPProvider_MissingHost(t *testing.T) {
func TestNewMailerProvider_MissingHost(t *testing.T) {
config := types.ProviderConfig{
Name: "test",
Connector: "smtp",
@ -160,13 +179,13 @@ func TestNewSMTPProvider_MissingHost(t *testing.T) {
},
}
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
assert.Error(t, err)
assert.Nil(t, provider)
assert.Contains(t, err.Error(), "SMTP provider requires 'host' option")
assert.Contains(t, err.Error(), "mailer provider requires 'smtp' configuration")
}
func TestNewSMTPProvider_MissingUsername(t *testing.T) {
func TestNewMailerProvider_MissingUsername(t *testing.T) {
config := types.ProviderConfig{
Name: "test",
Connector: "smtp",
@ -178,13 +197,13 @@ func TestNewSMTPProvider_MissingUsername(t *testing.T) {
},
}
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
assert.Error(t, err)
assert.Nil(t, provider)
assert.Contains(t, err.Error(), "SMTP provider requires 'username' option")
assert.Contains(t, err.Error(), "mailer provider requires 'smtp' configuration")
}
func TestNewSMTPProvider_MissingPassword(t *testing.T) {
func TestNewMailerProvider_MissingPassword(t *testing.T) {
config := types.ProviderConfig{
Name: "test",
Connector: "smtp",
@ -196,13 +215,13 @@ func TestNewSMTPProvider_MissingPassword(t *testing.T) {
},
}
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
assert.Error(t, err)
assert.Nil(t, provider)
assert.Contains(t, err.Error(), "SMTP provider requires 'password' option")
assert.Contains(t, err.Error(), "mailer provider requires 'smtp' configuration")
}
func TestNewSMTPProvider_MissingFrom(t *testing.T) {
func TestNewMailerProvider_MissingFrom(t *testing.T) {
config := types.ProviderConfig{
Name: "test",
Connector: "smtp",
@ -214,25 +233,25 @@ func TestNewSMTPProvider_MissingFrom(t *testing.T) {
},
}
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
assert.Error(t, err)
assert.Nil(t, provider)
assert.Contains(t, err.Error(), "SMTP provider requires 'from' option")
assert.Contains(t, err.Error(), "mailer provider requires 'smtp' configuration")
}
// Test Provider Interface Methods
func TestGetType(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
assert.Equal(t, "smtp", provider.GetType())
assert.Equal(t, "mailer", provider.GetType())
}
func TestGetName(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
assert.Equal(t, "primary", provider.GetName())
@ -240,7 +259,7 @@ func TestGetName(t *testing.T) {
func TestValidate(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
err = provider.Validate()
@ -249,7 +268,7 @@ func TestValidate(t *testing.T) {
func TestValidate_MissingHost(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
provider.host = ""
@ -260,7 +279,7 @@ func TestValidate_MissingHost(t *testing.T) {
func TestValidate_InvalidPort(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
provider.port = 0
@ -271,7 +290,7 @@ func TestValidate_InvalidPort(t *testing.T) {
func TestValidate_MissingUsername(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
provider.username = ""
@ -282,7 +301,7 @@ func TestValidate_MissingUsername(t *testing.T) {
func TestValidate_MissingPassword(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
provider.password = ""
@ -293,7 +312,7 @@ func TestValidate_MissingPassword(t *testing.T) {
func TestValidate_MissingFrom(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
provider.from = ""
@ -304,7 +323,7 @@ func TestValidate_MissingFrom(t *testing.T) {
func TestClose(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
err = provider.Close()
@ -315,7 +334,7 @@ func TestClose(t *testing.T) {
func TestSend_NonEmailMessage(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
ctx := context.Background()
@ -328,7 +347,7 @@ func TestSend_NonEmailMessage(t *testing.T) {
func TestSend_EmailMessage_RealAPI_Primary(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Use context with reasonable timeout for SMTP operations
@ -370,7 +389,7 @@ func TestSend_EmailMessage_RealAPI_Primary(t *testing.T) {
func TestSend_EmailMessage_RealAPI_Reliable(t *testing.T) {
config := loadReliableTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Use context with reasonable timeout for SMTP operations
@ -413,7 +432,7 @@ func TestSend_EmailMessage_RealAPI_Reliable(t *testing.T) {
func TestSend_ContextTimeout_RealAPI(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Create a very short timeout context to test timeout functionality
@ -442,7 +461,7 @@ func TestSend_ContextTimeout_RealAPI(t *testing.T) {
func TestSendBatch_RealAPI(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Use context with reasonable timeout for SMTP batch operations
@ -481,7 +500,7 @@ func TestSendBatch_RealAPI(t *testing.T) {
func TestSend_MultipleRecipients_RealAPI(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
// Use context with reasonable timeout for SMTP operations
@ -516,7 +535,7 @@ func TestSend_MultipleRecipients_RealAPI(t *testing.T) {
func TestSend_WithCustomFrom(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
ctx := context.Background()
@ -541,7 +560,7 @@ func TestSend_WithCustomFrom(t *testing.T) {
func TestSend_PlainTextOnly(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
ctx := context.Background()
@ -566,7 +585,7 @@ func TestSend_PlainTextOnly(t *testing.T) {
func TestSend_HTMLOnly(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
ctx := context.Background()
@ -591,7 +610,7 @@ func TestSend_HTMLOnly(t *testing.T) {
func TestSend_MultipartMessage(t *testing.T) {
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
require.NoError(t, err)
ctx := context.Background()
@ -616,14 +635,14 @@ func TestSend_MultipartMessage(t *testing.T) {
// Benchmark Tests
func BenchmarkNewSMTPProvider(b *testing.B) {
func BenchmarkNewMailerProvider(b *testing.B) {
// Setup
t := &testing.T{}
config := loadPrimaryTestConfig(t)
b.ResetTimer()
for i := 0; i < b.N; i++ {
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
if err != nil {
b.Fatal(err)
}
@ -634,7 +653,7 @@ func BenchmarkNewSMTPProvider(b *testing.B) {
func BenchmarkValidate(b *testing.B) {
t := &testing.T{}
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
if err != nil {
b.Fatal(err)
}
@ -651,7 +670,7 @@ func BenchmarkValidate(b *testing.B) {
func BenchmarkBuildMessage(b *testing.B) {
t := &testing.T{}
config := loadPrimaryTestConfig(t)
provider, err := NewSMTPProvider(config)
provider, err := NewMailerProvider(config)
if err != nil {
b.Fatal(err)
}

View file

@ -0,0 +1,22 @@
package mailgun
import (
"context"
"fmt"
)
// Receive processes incoming messages/responses from Mailgun
func (p *Provider) Receive(ctx context.Context, data map[string]interface{}) error {
// TODO: Implement Mailgun webhook message processing
// This will handle:
// - Email delivery events
// - Email bounce events
// - Email complaint events
// - Email click/open tracking events
// - Incoming email messages
// For now, just log the received data
fmt.Printf("Mailgun provider received data: %+v\n", data)
return nil
}

View file

@ -0,0 +1,22 @@
package twilio
import (
"context"
"fmt"
)
// Receive processes incoming messages/responses from Twilio
func (p *Provider) Receive(ctx context.Context, data map[string]interface{}) error {
// TODO: Implement Twilio webhook message processing
// This will handle:
// - SMS delivery status callbacks
// - Incoming SMS messages
// - WhatsApp message status updates
// - WhatsApp incoming messages
// - Email delivery events (SendGrid webhooks)
// For now, just log the received data
fmt.Printf("Twilio provider received data: %+v\n", data)
return nil
}

View file

@ -10,6 +10,9 @@ type Provider interface {
// SendBatch sends multiple messages in batch
SendBatch(ctx context.Context, messages []*Message) error
// Receive processes incoming messages/responses from the provider
Receive(ctx context.Context, data map[string]interface{}) error
// GetType returns the provider type (smtp, twilio, mailgun, etc.)
GetType() string

View file

@ -48,7 +48,7 @@ type ProviderConfig struct {
types.MetaInfo
Name string `json:"name"`
Description string `json:"description,omitempty"`
Connector string `json:"connector"` // Provider type: smtp, twilio, mailgun
Connector string `json:"connector"` // Provider type: mailer, twilio, mailgun
Options map[string]interface{} `json:"options,omitempty"` // Provider-specific options
Enabled bool `json:"enabled,omitempty"` // Whether the provider is enabled (default: true)
}

View file

@ -0,0 +1 @@
package messenger