Repository: jhillyerd/enmime
Pull request #276 (2023-02-05 13:58:06)

be able to control randomness of boundary headers
in order to create reproducible email messages, i needed reproducible uuid strings which are used for boundary headers. therefore, i added a new top-level method `func SetRandom(r *rand.Rand)` which causes the UUID function to use that given Rand object. for instance, in my project i call it like `enmime.SetRandom(rand.New(rand.NewSource(1)))`. in the absence of such a call, it is initialized like `time.Now().UnixNano()` as before.


jhillyerd: I'm in favor of this feature, but I think we need to move away from using a global variable for the random source.  If your code, or its tests run in parallel, you will clobber the random seed and will not get deterministic output.

Perhaps we can store a randsource reference & mutex in the builder, then pass it into UUID()?
xoba: sure, thanks, that was my first thought as well, but then saw that threading it through the code might be cumbersome, having to go through `enmime.Part` type as well? but if you're still in favor of doing it that way, i could update this pull request accordingly.
jhillyerd: I just threaded [Parser into Part a couple days ago](https://github.com/jhillyerd/enmime/pull/274/files), it may be possible to leverage it and the Options stuff to get the random source into the right place.
xoba: i've updated this pull request with a specific `MailBuilder` option instead, please take a look and let me know what you think! thanks again.
jhillyerd: Overall I think this is a good approach, but I don't think we should add the [functional options pattern](https://golang.cafe/blog/golang-functional-options-pattern.html) to a builder, as those are two ways of doing the same thing; mixing paradigms will be confusing.

It also breaks the re-usability pattern I want the builder to support, where you can setup a root builder a specific way, and then re-use it for multiple emails.  There is no way to reset the rand source for each of those.

Let's just define a `func (p MailBuilder) RandSource(r *rand.Rand) MailBuilder`


xoba: got it thanks, i'll re-work it that way.
xoba: please take a look at updated request which i believe satisfies your requirements, thanks!

Patch:
diff --git a/builder.go b/builder.go
index 6ab17e0..99ecc3b 100644
--- a/builder.go
+++ b/builder.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"errors"
 	"io/ioutil"
+	"math/rand"
 	"mime"
 	"net/mail"
 	"net/textproto"
@@ -28,6 +29,7 @@ type MailBuilder struct {
 	text, html           []byte
 	inlines, attachments []*Part
 	err                  error
+	randSource           rand.Source
 }
 
 // Builder returns an empty MailBuilder struct.
@@ -35,6 +37,12 @@ func Builder() MailBuilder {
 	return MailBuilder{}
 }
 
+// RandSeed sets the seed for random uuid boundary strings.
+func (p MailBuilder) RandSeed(seed int64) MailBuilder {
+	p.randSource = stringutil.NewLockedSource(seed)
+	return p
+}
+
 // Error returns the stored error from a file attachment/inline read or nil.
 func (p MailBuilder) Error() error {
 	return p.err
@@ -391,9 +399,22 @@ func (p MailBuilder) Build() (*Part, error) {
 			h.Add(k, s)
 		}
 	}
+	if r := p.randSource; r != nil {
+		root.propagateRand(r)
+	}
 	return root, nil
 }
 
+func (p *Part) propagateRand(rand rand.Source) {
+	p.randSource = rand
+	for _, x := range []*Part{p.FirstChild, p.NextSibling} {
+		if x == nil {
+			continue
+		}
+		x.propagateRand(rand)
+	}
+}
+
 // SendWithReversePath encodes the message and sends it via the specified Sender.
 func (p MailBuilder) SendWithReversePath(sender Sender, from string) error {
 	buf := &bytes.Buffer{}
diff --git a/encode.go b/encode.go
index fab9075..7a2a4c4 100644
--- a/encode.go
+++ b/encode.go
@@ -104,7 +104,7 @@ func (p *Part) setupMIMEHeaders() transferEncoding {
 	// Setup headers.
 	if p.FirstChild != nil && p.Boundary == "" {
 		// Multipart, generate random boundary marker.
-		p.Boundary = "enmime-" + stringutil.UUID()
+		p.Boundary = "enmime-" + stringutil.UUID(p.randSource)
 	}
 	if p.ContentID != "" {
 		p.Header.Set(hnContentID, coding.ToIDHeader(p.ContentID))
diff --git a/internal/stringutil/rand_source.go b/internal/stringutil/rand_source.go
new file mode 100644
index 0000000..cfe3409
--- /dev/null
+++ b/internal/stringutil/rand_source.go
@@ -0,0 +1,44 @@
+package stringutil
+
+import (
+	"math/rand"
+	"sync"
+	"time"
+)
+
+var globalRandSource rand.Source
+
+func init() {
+	globalRandSource = NewLockedSource(time.Now().UTC().UnixNano())
+}
+
+// NewLockedSource creates a source of randomness using the given seed.
+func NewLockedSource(seed int64) rand.Source64 {
+	return &lockedSource{
+		lock: new(sync.Mutex),
+		s:    rand.NewSource(seed).(rand.Source64),
+	}
+}
+
+type lockedSource struct {
+	lock sync.Locker
+	s    rand.Source64
+}
+
+func (x *lockedSource) Int63() int64 {
+	x.lock.Lock()
+	defer x.lock.Unlock()
+	return x.s.Int63()
+}
+
+func (x *lockedSource) Uint64() uint64 {
+	x.lock.Lock()
+	defer x.lock.Unlock()
+	return x.s.Uint64()
+}
+
+func (x *lockedSource) Seed(seed int64) {
+	x.lock.Lock()
+	defer x.lock.Unlock()
+	x.s.Seed(seed)
+}
diff --git a/internal/stringutil/uuid.go b/internal/stringutil/uuid.go
index 04649c1..167915f 100644
--- a/internal/stringutil/uuid.go
+++ b/internal/stringutil/uuid.go
@@ -3,19 +3,15 @@ package stringutil
 import (
 	"fmt"
 	"math/rand"
-	"sync"
-	"time"
 )
 
-var uuidRand = rand.New(rand.NewSource(time.Now().UnixNano()))
-var uuidMutex = &sync.Mutex{}
-
-// UUID generates a random UUID according to RFC 4122.
-func UUID() string {
+// UUID generates a random UUID according to RFC 4122, using optional rand if supplied
+func UUID(rs rand.Source) string {
 	uuid := make([]byte, 16)
-	uuidMutex.Lock()
-	_, _ = uuidRand.Read(uuid)
-	uuidMutex.Unlock()
+	if rs == nil {
+		rs = globalRandSource
+	}
+	_, _ = rand.New(rs).Read(uuid)
 	// variant bits; see section 4.1.1
 	uuid[8] = uuid[8]&^0xc0 | 0x80
 	// version 4 (pseudo-random); see section 4.1.3
diff --git a/part.go b/part.go
index 32ebdb7..7b58383 100644
--- a/part.go
+++ b/part.go
@@ -6,6 +6,7 @@ import (
 	"encoding/base64"
 	"io"
 	"io/ioutil"
+	"math/rand"
 	"mime/quotedprintable"
 	"net/textproto"
 	"strconv"
@@ -26,11 +27,11 @@ const (
 // Part represents a node in the MIME multipart tree.  The Content-Type, Disposition and File Name
 // are parsed out of the header for easier access.
 type Part struct {
-	PartID      string               // PartID labels this parts position within the tree.
+	PartID      string               // PartID labels this part's position within the tree.
 	Parent      *Part                // Parent of this part (can be nil.)
 	FirstChild  *Part                // FirstChild is the top most child of this part.
 	NextSibling *Part                // NextSibling of this part.
-	Header      textproto.MIMEHeader // Header for this Part.
+	Header      textproto.MIMEHeader // Header for this part.
 
 	Boundary          string            // Boundary marker used within this part.
 	ContentID         string            // ContentID header for cid URL scheme.
@@ -47,6 +48,8 @@ type Part struct {
 	Epilogue []byte   // Epilogue contains data following the closing boundary marker.
 
 	parser *Parser // Provides access to parsing options.
+
+	randSource rand.Source // optional rand for uuid boundary generation
 }
 
 // NewPart creates a new Part object.


Test patch:
diff --git a/internal/stringutil/uuid_test.go b/internal/stringutil/uuid_test.go
index d3d99ac..7df6390 100644
--- a/internal/stringutil/uuid_test.go
+++ b/internal/stringutil/uuid_test.go
@@ -7,8 +7,8 @@ import (
 )
 
 func TestUUID(t *testing.T) {
-	id1 := stringutil.UUID()
-	id2 := stringutil.UUID()
+	id1 := stringutil.UUID(nil)
+	id2 := stringutil.UUID(nil)
 
 	if id1 == id2 {
 		t.Errorf("Random UUID should not equal another random UUID")
diff --git a/randoption_test.go b/randoption_test.go
new file mode 100644
index 0000000..adff889
--- /dev/null
+++ b/randoption_test.go
@@ -0,0 +1,93 @@
+package enmime_test
+
+import (
+	"bytes"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/jhillyerd/enmime"
+	"github.com/stretchr/testify/assert"
+)
+
+// TestRandOption checks that different randomness modes behave as expected, relative to one another.
+func TestRandOption(t *testing.T) {
+	types := []ReproducibilityMode{ZeroSource, OneSource, DefaultSource, TimestampSource}
+	for _, a := range types {
+		for _, b := range types {
+			ha, hb := buildEmail(t, a), buildEmail(t, b)
+			if a == b && a.IsReproducible() {
+				assert.Equal(t, ha, hb)
+			} else {
+				assert.NotEqual(t, ha, hb)
+			}
+		}
+	}
+}
+
+type ReproducibilityMode int
+
+const (
+	ZeroSource ReproducibilityMode = iota
+	OneSource
+	DefaultSource
+	TimestampSource
+)
+
+func (mode ReproducibilityMode) IsReproducible() bool {
+	switch mode {
+	case ZeroSource:
+		return true
+	case OneSource:
+		return true
+	case DefaultSource:
+		return false
+	case TimestampSource:
+		return false
+	default:
+		panic(fmt.Errorf("illegal mode: %d", mode))
+	}
+}
+
+func (mode ReproducibilityMode) String() string {
+	switch mode {
+	case ZeroSource:
+		return "ZeroSource"
+	case OneSource:
+		return "OneSource"
+	case DefaultSource:
+		return "DefaultSource"
+	case TimestampSource:
+		return "TimestampSource"
+	default:
+		panic(fmt.Errorf("illegal mode: %d", mode))
+	}
+}
+
+// buildEmail creates a string email, according to the given Reproducibilitymode.
+func buildEmail(t *testing.T, mode ReproducibilityMode) string {
+	t.Helper()
+	var b enmime.MailBuilder
+	switch mode {
+	case ZeroSource:
+		b = enmime.Builder().RandSeed(0)
+	case OneSource:
+		b = enmime.Builder().RandSeed(1)
+	case DefaultSource:
+		b = enmime.Builder()
+	case TimestampSource:
+		b = enmime.Builder().RandSeed(time.Now().UTC().UnixNano())
+	default:
+		panic(fmt.Errorf("illegal mode: %d", mode))
+	}
+	b = b.From("name", "same").To("anon", "anon@example.com").AddAttachment([]byte("testing"), "text/plain", "test.txt")
+	p, err := b.Build()
+	if err != nil {
+		t.Fatalf("can't build email: %v", err)
+	}
+	w := new(bytes.Buffer)
+	if err := p.Encode(w); err != nil {
+		t.Fatalf("can't encode part: %v", err)
+	}
+	return w.String()
+}