Skip to content

Commit 965916c

Browse files
v0.3.1 — The Locksmith Update (#73)
* refactor: extract shared IMAP package, consolidate duplicated dial/search logic Closes #37, closes #38 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use slog.Warn instead of log.Printf in email/delete Closes #39 * fix: match Peek field in FindBodySection calls to fetch options FindBodySection uses exact struct comparison. The fetch used Peek: true but the lookup used the default Peek: false, causing a mismatch on multipart messages. Closes #40 * security: enforce TLS for SMTP in email/send connector Replace smtp.SendMail (which silently falls back to plaintext) with explicit TLS: port 465 uses implicit TLS, all other ports use STARTTLS and fail if the server does not support it. Closes #34 * security: add tmpfs /tmp mount to Docker containers Adds a tmpfs mount for /tmp with noexec,nosuid flags. ReadOnlyRootfs is not enabled by default as it breaks browser/run containers that need npm/pip install. Closes #41 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add AdvanceExecution godoc caveat for hot-reload behavior Closes #43 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: document TypeScript limitations in browser/run connector Closes #42 * docs: document browser/run script field trust boundary Closes #35 * docs: document error message infrastructure leakage risk Closes #44 * docs: document TLS enforcement for email/send connector * chore: remove npm wrapper references Closes #72 * fix: address CodeRabbit review findings - Add /tmp to blockedMountTargets to prevent user mount conflicts with tmpfs - Use context-aware dialing (DialContext) in SMTP connector for timeout respect - Fix markdown blank line before closing admonition fence Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ci: upgrade site build to Node 24 The runner's default Node 20 is no longer supported. Add explicit setup-node step with Node 24 before setup-bun. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ci: remove site build workflow Site deploys via Cloudflare Workers directly. The CI build step is redundant. Will re-add with lint/test jobs when those are configured. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update examples page path for monorepo structure The examples directory moved from root examples/ to packages/site/src/content/examples/ during the monorepo conversion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review feedback - Validate username/password in ParseCredentialMap for early, clear errors - Fix FindBodySection Peek mismatch in email_receive.go messageBufferToMap (same class of bug as #40, propagate Peek value from fetch options) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use process.cwd() for examples path in site build import.meta.url resolves relative to the built output directory during Astro's static build, not the source tree. Use process.cwd() which is always packages/site/ during build. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 746aebd commit 965916c

20 files changed

Lines changed: 889 additions & 205 deletions

File tree

.github/workflows/site-ci.yml

Lines changed: 0 additions & 24 deletions
This file was deleted.

go.work.sum

Lines changed: 356 additions & 0 deletions
Large diffs are not rendered by default.

marketing/growth-strategy.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,6 @@ Every additional installation method removes friction. Prioritize by audience ov
146146
| Helm chart | P0 (done) | None | K8s is the deployment target |
147147
| Binary releases (GitHub Releases) | P0 | Low | goreleaser config, covers Linux/macOS/Windows |
148148
| Homebrew tap | P1 | Low | macOS developers, easy to maintain a tap |
149-
| npm wrapper | P2 | Low | Enables `npx mantle` for polyglot teams |
150149
| Nix package | P3 | Medium | Nix users are vocal advocates, good word-of-mouth |
151150
| AUR (Arch Linux) | P3 | Low | Small but passionate community |
152151

packages/engine/internal/connector/docker.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import (
2323
)
2424

2525
// blockedMountTargets lists container paths that are never allowed as mount targets.
26-
var blockedMountTargets = []string{"/proc", "/sys", "/dev", "/var/run/docker.sock"}
26+
var blockedMountTargets = []string{"/proc", "/sys", "/dev", "/var/run/docker.sock", "/tmp"}
2727

2828
// DockerRunConnector runs a container to completion and captures output.
2929
type DockerRunConnector struct{}
@@ -294,6 +294,9 @@ func (c *DockerRunConnector) Execute(ctx context.Context, params map[string]any)
294294
NetworkMode: container.NetworkMode(cfg.Network),
295295
CapDrop: []string{"ALL"},
296296
SecurityOpt: []string{"no-new-privileges"},
297+
Tmpfs: map[string]string{
298+
"/tmp": "rw,noexec,nosuid,size=256m",
299+
},
297300
Resources: container.Resources{
298301
PidsLimit: int64Ptr(512),
299302
},

packages/engine/internal/connector/email.go

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package connector
22

33
import (
44
"context"
5+
"crypto/tls"
56
"fmt"
67
"net"
78
"net/smtp"
@@ -63,14 +64,80 @@ func (c *EmailSendConnector) Execute(ctx context.Context, params map[string]any)
6364
msg := buildMessage(from, recipients, subject, body, isHTML)
6465

6566
addr := net.JoinHostPort(host, port)
67+
tlsCfg := &tls.Config{ServerName: host} //nolint:gosec // MinVersion not set; Go's default is TLS 1.2+
68+
dialer := &net.Dialer{}
69+
70+
var client *smtp.Client
71+
72+
if port == "465" {
73+
// Implicit TLS (SMTPS): dial with TLS directly, respecting context deadline.
74+
conn, dialErr := dialer.DialContext(ctx, "tcp", addr)
75+
if dialErr != nil {
76+
return nil, fmt.Errorf("email/send: dial %s: %w", addr, dialErr)
77+
}
78+
tlsConn := tls.Client(conn, tlsCfg)
79+
if err := tlsConn.HandshakeContext(ctx); err != nil {
80+
_ = conn.Close()
81+
return nil, fmt.Errorf("email/send: TLS handshake with %s: %w", addr, err)
82+
}
83+
c, newErr := smtp.NewClient(tlsConn, host)
84+
if newErr != nil {
85+
_ = tlsConn.Close()
86+
return nil, fmt.Errorf("email/send: SMTP handshake: %w", newErr)
87+
}
88+
client = c
89+
} else {
90+
// Explicit TLS (STARTTLS): connect plaintext, then upgrade.
91+
conn, dialErr := dialer.DialContext(ctx, "tcp", addr)
92+
if dialErr != nil {
93+
return nil, fmt.Errorf("email/send: dial %s: %w", addr, dialErr)
94+
}
95+
c, newErr := smtp.NewClient(conn, host)
96+
if newErr != nil {
97+
_ = conn.Close()
98+
return nil, fmt.Errorf("email/send: SMTP handshake: %w", newErr)
99+
}
100+
ok, _ := c.Extension("STARTTLS")
101+
if !ok {
102+
_ = c.Close()
103+
return nil, fmt.Errorf("email/send: server %s does not support STARTTLS; refusing to send credentials in plaintext", host)
104+
}
105+
if startErr := c.StartTLS(tlsCfg); startErr != nil {
106+
_ = c.Close()
107+
return nil, fmt.Errorf("email/send: STARTTLS negotiation with %s: %w", host, startErr)
108+
}
109+
client = c
110+
}
111+
defer client.Close() //nolint:errcheck
66112

67-
var auth smtp.Auth
68113
if username != "" {
69-
auth = smtp.PlainAuth("", username, password, host)
114+
auth := smtp.PlainAuth("", username, password, host)
115+
if authErr := client.Auth(auth); authErr != nil {
116+
return nil, fmt.Errorf("email/send: SMTP auth: %w", authErr)
117+
}
70118
}
71119

72-
if err := smtp.SendMail(addr, auth, from, recipients, msg); err != nil {
73-
return nil, fmt.Errorf("email/send: %w", err)
120+
if err := client.Mail(from); err != nil {
121+
return nil, fmt.Errorf("email/send: MAIL FROM: %w", err)
122+
}
123+
for _, rcpt := range recipients {
124+
if err := client.Rcpt(rcpt); err != nil {
125+
return nil, fmt.Errorf("email/send: RCPT TO <%s>: %w", rcpt, err)
126+
}
127+
}
128+
wc, err := client.Data()
129+
if err != nil {
130+
return nil, fmt.Errorf("email/send: DATA command: %w", err)
131+
}
132+
if _, err = wc.Write(msg); err != nil {
133+
_ = wc.Close()
134+
return nil, fmt.Errorf("email/send: writing message body: %w", err)
135+
}
136+
if err = wc.Close(); err != nil {
137+
return nil, fmt.Errorf("email/send: closing DATA writer: %w", err)
138+
}
139+
if err = client.Quit(); err != nil {
140+
return nil, fmt.Errorf("email/send: QUIT: %w", err)
74141
}
75142

76143
return map[string]any{

packages/engine/internal/connector/email_delete.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package connector
33
import (
44
"context"
55
"fmt"
6-
"log"
6+
"log/slog"
77

88
"github.com/emersion/go-imap/v2"
99
)
@@ -60,7 +60,10 @@ func (c *EmailDeleteConnector) Execute(ctx context.Context, params map[string]an
6060
// Warning: EXPUNGE removes ALL messages currently marked \Deleted in the
6161
// selected mailbox, not just the targeted UID. This may delete additional
6262
// messages if other messages were marked \Deleted concurrently.
63-
log.Printf("email/delete: UIDExpunge not supported (uid=%d), falling back to EXPUNGE which removes ALL \\Deleted messages: %v", uid, err)
63+
slog.Warn("email/delete: UIDExpunge not supported, falling back to EXPUNGE which removes ALL \\Deleted messages",
64+
"uid", uid,
65+
"error", err,
66+
)
6467
if err2 := client.Expunge().Close(); err2 != nil {
6568
return nil, fmt.Errorf("email/delete: expunging message: %w", err2)
6669
}

packages/engine/internal/connector/email_move_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"net/smtp"
77
"testing"
88
"time"
9+
10+
imaplib "github.com/dvflw/mantle/internal/imap"
911
)
1012

1113
// TestEmailMoveParamValidation verifies that required parameters are enforced.
@@ -131,7 +133,7 @@ func TestEmailMove_MovesMessage(t *testing.T) {
131133
// Used in tests to ensure the target folder exists before moving messages.
132134
func createTargetFolder(t *testing.T, host, imapPort, username, password, folder string) {
133135
t.Helper()
134-
cfg := &imapConfig{
136+
cfg := &imaplib.Config{
135137
Host: host,
136138
Port: imapPort,
137139
Username: username,

packages/engine/internal/connector/email_receive.go

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010
"time"
1111

12+
imaplib "github.com/dvflw/mantle/internal/imap"
1213
"github.com/emersion/go-imap/v2"
1314
"github.com/emersion/go-imap/v2/imapclient"
1415
)
@@ -74,7 +75,7 @@ func (c *EmailReceiveConnector) Execute(ctx context.Context, params map[string]a
7475
}
7576

7677
// Build search criteria based on the filter.
77-
criteria := buildSearchCriteria(filter)
78+
criteria := imaplib.BuildSearchCriteria(filter)
7879

7980
// Use UID search so we can build a UIDSet for Fetch.
8081
searchData, err := client.UIDSearch(criteria, nil).Wait()
@@ -115,7 +116,7 @@ func (c *EmailReceiveConnector) Execute(ctx context.Context, params map[string]a
115116
},
116117
}
117118

118-
messages, err := fetchMessages(client, uidSet, fetchOptions)
119+
messages, err := fetchMessages(client, uidSet, fetchOptions, !markSeen)
119120
if err != nil {
120121
return nil, fmt.Errorf("email/receive: fetch failed: %w", err)
121122
}
@@ -140,30 +141,9 @@ func (c *EmailReceiveConnector) Execute(ctx context.Context, params map[string]a
140141
}, nil
141142
}
142143

143-
// buildSearchCriteria constructs IMAP search criteria for the given filter name.
144-
func buildSearchCriteria(filter string) *imap.SearchCriteria {
145-
switch filter {
146-
case "unseen":
147-
return &imap.SearchCriteria{
148-
NotFlag: []imap.Flag{imap.FlagSeen},
149-
}
150-
case "flagged":
151-
return &imap.SearchCriteria{
152-
Flag: []imap.Flag{imap.FlagFlagged},
153-
}
154-
case "recent":
155-
// \Recent is an IMAP4rev1 flag; use string literal.
156-
return &imap.SearchCriteria{
157-
Flag: []imap.Flag{imap.Flag(`\Recent`)},
158-
}
159-
default: // "all"
160-
return &imap.SearchCriteria{}
161-
}
162-
}
163-
164144
// fetchMessages executes the FETCH command and converts the results into the
165145
// output map format expected by the connector.
166-
func fetchMessages(client *imapclient.Client, uidSet imap.UIDSet, opts *imap.FetchOptions) ([]map[string]any, error) {
146+
func fetchMessages(client *imapclient.Client, uidSet imap.UIDSet, opts *imap.FetchOptions, peek bool) ([]map[string]any, error) {
167147
cmd := client.Fetch(uidSet, opts)
168148

169149
var out []map[string]any
@@ -179,7 +159,7 @@ func fetchMessages(client *imapclient.Client, uidSet imap.UIDSet, opts *imap.Fet
179159
return nil, err
180160
}
181161

182-
msg := messageBufferToMap(buf)
162+
msg := messageBufferToMap(buf, peek)
183163
out = append(out, msg)
184164
}
185165

@@ -191,7 +171,9 @@ func fetchMessages(client *imapclient.Client, uidSet imap.UIDSet, opts *imap.Fet
191171
}
192172

193173
// messageBufferToMap converts a FetchMessageBuffer into the output map format.
194-
func messageBufferToMap(buf *imapclient.FetchMessageBuffer) map[string]any {
174+
// peek must match the Peek value used in the FetchItemBodySection so that
175+
// FindBodySection can locate the correct section by exact struct comparison.
176+
func messageBufferToMap(buf *imapclient.FetchMessageBuffer, peek bool) map[string]any {
195177
msg := map[string]any{
196178
"message_id": "",
197179
"from": "",
@@ -230,7 +212,7 @@ func messageBufferToMap(buf *imapclient.FetchMessageBuffer) map[string]any {
230212

231213
// Populate body text from the TEXT body section, and extract headers from
232214
// the raw bytes if a full body section is present.
233-
bodySection := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierText}
215+
bodySection := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierText, Peek: peek}
234216
rawText := buf.FindBodySection(bodySection)
235217
if rawText != nil {
236218
msg["body"] = extractBodyText(rawText)

packages/engine/internal/connector/email_receive_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"testing"
99
"time"
1010

11+
imaplib "github.com/dvflw/mantle/internal/imap"
1112
"github.com/testcontainers/testcontainers-go"
1213
"github.com/testcontainers/testcontainers-go/wait"
1314
)
@@ -90,7 +91,7 @@ func TestEmailReceiveFilterCriteria(t *testing.T) {
9091

9192
for _, tt := range tests {
9293
t.Run(tt.filter, func(t *testing.T) {
93-
c := buildSearchCriteria(tt.filter)
94+
c := imaplib.BuildSearchCriteria(tt.filter)
9495
if c == nil {
9596
t.Fatal("buildSearchCriteria returned nil")
9697
}
Lines changed: 8 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,18 @@
11
package connector
22

33
import (
4-
"crypto/tls"
5-
"fmt"
6-
"net"
7-
4+
imaplib "github.com/dvflw/mantle/internal/imap"
85
"github.com/emersion/go-imap/v2/imapclient"
96
)
107

11-
// imapConfig holds IMAP connection parameters extracted from a credential.
12-
type imapConfig struct {
13-
Host string
14-
Port string
15-
Username string
16-
Password string
17-
UseTLS bool
18-
}
19-
20-
// parseIMAPCredential extracts IMAP config from a credential map.
21-
func parseIMAPCredential(params map[string]any) (*imapConfig, error) {
22-
cred, ok := params["_credential"].(map[string]string)
23-
if !ok {
24-
return nil, fmt.Errorf("IMAP credential is required")
25-
}
26-
cfg := &imapConfig{
27-
Host: cred["host"],
28-
Port: cred["port"],
29-
Username: cred["username"],
30-
Password: cred["password"],
31-
}
32-
if cfg.Host == "" {
33-
return nil, fmt.Errorf("IMAP credential must include 'host'")
34-
}
35-
if cfg.Port == "" {
36-
cfg.Port = "993"
37-
}
38-
if useTLS, ok := cred["use_tls"]; ok && useTLS == "false" {
39-
cfg.UseTLS = false
40-
} else {
41-
cfg.UseTLS = true
42-
}
43-
return cfg, nil
8+
// parseIMAPCredential extracts IMAP config from connector params.
9+
// It delegates to the shared imap package.
10+
func parseIMAPCredential(params map[string]any) (*imaplib.Config, error) {
11+
return imaplib.ParseCredential(params)
4412
}
4513

4614
// imapDial connects to an IMAP server and logs in.
47-
func imapDial(cfg *imapConfig) (*imapclient.Client, error) {
48-
addr := net.JoinHostPort(cfg.Host, cfg.Port)
49-
var client *imapclient.Client
50-
var err error
51-
if cfg.UseTLS {
52-
client, err = imapclient.DialTLS(addr, &imapclient.Options{
53-
TLSConfig: &tls.Config{
54-
ServerName: cfg.Host,
55-
MinVersion: tls.VersionTLS12,
56-
},
57-
})
58-
} else {
59-
client, err = imapclient.DialInsecure(addr, nil)
60-
}
61-
if err != nil {
62-
return nil, fmt.Errorf("connecting to IMAP server %s: %w", addr, err)
63-
}
64-
if err := client.Login(cfg.Username, cfg.Password).Wait(); err != nil {
65-
_ = client.Close()
66-
return nil, fmt.Errorf("IMAP login failed: %w", err)
67-
}
68-
return client, nil
15+
// It delegates to the shared imap package.
16+
func imapDial(cfg *imaplib.Config) (*imapclient.Client, error) {
17+
return imaplib.Dial(cfg)
6918
}

0 commit comments

Comments
 (0)