all repos — postern @ fc287763b9ce6b6f9823e11c0b1173da0446ebc5

Modern mail management

internal/policy/parse.go (view raw)

  1package policy
  2
  3import (
  4	"fmt"
  5	"log"
  6	"net"
  7	"net/mail"
  8	"net/netip"
  9	"os"
 10
 11	"postern/internal/db"
 12
 13	"go.starlark.net/starlark"
 14	"go.starlark.net/starlarkstruct"
 15	"go.starlark.net/syntax"
 16)
 17
 18type RelayConfig struct {
 19	Host     string
 20	Port     int
 21	Username string
 22	Password string
 23}
 24
 25type Engine struct {
 26	Listeners   []ListenerConfig
 27	UsersModule *UsersModule
 28	globals     starlark.StringDict
 29	thread      *starlark.Thread
 30}
 31
 32type ListenerConfig struct {
 33	Address     netip.AddrPort
 34	Role        string
 35	TLSCertFile string
 36	TLSKeyFile  string
 37}
 38
 39type Address struct {
 40	Address, Name, Raw string
 41}
 42
 43func (a Address) String() string        { return a.Raw }
 44func (a Address) Type() string          { return "Address" }
 45func (a Address) Freeze()               {}
 46func (a Address) Truth() starlark.Bool  { return true }
 47func (a Address) Hash() (uint32, error) { return 0, nil }
 48
 49func (a Address) Attr(name string) (starlark.Value, error) {
 50	switch name {
 51	case "address":
 52		return starlark.String(a.Address), nil
 53	case "name":
 54		return starlark.String(a.Name), nil
 55	case "raw":
 56		return starlark.String(a.Raw), nil
 57	}
 58	return nil, nil
 59}
 60
 61func (a Address) AttrNames() []string {
 62	return []string{"address", "name", "raw"}
 63}
 64
 65type MessageContext struct {
 66	HeaderFrom Address
 67	HeaderTo   Address
 68	Subject    string
 69	Headers    mail.Header
 70	Size       int64
 71}
 72
 73func (m MessageContext) String() string { return "<MessageContext>" }
 74func (m MessageContext) Type() string   { return "MessageContext" }
 75func (m MessageContext) Freeze() {
 76	m.HeaderFrom.Freeze()
 77	m.HeaderTo.Freeze()
 78}
 79func (m MessageContext) Truth() starlark.Bool  { return true }
 80func (m MessageContext) Hash() (uint32, error) { return 0, nil }
 81
 82func (m MessageContext) Attr(name string) (starlark.Value, error) {
 83	switch name {
 84	case "header_from":
 85		return m.HeaderFrom, nil
 86	case "header_to":
 87		return m.HeaderTo, nil
 88	case "subject":
 89		return starlark.String(m.Subject), nil
 90	case "size":
 91		return starlark.MakeInt64(m.Size), nil
 92	}
 93	return nil, nil
 94}
 95
 96func (m MessageContext) AttrNames() []string {
 97	return []string{"header_from", "subject", "size"}
 98}
 99
100// MDA implements starlark.Value and starlark.HasAttrs
101// so it can be exposed as the `mda` global object in the script.
102type MDA struct {
103	parsedListeners []ListenerConfig
104}
105
106func (m *MDA) String() string        { return "<module mda>" }
107func (m *MDA) Type() string          { return "module" }
108func (m *MDA) Freeze()               {}
109func (m *MDA) Truth() starlark.Bool  { return true }
110func (m *MDA) Hash() (uint32, error) { return 0, nil }
111
112// Attr resolves mda.listen, mda.accept, mda.reject, mda.deliver_to, and mda.quarantine when called in Starlark
113func (m *MDA) Attr(name string) (starlark.Value, error) {
114	switch name {
115	case "listen":
116		return starlark.NewBuiltin("listen", m.mdaListen), nil
117	}
118	return nil, nil
119}
120func (m *MDA) AttrNames() []string {
121	return []string{"listen"}
122}
123
124type Conn struct {
125	RemoteAddr starlark.String
126	ClientIP   starlark.String
127	Meta       *starlarkstruct.Struct
128}
129
130func (c Conn) String() string        { return "<Conn>" }
131func (c Conn) Type() string          { return "Conn" }
132func (c Conn) Freeze()               { c.Meta.Freeze() }
133func (c Conn) Truth() starlark.Bool  { return true }
134func (c Conn) Hash() (uint32, error) { return 0, nil }
135
136func (c Conn) Attr(name string) (starlark.Value, error) {
137	switch name {
138	case "remote_addr":
139		return c.RemoteAddr, nil
140	case "client_ip":
141		return c.ClientIP, nil
142	case "meta":
143		return c.Meta, nil
144	}
145	return nil, nil
146}
147
148func (c Conn) AttrNames() []string {
149	return []string{"remote_addr", "client_ip", "meta"}
150}
151
152func ConnToStarlark(conn net.Conn) Conn {
153	remoteAddr := conn.RemoteAddr().String()
154	var clientIP string
155	if tcpAddr, ok := conn.RemoteAddr().(*net.TCPAddr); ok {
156		clientIP = tcpAddr.IP.String()
157	} else {
158		clientIP = remoteAddr
159	}
160
161	return Conn{
162		RemoteAddr: starlark.String(remoteAddr),
163		ClientIP:   starlark.String(clientIP),
164		Meta:       starlarkstruct.FromStringDict(starlark.None, starlark.StringDict{}),
165	}
166}
167
168// mdaListen handles mda.listen(address="...", role="...")
169func (m *MDA) mdaListen(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
170	var address, role, tlsCertFile, tlsKeyFile string
171
172	// starlark.UnpackArgs maps Starlark arguments to Go variables safely
173	err := starlark.UnpackArgs(
174		b.Name(),
175		args,
176		kwargs,
177		"address", &address,
178		"role", &role,
179		"tls_cert_file", &tlsCertFile,
180		"tls_key_file", &tlsKeyFile)
181	if err != nil {
182		return nil, err
183	}
184
185	addrPort, err := netip.ParseAddrPort(address)
186	if err != nil {
187		return nil, err
188	}
189
190	// Save to our Go state
191	m.parsedListeners = append(m.parsedListeners, ListenerConfig{
192		Address:     addrPort,
193		Role:        role,
194		TLSCertFile: tlsCertFile,
195		TLSKeyFile:  tlsKeyFile,
196	})
197
198	return starlark.None, nil
199}
200
201func NewEngine(policyFile string, database *db.DB) (Engine, error) {
202	script, err := os.ReadFile(policyFile)
203	if err != nil {
204		log.Fatal(err)
205	}
206
207	// Create an 'os' module containing our getenv function
208	osModule := &starlarkstruct.Module{
209		Name: "os",
210		Members: starlark.StringDict{
211			"getenv": starlark.NewBuiltin("getenv", starlarkGetenv),
212		},
213	}
214
215	// Create the users module
216	usersModule := NewUsersModule(database)
217
218	// Expose the "os", "mda", and "users" modules to the Starlark global environment
219	m := &MDA{
220		parsedListeners: make([]ListenerConfig, 0),
221	}
222	predeclared := starlark.StringDict{
223		"mda":   m,
224		"os":    osModule,
225		"users": usersModule,
226	}
227
228	// Create a Starlark thread. We customize the Print function
229	// so the Starlark `print()` built-in logs cleanly in Go.
230	thread := &starlark.Thread{
231		Name: "mda-main",
232		Print: func(_ *starlark.Thread, msg string) {
233			fmt.Printf("[init.star]: %s\n", msg)
234		},
235	}
236
237	// 1. Evaluate the script
238	globals, err := starlark.ExecFileOptions(&syntax.FileOptions{}, thread, "policy.star", script, predeclared)
239	if err != nil {
240		return Engine{}, err
241	}
242
243	// 2. Call the init() function
244	initVal, ok := globals["init"]
245	if !ok {
246		return Engine{}, fmt.Errorf("init not found in policy")
247	}
248
249	_, err = starlark.Call(thread, initVal, nil, nil)
250	if err != nil {
251		return Engine{}, err
252	}
253
254	engine := Engine{
255		Listeners:   m.parsedListeners,
256		UsersModule: usersModule,
257		globals:     globals,
258		thread:      thread,
259	}
260	return engine, err
261}
262
263func (e *Engine) OnMessageSubmit(user User, msg MessageContext) (RelayConfig, error) {
264	hookVal, ok := e.globals["on_message_submit"]
265	if !ok {
266		return RelayConfig{}, nil
267	}
268
269	result, err := starlark.Call(e.thread, hookVal, starlark.Tuple{user, msg}, nil)
270	if err != nil {
271		return RelayConfig{}, err
272	}
273
274	if result == starlark.None {
275		return RelayConfig{}, nil
276	}
277
278	dict, ok := result.(*starlark.Dict)
279	if !ok {
280		return RelayConfig{}, fmt.Errorf("on_message_submit must return a dict, got %s", result.Type())
281	}
282
283	relay := RelayConfig{
284		Host:     dictString(dict, "host"),
285		Port:     dictInt(dict, "port"),
286		Username: dictString(dict, "username"),
287		Password: dictString(dict, "password"),
288	}
289	return relay, nil
290}
291
292func dictString(dict *starlark.Dict, key string) string {
293	v, _, err := dict.Get(starlark.String(key))
294	if err != nil || v == starlark.None {
295		return ""
296	}
297	if s, ok := v.(starlark.String); ok {
298		return string(s)
299	}
300	return ""
301}
302
303func dictInt(dict *starlark.Dict, key string) int {
304	v, _, err := dict.Get(starlark.String(key))
305	if err != nil || v == starlark.None {
306		return 0
307	}
308	if i, ok := v.(starlark.Int); ok {
309		if n, ok := i.Int64(); ok {
310			return int(n)
311		}
312	}
313	return 0
314}
315
316func (e *Engine) OnMessageDeliver(conn Conn, user User, msg MessageContext) (string, error) {
317	defaultMailbox := "INBOX"
318	hookVal, ok := e.globals["on_message_deliver"]
319	if !ok {
320		return defaultMailbox, nil
321	}
322
323	result, err := starlark.Call(e.thread, hookVal, starlark.Tuple{conn, user, msg}, nil)
324	if err != nil {
325		return defaultMailbox, err
326	}
327
328	mailbox := defaultMailbox
329	if result != starlark.None {
330		if s, ok := result.(starlark.String); ok {
331			mailbox = string(s)
332		}
333	}
334
335	return mailbox, nil
336}
337
338func (e *Engine) OnMessageMoved(user User, msg MessageContext, sourceFolder, destFolder string) error {
339	hookVal, ok := e.globals["on_message_moved"]
340	if !ok {
341		return nil
342	}
343
344	_, err := starlark.Call(e.thread, hookVal, starlark.Tuple{user, msg, starlark.String(sourceFolder), starlark.String(destFolder)}, nil)
345	return err
346}
347
348// MessageContextFromEnvelope builds a policy.MessageContext from the envelope
349// fields of a stored message. The header_from address is parsed into an
350// Address; if parsing fails the raw string is preserved.
351func MessageContextFromEnvelope(from, subject string, size int64) MessageContext {
352	fromAddr, err := ParseAddress(from)
353	if err != nil || from == "" {
354		fromAddr = Address{Raw: from, Address: from}
355	}
356	return MessageContext{
357		HeaderFrom: fromAddr,
358		Subject:    subject,
359		Size:       size,
360	}
361}
362
363func ParseAddress(email string) (Address, error) {
364	a, err := mail.ParseAddress(email)
365	if err != nil {
366		return Address{}, err
367	}
368
369	addr := Address{
370		Raw:     email,
371		Address: a.Address,
372		Name:    a.Name,
373	}
374
375	return addr, nil
376}
377
378// starlarkGetenv implements Python's os.getenv("KEY", "default")
379func starlarkGetenv(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
380	var key string
381	var def starlark.Value = starlark.None
382
383	if err := starlark.UnpackArgs(b.Name(), args, kwargs, "key", &key, "default?", &def); err != nil {
384		return nil, err
385	}
386
387	if val, exists := os.LookupEnv(key); exists {
388		return starlark.String(val), nil
389	}
390	return def, nil
391}