fix: SMTP HELO hostname (#1284)

nanookclaw created

## What?

Fixes #1084 by using the local OS hostname for the SMTP `HELO`/`EHLO`
greeting instead of always sending `localhost`.

Both normal message sending and calendar replies now call a shared
helper that returns `os.Hostname()` when available, with `localhost`
kept as the fallback for hostname lookup failures or empty hostnames.

## Why?

Some SMTP anti-spam checks reject or penalize public clients that
identify as `localhost`. Sending the real client hostname matches the
issue's requested behavior while preserving a safe fallback if hostname
lookup is unavailable.

---------

Signed-off-by: Nanook <nanookclaw@users.noreply.github.com>

Change summary

sender/sender.go      | 19 ++++++++++++++++---
sender/sender_test.go | 20 ++++++++++++++++++++
2 files changed, 36 insertions(+), 3 deletions(-)

Detailed changes

sender/sender.go 🔗

@@ -79,7 +79,10 @@ func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
 // randReader is the source of randomness for boundary generation. It is a
 // variable so tests can swap it with a deterministic or failing reader. By
 // default it is crypto/rand.Reader.
-var randReader io.Reader = rand.Reader
+var (
+	randReader io.Reader = rand.Reader
+	osHostname           = os.Hostname
+)
 
 // smimeOuterBoundary returns a fresh, high-entropy MIME boundary for an S/MIME
 // multipart/signed wrapper. If crypto/rand cannot supply randomness it returns
@@ -92,6 +95,16 @@ func smimeOuterBoundary() (string, error) {
 	return "signed-" + fmt.Sprintf("%x", rb[:]), nil
 }
 
+// smtpHelloHostname returns the hostname used in the SMTP HELO/EHLO greeting.
+// It falls back to localhost when the OS hostname cannot be read.
+func smtpHelloHostname() string {
+	hostname, err := osHostname()
+	if err != nil || strings.TrimSpace(hostname) == "" {
+		return "localhost"
+	}
+	return hostname
+}
+
 // generateMessageID creates a unique Message-ID header.
 func generateMessageID(from string) string {
 	buf := make([]byte, 16)
@@ -672,7 +685,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 	}
 	defer c.Close()
 
-	if err = c.Hello("localhost"); err != nil {
+	if err = c.Hello(smtpHelloHostname()); err != nil {
 		return nil, err
 	}
 
@@ -884,7 +897,7 @@ func SendCalendarReply(account *config.Account, to []string, subject, plainBody
 	}
 	defer c.Close()
 
-	if err = c.Hello("localhost"); err != nil {
+	if err = c.Hello(smtpHelloHostname()); err != nil {
 		return nil, err
 	}
 

sender/sender_test.go 🔗

@@ -56,6 +56,26 @@ func TestSMIMEOuterBoundary_Success(t *testing.T) {
 // Ensure io is referenced even if a future refactor removes it indirectly.
 var _ io.Reader = failingReader{}
 
+func TestSMTPHelloHostname(t *testing.T) {
+	orig := osHostname
+	t.Cleanup(func() { osHostname = orig })
+
+	osHostname = func() (string, error) { return "mail.example.com", nil }
+	if got := smtpHelloHostname(); got != "mail.example.com" {
+		t.Fatalf("expected hostname, got %q", got)
+	}
+
+	osHostname = func() (string, error) { return "", nil }
+	if got := smtpHelloHostname(); got != "localhost" {
+		t.Fatalf("expected localhost fallback for empty hostname, got %q", got)
+	}
+
+	osHostname = func() (string, error) { return "ignored", errors.New("hostname unavailable") }
+	if got := smtpHelloHostname(); got != "localhost" {
+		t.Fatalf("expected localhost fallback on error, got %q", got)
+	}
+}
+
 // TestGenerateMessageID ensures the Message-ID has the correct format.
 func TestGenerateMessageID(t *testing.T) {
 	from := "test@example.com"