This spins up the entire stack including a PostgreSQL database (preloaded with migrations and permission/demo seeds), a hot-reloading Go backend, and the Portal UI dev server:
git clone git@github.com:weprodev/wpd-message-gateway.git
cd wpd-message-gateway
make devIf you prefer to run components directly on your host machine:
git clone git@github.com:weprodev/wpd-message-gateway.git
cd wpd-message-gateway
make install
make startAdding a provider does not require modifying any existing code. This follows the Open/Closed Principle: open for extension, closed for modification.
The system uses a self-registration pattern via Go's init() mechanism. When you create a new provider package with a register.go file and add a blank import in internal/app/imports.go, the provider becomes available in both:
- The embedded SDK (
gateway.New(...)— standalone Go usage) - The HTTP server (via UI-managed integrations in the database)
mkdir -p pkg/provider/sendgrid// pkg/provider/sendgrid/sendgrid.go
package sendgrid
import (
"context"
"fmt"
"github.com/weprodev/wpd-message-gateway/pkg/contracts"
)
const ProviderName = "sendgrid"
// Provider implements contracts.EmailSender for SendGrid.
type Provider struct {
apiKey string
fromEmail string
fromName string
}
// compile-time interface check
var _ contracts.EmailSender = (*Provider)(nil)
func New(cfg Config) (*Provider, error) {
if cfg.APIKey == "" {
return nil, fmt.Errorf("sendgrid: API key required")
}
return &Provider{
apiKey: cfg.APIKey,
fromEmail: cfg.FromEmail,
fromName: cfg.FromName,
}, nil
}
func (p *Provider) Send(ctx context.Context, email contracts.Email) (*contracts.SendResult, error) {
// Call SendGrid HTTP API here
return &contracts.SendResult{
ID: "sg-msg-id",
Message: "queued",
}, nil
}
// Config holds SendGrid credentials.
type Config struct {
APIKey string
FromEmail string
FromName string
}// pkg/provider/sendgrid/register.go
package sendgrid
import (
"github.com/weprodev/wpd-message-gateway/pkg/contracts"
"github.com/weprodev/wpd-message-gateway/pkg/registry"
)
func init() {
registry.RegisterEmailProvider("sendgrid", func(cfg registry.EmailConfig) (contracts.EmailSender, error) {
return New(Config{
APIKey: cfg.APIKey,
FromEmail: cfg.FromEmail,
FromName: cfg.FromName,
})
})
}// internal/app/imports.go
package app
import (
// Provider self-registration — order does not matter
_ "github.com/weprodev/wpd-message-gateway/pkg/provider/mailgun"
_ "github.com/weprodev/wpd-message-gateway/pkg/provider/memory"
_ "github.com/weprodev/wpd-message-gateway/pkg/provider/sendgrid" // ← add this
)That's all that's needed for both SDK and server modes.
gw, _ := gateway.New(gateway.Config{
DefaultEmailProvider: "sendgrid",
EmailProviders: map[string]gateway.EmailConfig{
"sendgrid": {
CommonConfig: gateway.CommonConfig{APIKey: "SG.xxx"},
FromEmail: "noreply@example.com",
FromName: "MyApp",
},
},
})
gw.SendEmail(ctx, email)- Start the server and obtain a portal JWT (
POST /api/v1/auth/login) POST /api/v1/workspaces/:wid/integrationswith channel email, provider sendgrid, and JSON config (api_key,from_email,from_name) — or use the Portal Integrations page- The
GatewayServicereads this from DB and instantiates your provider via the registry factory
Provider config in server mode is stored AES-encrypted in the
integrationstable — configure via Portal UI or REST, not YAML files.
// pkg/provider/sendgrid/sendgrid_test.go
package sendgrid
import (
"context"
"testing"
"github.com/weprodev/wpd-message-gateway/pkg/contracts"
)
func TestNew(t *testing.T) {
tests := []struct {
name string
cfg Config
wantErr bool
}{
{"valid", Config{APIKey: "key", FromEmail: "a@b.com"}, false},
{"missing key", Config{}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := New(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestSend(t *testing.T) {
p, err := New(Config{APIKey: "key", FromEmail: "noreply@example.com"})
if err != nil {
t.Fatal(err)
}
result, err := p.Send(context.Background(), &contracts.Email{
To: []string{"user@example.com"},
Subject: "Test",
HTML: "<p>Hello</p>",
})
if err != nil {
t.Errorf("Send() error = %v", err)
}
if result == nil || result.ID == "" {
t.Error("expected non-empty result ID")
}
}make audit # format, lint, test, security scanpkg/provider/
├── mailgun/
│ ├── mailgun.go # Provider implementation
│ └── register.go # init() self-registration
│
├── memory/ # Built-in dev/test capture provider
│ ├── store.go # Shared in-process message store
│ ├── email.go # Memory email provider
│ ├── sms.go # Memory SMS provider
│ ├── push.go # Memory push provider
│ ├── chat.go # Memory chat provider
│ └── register.go # init() self-registration
│
└── sendgrid/ # Your new provider
├── sendgrid.go
├── sendgrid_test.go
└── register.go ← KEY FILE: always required
┌─────────────────────┐ init() ┌────────────────────────────┐
│ Provider Package │──────────▶ │ Provider Registry │
│ (register.go) │ │ (pkg/registry) │
│ │ │ │
│ emailFactories[ │ │ emailFactories[ │
│ "sendgrid" │ │ "sendgrid" │
│ ] = factory func │ │ ] = factory func │
└─────────────────────┘ └────────────────────────────┘
│
┌────────────┴────────────┐
│ │
SDK gateway.New() Server GatewayService
(static config) (reads from DB integrations)
Same pattern — use the corresponding registry function and port interface:
| Channel | Registry function | Contract interface |
|---|---|---|
registry.RegisterEmailProvider(name, factory) |
contracts.EmailSender |
|
| SMS | registry.RegisterSMSProvider(name, factory) |
contracts.SMSSender |
| Push | registry.RegisterPushProvider(name, factory) |
contracts.PushSender |
| Chat | registry.RegisterChatProvider(name, factory) |
contracts.ChatSender |
-
/smell develop— no BLOCKER/HIGH (verification.md) -
make auditpasses (format, lint, test, security) - Tests added with meaningful coverage
- Interface check:
var _ contracts.EmailSender = (*Provider)(nil) -
register.gowithinit()for self-registration - Blank import added to
internal/app/imports.go - No provider credentials in YAML — use environment variables (embedded SDK) or Portal REST API (server mode)
- Commit messages follow Conventional Commits
We use Conventional Commits for automatic versioning:
| Message | Version bump |
|---|---|
feat(sendgrid): add email provider |
Minor |
fix(mailgun): handle rate limit 429 |
Patch |
docs: update contributing guide |
Patch |
feat!: change auth to email+PIN |
Major |
- Architecture — System design and layers
- Usage — API reference
- Code Conventions — Coding standards
- Workflow Guide — CI/CD, commits, releases