1package sender
2
3import (
4 "bytes"
5 "crypto/rand"
6 "crypto/tls"
7 "crypto/x509"
8 "encoding/base64"
9 "encoding/pem"
10 "errors"
11 "fmt"
12 "mime"
13 "mime/multipart"
14 "mime/quotedprintable"
15 "net/smtp"
16 "net/textproto"
17 "os"
18 "path/filepath"
19 "strings"
20 "time"
21
22 "github.com/floatpane/matcha/clib"
23 "github.com/floatpane/matcha/config"
24 "go.mozilla.org/pkcs7"
25)
26
27// loginAuth implements the SMTP LOGIN authentication mechanism.
28// Some SMTP servers (e.g. Mailo) only support LOGIN and not PLAIN.
29type loginAuth struct {
30 username, password string
31}
32
33func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
34 return "LOGIN", nil, nil
35}
36
37func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
38 if !more {
39 return nil, nil
40 }
41 prompt := strings.TrimSpace(string(fromServer))
42 switch strings.ToLower(prompt) {
43 case "username:":
44 return []byte(a.username), nil
45 case "password:":
46 return []byte(a.password), nil
47 default:
48 return nil, fmt.Errorf("unexpected LOGIN prompt: %s", prompt)
49 }
50}
51
52// generateMessageID creates a unique Message-ID header.
53func generateMessageID(from string) string {
54 buf := make([]byte, 16)
55 _, err := rand.Read(buf)
56 if err != nil {
57 return fmt.Sprintf("<%d.%s>", time.Now().UnixNano(), from)
58 }
59 return fmt.Sprintf("<%x@%s>", buf, from)
60}
61
62// SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments.
63func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME bool, encryptSMIME bool) error {
64 smtpServer := account.GetSMTPServer()
65 smtpPort := account.GetSMTPPort()
66
67 if smtpServer == "" {
68 return fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider)
69 }
70
71 plainAuth := smtp.PlainAuth("", account.Email, account.Password, smtpServer)
72 loginAuthFallback := &loginAuth{username: account.Email, password: account.Password}
73
74 fromHeader := account.FetchEmail
75 if account.Name != "" {
76 fromHeader = fmt.Sprintf("%s <%s>", account.Name, account.FetchEmail)
77 }
78
79 // Main message buffer
80 var innerMsg bytes.Buffer
81 innerWriter := multipart.NewWriter(&innerMsg)
82 innerHeaders := fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary())
83
84 // Set top-level headers for a mixed message type to support content and attachments
85 headers := map[string]string{
86 "From": fromHeader,
87 "To": strings.Join(to, ", "),
88 "Subject": subject,
89 "Date": time.Now().Format(time.RFC1123Z),
90 "Message-ID": generateMessageID(account.FetchEmail),
91 "MIME-Version": "1.0",
92 }
93
94 if len(cc) > 0 {
95 headers["Cc"] = strings.Join(cc, ", ")
96 }
97
98 if inReplyTo != "" {
99 headers["In-Reply-To"] = inReplyTo
100 if len(references) > 0 {
101 headers["References"] = strings.Join(references, " ") + " " + inReplyTo
102 } else {
103 headers["References"] = inReplyTo
104 }
105 }
106
107 // --- Body Part (multipart/related) ---
108 // This part contains the multipart/alternative (text/html) and any inline images.
109 relatedHeader := textproto.MIMEHeader{}
110 relatedBoundary := "related-" + innerWriter.Boundary()
111 relatedHeader.Set("Content-Type", "multipart/related; boundary=\""+relatedBoundary+"\"")
112 relatedPartWriter, err := innerWriter.CreatePart(relatedHeader)
113 if err != nil {
114 return err
115 }
116 relatedWriter := multipart.NewWriter(relatedPartWriter)
117 relatedWriter.SetBoundary(relatedBoundary)
118
119 // --- Alternative Part (text and html) ---
120 altHeader := textproto.MIMEHeader{}
121 altBoundary := "alt-" + innerWriter.Boundary()
122 altHeader.Set("Content-Type", "multipart/alternative; boundary=\""+altBoundary+"\"")
123 altPartWriter, err := relatedWriter.CreatePart(altHeader)
124 if err != nil {
125 return err
126 }
127 altWriter := multipart.NewWriter(altPartWriter)
128 altWriter.SetBoundary(altBoundary)
129
130 // Plain text part
131 textHeader := textproto.MIMEHeader{
132 "Content-Type": {"text/plain; charset=UTF-8"},
133 "Content-Transfer-Encoding": {"quoted-printable"},
134 }
135 textPart, err := altWriter.CreatePart(textHeader)
136 if err != nil {
137 return err
138 }
139 qpText := quotedprintable.NewWriter(textPart)
140 fmt.Fprint(qpText, plainBody)
141 qpText.Close()
142
143 // HTML part
144 htmlHeader := textproto.MIMEHeader{
145 "Content-Type": {"text/html; charset=UTF-8"},
146 "Content-Transfer-Encoding": {"quoted-printable"},
147 }
148 htmlPart, err := altWriter.CreatePart(htmlHeader)
149 if err != nil {
150 return err
151 }
152 qpHTML := quotedprintable.NewWriter(htmlPart)
153 fmt.Fprint(qpHTML, htmlBody)
154 qpHTML.Close()
155
156 altWriter.Close() // Finish the alternative part
157
158 // --- Inline Images ---
159 for cid, data := range images {
160 ext := filepath.Ext(strings.Split(cid, "@")[0])
161 mimeType := mime.TypeByExtension(ext)
162 if mimeType == "" {
163 mimeType = "application/octet-stream"
164 }
165
166 imgHeader := textproto.MIMEHeader{}
167 imgHeader.Set("Content-Type", mimeType)
168 imgHeader.Set("Content-Transfer-Encoding", "base64")
169 imgHeader.Set("Content-ID", "<"+cid+">")
170 imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"")
171
172 imgPart, err := relatedWriter.CreatePart(imgHeader)
173 if err != nil {
174 return err
175 }
176 // data is already base64 encoded, but needs MIME line wrapping (76 chars per line)
177 imgPart.Write([]byte(clib.WrapBase64(string(data))))
178 }
179
180 relatedWriter.Close() // Finish the related part
181
182 // --- Attachments ---
183 for filename, data := range attachments {
184 mimeType := mime.TypeByExtension(filepath.Ext(filename))
185 if mimeType == "" {
186 mimeType = "application/octet-stream"
187 }
188
189 partHeader := textproto.MIMEHeader{}
190 partHeader.Set("Content-Type", mimeType)
191 partHeader.Set("Content-Transfer-Encoding", "base64")
192 partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
193
194 attachmentPart, err := innerWriter.CreatePart(partHeader)
195 if err != nil {
196 return err
197 }
198 encodedData := base64.StdEncoding.EncodeToString(data)
199 // MIME requires base64 to be line-wrapped at 76 characters
200 attachmentPart.Write([]byte(clib.WrapBase64(encodedData)))
201 }
202
203 innerWriter.Close() // Finish the inner message
204
205 var msg bytes.Buffer
206 for k, v := range headers {
207 fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
208 }
209
210 innerBodyBytes := append([]byte(innerHeaders), innerMsg.Bytes()...)
211
212 var payloadToEncrypt []byte
213
214 // Handle S/MIME Detached Signing
215 if signSMIME {
216 if account.SMIMECert == "" || account.SMIMEKey == "" {
217 return errors.New("S/MIME certificate or key path is missing")
218 }
219
220 certData, err := os.ReadFile(account.SMIMECert)
221 if err != nil {
222 return err
223 }
224 keyData, err := os.ReadFile(account.SMIMEKey)
225 if err != nil {
226 return err
227 }
228
229 certBlock, _ := pem.Decode(certData)
230 if certBlock == nil {
231 return errors.New("failed to parse certificate PEM")
232 }
233 cert, err := x509.ParseCertificate(certBlock.Bytes)
234 if err != nil {
235 return err
236 }
237
238 keyBlock, _ := pem.Decode(keyData)
239 if keyBlock == nil {
240 return errors.New("failed to parse private key PEM")
241 }
242 privKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
243 if err != nil {
244 privKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
245 if err != nil {
246 return err
247 }
248 }
249
250 canonicalBody := bytes.ReplaceAll(innerBodyBytes, []byte("\r\n"), []byte("\n"))
251 canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
252
253 signedData, err := pkcs7.NewSignedData(canonicalBody)
254 if err != nil {
255 return err
256 }
257 if err := signedData.AddSigner(cert, privKey, pkcs7.SignerInfoConfig{}); err != nil {
258 return err
259 }
260 detachedSig, err := signedData.Finish()
261 if err != nil {
262 return err
263 }
264
265 outerBoundary := "signed-" + innerWriter.Boundary()
266 var signedMsg bytes.Buffer
267 fmt.Fprintf(&signedMsg, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=\"sha-256\"; boundary=\"%s\"\r\n\r\n", outerBoundary)
268 fmt.Fprintf(&signedMsg, "This is a cryptographically signed message in MIME format.\r\n\r\n")
269 fmt.Fprintf(&signedMsg, "--%s\r\n", outerBoundary)
270 signedMsg.Write(canonicalBody)
271 fmt.Fprintf(&signedMsg, "\r\n--%s\r\n", outerBoundary)
272 fmt.Fprintf(&signedMsg, "Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
273 fmt.Fprintf(&signedMsg, "Content-Transfer-Encoding: base64\r\n")
274 fmt.Fprintf(&signedMsg, "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
275 signedMsg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(detachedSig)))
276 fmt.Fprintf(&signedMsg, "\r\n--%s--\r\n", outerBoundary)
277
278 if encryptSMIME {
279 payloadToEncrypt = bytes.ReplaceAll(signedMsg.Bytes(), []byte("\r\n"), []byte("\n"))
280 payloadToEncrypt = bytes.ReplaceAll(payloadToEncrypt, []byte("\n"), []byte("\r\n"))
281 } else {
282 msg.Write(signedMsg.Bytes())
283 }
284 } else {
285 canonicalBody := bytes.ReplaceAll(innerBodyBytes, []byte("\r\n"), []byte("\n"))
286 canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
287
288 if encryptSMIME {
289 payloadToEncrypt = canonicalBody
290 } else {
291 fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary())
292 msg.Write(innerMsg.Bytes())
293 }
294 }
295
296 // Handle S/MIME Encryption
297 if encryptSMIME {
298 // Include the sender's own email so it can be decrypted in the Sent folder
299 allRecipients := append([]string{account.Email}, to...)
300 allRecipients = append(allRecipients, cc...)
301 allRecipients = append(allRecipients, bcc...)
302
303 cfgDir, _ := config.GetConfigDir()
304 certsDir := filepath.Join(cfgDir, "certs")
305 var certs []*x509.Certificate
306
307 for _, em := range allRecipients {
308 em = strings.TrimSpace(em)
309 if strings.Contains(em, "<") {
310 parts := strings.Split(em, "<")
311 if len(parts) == 2 {
312 em = strings.TrimSuffix(parts[1], ">")
313 }
314 }
315
316 var certPath string
317 // If this is our own account, use the path from settings rather than requiring it in the certs folder
318 if strings.EqualFold(em, account.Email) && account.SMIMECert != "" {
319 certPath = account.SMIMECert
320 } else {
321 certPath = filepath.Join(certsDir, em+".pem")
322 }
323
324 if certData, err := os.ReadFile(certPath); err == nil {
325 if block, _ := pem.Decode(certData); block != nil {
326 if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
327 certs = append(certs, cert)
328 }
329 }
330 }
331 }
332
333 if len(certs) == 0 {
334 return errors.New("cannot encrypt: no valid public certificates found for recipients")
335 }
336
337 encryptedDer, err := pkcs7.Encrypt(payloadToEncrypt, certs)
338 if err != nil {
339 return err
340 }
341
342 msg.WriteString("Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name=\"smime.p7m\"\r\n")
343 msg.WriteString("Content-Transfer-Encoding: base64\r\n")
344 msg.WriteString("Content-Disposition: attachment; filename=\"smime.p7m\"\r\n\r\n")
345 msg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(encryptedDer)))
346 }
347
348 // Combine all recipients for the envelope
349 allRecipients := append([]string{}, to...)
350 allRecipients = append(allRecipients, cc...)
351 allRecipients = append(allRecipients, bcc...)
352
353 addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
354
355 tlsConfig := &tls.Config{
356 ServerName: smtpServer,
357 InsecureSkipVerify: account.Insecure,
358 }
359
360 var c *smtp.Client
361
362 // Port 465 uses implicit TLS (the connection starts with TLS).
363 // All other ports use plain TCP with optional STARTTLS upgrade.
364 if smtpPort == 465 {
365 conn, err := tls.Dial("tcp", addr, tlsConfig)
366 if err != nil {
367 return err
368 }
369 c, err = smtp.NewClient(conn, smtpServer)
370 if err != nil {
371 conn.Close()
372 return err
373 }
374 } else {
375 var err error
376 c, err = smtp.Dial(addr)
377 if err != nil {
378 return err
379 }
380 }
381 defer c.Close()
382
383 if err = c.Hello("localhost"); err != nil {
384 return err
385 }
386
387 // Trigger STARTTLS if supported (not needed for implicit TLS on port 465)
388 if smtpPort != 465 {
389 if ok, _ := c.Extension("STARTTLS"); ok {
390 if err = c.StartTLS(tlsConfig); err != nil {
391 return err
392 }
393 }
394 }
395
396 // Authenticate using the best available mechanism.
397 // c.Extension("AUTH") returns the list of supported mechanisms.
398 if ok, mechs := c.Extension("AUTH"); ok {
399 mechList := strings.ToUpper(mechs)
400 if strings.Contains(mechList, "PLAIN") {
401 err = c.Auth(plainAuth)
402 } else if strings.Contains(mechList, "LOGIN") {
403 err = c.Auth(loginAuthFallback)
404 } else {
405 // Fall back to PLAIN and let the server decide
406 err = c.Auth(plainAuth)
407 }
408 if err != nil {
409 return err
410 }
411 }
412
413 // Send Envelope
414 if err = c.Mail(account.Email); err != nil {
415 return err
416 }
417 for _, r := range allRecipients {
418 if err = c.Rcpt(r); err != nil {
419 return err
420 }
421 }
422
423 // Write Data
424 w, err := c.Data()
425 if err != nil {
426 return err
427 }
428 _, err = w.Write(msg.Bytes())
429 if err != nil {
430 return err
431 }
432 err = w.Close()
433 if err != nil {
434 return err
435 }
436
437 return c.Quit()
438}