package main import ( "bytes" "fmt" "io" "mime" "mime/multipart" "net/mail" "sort" "strings" "time" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" "github.com/google/uuid" "github.com/wailsapp/wails/v2/pkg/runtime" ) func (app *App) SendMail(fromAddress EmailAddress, conv Conversation, body string) (*Conversation, error) { app.Log("Sending message..."). Stdout(). StatusLine() defer app.Log("done sending."). Stdout(). StatusLine() conn, ok := mailConnections[fromAddress.Value] if !ok { return nil, fmt.Errorf("no active mail connection for %s", fromAddress.Value) } if conv.ID == 0 { conv.Subject = "New via EMessage" if err := app.DB.Create(&conv).Error; err != nil { return nil, fmt.Errorf("failed to create conversation: %w", err) } } toEmails := make([]string, 0) for _, r := range conv.Recipients { if r.ID == fromAddress.ID || r.Value == fromAddress.Value { continue } toEmails = append(toEmails, r.Value) } fromName := "" contact := app.GetUserContact() for _, e := range contact.Emails { if e.Value == fromAddress.Value { fromName = strings.TrimSpace(contact.Firstname + " " + contact.Lastname) break } } from := fromAddress.Value if fromName != "" { from = fmt.Sprintf("%s <%s>", fromName, fromAddress.Value) } headers := make(mail.Header) headers["Message-ID"] = []string{"<" + uuid.NewString() + fromAddress.Value + ">"} headers["From"] = []string{from} headers["To"] = toEmails headers["Subject"] = []string{conv.Subject} headers["Date"] = []string{time.Now().Format(time.RFC1123Z)} headers["FE-Application"] = []string{"emessage"} headers["FE-Context"] = []string{"message"} headers["MIME-Version"] = []string{"1.0"} headers["Content-Type"] = []string{"text/plain; charset=UTF-8"} var msgBuf bytes.Buffer for key, vals := range headers { for _, v := range vals { fmt.Fprintf(&msgBuf, "%s: %s\r\n", key, v) } } msgBuf.WriteString("\r\n") msgBuf.WriteString(body) msgBytes := msgBuf.Bytes() app.Log("Sending from %s to %s", fromAddress.Value, toEmails).Stdout() if err := conn.SmtpClient.Noop(); err != nil { if err := conn.SmtpClient.Auth(*conn.SmtpSasl); err != nil { return nil, fmt.Errorf("SMTP presend auth failed: %w", err) } } else if err := conn.SmtpClient.SendMail(fromAddress.Value, toEmails, bytes.NewReader(msgBytes)); err != nil { return nil, fmt.Errorf("SMTP send failed: %w", err) } ImapSyncMutex.Lock() defer ImapSyncMutex.Unlock() app.Log("Committing...").Stdout().StatusLine() if conn.ImapClient != nil { flags := []imap.Flag{imap.FlagSeen} appendCmd := conn.ImapClient.Append(AppMailbox, int64(len(msgBytes)), &imap.AppendOptions{Flags: flags}) if _, err := appendCmd.Write(msgBytes); err != nil { app.Log("Error: failed to append sent message to IMAP: %v", err).Stdout().StatusLine() } else if err := appendCmd.Close(); err != nil { app.Log("failed to close message: %v", err).Stdout().StatusLine() } else if _, err := appendCmd.Wait(); err != nil { app.Log("Error: IMAP append Wait failed: %v", err).Stdout().StatusLine() } } else { app.Log("Error: no IMAP client available to store copy of sent message").Stdout().StatusLine() } app.Log("Committed.").Stdout().StatusLine() go func() { if err := app.syncMessages(); err != nil { app.Log("Sync Error: %s\n", err.Error()).Stdout().StatusLine() } }() return &conv, nil } func hasFlag(flags []imap.Flag, target imap.Flag) bool { for _, f := range flags { if f == target { return true } } return false } func joinAddresses(addresses []imap.Address) string { if len(addresses) == 0 { return "" } parts := make([]string, 0) for _, addr := range addresses { local := addr.Addr() if addr.Name != "" { local = addr.Name + " <" + local + ">" } parts = append(parts, local) } return strings.Join(parts, ", ") } func extractHeaders(env *imap.Envelope) []MailHeader { if env == nil { return nil } var headers []MailHeader if env.Subject != "" { headers = append(headers, MailHeader{Key: "Subject", Value: env.Subject}) } if !env.Date.IsZero() { headers = append(headers, MailHeader{Key: "Date", Value: env.Date.Format(time.RFC1123Z)}) } if from := joinAddresses(env.From); from != "" { headers = append(headers, MailHeader{Key: "From", Value: from}) } if sender := joinAddresses(env.Sender); sender != "" { headers = append(headers, MailHeader{Key: "Sender", Value: sender}) } if replyTo := joinAddresses(env.ReplyTo); replyTo != "" { headers = append(headers, MailHeader{Key: "Reply-To", Value: replyTo}) } if to := joinAddresses(env.To); to != "" { headers = append(headers, MailHeader{Key: "To", Value: to}) } if cc := joinAddresses(env.Cc); cc != "" { headers = append(headers, MailHeader{Key: "Cc", Value: cc}) } if bcc := joinAddresses(env.Bcc); bcc != "" { headers = append(headers, MailHeader{Key: "Bcc", Value: bcc}) } if len(env.InReplyTo) != 0 { headers = append(headers, MailHeader{ Key: "In-Reply-To", Value: strings.Join(env.InReplyTo, ", "), }) } if env.MessageID != "" { headers = append(headers, MailHeader{Key: "Message-ID", Value: env.MessageID}) } return headers } func GetFromHeaders(headers []MailHeader, key string) string { for _, h := range headers { if strings.EqualFold(h.Key, key) { return h.Value } } return "" } func collectRecipients(msg *Message) []string { addressSet := make(map[string]struct{}) for _, key := range []string{"From", "To", "Cc"} { raw := GetFromHeaders(msg.Headers, key) if raw == "" { continue } addresses, err := mail.ParseAddressList(raw) if err != nil { continue } for _, addr := range addresses { addressSet[addr.Address] = struct{}{} } } result := make([]string, 0) for addr := range addressSet { result = append(result, addr) } sort.Strings(result) return result } func (app *App) resolveSender(from string) (EmailAddress, error) { return app.GetOrCreateEmailAddress(from) } func (app *App) parseMessage(buf *imapclient.FetchMessageBuffer) (Message, error) { if buf == nil { return Message{}, fmt.Errorf("nil message buffer") } env := buf.Envelope app.Log(env.MessageID).Stdout() flags := buf.Flags intDate := buf.InternalDate uid := buf.UID msg := Message{ MailMessageStandard: MailMessageStandard{ From: joinAddresses(env.From), To: joinAddresses(env.To), Subject: env.Subject, Date: intDate, ServerMessageID: env.MessageID, UID: uint32(uid), Seen: hasFlag(flags, imap.FlagSeen), Answered: hasFlag(flags, imap.FlagAnswered), Deleted: hasFlag(flags, imap.FlagDeleted), EmailID: "", ThreadID: "", }, Internal: true, Headers: extractHeaders(env), } return msg, nil } func (app *App) PopulateMissingMessageContents(budget int) (int, error) { ImapSyncMutex.Lock() defer runtime.EventsEmit(app.ctx, "reload") defer ImapSyncMutex.Unlock() app.Log("Populating missing message contents..."). Stdout().StatusLine() var messages []Message err := app.DB. Preload("Conversation.ServerRecipient"). Where("id NOT IN (SELECT DISTINCT message_id FROM message_contents)"). Order("date DESC"). Limit(budget). Find(&messages).Error if err != nil { return 0, err } fetchedCount := 0 for _, msg := range messages { conv := msg.Conversation if conv.ID == 0 || conv.ServerRecipient.Value == "" { app.Log("Skipping message %d: no valid conversation/server email", msg.ID).Stdout() continue } serverEmail := conv.ServerRecipient.Value conn, ok := mailConnections[serverEmail] if !ok { app.Log("Skipping message %d: no connection for %s", msg.ID, serverEmail).Stdout() continue } if _, err := conn.ImapClient.Select(AppMailbox, nil).Wait(); err != nil { app.Log("Select error for message %d: %v", msg.ID, err).Stdout() continue } uidSet := imap.UIDSetNum(imap.UID(msg.UID)) fetchOpts := &imap.FetchOptions{ UID: true, BodySection: []*imap.FetchItemBodySection{{}}, } fetchCmd := conn.ImapClient.Fetch(uidSet, fetchOpts) for { fetchData := fetchCmd.Next() if fetchData == nil { break } buf, err := fetchData.Collect() if err != nil { app.Log("Collect error for message %d: %v", msg.ID, err).Stdout() continue } raw := buf.FindBodySection(&imap.FetchItemBodySection{}) if raw == nil { app.Log("No body section for message %d", msg.ID).Stdout() continue } r := io.NopCloser(strings.NewReader(string(raw))) parsedMsg, err := mail.ReadMessage(r) if err != nil { app.Log("Failed to parse message %d: %v", msg.ID, err).Stdout() continue } mediaType, params, err := mime.ParseMediaType(parsedMsg.Header.Get("Content-Type")) if err != nil { mediaType = "text/plain" params = nil } parts, err := app.extractMIMEParts(parsedMsg.Body, mediaType, params["boundary"]) if err != nil { app.Log("MIME extraction error for message %d: %v", msg.ID, err).Stdout() continue } for _, part := range parts { part.MessageID = msg.ID if err := app.DB.Create(&part).Error; err != nil { app.Log("Failed to store part for message %d: %v", msg.ID, err).Stdout() } } fetchedCount++ } fetchCmd.Close() } app.Log("Populated content for %d messages.", fetchedCount). Stdout().StatusLine() return fetchedCount, nil } func (app *App) extractMIMEParts(body io.Reader, mediaType, boundary string) ([]MessageContent, error) { var parts []MessageContent if strings.HasPrefix(mediaType, "multipart/") && boundary != "" { mr := multipart.NewReader(body, boundary) for { p, err := mr.NextPart() if err == io.EOF { break } if err != nil { return nil, err } subMediaType, subParams, err := mime.ParseMediaType(p.Header.Get("Content-Type")) if err != nil { subMediaType = "application/octet-stream" subParams = nil } subBody, err := io.ReadAll(p) if err != nil { return nil, err } if strings.HasPrefix(subMediaType, "multipart/") { subParts, err := app.extractMIMEParts( strings.NewReader(string(subBody)), subMediaType, subParams["boundary"], ) if err != nil { return nil, err } parts = append(parts, subParts...) } else { parts = append(parts, MessageContent{ MIMEType: subMediaType, Content: subBody, }) } } } else { rawBody, err := io.ReadAll(body) if err != nil { return nil, err } parts = append(parts, MessageContent{ MIMEType: mediaType, Content: rawBody, }) } return parts, nil }