add features for http message handling
jump to
@@ -4,6 +4,7 @@ go 1.27
require ( github.com/emersion/go-imap/v2 v2.0.0-beta.8 + github.com/emersion/go-message v0.18.2 github.com/emersion/go-msgauth v0.7.0 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-smtp v0.25.0@@ -17,13 +18,13 @@ )
require ( github.com/dustin/go-humanize v1.0.1 // indirect - github.com/emersion/go-message v0.18.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.10 // indirect - golang.org/x/sys v0.47.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect modernc.org/libc v1.75.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect
@@ -36,21 +36,17 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.starlark.net v0.0.0-20260708150628-5395d018f003 h1:cAxcqHgW8fnmT0cEBU3TzvVYHIFt8IIGDMWUF6rImk4= -go.starlark.net v0.0.0-20260708150628-5395d018f003/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= go.starlark.net v0.0.0-20260904161901-6ecada49e42f h1:m+AwfAsRB2E9t6xjYPQ/0quPxDjXihZSjVr03BmwTs8= go.starlark.net v0.0.0-20260904161901-6ecada49e42f/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=@@ -58,16 +54,16 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=@@ -76,12 +72,14 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= 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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=@@ -98,22 +96,16 @@ modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.75.3 h1:vCqT5+R0jPXMnvMkGo0T2zXvFNth+lYXVCx5X7CCX/g= -modernc.org/libc v1.75.3/go.mod h1:MjAX68G+0oufI+hNuh0QXcK+Ap+sL8bNPPcIC6EqOfo= modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU= -modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= -modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
@@ -24,12 +24,12 @@ }
} // clientListUsers fetches the list of users from the daemon. -func (c *Command) clientListUsers(ctx context.Context) ([]model.User, error) { +func (c *Command) clientListUsers(ctx context.Context) (_ []model.User, err error) { resp, err := c.httpClient.Get("http://unix/users") if err != nil { return nil, fmt.Errorf("failed to list users: %w", err) } - defer resp.Body.Close() + defer model.Wrap(resp.Body.Close, &err, "clientListUsers closing response body") if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to list users: %d", resp.StatusCode)@@ -44,7 +44,7 @@ return users, nil
} // clientCreateUser creates a new user via the daemon. -func (c *Command) clientCreateUser(ctx context.Context, username string) (model.User, error) { +func (c *Command) clientCreateUser(ctx context.Context, username string) (_ model.User, err error) { reqBody := map[string]string{"username": username} body, err := json.Marshal(reqBody) if err != nil {@@ -55,7 +55,7 @@ resp, err := c.httpClient.Post("http://unix/users", "application/json", bytes.NewReader(body))
if err != nil { return model.User{}, fmt.Errorf("failed to create user: %w", err) } - defer resp.Body.Close() + defer model.Wrap(resp.Body.Close, &err, "clientCreateUser closing response body") if resp.StatusCode != http.StatusCreated { return model.User{}, fmt.Errorf("failed to create user: %d", resp.StatusCode)@@ -70,7 +70,7 @@ return user, nil
} // clientRemoveUser removes a user via the daemon. -func (c *Command) clientRemoveUser(ctx context.Context, username string) error { +func (c *Command) clientRemoveUser(ctx context.Context, username string) (err error) { req, err := http.NewRequestWithContext(ctx, http.MethodDelete, "http://unix/users/"+username, nil) if err != nil { return fmt.Errorf("failed to create delete user request: %w", err)@@ -80,7 +80,7 @@ resp, err := c.httpClient.Do(req)
if err != nil { return fmt.Errorf("failed to remove user: %w", err) } - defer resp.Body.Close() + defer model.Wrap(resp.Body.Close, &err, "clientRemoveUser closing response body") if resp.StatusCode != http.StatusNoContent { return fmt.Errorf("failed to remove user: %d", resp.StatusCode)@@ -106,7 +106,7 @@ resp, err := c.httpClient.Post(path, "application/json", bytes.NewReader(body))
if err != nil { return fmt.Errorf("failed to add user address: %w", err) } - defer resp.Body.Close() + defer model.Wrap(resp.Body.Close, &err, "clientAddUserAddress closing response body") if resp.StatusCode != http.StatusCreated { return fmt.Errorf("failed to add user address: %d", resp.StatusCode)@@ -121,7 +121,7 @@ resp, err := c.httpClient.Get("http://unix/smtp-auth")
if err != nil { return nil, fmt.Errorf("failed to list smtp auth: %w", err) } - defer resp.Body.Close() + defer model.Wrap(resp.Body.Close, &err, "clientListSMTPAuth closing response body") if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to list smtp auth: %d", resp.StatusCode)@@ -136,7 +136,7 @@ return auths, nil
} // clientCreateSMTPAuth creates a new SMTP auth user via the daemon. -func (c *Command) clientCreateSMTPAuth(ctx context.Context, name string) (model.SMTPAuth, error) { +func (c *Command) clientCreateSMTPAuth(_ context.Context, name string) (model.SMTPAuth, error) { reqBody := map[string]string{"name": name} body, err := json.Marshal(reqBody) if err != nil {@@ -147,7 +147,7 @@ resp, err := c.httpClient.Post("http://unix/smtp-auth", "application/json", bytes.NewReader(body))
if err != nil { return model.SMTPAuth{}, fmt.Errorf("failed to create smtp auth: %w", err) } - defer resp.Body.Close() + defer model.Wrap(resp.Body.Close, &err, "clientCreateSMTPAuth closing response body") if resp.StatusCode != http.StatusCreated { return model.SMTPAuth{}, fmt.Errorf("failed to create smtp auth: %d", resp.StatusCode)@@ -176,7 +176,7 @@ resp, err := c.httpClient.Do(req)
if err != nil { return fmt.Errorf("failed to remove smtp auth: %w", err) } - defer resp.Body.Close() + defer model.Wrap(resp.Body.Close, &err, "clientRemoveSMTPAuth closing response body") if resp.StatusCode != http.StatusNoContent { return fmt.Errorf("failed to remove smtp auth: %d", resp.StatusCode)@@ -191,7 +191,7 @@ resp, err := c.httpClient.Get("http://unix/dkim")
if err != nil { return nil, fmt.Errorf("failed to list dkim: %w", err) } - defer resp.Body.Close() + defer model.Wrap(resp.Body.Close, &err, "clientListDKIM closing response body") if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to list dkim: %d", resp.StatusCode)@@ -217,7 +217,7 @@ resp, err := c.httpClient.Post("http://unix/dkim", "application/json", bytes.NewReader(body))
if err != nil { return "", fmt.Errorf("failed to add dkim: %w", err) } - defer resp.Body.Close() + defer model.Wrap(resp.Body.Close, &err, "clientAddDKIM closing response body") if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("failed to add dkim: %d", resp.StatusCode)@@ -232,7 +232,7 @@ return result["dns_record"], nil
} // clientGetDKIM gets the public DNS record for a DKIM selector via the daemon. -func (c *Command) clientGetDKIM(ctx context.Context, selector, domain string) (string, error) { +func (c *Command) clientGetDKIM(_ context.Context, selector, domain string) (string, error) { path, err := url.JoinPath("http://unix/dkim", selector, domain) if err != nil { return "", fmt.Errorf("failed to create dkim request path: %w", err)@@ -241,7 +241,7 @@ resp, err := c.httpClient.Get(path)
if err != nil { return "", fmt.Errorf("failed to get dkim: %w", err) } - defer resp.Body.Close() + defer model.Wrap(resp.Body.Close, &err, "clientGetDKIM closing response body") if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("failed to get dkim: %d", resp.StatusCode)@@ -256,7 +256,7 @@ return result["dns_record"], nil
} // clientSetDKIMEnabled enables/disables a DKIM selector via the daemon. -func (c *Command) clientSetDKIMEnabled(ctx context.Context, selector, domain string, enable bool, force bool) error { +func (c *Command) clientSetDKIMEnabled(_ context.Context, selector, domain string, enable bool, force bool) error { enableKeyword := "enable" if !enable { enableKeyword = "disable"@@ -276,7 +276,7 @@ resp, err := c.httpClient.Post(path, "application/json", nil)
if err != nil { return fmt.Errorf("failed to set dkim enabled: %w", err) } - defer resp.Body.Close() + defer model.Wrap(resp.Body.Close, &err, "clientSetDKIMEnabled closing response body") if resp.StatusCode != http.StatusNoContent { return fmt.Errorf("failed to set dkim enabled: %d", resp.StatusCode)@@ -300,7 +300,7 @@ resp, err := c.httpClient.Do(req)
if err != nil { return fmt.Errorf("failed to remove dkim: %w", err) } - defer resp.Body.Close() + defer model.Wrap(resp.Body.Close, &err, "clientRemoveDKIM closing response body") if resp.StatusCode != http.StatusNoContent { return fmt.Errorf("failed to remove dkim: %d", resp.StatusCode)@@ -315,7 +315,7 @@ resp, err := c.httpClient.Post("http://unix/prune", "application/json", nil)
if err != nil { return 0, fmt.Errorf("failed to prune mails: %w", err) } - defer resp.Body.Close() + defer model.Wrap(resp.Body.Close, &err, "clientPruneMails closing response body") if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("failed to prune mails: %d", resp.StatusCode)
@@ -15,7 +15,12 @@ if err != nil {
t.Fatal(err) } - defer posternDB.Close() + defer func(posternDB *DB) { + err := posternDB.Close() + if err != nil { + t.Fatal(err) + } + }(posternDB) key := "test-concurrency-key" if err := posternDB.KVSet(t.Context(), key, 0); err != nil {@@ -81,7 +86,12 @@ if err != nil {
t.Fatal(err) } - defer posternDB.Close() + defer func(posternDB *DB) { + err := posternDB.Close() + if err != nil { + t.Fatal(err) + } + }(posternDB) key := "test-key" if err := posternDB.KVSet(t.Context(), key, "value"); err != nil {@@ -103,7 +113,12 @@ if err != nil {
t.Fatal(err) } - defer posternDB.Close() + defer func(posternDB *DB) { + err := posternDB.Close() + if err != nil { + t.Fatal(err) + } + }(posternDB) key := "test-key" var testVal int64 = 42
@@ -131,7 +131,7 @@ return out, nil
} func (db *DB) AppendMessage(ctx context.Context, mailboxID, userID int, - blobHash string, size int64, internalDate string, flags []string, parsedMsg *mail.Message) (uint32, error) { + blobHash string, size int64, internalDate string, flags []string, msgHeader mail.Header) (uint32, error) { tx, err := db.write.BeginTx(ctx, nil) if err != nil {@@ -162,8 +162,8 @@ // The no-op DO UPDATE forces RETURNING to yield the existing id on conflict.
var internalMessageID int64 var subject, messageID, inReplyTo, fromAddr, sender, replyTo, toAddr, cc, bcc sql.NullString var datestring sql.NullString - if parsedMsg != nil && parsedMsg.Header != nil { - date, err := parsedMsg.Header.Date() + if msgHeader != nil { + date, err := msgHeader.Date() if err == nil && !date.IsZero() { datestring = sql.NullString{ Valid: true,@@ -171,7 +171,7 @@ String: date.UTC().Format(time.RFC3339),
} } - for headerKey, headerValue := range parsedMsg.Header { + for headerKey, headerValue := range msgHeader { switch headerKey { case "Subject": subject = sql.NullString{
@@ -16,7 +16,12 @@ if err != nil {
log.Fatal(err) } - defer posternDB.Close() + defer func(posternDB *DB) { + err := posternDB.Close() + if err != nil { + log.Fatal(err) + } + }(posternDB) tx, err := posternDB.GetWriteTx(t.Context()) if err != nil {
@@ -5,6 +5,7 @@ "context"
"database/sql" "errors" "fmt" + "io" "log/slog" "math" "slices"@@ -1350,7 +1351,12 @@ // has already succeeded, so policy errors are logged but do not fail the
// MOVE command. policyUser := policy.UserToStarlark(m.user, m.db) for _, mv := range movedMessages { - msg := policy.MessageContextFromEnvelope(mv.EnvelopeFrom.String, mv.EnvelopeSubject.String, mv.RFC822Size) + msg := &policy.Message{ + OpenStream: func() (io.ReadCloser, error) { + return m.persistence.BlobReader(mv.BlobHash) + }, + Size: mv.RFC822Size, + } if err := m.policyEngine.OnMessageMoved(policyUser, msg, m.selectedMailbox.Name, dest); err != nil { logger.ErrorContext(ctx, "on_message_moved policy hook error", "error", err, "uid", mv.UID) }
@@ -1,6 +1,7 @@
package imap import ( + "bytes" "context" "errors" "fmt"@@ -18,35 +19,23 @@ func (i *Server) SubmitMessage(ctx context.Context, conn net.Conn, user model.User, r io.Reader) error {
logger := slog.With("component", "internal_submission", "user_id", user.ID) internalTime := time.Now() - fileName, size, parsedMsg, err := i.persistence.WriteBlobMessage(r) + var msgBuffer bytes.Buffer + r = io.TeeReader(r, &msgBuffer) + + fileName, size, msgHeader, err := i.persistence.WriteBlobMessage(r) if err != nil { return fmt.Errorf("write blob: %w", err) } - if parsedMsg == nil { - return errors.New("parsed message is nil") - } - policyUser := policy.UserToStarlark(user, i.db) policyConn := policy.ConnToStarlark(conn) - fromAddr, err := policy.ParseAddress(parsedMsg.Header.Get("From")) - if err != nil { - return model.ErrMissingFromHeader - } - toAddr, err := policy.ParseAddress(parsedMsg.Header.Get("To")) - if err != nil { - return errors.New("missing To header") - } - subject := parsedMsg.Header.Get("Subject") - msgSize := size - - msg := policy.MessageContext{ - HeaderFrom: fromAddr, - HeaderTo: toAddr, - Subject: subject, - Headers: parsedMsg.Header, - Size: msgSize, + msgBytes := msgBuffer.Bytes() + msg := &policy.Message{ + OpenStream: func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(msgBytes)), nil + }, + Size: size, } destinationMailbox, err := i.policyEngine.OnMessageDeliver(policyConn, policyUser, msg)@@ -78,7 +67,7 @@ return fmt.Errorf("get mailbox ID: %w", err)
} } - _, err = i.db.AppendMessage(ctx, mailboxID, user.ID, fileName, size, internalTime.UTC().Format(time.RFC3339), nil, parsedMsg) + _, err = i.db.AppendMessage(ctx, mailboxID, user.ID, fileName, size, internalTime.UTC().Format(time.RFC3339), nil, msgHeader) if err != nil { return fmt.Errorf("append message: %w", err) }
@@ -1,8 +1,24 @@
package model -import "errors" +import ( + "errors" + "fmt" +) var ErrMissingFromHeader = errors.New("missing From header") var ErrAlreadyExists = errors.New("already exists") var ErrNotFound = errors.New("not found") var ErrInvalidInput = errors.New("invalid input") + +// Wrap solves the `defer r.Close()` issue by returning the error value to the caller. +// Idea used from: https://cs.opensource.google/go/x/pkgsite/+/refs/tags/v0.5.0:internal/derrors/derrors.go;l=240 +func Wrap(errFunc func() error, errp *error, format string, args ...any) { + if errFunc == nil { + return + } + + tmpErr := errFunc() + if tmpErr != nil { + *errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), tmpErr) + } +}
@@ -0,0 +1,20 @@
+package model + +import ( + "errors" + "testing" +) + +func TestWrapError(t *testing.T) { + testFunc := func() (err error) { + defer Wrap(func() error { + return errors.New("wrap error") + }, &err, "testfunc") + return nil + } + + err := testFunc() + if err == nil { + t.Error("expected error, got nil") + } +}
@@ -76,8 +76,8 @@ return nonceHex, size, nil
} // WriteBlobMessage parses the RFC 5322 headers from r, then gzips and streams the entire message to disk. -func (p *Persistence) WriteBlobMessage(r io.Reader) (string, int64, *mail.Message, error) { - msg, fullReader, err := parseMailHeader(r) +func (p *Persistence) WriteBlobMessage(r io.Reader) (string, int64, mail.Header, error) { + msg, fullReader, err := ParseMailHeader(r) if err != nil { return "", 0, nil, fmt.Errorf("parsing message header: %w", err) }@@ -90,8 +90,8 @@
return nonceHex, size, msg, nil } -// parseMailHeader extracts RFC 5322 mail headers line-by-line without reading the whole body into memory. -func parseMailHeader(r io.Reader) (*mail.Message, io.Reader, error) { +// ParseMailHeader extracts RFC 5322 mail headers line-by-line without reading the whole body into memory. +func ParseMailHeader(r io.Reader) (mail.Header, io.Reader, error) { var headerBuf bytes.Buffer br := bufio.NewReader(r)@@ -120,7 +120,7 @@ }
// Reconstruct the exact stream: header bytes first, then remaining body bytes in br fullReader := io.MultiReader(&headerBuf, br) - return msg, fullReader, nil + return msg.Header, fullReader, nil } // BlobReader decrypts and unzips the stored blob
@@ -12,6 +12,8 @@ "net/http"
"strings" "time" + "postern/internal/model" + "go.starlark.net/starlark" )@@ -248,7 +250,7 @@ return rb, nil
} // rbSend executes the HTTP request -func (rb *RequestBuilder) rbSend(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { +func (rb *RequestBuilder) rbSend(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (_ starlark.Value, err error) { ctx, ok := thread.Local("context").(context.Context) if !ok || ctx == nil { ctx = context.Background()@@ -278,7 +280,7 @@ resp, err := rb.client.Do(req)
if err != nil { return nil, fmt.Errorf("http request failed: %s", err) } - defer resp.Body.Close() + defer model.Wrap(resp.Body.Close, &err, "rbSend closing response body") body, err := io.ReadAll(resp.Body) if err != nil {@@ -350,7 +352,7 @@ }
// rJson parses the response body as JSON func (r *Response) rJson(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - var v interface{} + var v any if err := json.Unmarshal(r.bodyBytes, &v); err != nil { return nil, fmt.Errorf("response body is not valid JSON: %s", err) }
@@ -3,6 +3,7 @@
import ( "bytes" "encoding/json" + "log" "net/http" "net/http/httptest" "os"@@ -37,7 +38,12 @@ if err != nil {
t.Fatal(err) } - defer posternDB.Close() + defer func(posternDB *db.DB) { + err := posternDB.Close() + if err != nil { + log.Fatal(err) + } + }(posternDB) // Create a test server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {@@ -45,20 +51,20 @@ // Check for expected headers
if r.Header.Get("Authorization") == "Bearer secret" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"key": "value"}`)) + _, _ = w.Write([]byte(`{"key": "value"}`)) return } if r.Header.Get("Authorization") == "Basic dXNlcjpwYXNz" { w.Header().Set("X-Test", "abc") w.WriteHeader(http.StatusOK) - w.Write([]byte("success")) + _, _ = w.Write([]byte("success")) return } if r.URL.Path == "/error" { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Internal Server Error")) + _, _ = w.Write([]byte("Internal Server Error")) return }@@ -68,12 +74,12 @@ // This will be caught by the 50ms timeout in the test
// Note: This is a test to ensure timeout works properly // We'll check for this in the timeout test case w.WriteHeader(http.StatusOK) - w.Write([]byte("delayed response")) + _, _ = w.Write([]byte("delayed response")) return } w.WriteHeader(http.StatusOK) - w.Write([]byte("ok")) + _, _ = w.Write([]byte("ok")) })) defer server.Close()
@@ -18,7 +18,12 @@ if err != nil {
t.Fatal(err) } - defer posternDB.Close() + defer func(posternDB *db.DB) { + err := posternDB.Close() + if err != nil { + t.Fatal(err) + } + }(posternDB) policy := ` def init():
@@ -2,59 +2,290 @@ package policy
import ( "errors" + "fmt" + "io" + "mime" "net/mail" + "strings" + "sync" + "postern/internal/model" + "postern/internal/persistence" + + "github.com/emersion/go-message" + _ "github.com/emersion/go-message/charset" // Importing package automatically sets message.CharsetReader "go.starlark.net/starlark" ) -type MessageContext struct { - HeaderFrom Address - HeaderTo Address - Subject string - Headers mail.Header +type Message struct { + OpenStream func() (io.ReadCloser, error) Size int64 + + bodyOnce sync.Once + bodyErr error + body []byte + + headerOnce sync.Once + headerErr error + header mail.Header + + attachmentsOnce sync.Once + attachmentsErr error + textBody string + htmlBody string + attachments []Attachment } -func (m MessageContext) String() string { return "<MessageContext>" } -func (m MessageContext) Type() string { return "MessageContext" } -func (m MessageContext) Freeze() { - m.HeaderFrom.Freeze() - m.HeaderTo.Freeze() -} -func (m MessageContext) Truth() starlark.Bool { return true } -func (m MessageContext) Hash() (uint32, error) { +func (m *Message) String() string { return "<MessageContext>" } +func (m *Message) Type() string { return "MessageContext" } +func (m *Message) Freeze() {} +func (m *Message) Truth() starlark.Bool { return true } +func (m *Message) Hash() (uint32, error) { return 0, errors.New("unhashable") } -func (m MessageContext) Attr(name string) (starlark.Value, error) { +func (m *Message) Attr(name string) (starlark.Value, error) { switch name { case "header_from": - return m.HeaderFrom, nil + return m.HeaderFrom() case "header_to": - return m.HeaderTo, nil + return m.HeaderTo() case "subject": - return starlark.String(m.Subject), nil + return m.Subject() case "size": return starlark.MakeInt64(m.Size), nil + case "body": + return m.Body() + case "attachments": + return m.Attachments() + case "text_body": + return m.TextBody() + case "html_body": + return m.HtmlBody() } return nil, nil } -func (m MessageContext) AttrNames() []string { - return []string{"header_from", "header_to", "subject", "size"} +func (m *Message) AttrNames() []string { + return []string{"header_from", "header_to", "subject", "size", "body", "attachments", "text_body", "html_body"} } -// MessageContextFromEnvelope builds a policy.MessageContext from the envelope -// fields of a stored message. The header_from address is parsed into an -// Address; if parsing fails the raw string is preserved. -func MessageContextFromEnvelope(from, subject string, size int64) MessageContext { - fromAddr, err := ParseAddress(from) - if err != nil || from == "" { - fromAddr = Address{Raw: from, Address: from} +func (m *Message) ensureHeaders() (_ mail.Header, err error) { + m.headerOnce.Do(func() { + r, err := m.OpenStream() + if err != nil { + m.headerErr = err + return + } + defer model.Wrap(r.Close, &err, "ensureHeaders closing file stream") + m.header, _, m.headerErr = persistence.ParseMailHeader(r) + }) + return m.header, m.headerErr +} + +func (m *Message) HeaderFrom() (Address, error) { + h, err := m.ensureHeaders() + if err != nil { + return Address{}, err + } + fromAddr, err := ParseAddress(h.Get("From")) + if err != nil { + return Address{}, model.ErrMissingFromHeader + } + return fromAddr, nil +} + +func (m *Message) HeaderTo() (Address, error) { + h, err := m.ensureHeaders() + if err != nil { + return Address{}, err + } + toAddr, err := ParseAddress(h.Get("To")) + if err != nil { + return Address{}, errors.New("missing To header") + } + return toAddr, nil +} + +func (m *Message) Subject() (starlark.Value, error) { + h, err := m.ensureHeaders() + if err != nil { + return nil, err + } + return starlark.String(h.Get("Subject")), nil +} + +func (m *Message) Body() (_ starlark.Value, err error) { + m.bodyOnce.Do(func() { + r, err := m.OpenStream() + if err != nil { + m.bodyErr = err + return + } + defer model.Wrap(r.Close, &err, "body closing file stream") + m.body, m.bodyErr = io.ReadAll(r) + }) + return starlark.Bytes(m.body), m.bodyErr +} + +// Attachments returns a list of attachment objects extracted from the message. +func (m *Message) Attachments() (starlark.Value, error) { + _, _, attachments, err := m.messageParts() + if err != nil { + return nil, err + } + elems := make([]starlark.Value, len(attachments)) + for i, a := range attachments { + elems[i] = a + } + return starlark.NewList(elems), nil +} + +// TextBody returns the plain text body of the message. +func (m *Message) TextBody() (starlark.Value, error) { + textBody, _, _, err := m.messageParts() + if err != nil { + return nil, err + } + return starlark.String(textBody), nil +} + +// HtmlBody returns the HTML body of the message. +func (m *Message) HtmlBody() (starlark.Value, error) { + _, htmlBody, _, err := m.messageParts() + if err != nil { + return nil, err } - return MessageContext{ - HeaderFrom: fromAddr, - Subject: subject, - Size: size, + return starlark.String(htmlBody), nil +} + +// Attachment represents an extracted binary attachment or media file. +type Attachment struct { + Filename string + ContentType string + Data []byte +} + +func (a Attachment) String() string { return "<Attachment>" } +func (a Attachment) Type() string { return "Attachment" } +func (a Attachment) Freeze() {} +func (a Attachment) Truth() starlark.Bool { return true } +func (a Attachment) Hash() (uint32, error) { + return 0, errors.New("unhashable") +} + +func (a Attachment) Attr(name string) (starlark.Value, error) { + switch name { + case "filename": + return starlark.String(a.Filename), nil + case "content_type": + return starlark.String(a.ContentType), nil + case "data": + return starlark.Bytes(a.Data), nil } + return nil, nil +} + +func (a Attachment) AttrNames() []string { + return []string{"filename", "content_type", "data"} +} + +// messageParts returns the plain text body, html body and attachments. +func (m *Message) messageParts() (_ string, _ string, _ []Attachment, err error) { + m.attachmentsOnce.Do(func() { + r, err := m.OpenStream() + if err != nil { + m.attachmentsErr = err + return + } + defer model.Wrap(r.Close, &err, "messageParts closing file stream") + + msg, err := message.Read(r) + if err != nil { + m.attachmentsErr = err + return + } + + // Walk recursively traverses all nested multipart containers (mixed, alternative, related, etc.) + m.attachmentsErr = msg.Walk(func(path []int, entity *message.Entity, err error) error { + if err != nil { + return err + } + + // Skip top-level or sub-container multipart headers; we only want leaf nodes with content + if entity.MultipartReader() != nil { + return nil + } + + // Parse Content-Type header + contentType, params, err := entity.Header.ContentType() + if err != nil { + contentType = "text/plain" + } + mediaType, _, _ := mime.ParseMediaType(contentType) + + // Parse Content-Disposition header + disposition, dispParams, _ := entity.Header.ContentDisposition() + + // Extract filename (check disposition first, fallback to Content-Type parameter) + filename := dispParams["filename"] + if filename == "" { + filename = params["name"] + } + + // Read entity body payload (go-message automatically handles base64, quoted-printable, and charsets) + bodyBytes, err := io.ReadAll(entity.Body) + if err != nil { + return fmt.Errorf("failed to read entity body: %w", err) + } + + // 1. Explicit Attachments (Content-Disposition: attachment) + if disposition == "attachment" { + m.attachments = append(m.attachments, Attachment{ + Filename: filename, + ContentType: mediaType, + Data: bodyBytes, + }) + return nil + } + + // 2. Text / HTML / Binary categorization + switch mediaType { + case "text/plain": + if m.textBody == "" { + m.textBody = string(bodyBytes) + } else { + m.textBody += "\n" + string(bodyBytes) + } + + case "text/html": + if m.htmlBody == "" { + m.htmlBody = string(bodyBytes) + } else { + m.htmlBody += "\n" + string(bodyBytes) + } + + default: + // Non-text payloads (PDFs, images, audio, application binaries, inline images, etc.) + if isBinaryType(mediaType) || disposition == "inline" { + m.attachments = append(m.attachments, Attachment{ + Filename: filename, + ContentType: mediaType, + Data: bodyBytes, + }) + } + } + + return nil + }) + }) + + return m.textBody, m.htmlBody, m.attachments, m.attachmentsErr +} + +func isBinaryType(mediaType string) bool { + return strings.HasPrefix(mediaType, "image/") || + strings.HasPrefix(mediaType, "application/") || + strings.HasPrefix(mediaType, "audio/") || + strings.HasPrefix(mediaType, "video/") }
@@ -0,0 +1,288 @@
+package policy + +import ( + "bytes" + "fmt" + "io" + "log" + "os" + "path" + "slices" + "strings" + "testing" + + "postern/internal/db" + + "github.com/emersion/go-message/mail" + "go.starlark.net/starlark" +) + +func TestExampleModule(t *testing.T) { + posternDB, err := db.OpenDB(&db.Config{ + DBPath: path.Join(t.TempDir(), "meta.db"), + }) + if err != nil { + t.Fatal(err) + } + + defer func(posternDB *db.DB) { + err := posternDB.Close() + if err != nil { + log.Fatal(err) + } + }(posternDB) + + policy := ` +def init(): + return None + +def example(user, msg, source_folder, dest_folder): + filenames = [] + pdf_data = b"" + bin_data = "" + for a in msg.attachments: + filenames.append(a.filename) + if a.filename == "sample.pdf": + pdf_data = a.data + elif a.filename == "data.bin": + bin_data = a.data + return { + "subject": msg.subject, + "from": msg.header_from.address, + "to": msg.header_to.address, + "text_body": msg.text_body, + "html_body": msg.html_body, + "attachment_count": len(msg.attachments), + "filenames": filenames, + "pdf_data": pdf_data, + "bin_data": bin_data, + } +` + + policyFile := path.Join(t.TempDir(), "policy.star") + err = os.WriteFile(policyFile, []byte(policy), 0644) + if err != nil { + t.Fatal(err) + } + + e, err := NewEngine(policyFile, posternDB) + if err != nil { + t.Fatal(err) + } + + hookVal, ok := e.globals["example"] + if !ok { + t.Fatal("example not found in engine globals") + } + + msgBody, err := testMessage.assemble() + if err != nil { + t.Fatal(err) + } + + m := Message{ + Size: 0, + OpenStream: func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(msgBody)), nil + }, + } + + k, v := starlark.String("key"), starlark.MakeInt(1) + result, err := starlark.Call(e.thread, hookVal, starlark.Tuple{k, &m, k, v}, nil) + if err != nil { + t.Fatal(err) + } + + // Assert the returned dict contains the expected values + dict, ok := result.(*starlark.Dict) + if !ok { + t.Fatalf("expected dict result, got %s", result.Type()) + } + + // Helper to get bytes value from dict + dictBytes := func(dict *starlark.Dict, key string) []byte { + v, _, err := dict.Get(starlark.String(key)) + if err != nil || v == starlark.None { + return nil + } + if b, ok := v.(starlark.Bytes); ok { + return []byte(b) + } + return nil + } + + // Helper to get list value from dict + dictList := func(dict *starlark.Dict, key string) []string { + v, _, err := dict.Get(starlark.String(key)) + if err != nil || v == starlark.None { + return nil + } + if l, ok := v.(*starlark.List); ok { + r := make([]string, l.Len()) + for i := 0; i < l.Len(); i++ { + item := l.Index(i) + if s, ok := item.(starlark.String); ok { + r[i] = string(s) + } + } + return r + } + return nil + } + + // Validate each field + if got := dictString(dict, "subject"); got != testMessage.subject { + t.Errorf("subject = %q, want %q", got, testMessage.subject) + } + + if got := dictString(dict, "from"); got != testMessage.fromAddress { + t.Errorf("from = %q, want %q", got, testMessage.fromAddress) + } + + if got := dictString(dict, "to"); got != testMessage.toAddress { + t.Errorf("to = %q, want %q", got, testMessage.toAddress) + } + + expectedTextBody := strings.ReplaceAll(testMessage.textBody, "\n", "\r\n") + if got := dictString(dict, "text_body"); got != expectedTextBody { + t.Errorf("text_body = %q, want %q", got, expectedTextBody) + } + + if got := dictString(dict, "html_body"); got != testMessage.htmlBody { + t.Errorf("html_body = %q, want %q", got, testMessage.htmlBody) + } + + if got := dictInt(dict, "attachment_count"); got != len(testMessage.attachments) { + t.Errorf("attachment_count = %d, want %d", got, len(testMessage.attachments)) + } + + filenames := dictList(dict, "filenames") + if len(filenames) != len(testMessage.attachments) { + t.Errorf("filenames length = %d, want %d", len(filenames), len(testMessage.attachments)) + } + + for _, f := range testMessage.attachments { + if !slices.Contains(filenames, f.filename) { + t.Errorf("filenames = %q, want %q", filenames, f.filename) + } + } + + if got := dictBytes(dict, "pdf_data"); string(got) != string(testMessage.attachments[0].data) { + t.Errorf("pdf_data = %v, want %v", got, testMessage.attachments[0].data) + } + + if got := dictBytes(dict, "bin_data"); string(got) != string(testMessage.attachments[1].data) { + t.Errorf("bin_data = %v, want %v", got, testMessage.attachments[1].data) + } +} + +type attachmentFixture struct { + filename string + contentType string + data []byte +} + +type messageFixture struct { + subject string + fromHeader string + fromAddress string + toAddress string + textBody string + htmlBody string + attachments []attachmentFixture +} + +var testMessage = messageFixture{ + subject: "Test Subject", + fromHeader: `"noreply@example.com" <noreply@example.com>`, + fromAddress: "noreply@example.com", + toAddress: "postern@example.com", + textBody: "Hello,\n\nThis is the plain text body of the email.", + htmlBody: "<html><body><h1>Hello</h1><p>This is the <b>HTML</b> body of the email.</p></body></html>", + attachments: []attachmentFixture{ + {filename: "sample.pdf", contentType: "application/pdf", data: []byte("%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF")}, + {filename: "data.bin", contentType: "application/octet-stream", data: []byte{0x7F, 0x45, 0x4C, 0x46, 0x02, 0x01, 0x01, 0x00, 0x00}}, + }, +} + +func (f messageFixture) assemble() ([]byte, error) { + headers := map[string][]string{ + "Delivered-To": {"postern@example.com"}, + "Received": {"by mx1.p25.dev with ESMTP id b9d16c6997d311f18ac19200070e8571; Fri, 14 Aug 2026 11:31:44 +0000"}, + "Date": {"Fri, 14 Aug 2026 11:31:43 +0000 (UTC)"}, + "From": {f.fromHeader}, + "To": {f.toAddress}, + "Message-ID": {"<8555705.115511.1786707553174@example.com>"}, + "Subject": {f.subject}, + } + + var buf bytes.Buffer + + // Create top-level writer (defaults to multipart/mixed when inline/attachment parts are added) + mw, err := mail.CreateWriter(&buf, mail.HeaderFromMap(headers)) + if err != nil { + return nil, fmt.Errorf("failed to create mail writer: %w", err) + } + + inlineWriter, err := mw.CreateInline() + if err != nil { + return nil, fmt.Errorf("failed to create inline writer: %w", err) + } + + // Plain Text Part + var textHeader mail.InlineHeader + textHeader.SetContentType("text/plain", map[string]string{"charset": "utf-8"}) + textWriter, err := inlineWriter.CreatePart(textHeader) + if err != nil { + return nil, fmt.Errorf("failed to create text part: %w", err) + } + if _, err := textWriter.Write([]byte(f.textBody)); err != nil { + return nil, err + } + if err := textWriter.Close(); err != nil { + return nil, err + } + + // HTML Part + var htmlHeader mail.InlineHeader + htmlHeader.SetContentType("text/html", map[string]string{"charset": "utf-8"}) + htmlWriter, err := inlineWriter.CreatePart(htmlHeader) + if err != nil { + return nil, fmt.Errorf("failed to create HTML part: %w", err) + } + if _, err := htmlWriter.Write([]byte(f.htmlBody)); err != nil { + return nil, err + } + if err := htmlWriter.Close(); err != nil { + return nil, err + } + + // Close alternative container + if err := inlineWriter.Close(); err != nil { + return nil, err + } + + // Attachments + for _, att := range f.attachments { + var header mail.AttachmentHeader + header.SetContentType(att.contentType, nil) + header.SetFilename(att.filename) + writer, err := mw.CreateAttachment(header) + if err != nil { + return nil, fmt.Errorf("failed to create attachment %s: %w", att.filename, err) + } + if _, err := writer.Write(att.data); err != nil { + return nil, err + } + if err := writer.Close(); err != nil { + return nil, err + } + } + + // Finalize email body formatting and boundaries + if err := mw.Close(); err != nil { + return nil, err + } + + return buf.Bytes(), nil +}
@@ -100,7 +100,7 @@ }
return engine, err } -func (e *Engine) OnMessageSubmit(user User, msg MessageContext) (RelayConfig, error) { +func (e *Engine) OnMessageSubmit(user User, msg *Message) (RelayConfig, error) { hookVal, ok := e.globals["on_message_submit"] if !ok { return RelayConfig{}, nil@@ -129,7 +129,7 @@ }
return relay, nil } -func (e *Engine) OnMessageDeliver(conn Conn, user User, msg MessageContext) (string, error) { +func (e *Engine) OnMessageDeliver(conn Conn, user User, msg *Message) (string, error) { defaultMailbox := "INBOX" hookVal, ok := e.globals["on_message_deliver"] if !ok {@@ -151,7 +151,7 @@
return mailbox, nil } -func (e *Engine) OnMessageMoved(user User, msg MessageContext, sourceFolder, destFolder string) error { +func (e *Engine) OnMessageMoved(user User, msg *Message, sourceFolder, destFolder string) error { hookVal, ok := e.globals["on_message_moved"] if !ok { return nil
@@ -163,14 +163,17 @@ return []string{"get_decision", "route_to"}
} func (g *UserGatekeeper) getDecision(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, _ []starlark.Tuple) (starlark.Value, error) { - var msg MessageContext + var msg *Message if err := starlark.UnpackPositionalArgs("get_decision", args, nil, 1, &msg); err != nil { return nil, err } - fromAddress := msg.HeaderFrom.Address + fromAddress, err := msg.HeaderFrom() + if err != nil { + return nil, err + } slog.Info("retrieving gatekeeper decision", "module", "get_decision", "user", g.user.Name, "from_address", fromAddress) - destination, err := g.db.GetGatekeepDecision(context.Background(), g.user.ID, fromAddress) + destination, err := g.db.GetGatekeepDecision(context.Background(), g.user.ID, fromAddress.Address) if err != nil { if errors.Is(err, sql.ErrNoRows) { slog.Info("gatekeeper decision not found", "module", "get_decision")
@@ -233,9 +233,14 @@ return errInternalServerError
} // Call policy hook to determine relay config - msgCtx := policy.MessageContextFromEnvelope(s.mailFrom, "", int64(len(signed))) + msg := &policy.Message{ + OpenStream: func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(message)), nil + }, + Size: int64(len(message)), + } user := policy.UserToStarlark(s.authenticatedUser, s.db) - relayConfig, err := s.policyEngine.OnMessageSubmit(user, msgCtx) + relayConfig, err := s.policyEngine.OnMessageSubmit(user, msg) if err != nil { s.logger.ErrorContext(ctx, "policy on_message_submit", "error", err) return errInternalServerError