Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 0 additions & 24 deletions .github/workflows/site-ci.yml

This file was deleted.

356 changes: 356 additions & 0 deletions go.work.sum

Large diffs are not rendered by default.

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

Expand Down
5 changes: 4 additions & 1 deletion packages/engine/internal/connector/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import (
)

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

// DockerRunConnector runs a container to completion and captures output.
type DockerRunConnector struct{}
Expand Down Expand Up @@ -294,6 +294,9 @@ func (c *DockerRunConnector) Execute(ctx context.Context, params map[string]any)
NetworkMode: container.NetworkMode(cfg.Network),
CapDrop: []string{"ALL"},
SecurityOpt: []string{"no-new-privileges"},
Tmpfs: map[string]string{
"/tmp": "rw,noexec,nosuid,size=256m",
},
Resources: container.Resources{
PidsLimit: int64Ptr(512),
},
Expand Down
75 changes: 71 additions & 4 deletions packages/engine/internal/connector/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package connector

import (
"context"
"crypto/tls"
"fmt"
"net"
"net/smtp"
Expand Down Expand Up @@ -63,14 +64,80 @@ func (c *EmailSendConnector) Execute(ctx context.Context, params map[string]any)
msg := buildMessage(from, recipients, subject, body, isHTML)

addr := net.JoinHostPort(host, port)
tlsCfg := &tls.Config{ServerName: host} //nolint:gosec // MinVersion not set; Go's default is TLS 1.2+
dialer := &net.Dialer{}

var client *smtp.Client

if port == "465" {
// Implicit TLS (SMTPS): dial with TLS directly, respecting context deadline.
conn, dialErr := dialer.DialContext(ctx, "tcp", addr)
if dialErr != nil {
return nil, fmt.Errorf("email/send: dial %s: %w", addr, dialErr)
}
tlsConn := tls.Client(conn, tlsCfg)
if err := tlsConn.HandshakeContext(ctx); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("email/send: TLS handshake with %s: %w", addr, err)
}
c, newErr := smtp.NewClient(tlsConn, host)
if newErr != nil {
_ = tlsConn.Close()
return nil, fmt.Errorf("email/send: SMTP handshake: %w", newErr)
}
client = c
} else {
// Explicit TLS (STARTTLS): connect plaintext, then upgrade.
conn, dialErr := dialer.DialContext(ctx, "tcp", addr)
if dialErr != nil {
return nil, fmt.Errorf("email/send: dial %s: %w", addr, dialErr)
}
c, newErr := smtp.NewClient(conn, host)
if newErr != nil {
_ = conn.Close()
return nil, fmt.Errorf("email/send: SMTP handshake: %w", newErr)
}
ok, _ := c.Extension("STARTTLS")
if !ok {
_ = c.Close()
return nil, fmt.Errorf("email/send: server %s does not support STARTTLS; refusing to send credentials in plaintext", host)
}
if startErr := c.StartTLS(tlsCfg); startErr != nil {
_ = c.Close()
return nil, fmt.Errorf("email/send: STARTTLS negotiation with %s: %w", host, startErr)
}
client = c
}
defer client.Close() //nolint:errcheck

var auth smtp.Auth
if username != "" {
auth = smtp.PlainAuth("", username, password, host)
auth := smtp.PlainAuth("", username, password, host)
if authErr := client.Auth(auth); authErr != nil {
return nil, fmt.Errorf("email/send: SMTP auth: %w", authErr)
}
}

if err := smtp.SendMail(addr, auth, from, recipients, msg); err != nil {
return nil, fmt.Errorf("email/send: %w", err)
if err := client.Mail(from); err != nil {
return nil, fmt.Errorf("email/send: MAIL FROM: %w", err)
}
for _, rcpt := range recipients {
if err := client.Rcpt(rcpt); err != nil {
return nil, fmt.Errorf("email/send: RCPT TO <%s>: %w", rcpt, err)
}
}
wc, err := client.Data()
if err != nil {
return nil, fmt.Errorf("email/send: DATA command: %w", err)
}
if _, err = wc.Write(msg); err != nil {
_ = wc.Close()
return nil, fmt.Errorf("email/send: writing message body: %w", err)
}
if err = wc.Close(); err != nil {
return nil, fmt.Errorf("email/send: closing DATA writer: %w", err)
}
if err = client.Quit(); err != nil {
return nil, fmt.Errorf("email/send: QUIT: %w", err)
}

return map[string]any{
Expand Down
7 changes: 5 additions & 2 deletions packages/engine/internal/connector/email_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package connector
import (
"context"
"fmt"
"log"
"log/slog"

"github.com/emersion/go-imap/v2"
)
Expand Down Expand Up @@ -60,7 +60,10 @@ func (c *EmailDeleteConnector) Execute(ctx context.Context, params map[string]an
// Warning: EXPUNGE removes ALL messages currently marked \Deleted in the
// selected mailbox, not just the targeted UID. This may delete additional
// messages if other messages were marked \Deleted concurrently.
log.Printf("email/delete: UIDExpunge not supported (uid=%d), falling back to EXPUNGE which removes ALL \\Deleted messages: %v", uid, err)
slog.Warn("email/delete: UIDExpunge not supported, falling back to EXPUNGE which removes ALL \\Deleted messages",
"uid", uid,
"error", err,
)
if err2 := client.Expunge().Close(); err2 != nil {
return nil, fmt.Errorf("email/delete: expunging message: %w", err2)
}
Expand Down
4 changes: 3 additions & 1 deletion packages/engine/internal/connector/email_move_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"net/smtp"
"testing"
"time"

imaplib "github.com/dvflw/mantle/internal/imap"
)

// TestEmailMoveParamValidation verifies that required parameters are enforced.
Expand Down Expand Up @@ -131,7 +133,7 @@ func TestEmailMove_MovesMessage(t *testing.T) {
// Used in tests to ensure the target folder exists before moving messages.
func createTargetFolder(t *testing.T, host, imapPort, username, password, folder string) {
t.Helper()
cfg := &imapConfig{
cfg := &imaplib.Config{
Host: host,
Port: imapPort,
Username: username,
Expand Down
36 changes: 9 additions & 27 deletions packages/engine/internal/connector/email_receive.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"time"

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

// Build search criteria based on the filter.
criteria := buildSearchCriteria(filter)
criteria := imaplib.BuildSearchCriteria(filter)

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

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

// buildSearchCriteria constructs IMAP search criteria for the given filter name.
func buildSearchCriteria(filter string) *imap.SearchCriteria {
switch filter {
case "unseen":
return &imap.SearchCriteria{
NotFlag: []imap.Flag{imap.FlagSeen},
}
case "flagged":
return &imap.SearchCriteria{
Flag: []imap.Flag{imap.FlagFlagged},
}
case "recent":
// \Recent is an IMAP4rev1 flag; use string literal.
return &imap.SearchCriteria{
Flag: []imap.Flag{imap.Flag(`\Recent`)},
}
default: // "all"
return &imap.SearchCriteria{}
}
}

// fetchMessages executes the FETCH command and converts the results into the
// output map format expected by the connector.
func fetchMessages(client *imapclient.Client, uidSet imap.UIDSet, opts *imap.FetchOptions) ([]map[string]any, error) {
func fetchMessages(client *imapclient.Client, uidSet imap.UIDSet, opts *imap.FetchOptions, peek bool) ([]map[string]any, error) {
cmd := client.Fetch(uidSet, opts)

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

msg := messageBufferToMap(buf)
msg := messageBufferToMap(buf, peek)
out = append(out, msg)
}

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

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

// Populate body text from the TEXT body section, and extract headers from
// the raw bytes if a full body section is present.
bodySection := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierText}
bodySection := &imap.FetchItemBodySection{Specifier: imap.PartSpecifierText, Peek: peek}
rawText := buf.FindBodySection(bodySection)
if rawText != nil {
msg["body"] = extractBodyText(rawText)
Expand Down
3 changes: 2 additions & 1 deletion packages/engine/internal/connector/email_receive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"
"time"

imaplib "github.com/dvflw/mantle/internal/imap"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
Expand Down Expand Up @@ -90,7 +91,7 @@ func TestEmailReceiveFilterCriteria(t *testing.T) {

for _, tt := range tests {
t.Run(tt.filter, func(t *testing.T) {
c := buildSearchCriteria(tt.filter)
c := imaplib.BuildSearchCriteria(tt.filter)
if c == nil {
t.Fatal("buildSearchCriteria returned nil")
}
Expand Down
67 changes: 8 additions & 59 deletions packages/engine/internal/connector/imap.go
Original file line number Diff line number Diff line change
@@ -1,69 +1,18 @@
package connector

import (
"crypto/tls"
"fmt"
"net"

imaplib "github.com/dvflw/mantle/internal/imap"
"github.com/emersion/go-imap/v2/imapclient"
)

// imapConfig holds IMAP connection parameters extracted from a credential.
type imapConfig struct {
Host string
Port string
Username string
Password string
UseTLS bool
}

// parseIMAPCredential extracts IMAP config from a credential map.
func parseIMAPCredential(params map[string]any) (*imapConfig, error) {
cred, ok := params["_credential"].(map[string]string)
if !ok {
return nil, fmt.Errorf("IMAP credential is required")
}
cfg := &imapConfig{
Host: cred["host"],
Port: cred["port"],
Username: cred["username"],
Password: cred["password"],
}
if cfg.Host == "" {
return nil, fmt.Errorf("IMAP credential must include 'host'")
}
if cfg.Port == "" {
cfg.Port = "993"
}
if useTLS, ok := cred["use_tls"]; ok && useTLS == "false" {
cfg.UseTLS = false
} else {
cfg.UseTLS = true
}
return cfg, nil
// parseIMAPCredential extracts IMAP config from connector params.
// It delegates to the shared imap package.
func parseIMAPCredential(params map[string]any) (*imaplib.Config, error) {
return imaplib.ParseCredential(params)
}

// imapDial connects to an IMAP server and logs in.
func imapDial(cfg *imapConfig) (*imapclient.Client, error) {
addr := net.JoinHostPort(cfg.Host, cfg.Port)
var client *imapclient.Client
var err error
if cfg.UseTLS {
client, err = imapclient.DialTLS(addr, &imapclient.Options{
TLSConfig: &tls.Config{
ServerName: cfg.Host,
MinVersion: tls.VersionTLS12,
},
})
} else {
client, err = imapclient.DialInsecure(addr, nil)
}
if err != nil {
return nil, fmt.Errorf("connecting to IMAP server %s: %w", addr, err)
}
if err := client.Login(cfg.Username, cfg.Password).Wait(); err != nil {
_ = client.Close()
return nil, fmt.Errorf("IMAP login failed: %w", err)
}
return client, nil
// It delegates to the shared imap package.
func imapDial(cfg *imaplib.Config) (*imapclient.Client, error) {
return imaplib.Dial(cfg)
}
Loading
Loading