sender.go

  1package sender
  2
  3import (
  4	"bytes"
  5	"crypto/rand"
  6	"crypto/tls"
  7	"encoding/base64"
  8	"fmt"
  9	"mime"
 10	"mime/multipart"
 11	"net/smtp"
 12	"net/textproto"
 13	"path/filepath"
 14	"strings"
 15	"time"
 16
 17	"github.com/floatpane/matcha/config"
 18)
 19
 20// generateMessageID creates a unique Message-ID header.
 21func generateMessageID(from string) string {
 22	buf := make([]byte, 16)
 23	_, err := rand.Read(buf)
 24	if err != nil {
 25		return fmt.Sprintf("<%d.%s>", time.Now().UnixNano(), from)
 26	}
 27	return fmt.Sprintf("<%x@%s>", buf, from)
 28}
 29
 30// SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments.
 31func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string) error {
 32	smtpServer := account.GetSMTPServer()
 33	smtpPort := account.GetSMTPPort()
 34
 35	if smtpServer == "" {
 36		return fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider)
 37	}
 38
 39	auth := smtp.PlainAuth("", account.Email, account.Password, smtpServer)
 40
 41	fromHeader := account.FetchEmail
 42	if account.Name != "" {
 43		fromHeader = fmt.Sprintf("%s <%s>", account.Name, account.FetchEmail)
 44	}
 45
 46	// Main message buffer
 47	var msg bytes.Buffer
 48	mainWriter := multipart.NewWriter(&msg)
 49
 50	// Set top-level headers for a mixed message type to support content and attachments
 51	headers := map[string]string{
 52		"From":         fromHeader,
 53		"To":           strings.Join(to, ", "),
 54		"Subject":      subject,
 55		"Date":         time.Now().Format(time.RFC1123Z),
 56		"Message-ID":   generateMessageID(account.FetchEmail),
 57		"Content-Type": "multipart/mixed; boundary=" + mainWriter.Boundary(),
 58	}
 59
 60	if len(cc) > 0 {
 61		headers["Cc"] = strings.Join(cc, ", ")
 62	}
 63
 64	if inReplyTo != "" {
 65		headers["In-Reply-To"] = inReplyTo
 66		if len(references) > 0 {
 67			headers["References"] = strings.Join(references, " ") + " " + inReplyTo
 68		} else {
 69			headers["References"] = inReplyTo
 70		}
 71	}
 72
 73	for k, v := range headers {
 74		fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
 75	}
 76	fmt.Fprintf(&msg, "\r\n") // End of headers
 77
 78	// --- Body Part (multipart/related) ---
 79	// This part contains the multipart/alternative (text/html) and any inline images.
 80	relatedHeader := textproto.MIMEHeader{}
 81	relatedBoundary := "related-" + mainWriter.Boundary()
 82	relatedHeader.Set("Content-Type", "multipart/related; boundary="+relatedBoundary)
 83	relatedPartWriter, err := mainWriter.CreatePart(relatedHeader)
 84	if err != nil {
 85		return err
 86	}
 87	relatedWriter := multipart.NewWriter(relatedPartWriter)
 88	relatedWriter.SetBoundary(relatedBoundary)
 89
 90	// --- Alternative Part (text and html) ---
 91	altHeader := textproto.MIMEHeader{}
 92	altBoundary := "alt-" + mainWriter.Boundary()
 93	altHeader.Set("Content-Type", "multipart/alternative; boundary="+altBoundary)
 94	altPartWriter, err := relatedWriter.CreatePart(altHeader)
 95	if err != nil {
 96		return err
 97	}
 98	altWriter := multipart.NewWriter(altPartWriter)
 99	altWriter.SetBoundary(altBoundary)
100
101	// Plain text part
102	textPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=UTF-8"}})
103	if err != nil {
104		return err
105	}
106	fmt.Fprint(textPart, plainBody)
107
108	// HTML part
109	htmlPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/html; charset=UTF-8"}})
110	if err != nil {
111		return err
112	}
113	fmt.Fprint(htmlPart, htmlBody)
114
115	altWriter.Close() // Finish the alternative part
116
117	// --- Inline Images ---
118	for cid, data := range images {
119		ext := filepath.Ext(strings.Split(cid, "@")[0])
120		mimeType := mime.TypeByExtension(ext)
121		if mimeType == "" {
122			mimeType = "application/octet-stream"
123		}
124
125		imgHeader := textproto.MIMEHeader{}
126		imgHeader.Set("Content-Type", mimeType)
127		imgHeader.Set("Content-Transfer-Encoding", "base64")
128		imgHeader.Set("Content-ID", "<"+cid+">")
129		imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"")
130
131		imgPart, err := relatedWriter.CreatePart(imgHeader)
132		if err != nil {
133			return err
134		}
135		// data is already base64 encoded, but needs MIME line wrapping (76 chars per line)
136		imgPart.Write([]byte(wrapBase64(string(data))))
137	}
138
139	relatedWriter.Close() // Finish the related part
140
141	// --- Attachments ---
142	for filename, data := range attachments {
143		mimeType := mime.TypeByExtension(filepath.Ext(filename))
144		if mimeType == "" {
145			mimeType = "application/octet-stream"
146		}
147
148		partHeader := textproto.MIMEHeader{}
149		partHeader.Set("Content-Type", mimeType)
150		partHeader.Set("Content-Transfer-Encoding", "base64")
151		partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
152
153		attachmentPart, err := mainWriter.CreatePart(partHeader)
154		if err != nil {
155			return err
156		}
157		encodedData := base64.StdEncoding.EncodeToString(data)
158		// MIME requires base64 to be line-wrapped at 76 characters
159		attachmentPart.Write([]byte(wrapBase64(encodedData)))
160	}
161
162	mainWriter.Close() // Finish the main message
163
164	// Combine all recipients for the envelope
165	allRecipients := append([]string{}, to...)
166	allRecipients = append(allRecipients, cc...)
167	allRecipients = append(allRecipients, bcc...)
168
169	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
170
171	// Custom SMTP dialer to support skipping TLS verification for Proton Bridge
172	c, err := smtp.Dial(addr)
173	if err != nil {
174		return err
175	}
176	defer c.Close()
177
178	if err = c.Hello("localhost"); err != nil {
179		return err
180	}
181
182	// Trigger STARTTLS if supported
183	if ok, _ := c.Extension("STARTTLS"); ok {
184		tlsConfig := &tls.Config{
185			ServerName:         smtpServer,
186			InsecureSkipVerify: account.Insecure,
187		}
188		if err = c.StartTLS(tlsConfig); err != nil {
189			return err
190		}
191	}
192
193	// Authenticate
194	if auth != nil {
195		if ok, _ := c.Extension("AUTH"); ok {
196			if err = c.Auth(auth); err != nil {
197				return err
198			}
199		}
200	}
201
202	// Send Envelope
203	if err = c.Mail(account.Email); err != nil {
204		return err
205	}
206	for _, r := range allRecipients {
207		if err = c.Rcpt(r); err != nil {
208			return err
209		}
210	}
211
212	// Write Data
213	w, err := c.Data()
214	if err != nil {
215		return err
216	}
217	_, err = w.Write(msg.Bytes())
218	if err != nil {
219		return err
220	}
221	err = w.Close()
222	if err != nil {
223		return err
224	}
225
226	return c.Quit()
227}
228
229// wrapBase64 wraps base64-encoded data at 76 characters per line as required by MIME.
230func wrapBase64(data string) string {
231	const lineLength = 76
232	var result strings.Builder
233	for i := 0; i < len(data); i += lineLength {
234		end := i + lineLength
235		if end > len(data) {
236			end = len(data)
237		}
238		result.WriteString(data[i:end])
239		if end < len(data) {
240			result.WriteString("\r\n")
241		}
242	}
243	return result.String()
244}