all repos — emessage @ 4938b2e7cc9504d8085b021945ec49563a3eaa67

The EMessage email client

broadly works
Robert Ismo me@robertismo.com
Thu, 25 Jun 2026 04:44:41 -0500
commit

4938b2e7cc9504d8085b021945ec49563a3eaa67

parent

90d9fff40df22e96a11a479011c3c77e700d4416

M MakefileMakefile

@@ -1,4 +1,11 @@

+allplatforms := 'darwin,darwin/amd64,darwin/arm64,darwin/universal,linux,linux/amd64,linux/arm64,linux/arm,windows,windows/amd64,windows/arm64,windows/386' +supported := 'darwin,darwin/amd64,darwin/arm64,darwin/universal,linux,linux/amd64,windows,windows/amd64,windows/arm64,windows/386' + build: - wails build -tags webkit2_41 + go build -tags edev,eserver -o build/bin/server . + wails build -v 2 -tags webkit2_41,edev,eclient -platform $(supported) dev: - wails dev -tags webkit2_41 + go build -tags edev,eserver -o build/bin/server . + wails dev -tags webkit2_41,edev,eclient + +.PHONY: build dev
M README.mdREADME.md

@@ -1,1 +1,10 @@

# Machine Telecom's EMessage + + +## Quality of Life Features needed + + - [ ] Create a contact when the user adds a new email address as a recipient on the envelope + - [ ] User Arrow keys, Enter, and Esc on the focused ContactSelector + - [ ] Handle a different client replying to an EMessage + - [x] Add a Status line + - [ ] Actually define proper subject for new messages
M app.goapp.go

@@ -6,11 +6,13 @@

"github.com/adrg/xdg" "gorm.io/driver/sqlite" "gorm.io/gorm" + "gorm.io/gorm/logger" "gorm.io/gorm/clause" ) type AppConfig struct { Title string + Version string } type App struct {

@@ -22,26 +24,41 @@ }

func NewApp(config AppConfig) *App { app := &App{Config: config} - db, err := gorm.Open(sqlite.Open(app.GetDataFile()), &gorm.Config{}) + db, err := gorm.Open(sqlite.Open(app.GetDataFile()), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) if err != nil { panic(err) } - app.DB = db.Session(&gorm.Session{FullSaveAssociations: true}) + app.DB = db app.migrate() return app } func (app *App) startup(ctx context.Context) { + app.Log("Running startup."). + Stdout() + defer app.Log("Startup finished."). + Stdout() app.ctx = ctx app.EstablishMailConnections() + return } func (app *App) shutdown(ctx context.Context) { + app.Log("Running shutdown."). + Stdout() + defer app.Log("finished shutdown."). + Stdout() CloseMailConnections() return } func (app *App) migrate() { + app.Log("Starting migration..."). + Stdout() + defer app.Log("finished migration."). + Stdout() app.DB.AutoMigrate(&Contact{}) app.DB.AutoMigrate(&EmailAddress{}) app.DB.AutoMigrate(&PhoneNumber{})

@@ -54,11 +71,24 @@ FirstOrCreate(

&app.UserContact, Contact{Model: gorm.Model{ID: 1}}, ) + app.Log("Ensuring contact."). + Stdout() app.DB.AutoMigrate(&MailAccountConnection{}) + app.DB.AutoMigrate(&Conversation{}) + app.DB.AutoMigrate(&Message{}) + app.DB.AutoMigrate(&MailHeader{}) + app.DB.AutoMigrate(&LastSynced{}) + app.DB.AutoMigrate(&MessageContent{}) + app.DB.AutoMigrate(&DisplayImage{}) + return } func (app *App) GetTitle() string { return app.Config.Title +} + +func (app *App) GetVersion() string { + return app.Config.Version } func (app *App) GetDataFile() string {
M contact.gocontact.go

@@ -1,11 +1,13 @@

package main import ( + "errors" + "net/mail" "strings" "time" - "errors" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type Contact struct {

@@ -14,6 +16,8 @@ Firstname string

Lastname string Association string Job string + DisplayImageID uint + DisplayImage DisplayImage Emails []EmailAddress PhoneNumbers []PhoneNumber Urls []URL

@@ -25,8 +29,9 @@ }

func (contact Contact) Validate() error { if len(strings.TrimSpace(contact.Firstname)) == 0 && - len(strings.TrimSpace(contact.Lastname)) == 0 { - return errors.New("Not a valid name") + len(strings.TrimSpace(contact.Lastname)) == 0 && + len(contact.Emails) == 0 { + return errors.New("Not a valid contact") } return nil }

@@ -35,14 +40,38 @@ func (app *App) GetUserContact() Contact {

return app.UserContact } +func (app *App) NewContact() Contact { + return Contact{} +} + func (app *App) SaveContact(contact Contact) error { if err := contact.Validate(); err != nil { return err } if contact.ID == 1 { + app.Log("Updating user contact."). + Stdout(). + StatusLine() app.UserContact = contact } - return app.DB.Save(&contact).Error + return app.DB. + Session(&gorm.Session{FullSaveAssociations: true}). + Save(&contact).Error +} + +func (app *App) GetAllContactsSorted() ([]Contact, error) { + var contacts []Contact + app.Log("Getting contacts..."). + Stdout(). + StatusLine() + err := app.DB. + Preload(clause.Associations). + Order("lastname ASC, firstname ASC, association ASC"). + Find(&contacts).Error + app.Log("found %d contacts.", len(contacts)). + Stdout(). + StatusLine() + return contacts, err } type EmailAddress struct {

@@ -53,6 +82,130 @@

Value string } +func (app *App) GetContactFromEmail(email EmailAddress) Contact { + var contact Contact + app.DB.Preload(clause.Associations).First(&contact, email.ContactID) + return contact +} + +func (app *App) GetContactsFromEmails(emails []EmailAddress) ([]Contact, error) { + if len(emails) == 0 { + return nil, nil + } + + var emailIDs []uint + var emailValues []string + seen := make(map[string]struct{}) + for _, e := range emails { + if _, ok := seen[e.Value]; !ok { + emailIDs = append(emailIDs, e.ID) + emailValues = append(emailValues, e.Value) + seen[e.Value] = struct{}{} + } + } + + var contacts []Contact + err := app.DB. + Preload(clause.Associations). + Where("EXISTS (SELECT 1 FROM email_addresses WHERE contact_id = contacts.id AND (id IN ? OR value IN ?))", + emailIDs, emailValues). + Find(&contacts).Error + if err != nil { + return nil, err + } + + return contacts, nil +} + +func (app *App) GetOrCreateEmailAddress(rawEmail string) (EmailAddress, error) { + addr, err := mail.ParseAddress(rawEmail) + if err != nil { + addr = &mail.Address{Address: rawEmail} + } + + var existing EmailAddress + result := app.DB.Where("value = ?", addr.Address).First(&existing) + if result.Error == nil { + return existing, nil + } + if !errors.Is(result.Error, gorm.ErrRecordNotFound) { + return existing, result.Error + } + + newContact := Contact{ + Emails: []EmailAddress{{ + Key: "Personal", + Value: addr.Address, + }}, + } + + if err := app.DB.Session(&gorm.Session{FullSaveAssociations: true}).Create(&newContact).Error; err != nil { + return existing, err + } + + return newContact.Emails[0], nil +} + +func (app *App) GetRecipientDisplay(recipients []EmailAddress) []string { + if len(recipients) == 0 { + return nil + } + + var emailIDs []uint + var emailValues []string + seen := make(map[string]struct{}) + for _, r := range recipients { + if _, ok := seen[r.Value]; !ok { + emailIDs = append(emailIDs, r.ID) + emailValues = append(emailValues, r.Value) + seen[r.Value] = struct{}{} + } + } + + var contacts []Contact + app.DB. + Preload("Emails"). + Where("EXISTS (SELECT 1 FROM email_addresses WHERE contact_id = contacts.id AND (id IN ? OR value IN ?))", + emailIDs, emailValues). + Find(&contacts) + + emailToContact := make(map[string]*Contact) + for i := range contacts { + c := &contacts[i] + for _, e := range c.Emails { + emailToContact[e.Value] = c + } + } + + contactCount := make(map[uint]int) + for _, r := range recipients { + if c := emailToContact[r.Value]; c != nil { + contactCount[c.ID]++ + } + } + + result := make([]string, len(recipients)) + for i, r := range recipients { + c := emailToContact[r.Value] + if c == nil { + result[i] = r.Value + continue + } + name := strings.TrimSpace(c.Firstname + " " + c.Lastname) + if name == "" { + name = c.Association + } + if name != "" && contactCount[c.ID] > 1 { + result[i] = name + " <" + r.Value + ">" + } else if name != "" { + result[i] = name + } else { + result[i] = "<" + r.Value + ">" + } + } + return result +} + type PhoneNumber struct { gorm.Model ContactID uint

@@ -97,4 +250,3 @@ Key string

Value string } -
A conversation.go

@@ -0,0 +1,387 @@

+package main + +import ( + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" + "gorm.io/gorm" +) + +type Conversation struct { + gorm.Model + Subject string + IsUnread bool + ServerRecipientID uint + ServerRecipient EmailAddress + DisplayImageID uint + DisplayImage DisplayImage + Recipients []EmailAddress `gorm:"many2many:conversation_email_addresses;"` + Messages []Message +} + +func (app *App) NewConversation() (conv Conversation) { + conv.Recipients = []EmailAddress{{}} + return conv +} + +func (app *App) GetConversationPreviewSubject(conv Conversation) string { + if len(conv.Recipients) > 1 { + return strings.Join(app.GetRecipientDisplay(conv.Recipients[1:]), ", ") + } else { + return "New Message" + } +} + +func (app *App) GetConversationPreviewText(conv Conversation) string { + var msg Message + err := app.DB. + Where("conversation_id = ?", conv.ID). + Order("created_at DESC"). + Preload("MessageContents"). + First(&msg).Error + if err != nil { + return "" + } + + if len(msg.MessageContents) > 0 { + s := string(msg.MessageContents[0].Content) + runes := []rune(s) + if len(runes) > 100 { + s = string(runes[:100]) + "..." + } + return s + } + return "" +} + +func (app *App) GetConversationPreviewTime(conv Conversation) string { + var msg Message + err := app.DB. + Where("conversation_id = ?", conv.ID). + Order("created_at DESC"). + First(&msg).Error + if err != nil { + return "" + } + + return relativeTime(msg.Date) +} + +func relativeTime(t time.Time) string { + now := time.Now().Local() + t = t.Local() + nowDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + tDay := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) + + days := int(nowDay.Sub(tDay).Hours() / 24) + + switch { + case days == 0: + return t.Format("3:04 PM") + case days == 1: + return "yesterday" + case days < 7: + return fmt.Sprintf("%d days ago", days) + case days < 28: + weeks := days / 7 + if weeks == 1 { + return "1 week ago" + } + return fmt.Sprintf("%d weeks ago", weeks) + case days < 365: + months := days / 28 + if months == 1 { + return "1 month ago" + } + return fmt.Sprintf("%d months ago", months) + default: + years := days / 365 + if years == 1 { + return "1 year ago" + } + return fmt.Sprintf("%d years ago", years) + } +} + +func (app *App) GetAllConversations() ([]Conversation, error) { + var conversations []Conversation + err := app.DB. + Preload("Recipients"). + Preload("ServerRecipient"). + Order("updated_at DESC"). + Find(&conversations).Error + return conversations, err +} + +type MailMessageStandard struct { + From string + To string + Subject string + Date time.Time + ServerMessageID string `gorm:"unique"` + UID uint32 + Seen bool + Answered bool + Deleted bool + EmailID string + ThreadID string +} + +type Message struct { + gorm.Model + MailMessageStandard + ConversationID uint + Conversation Conversation + SenderID uint + Sender EmailAddress + Hidden bool + Internal bool + Headers []MailHeader + MessageContents []MessageContent +} + +type MailHeader struct { + gorm.Model + MessageID uint + Key string + Value string +} + +type MessageContent struct { + gorm.Model + MessageID uint + MIMEType string + Content []byte +} + +type LastSynced struct { + gorm.Model + EmailAddressID uint + EmailAddress EmailAddress + UID uint32 +} + +func (app *App) getLastSynced(email EmailAddress) (LastSynced, error) { + var last LastSynced + app.Log("Getting last synced for %s.", email.Value). + Stdout(). + StatusLine() + defer app.Log("Last synced for %s is %d.", email.Value, last.UID). + Stdout() + err := app.DB.Where("email_address_id = ?", email.ID). + First(&last). + Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return LastSynced{}, nil + } + return last, err +} + +func (app *App) setLastSynced(email EmailAddress, uid uint32) error { + var last LastSynced + app.Log("Setting last synced for %s to %d.", email.Value, uid). + Stdout() + result := app.DB.Where(LastSynced{EmailAddressID: email.ID}). + Assign(LastSynced{UID: uid}). + FirstOrCreate(&last) + return result.Error +} + +func (app *App) fetchFilteredMessages(conn MailAccountConnection) ([]*imapclient.FetchMessageBuffer, error) { + var emailAddr EmailAddress + if err := app.DB.Where("value = ?", conn.Email).First(&emailAddr).Error; err != nil { + return nil, err + } + + lastSynced, err := app.getLastSynced(emailAddr) + if err != nil { + return nil, err + } + + selectData, err := conn.ImapClient.Select(AppMailbox, nil).Wait() + if err != nil { + return nil, err + } + + startUID := uint32(1) + if lastSynced.UID > 0 { + startUID = lastSynced.UID + 1 + } + endUID := uint32(0) + if selectData.UIDNext > 1 { + endUID = uint32(selectData.UIDNext) - 1 + } + + if startUID > endUID { + return []*imapclient.FetchMessageBuffer{}, nil + } + + uidRange := []imap.UIDRange{{ + Start: imap.UID(startUID), + Stop: imap.UID(endUID), + }} + + criteria := &imap.SearchCriteria{ + Header: []imap.SearchCriteriaHeaderField{{ + Key: "FE-Application", + Value: "emessage", + }}, + UID: []imap.UIDSet{uidRange}, + } + + searchData, err := conn.ImapClient.UIDSearch(criteria, nil).Wait() + if err != nil { + return nil, err + } + uids := searchData.AllUIDs() + if len(uids) == 0 { + return nil, nil + } + + fetchOptions := &imap.FetchOptions{ + UID: true, + Envelope: true, + Flags: true, + InternalDate: true, + } + uidSet := imap.UIDSetNum(uids...) + fetchCmd := conn.ImapClient.Fetch(uidSet, fetchOptions) + defer fetchCmd.Close() + + var results []*imapclient.FetchMessageBuffer + for { + msg := fetchCmd.Next() + if msg == nil { + break + } + buf, err := msg.Collect() + if err != nil { + return nil, err + } + results = append(results, buf) + } + return results, nil +} + +func (app *App) findOrCreateConversation(recipients []string, serverRecipient EmailAddress) (*Conversation, error) { + emailIDs := make([]uint, len(recipients)) + for i, r := range recipients { + addr, err := app.GetOrCreateEmailAddress(r) + if err != nil { + return nil, err + } + emailIDs[i] = addr.ID + } + sort.Slice(emailIDs, func(i, j int) bool { return emailIDs[i] < emailIDs[j] }) + + sql := ` + SELECT cea.conversation_id + FROM conversation_email_addresses cea + JOIN ( + SELECT conversation_id + FROM conversation_email_addresses + GROUP BY conversation_id + HAVING COUNT(DISTINCT email_address_id) = ? + ) total ON total.conversation_id = cea.conversation_id + WHERE cea.email_address_id IN ? + GROUP BY cea.conversation_id + HAVING COUNT(DISTINCT cea.email_address_id) = ? + LIMIT 1 + ` + var convID uint + err := app.DB.Raw(sql, len(emailIDs), emailIDs, len(emailIDs)).Scan(&convID).Error + if err != nil { + return nil, err + } + + if convID != 0 { + var conv Conversation + if err := app.DB.Preload("Recipients").Preload("ServerRecipient").First(&conv, convID).Error; err != nil { + return nil, err + } + return &conv, nil + } + + emailRecipients := make([]EmailAddress, 0, len(emailIDs)) + for _, id := range emailIDs { + var addr EmailAddress + if err := app.DB.First(&addr, id).Error; err != nil { + return nil, err + } + emailRecipients = append(emailRecipients, addr) + } + + containsServer := false + for _, addr := range emailRecipients { + if addr.ID == serverRecipient.ID { + containsServer = true + break + } + } + if !containsServer { + emailRecipients = append(emailRecipients, serverRecipient) + } + + conv := Conversation{ + ServerRecipientID: serverRecipient.ID, + ServerRecipient: serverRecipient, + Recipients: emailRecipients, + Messages: []Message{}, + } + + if err := app.DB.Create(&conv).Error; err != nil { + return nil, err + } + + if err := app.DB.Preload("Recipients").Preload("ServerRecipient").First(&conv, conv.ID).Error; err != nil { + return nil, err + } + return &conv, nil +} + +type PaginatedMessages struct { + Messages []Message `json:"messages"` + Total int64 `json:"total"` +} + +func (app *App) GetMessagesFromConversation(conv Conversation, page, pageSize int) (*PaginatedMessages, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + + convID := conv.ID + var total int64 + if err := app.DB.Model(&Message{}). + Where("conversation_id = ?", convID). + Count(&total).Error; err != nil { + return nil, err + } + + offset := (page - 1) * pageSize + var messages []Message + err := app.DB. + Where("conversation_id = ?", convID). + Preload("Headers"). + Preload("MessageContents"). + Preload("Sender"). + Order("date DESC, id ASC"). + Offset(offset). + Limit(pageSize). + Find(&messages).Error + if err != nil { + return nil, err + } + + return &PaginatedMessages{ + Messages: messages, + Total: total, + }, nil +}
A development.go

@@ -0,0 +1,13 @@

+//go:build edev + +package main + +import () + + +var ( + EMessageRemoteServerAddress = "localhost:8080" + EMessageVersionHistory = []string{ + "prerelease 1 . 1", + } +)
A docs/go-imap/index

@@ -0,0 +1,152 @@

+ + + type AppendCommand + func (cmd *AppendCommand) Close() error + func (cmd *AppendCommand) Wait() (*imap.AppendData, error) + func (cmd *AppendCommand) Write(b []byte) (int, error) + type CapabilityCommand + func (cmd *CapabilityCommand) Wait() (imap.CapSet, error) + type Client + func DialInsecure(address string, options *Options) (*Client, error) + func DialStartTLS(address string, options *Options) (*Client, error) + func DialTLS(address string, options *Options) (*Client, error) + func New(conn net.Conn, options *Options) *Client + func NewStartTLS(conn net.Conn, options *Options) (*Client, error) + func (c *Client) Append(mailbox string, size int64, options *imap.AppendOptions) *AppendCommand + func (c *Client) Authenticate(saslClient sasl.Client) error + func (c *Client) Capability() *CapabilityCommand + func (c *Client) Caps() imap.CapSet + func (c *Client) Close() error + func (c *Client) Closed() <-chan struct{} + func (c *Client) Copy(numSet imap.NumSet, mailbox string) *CopyCommand + func (c *Client) Create(mailbox string, options *imap.CreateOptions) *Command + func (c *Client) Delete(mailbox string) *Command + func (c *Client) Enable(caps ...imap.Cap) *EnableCommand + func (c *Client) Expunge() *ExpungeCommand + func (c *Client) Fetch(numSet imap.NumSet, options *imap.FetchOptions) *FetchCommand + func (c *Client) GetACL(mailbox string) *GetACLCommand + func (c *Client) GetMetadata(mailbox string, entries []string, options *GetMetadataOptions) *GetMetadataCommand + func (c *Client) GetQuota(root string) *GetQuotaCommand + func (c *Client) GetQuotaRoot(mailbox string) *GetQuotaRootCommand + func (c *Client) ID(idData *imap.IDData) *IDCommand + func (c *Client) Idle() (*IdleCommand, error) + func (c *Client) List(ref, pattern string, options *imap.ListOptions) *ListCommand + func (c *Client) Login(username, password string) *Command + func (c *Client) Logout() *Command + func (c *Client) Mailbox() *SelectedMailbox + func (c *Client) Move(numSet imap.NumSet, mailbox string) *MoveCommand + func (c *Client) MyRights(mailbox string) *MyRightsCommand + func (c *Client) Namespace() *NamespaceCommand + func (c *Client) Noop() *Command + func (c *Client) Rename(mailbox, newName string, options *imap.RenameOptions) *Command + func (c *Client) Search(criteria *imap.SearchCriteria, options *imap.SearchOptions) *SearchCommand + func (c *Client) Select(mailbox string, options *imap.SelectOptions) *SelectCommand + func (c *Client) SetACL(mailbox string, ri imap.RightsIdentifier, rm imap.RightModification, ...) *SetACLCommand + func (c *Client) SetMetadata(mailbox string, entries map[string]*[]byte) *Command + func (c *Client) SetQuota(root string, limits map[imap.QuotaResourceType]int64) *Command + func (c *Client) Sort(options *SortOptions) *SortCommand + func (c *Client) State() imap.ConnState + func (c *Client) Status(mailbox string, options *imap.StatusOptions) *StatusCommand + func (c *Client) Store(numSet imap.NumSet, store *imap.StoreFlags, options *imap.StoreOptions) *FetchCommand + func (c *Client) Subscribe(mailbox string) *Command + func (c *Client) Thread(options *ThreadOptions) *ThreadCommand + func (c *Client) UIDExpunge(uids imap.UIDSet) *ExpungeCommand + func (c *Client) UIDSearch(criteria *imap.SearchCriteria, options *imap.SearchOptions) *SearchCommand + func (c *Client) UIDSort(options *SortOptions) *SortCommand + func (c *Client) UIDThread(options *ThreadOptions) *ThreadCommand + func (c *Client) Unauthenticate() *Command + func (c *Client) Unselect() *Command + func (c *Client) UnselectAndExpunge() *Command + func (c *Client) Unsubscribe(mailbox string) *Command + func (c *Client) WaitGreeting() error + type Command + func (cmd *Command) Wait() error + type CopyCommand + func (cmd *CopyCommand) Wait() (*imap.CopyData, error) + type EnableCommand + func (cmd *EnableCommand) Wait() (*EnableData, error) + type EnableData + type ExpungeCommand + func (cmd *ExpungeCommand) Close() error + func (cmd *ExpungeCommand) Collect() ([]uint32, error) + func (cmd *ExpungeCommand) Next() uint32 + type FetchBinarySectionBuffer + type FetchBodySectionBuffer + type FetchCommand + func (cmd *FetchCommand) Close() error + func (cmd *FetchCommand) Collect() ([]*FetchMessageBuffer, error) + func (cmd *FetchCommand) Next() *FetchMessageData + type FetchItemData + type FetchItemDataBinarySection + func (dataItem *FetchItemDataBinarySection) MatchCommand(item *imap.FetchItemBinarySection) bool + type FetchItemDataBinarySectionSize + func (data *FetchItemDataBinarySectionSize) MatchCommand(item *imap.FetchItemBinarySectionSize) bool + type FetchItemDataBodySection + func (dataItem *FetchItemDataBodySection) MatchCommand(item *imap.FetchItemBodySection) bool + type FetchItemDataBodyStructure + type FetchItemDataEnvelope + type FetchItemDataFlags + type FetchItemDataInternalDate + type FetchItemDataModSeq + type FetchItemDataRFC822Size + type FetchItemDataUID + type FetchMessageBuffer + func (buf *FetchMessageBuffer) FindBinarySection(section *imap.FetchItemBinarySection) []byte + func (buf *FetchMessageBuffer) FindBinarySectionSize(part []int) (uint32, bool) + func (buf *FetchMessageBuffer) FindBodySection(section *imap.FetchItemBodySection) []byte + type FetchMessageData + func (data *FetchMessageData) Collect() (*FetchMessageBuffer, error) + func (data *FetchMessageData) Next() FetchItemData + type GetACLCommand + func (cmd *GetACLCommand) Wait() (*GetACLData, error) + type GetACLData + type GetMetadataCommand + func (cmd *GetMetadataCommand) Wait() (*GetMetadataData, error) + type GetMetadataData + type GetMetadataDepth + func (depth GetMetadataDepth) String() string + type GetMetadataOptions + type GetQuotaCommand + func (cmd *GetQuotaCommand) Wait() (*QuotaData, error) + type GetQuotaRootCommand + func (cmd *GetQuotaRootCommand) Wait() ([]QuotaData, error) + type IDCommand + func (r *IDCommand) Wait() (*imap.IDData, error) + type IdleCommand + func (cmd *IdleCommand) Close() error + func (cmd *IdleCommand) Wait() error + type ListCommand + func (cmd *ListCommand) Close() error + func (cmd *ListCommand) Collect() ([]*imap.ListData, error) + func (cmd *ListCommand) Next() *imap.ListData + type MoveCommand + func (cmd *MoveCommand) Wait() (*MoveData, error) + type MoveData + type MyRightsCommand + func (cmd *MyRightsCommand) Wait() (*MyRightsData, error) + type MyRightsData + type NamespaceCommand + func (cmd *NamespaceCommand) Wait() (*imap.NamespaceData, error) + type Options + type QuotaData + type QuotaResourceData + type SearchCommand + func (cmd *SearchCommand) Wait() (*imap.SearchData, error) + type SelectCommand + func (cmd *SelectCommand) Wait() (*imap.SelectData, error) + type SelectedMailbox + type SetACLCommand + func (cmd *SetACLCommand) Wait() error + type SortCommand + func (cmd *SortCommand) Wait() ([]uint32, error) + type SortCriterion + type SortKey + type SortOptions + type StatusCommand + func (cmd *StatusCommand) Wait() (*imap.StatusData, error) + type ThreadCommand + func (cmd *ThreadCommand) Wait() ([]ThreadData, error) + type ThreadData + type ThreadOptions + type UnilateralDataHandler + type UnilateralDataMailbox
A docs/go-smtp/index

@@ -0,0 +1,72 @@

+ + + Variables + func SendMail(addr string, a sasl.Client, from string, to []string, r io.Reader) error + func SendMailTLS(addr string, a sasl.Client, from string, to []string, r io.Reader) error + type AuthSession + type Backend + type BackendFunc + func (f BackendFunc) NewSession(c *Conn) (Session, error) + type BodyType + type Client + func Dial(addr string) (*Client, error) + func DialStartTLS(addr string, tlsConfig *tls.Config) (*Client, error) + func DialTLS(addr string, tlsConfig *tls.Config) (*Client, error) + func NewClient(conn net.Conn) *Client + func NewClientLMTP(conn net.Conn) *Client + func NewClientStartTLS(conn net.Conn, tlsConfig *tls.Config) (*Client, error) + func (c *Client) Auth(a sasl.Client) error + func (c *Client) Close() error + func (c *Client) Data() (*DataCommand, error) + func (c *Client) Extension(ext string) (bool, string) + func (c *Client) Hello(localName string) error + func (c *Client) Mail(from string, opts *MailOptions) error + func (c *Client) MaxMessageSize() (size int, ok bool) + func (c *Client) Noop() error + func (c *Client) Quit() error + func (c *Client) Rcpt(to string, opts *RcptOptions) error + func (c *Client) Reset() error + func (c *Client) SendMail(from string, to []string, r io.Reader) error + func (c *Client) SupportsAuth(mech string) bool + func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) + func (c *Client) Verify(addr string) error + type Conn + func (c *Conn) Close() error + func (c *Conn) Conn() net.Conn + func (c *Conn) Hostname() string + func (c *Conn) Reject() + func (c *Conn) Server() *Server + func (c *Conn) Session() Session + func (c *Conn) TLSConnectionState() (state tls.ConnectionState, ok bool) + type DSNAddressType + type DSNNotify + type DSNReturn + type DataCommand + func (cmd *DataCommand) Close() error + func (cmd *DataCommand) CloseWithLMTPResponse() (map[string]*DataResponse, error) + func (cmd *DataCommand) CloseWithResponse() (*DataResponse, error) + func (cmd *DataCommand) Write(b []byte) (int, error) + type DataResponse + type DeliverByMode + type DeliverByOptions + type EnhancedCode + type LMTPDataError + func (lmtpErr LMTPDataError) Error() string + func (lmtpErr LMTPDataError) Unwrap() []error + type LMTPSession + type Logger + type MailOptions + type PriorityProfile + type RcptOptions + type SMTPError + func (err *SMTPError) Error() string + func (err *SMTPError) Temporary() bool + type Server + func NewServer(be Backend) *Server + func (s *Server) Close() error + func (s *Server) ListenAndServe() error + func (s *Server) ListenAndServeTLS() error + func (s *Server) Serve(l net.Listener) error + func (s *Server) Shutdown(ctx context.Context) error + type Session + type StatusCollector
A docs/net/mail/index

@@ -0,0 +1,15 @@

+ Variables + func ParseDate(date string) (time.Time, error) + type Address + func ParseAddress(address string) (*Address, error) + func ParseAddressList(list string) ([]*Address, error) + func (a *Address) String() string + type AddressParser + func (p *AddressParser) Parse(address string) (*Address, error) + func (p *AddressParser) ParseList(list string) ([]*Address, error) + type Header + func (h Header) AddressList(key string) ([]*Address, error) + func (h Header) Date() (time.Time, error) + func (h Header) Get(key string) string + type Message + func ReadMessage(r io.Reader) (msg *Message, err error)
A email.go

@@ -0,0 +1,412 @@

+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 +}
M frontend/src/app.htmlfrontend/src/app.html

@@ -4,6 +4,9 @@ <head>

<meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="text-scale" content="scale" /> + <meta name="wails-options" content="noautoinject" /> + <script src="/wails/ipc.js"></script> + <script src="/wails/runtime.js"></script> %sveltekit.head% </head> <body data-sveltekit-preload-data="hover">
A frontend/src/lib/components/ContactSelector.svelte

@@ -0,0 +1,87 @@

+<script> + import { global } from "$lib/global.svelte.js"; + + let { onselect, exclude } = $props(); + + let contacts = $derived(global.contacts.filter(c => c.Emails.length)); + + let search = $state(""); + let show = $state(false); + + function searchText(contact) { + const parts = [ + contact.Firstname, + contact.Lastname, + contact.Association, + contact.Job, + ...contact.Emails.map(e => e.Value), + ...contact.PhoneNumbers.map(p => p.Value), + ...contact.Urls.map(u => u.Value), + ...contact.StreetAddresses.map(s => `${s.Street1} ${s.Street2} ${s.City} ${s.State}`), + ...contact.Dates.map(d => d.Value.slice(0,10)), + ...contact.Other.map(o => o.Value), + ]; + const result = parts.join(" ").toLowerCase(); + return result; + } + + let filtered = $derived( + search + ? contacts.filter(c => { + return searchText(c).includes(search.toLowerCase()) + }) : contacts + ); + + let named = $derived(filtered.filter(c => c.Firstname || c.Lastname || c.Association)); + let nameless = $derived(filtered.filter(c => !(c.Firstname || c.Lastname || c.Association))); + + function pick(email) { + if (onselect) onselect(email); + show = false; + search = ""; + } + + function hide() { + setTimeout(() => { show = false; }, 200); + } +</script> + +<div class="relative"> + <input + class="w-full bg-header text-primary text-xs border-b-1 border-primary focus:outline-none" + type="text" + autofocus + bind:value={search} + onfocus={() => (show = true)} + onblur={hide}/> + {#if show && filtered.length > 0} + <ul class="flex flex-col absolute p-2 z-10 w-fit bg-header max-h-60 overflow-y-auto text-xs"> + {#each named as contact} + <li class="border-b-1 border-primary"> + <div class="px-2 py-1 font-semibold text-primary bg-header"> + {contact.Firstname} {contact.Lastname} + {#if contact.Association} + <span class="text-primary">({contact.Association})</span> + {/if} + </div> + {#each contact.Emails as email} + <button + class="w-full text-left px-3 py-1 hover:bg-input focus:bg-selected" + onclick={() => pick(email)}> + {email.Value} ({email.Key}) + </button> + {/each} + </li> + {/each} + {#each nameless as contact} + {#each contact.Emails as email} + <button + class="w-full text-left px-3 py-1 hover:bg-input focus:bg-selected" + onclick={() => pick(email)}> + {email.Value} + </button> + {/each} + {/each} + </ul> + {/if} +</div>
M frontend/src/lib/components/ControlButton.sveltefrontend/src/lib/components/ControlButton.svelte

@@ -1,7 +1,10 @@

-<script lang="ts"> - let { icon, ...rest } = $props(); +<script> + let { icon, highlight, ...rest } = $props(); + + let color = $derived(highlight ? 'selected' : 'control') </script> -<button class="w-4 fill-control stroke-control" +<div hidden class="fill-control stroke-control fill-selected stroke-selected" /> +<button class="w-4 fill-{color} stroke-{color}" {...rest}>{@html icon}</button>
M frontend/src/lib/components/ConversationPreview.sveltefrontend/src/lib/components/ConversationPreview.svelte

@@ -0,0 +1,55 @@

+<script lang="ts"> + import DisplayImage from '$lib/components/DisplayImage.svelte'; + import { goto } from "$app/navigation"; + import { EventsOn } from "$lib/wailsjs/runtime/runtime"; + import { + GetConversationPreviewSubject, + GetConversationPreviewText, + GetConversationPreviewTime, + GetContactsFromEmails, + } from "$lib/wailsjs/go/main/App"; + import { global } from "$lib/global.svelte.js"; + + let { conversation } = $props(); + + const bgColor = $derived(conversation.ID === global.conversation.ID ? + "selected" : "sidebar"); + const color = $derived(conversation.ID === global.conversation.ID ? + "inverted" : "primary"); + const readColor = $derived(conversation.IsUnread ? + "control" : bgColor); + + let subject = $state(""); + let text = $state(""); + let time = $state(""); + subject = await GetConversationPreviewSubject(conversation); + text = await GetConversationPreviewText(conversation); + time = await GetConversationPreviewTime(conversation); + + EventsOn("reload", async () => { + subject = await GetConversationPreviewSubject(conversation); + text = await GetConversationPreviewText(conversation); + time = await GetConversationPreviewTime(conversation); + }); + + async function click_handler() { + global.conversation = conversation; + global.activeContacts = await GetContactsFromEmails(conversation.Recipients); + goto("/") + } +</script> + +<div hidden class="bg-selected text-inverted" /> + +<div class="h-16 flex items-center text-{color} bg-{bgColor} gap-1 p-2 rounded-md" + onclick={click_handler}> + <div class="w-2 aspect-square rounded-full bg-{readColor}"></div> + <DisplayImage target={conversation} /> + <div class="flex w-full place-self-start flex-col"> + <div class="flex justify-between text-[10px]"> + <h4 class="font-bold text-ellipsis">{subject}</h4> + <span class="text-nowrap">{time}</span> + </div> + <p class="text-[8px]">{text}</p> + </div> +</div>
M frontend/src/lib/components/ConversationView.sveltefrontend/src/lib/components/ConversationView.svelte

@@ -1,2 +1,27 @@

-<section class="flex-1"> +<script lang="ts"> + import Message from '$lib/components/Message.svelte'; + import { EventsOn } from "$lib/wailsjs/runtime/runtime"; + import { GetMessagesFromConversation } from "$lib/wailsjs/go/main/App"; + import { global } from "$lib/global.svelte.js"; + + let { conversation } = $props(); + + let { messages } = $state([]); + + $effect(async () => { + const Page = await GetMessagesFromConversation(conversation, 1, 100); + messages = Page.messages; + }); + + EventsOn("reload", async () => { + const Page = await GetMessagesFromConversation(conversation, 1, 100); + messages = Page.messages; + }); +</script> + +<section class="flex-1 flex flex-col-reverse overflow-scroll px-8 py-4"> + {#each messages as message (message.ID)} + <Message {message} + sent={message.Sender.ID === conversation.ServerRecipient.ID || message.Sender.Value === conversation.ServerRecipient.Value} /> + {/each} </section>
A frontend/src/lib/components/DisplayImage.svelte

@@ -0,0 +1,11 @@

+<script> + let { src } = $props(); +</script> + +{#if src} + <img class="w-10 aspect-square rounded-t-full object-cover" + {src} + alt="Profile"/> +{:else} + <div class="w-10 aspect-square rounded-t-full bg-control"></div> +{/if}
M frontend/src/lib/components/EnvelopeControl.sveltefrontend/src/lib/components/EnvelopeControl.svelte

@@ -1,47 +1,87 @@

<script lang="ts"> + import ContactSelector from '$lib/components/ContactSelector.svelte'; + import { + GetUserContact, + GetActiveMailEmails, + GetRecipientDisplay, + } from "$lib/wailsjs/go/main/App"; import { goto } from "$app/navigation"; import { global } from "$lib/global.svelte.js"; - let { fromContact, toContact } = $props(); + let { conversation } = $props(); + + const userContact = await GetUserContact(); + const activeEmails = await GetActiveMailEmails(); - const fromName = `${fromContact.Firstname} ${fromContact.Lastname}`.trim(); - const toName = `${toContact.Firstname} ${toContact.Lastname}`.trim(); + let recipients = $state(conversation.Recipients || []); + let recipientInfo = $state([]); + $effect(async () => { + if (global.conversation.Recipients) { + global.conversation.Recipients[0] = conversation.ServerRecipient; + global.fromAddress = conversation.ServerRecipient; + } + recipientInfo = await GetRecipientDisplay(conversation.Recipients).then(list => { if (list) return list.slice(1) }); + }); - async function check_from_select () { - const selected = fromSlct.options[fromSlct.selectedIndex]; - if (selected && selected.id === 'editFromContact') { - global.contact = fromContact; - goto("/contact"); - } + + function add_recipient(email) { + if (recipients.some(r => r.ID === email.ID || r.Value === email.Value)) return; + recipients.push(email); + } + + function remove_recipient(index) { + recipients = recipients.filter((_, i) => i !== index); } - async function check_to_select () { - const selected = toSlct.options[toSlct.selectedIndex]; - if (selected && selected.id === 'editToContact') { - global.contact = toContact; - goto("/contact"); - } + + function handle_from_select(event) { + const selected = event.target.options[event.target.selectedIndex]; + if (selected && selected.dataset.action === 'edit') { + global.contact = userContact; + goto("/contact"); + } } </script> -<div class="flex gap-2 text-xs"> - <span>From: {fromName}</span> - <select id="fromSlct" - onchange={check_from_select}> - {#each fromContact.Emails as address} - <option value={address.value}>&lt;{address.Value}&gt; ({address.Key})</option> +<div class="flex gap-1 text-xs items-center w-full"> + <span class="flex items-center gap-1"> + From: + {#if conversation.ID === 0} + <select class="w-min" + onchange={handle_from_select} + bind:value={conversation.ServerRecipient}> + {#each userContact.Emails as email} + <option value={email} + disabled={!activeEmails.includes(email.Value)}> + &lt;{email.Value}&gt; ({email.Key}) + </option> + {/each} + {#if activeEmails.length === 0} + <option value={{}} selected>Nobody</option> + {/if} + <option data-action="edit">Edit my contact card</option> + </select> {:else} - <option>Nobody &lt;&gt;</option> - {/each} - <option id="editFromContact">Edit my contact card</option> - </select> - <span>To: {toName}</span> - <select id="toSlct" - onchange={check_to_select}> - {#each toContact.Emails as address} - <option value={address.value}>&lt;{address.Value}&gt; ({address.Key})</option> - {:else} - <option>Nobody &lt;&gt;</option> - {/each} - <option id="editToContact">Edit their contact card</option> - </select> + <p class="text-xs text-nowrap">{conversation.ServerRecipient.Value}</p> + {/if} + </span> + + <span class="flex items-center gap-1 w-full flex-wrap"> + To: + <span class="flex items-center gap-1 flex-wrap"> + {#each recipientInfo as display, i} + {#if conversation.ID === 0} + <button + class="text-center p-1 text-xs text-nowrap border-1 border-primary rounded" + onclick={() => remove_recipient(i)}> + {display} | &times; + </button> + {:else} + <p class="text-xs text-nowrap">{display}</p> + {/if} + {/each} + </span> + {#if conversation.ID === 0} + <ContactSelector onselect={add_recipient} /> + {/if} + </span> </div>
A frontend/src/lib/components/Message.svelte

@@ -0,0 +1,16 @@

+<script lang="ts"> + let { message, sent } = $props(); + + const side = sent ? "ml-auto" : "mr-auto"; + const color = sent ? "text-primary" : "text-inverted"; + const bg = sent ? "bg-inverted" : (message.Internal ? "bg-emessage-message" : "bg-external-message"); +</script> + +<div class="{side} {color} {bg} p-2 rounded-lg w-fit" hidden={message.Hidden}> + {#each message.MessageContents as content} + {#if content.MIMEType === "text/plain"} + {atob(content.Content)} + {:else} + {/if} + {/each} +</div>
M frontend/src/lib/components/MessageBar.sveltefrontend/src/lib/components/MessageBar.svelte

@@ -1,4 +1,18 @@

-<div class="rounded-md border-input border-1 px-1 w-full" contenteditable></div> +<script lang="ts"> + let { value = $bindable(), onsubmit } = $props(); + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + onsubmit(); + } + } +</script> + +<div class="rounded-md border-input border-1 px-1 w-full" + bind:innerText={value} + onkeydown={handleKeyDown} + contenteditable></div> <style> div{
M frontend/src/lib/global.svelte.jsfrontend/src/lib/global.svelte.js

@@ -1,3 +1,8 @@

export const global = $state({ + fromAddress: "", contact: null, + contacts: [], + activeContacts: [], + conversation: { ID: 0 }, + conversations: [], });
M frontend/src/lib/wailsjs/go/main/App.d.tsfrontend/src/lib/wailsjs/go/main/App.d.ts

@@ -2,18 +2,60 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL

// This file is automatically generated. DO NOT EDIT import {main} from '../models'; +export function EnsureAppMailboxExists():Promise<void>; + export function EstablishMailConnections():Promise<void>; +export function GetActiveMailEmails():Promise<Array<string>>; + +export function GetAllContactsSorted():Promise<Array<main.Contact>>; + +export function GetAllConversations():Promise<Array<main.Conversation>>; + +export function GetContactFromEmail(arg1:main.EmailAddress):Promise<main.Contact>; + +export function GetContactImage(arg1:main.Contact):Promise<main.ImageResponse>; + +export function GetContactsFromEmails(arg1:Array<main.EmailAddress>):Promise<Array<main.Contact>>; + +export function GetConversationPreviewSubject(arg1:main.Conversation):Promise<string>; + +export function GetConversationPreviewText(arg1:main.Conversation):Promise<string>; + +export function GetConversationPreviewTime(arg1:main.Conversation):Promise<string>; + export function GetDataFile():Promise<string>; export function GetMailConnections():Promise<Array<main.MailAccountConnection>>; export function GetMailPasswordKeyringService():Promise<string>; +export function GetMessagesFromConversation(arg1:main.Conversation,arg2:number,arg3:number):Promise<main.PaginatedMessages>; + +export function GetOrCreateEmailAddress(arg1:string):Promise<main.EmailAddress>; + +export function GetRecipientDisplay(arg1:Array<main.EmailAddress>):Promise<Array<string>>; + export function GetTitle():Promise<string>; export function GetUserContact():Promise<main.Contact>; +export function GetVersion():Promise<string>; + +export function Log(arg1:string,arg2:Array<any>):Promise<main.LogMessage>; + +export function NewContact():Promise<main.Contact>; + +export function NewConversation():Promise<main.Conversation>; + +export function PickContactImage(arg1:main.Contact):Promise<main.ImageResponse>; + +export function PopulateMissingMessageContents(arg1:number):Promise<number>; + export function SaveContact(arg1:main.Contact):Promise<void>; + +export function SaveContactImage(arg1:number,arg2:string):Promise<void>; export function SaveMailConnection(arg1:main.MailAccountConnection):Promise<void>; + +export function SendMail(arg1:main.EmailAddress,arg2:main.Conversation,arg3:string):Promise<main.Conversation>;
M frontend/src/lib/wailsjs/go/main/App.jsfrontend/src/lib/wailsjs/go/main/App.js

@@ -2,10 +2,50 @@ // @ts-check

// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function EnsureAppMailboxExists() { + return window['go']['main']['App']['EnsureAppMailboxExists'](); +} + export function EstablishMailConnections() { return window['go']['main']['App']['EstablishMailConnections'](); } +export function GetActiveMailEmails() { + return window['go']['main']['App']['GetActiveMailEmails'](); +} + +export function GetAllContactsSorted() { + return window['go']['main']['App']['GetAllContactsSorted'](); +} + +export function GetAllConversations() { + return window['go']['main']['App']['GetAllConversations'](); +} + +export function GetContactFromEmail(arg1) { + return window['go']['main']['App']['GetContactFromEmail'](arg1); +} + +export function GetContactImage(arg1) { + return window['go']['main']['App']['GetContactImage'](arg1); +} + +export function GetContactsFromEmails(arg1) { + return window['go']['main']['App']['GetContactsFromEmails'](arg1); +} + +export function GetConversationPreviewSubject(arg1) { + return window['go']['main']['App']['GetConversationPreviewSubject'](arg1); +} + +export function GetConversationPreviewText(arg1) { + return window['go']['main']['App']['GetConversationPreviewText'](arg1); +} + +export function GetConversationPreviewTime(arg1) { + return window['go']['main']['App']['GetConversationPreviewTime'](arg1); +} + export function GetDataFile() { return window['go']['main']['App']['GetDataFile'](); }

@@ -18,6 +58,18 @@ export function GetMailPasswordKeyringService() {

return window['go']['main']['App']['GetMailPasswordKeyringService'](); } +export function GetMessagesFromConversation(arg1, arg2, arg3) { + return window['go']['main']['App']['GetMessagesFromConversation'](arg1, arg2, arg3); +} + +export function GetOrCreateEmailAddress(arg1) { + return window['go']['main']['App']['GetOrCreateEmailAddress'](arg1); +} + +export function GetRecipientDisplay(arg1) { + return window['go']['main']['App']['GetRecipientDisplay'](arg1); +} + export function GetTitle() { return window['go']['main']['App']['GetTitle'](); }

@@ -26,10 +78,42 @@ export function GetUserContact() {

return window['go']['main']['App']['GetUserContact'](); } +export function GetVersion() { + return window['go']['main']['App']['GetVersion'](); +} + +export function Log(arg1, arg2) { + return window['go']['main']['App']['Log'](arg1, arg2); +} + +export function NewContact() { + return window['go']['main']['App']['NewContact'](); +} + +export function NewConversation() { + return window['go']['main']['App']['NewConversation'](); +} + +export function PickContactImage(arg1) { + return window['go']['main']['App']['PickContactImage'](arg1); +} + +export function PopulateMissingMessageContents(arg1) { + return window['go']['main']['App']['PopulateMissingMessageContents'](arg1); +} + export function SaveContact(arg1) { return window['go']['main']['App']['SaveContact'](arg1); } +export function SaveContactImage(arg1, arg2) { + return window['go']['main']['App']['SaveContactImage'](arg1, arg2); +} + export function SaveMailConnection(arg1) { return window['go']['main']['App']['SaveMailConnection'](arg1); } + +export function SendMail(arg1, arg2, arg3) { + return window['go']['main']['App']['SendMail'](arg1, arg2, arg3); +}
M frontend/src/lib/wailsjs/go/models.tsfrontend/src/lib/wailsjs/go/models.ts

@@ -281,6 +281,49 @@ }

return a; } } + export class DisplayImage { + ID: number; + // Go type: time + CreatedAt: any; + // Go type: time + UpdatedAt: any; + // Go type: gorm + DeletedAt: any; + MIMEType: string; + Content: number[]; + + static createFrom(source: any = {}) { + return new DisplayImage(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.ID = source["ID"]; + this.CreatedAt = this.convertValues(source["CreatedAt"], null); + this.UpdatedAt = this.convertValues(source["UpdatedAt"], null); + this.DeletedAt = this.convertValues(source["DeletedAt"], null); + this.MIMEType = source["MIMEType"]; + this.Content = source["Content"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class Contact { ID: number; // Go type: time

@@ -293,6 +336,8 @@ Firstname: string;

Lastname: string; Association: string; Job: string; + DisplayImageID: number; + DisplayImage: DisplayImage; Emails: EmailAddress[]; PhoneNumbers: PhoneNumber[]; Urls: URL[];

@@ -315,6 +360,8 @@ this.Firstname = source["Firstname"];

this.Lastname = source["Lastname"]; this.Association = source["Association"]; this.Job = source["Job"]; + this.DisplayImageID = source["DisplayImageID"]; + this.DisplayImage = this.convertValues(source["DisplayImage"], DisplayImage); this.Emails = this.convertValues(source["Emails"], EmailAddress); this.PhoneNumbers = this.convertValues(source["PhoneNumbers"], PhoneNumber); this.Urls = this.convertValues(source["Urls"], URL);

@@ -342,8 +389,258 @@ }

return a; } } + export class MessageContent { + ID: number; + // Go type: time + CreatedAt: any; + // Go type: time + UpdatedAt: any; + // Go type: gorm + DeletedAt: any; + MessageID: number; + MIMEType: string; + Content: number[]; + static createFrom(source: any = {}) { + return new MessageContent(source); + } + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.ID = source["ID"]; + this.CreatedAt = this.convertValues(source["CreatedAt"], null); + this.UpdatedAt = this.convertValues(source["UpdatedAt"], null); + this.DeletedAt = this.convertValues(source["DeletedAt"], null); + this.MessageID = source["MessageID"]; + this.MIMEType = source["MIMEType"]; + this.Content = source["Content"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class MailHeader { + ID: number; + // Go type: time + CreatedAt: any; + // Go type: time + UpdatedAt: any; + // Go type: gorm + DeletedAt: any; + MessageID: number; + Key: string; + Value: string; + + static createFrom(source: any = {}) { + return new MailHeader(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.ID = source["ID"]; + this.CreatedAt = this.convertValues(source["CreatedAt"], null); + this.UpdatedAt = this.convertValues(source["UpdatedAt"], null); + this.DeletedAt = this.convertValues(source["DeletedAt"], null); + this.MessageID = source["MessageID"]; + this.Key = source["Key"]; + this.Value = source["Value"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class Message { + ID: number; + // Go type: time + CreatedAt: any; + // Go type: time + UpdatedAt: any; + // Go type: gorm + DeletedAt: any; + From: string; + To: string; + Subject: string; + // Go type: time + Date: any; + ServerMessageID: string; + UID: number; + Seen: boolean; + Answered: boolean; + Deleted: boolean; + EmailID: string; + ThreadID: string; + ConversationID: number; + Conversation: Conversation; + SenderID: number; + Sender: EmailAddress; + Hidden: boolean; + Internal: boolean; + Headers: MailHeader[]; + MessageContents: MessageContent[]; + + static createFrom(source: any = {}) { + return new Message(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.ID = source["ID"]; + this.CreatedAt = this.convertValues(source["CreatedAt"], null); + this.UpdatedAt = this.convertValues(source["UpdatedAt"], null); + this.DeletedAt = this.convertValues(source["DeletedAt"], null); + this.From = source["From"]; + this.To = source["To"]; + this.Subject = source["Subject"]; + this.Date = this.convertValues(source["Date"], null); + this.ServerMessageID = source["ServerMessageID"]; + this.UID = source["UID"]; + this.Seen = source["Seen"]; + this.Answered = source["Answered"]; + this.Deleted = source["Deleted"]; + this.EmailID = source["EmailID"]; + this.ThreadID = source["ThreadID"]; + this.ConversationID = source["ConversationID"]; + this.Conversation = this.convertValues(source["Conversation"], Conversation); + this.SenderID = source["SenderID"]; + this.Sender = this.convertValues(source["Sender"], EmailAddress); + this.Hidden = source["Hidden"]; + this.Internal = source["Internal"]; + this.Headers = this.convertValues(source["Headers"], MailHeader); + this.MessageContents = this.convertValues(source["MessageContents"], MessageContent); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class Conversation { + ID: number; + // Go type: time + CreatedAt: any; + // Go type: time + UpdatedAt: any; + // Go type: gorm + DeletedAt: any; + Subject: string; + IsUnread: boolean; + ServerRecipientID: number; + ServerRecipient: EmailAddress; + DisplayImageID: number; + DisplayImage: DisplayImage; + Recipients: EmailAddress[]; + Messages: Message[]; + + static createFrom(source: any = {}) { + return new Conversation(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.ID = source["ID"]; + this.CreatedAt = this.convertValues(source["CreatedAt"], null); + this.UpdatedAt = this.convertValues(source["UpdatedAt"], null); + this.DeletedAt = this.convertValues(source["DeletedAt"], null); + this.Subject = source["Subject"]; + this.IsUnread = source["IsUnread"]; + this.ServerRecipientID = source["ServerRecipientID"]; + this.ServerRecipient = this.convertValues(source["ServerRecipient"], EmailAddress); + this.DisplayImageID = source["DisplayImageID"]; + this.DisplayImage = this.convertValues(source["DisplayImage"], DisplayImage); + this.Recipients = this.convertValues(source["Recipients"], EmailAddress); + this.Messages = this.convertValues(source["Messages"], Message); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + + + + export class ImageResponse { + DataURI: string; + Base64: string; + + static createFrom(source: any = {}) { + return new ImageResponse(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.DataURI = source["DataURI"]; + this.Base64 = source["Base64"]; + } + } + export class LogMessage { + + + static createFrom(source: any = {}) { + return new LogMessage(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + + } + } export class MailAccountConnection { ID: number; // Go type: time

@@ -407,6 +704,41 @@ return a;

} } + + + + export class PaginatedMessages { + messages: Message[]; + total: number; + + static createFrom(source: any = {}) { + return new PaginatedMessages(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.messages = this.convertValues(source["messages"], Message); + this.total = source["total"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + }
M frontend/src/routes/+layout.sveltefrontend/src/routes/+layout.svelte

@@ -7,33 +7,70 @@ import NoConversation from '$lib/components/NoConversation.svelte';

import settingsIcon from '$lib/assets/icons/settings.svg?raw'; import newIcon from '$lib/assets/icons/new.svg?raw'; import { goto } from "$app/navigation"; - import { GetUserContact } from "$lib/wailsjs/go/main/App"; + import { + GetVersion, + GetUserContact, + NewConversation, + GetAllContactsSorted, + GetAllConversations, + EstablishMailConnections, + } from "$lib/wailsjs/go/main/App"; + import { EventsOn } from "$lib/wailsjs/runtime/runtime"; import { global } from "$lib/global.svelte.js"; let { children } = $props(); - let conversations = []; - const userContact = await GetUserContact(); - global.contact = userContact; + const version = await GetVersion(); + global.contact = await GetUserContact(); + global.contacts = await GetAllContactsSorted(); + global.conversations = await GetAllConversations(); + + const statusLineQueue = $state([]); + setTimeout(() => + setInterval(() => + statusLineQueue.shift(), + 1000), + 5000); + EventsOn("status", message => { + console.log(message); + statusLineQueue.push(message); + }) + + EventsOn("reload", async () => { + global.contact = await GetUserContact(); + global.contacts = await GetAllContactsSorted(); + global.conversations = await GetAllConversations(); + }); + + async function add_new_conversation() { + const conversation = await NewConversation(); + global.conversation = conversation; + global.conversations = [conversation, ...global.conversations.filter(conv => conv.ID !== 0)]; + goto("/") + } </script> -<svelte:head> -</svelte:head> -<main class="flex w-[100vw] h-[100vh] text-primary"> +<main class="flex flex-col w-[100vw] h-[100vh] text-primary"> + <div class="flex w-full h-full"> <nav class="bg-sidebar w-1/4 h-full"> <section class="flex flex-col gap-2 p-2"> - <div class="flex justify-end gap-4"> - <ControlButton icon={settingsIcon} - onclick={() => goto("/settings")} /> - <ControlButton icon={newIcon} - onclick={() => goto("/")}/> + <div class="flex justify-between"> + <button class="text-xs" onclick={EstablishMailConnections}>{version}</button> + <div class="flex gap-4"> + <ControlButton icon={settingsIcon} + onclick={() => goto("/settings")} /> + <ControlButton icon={newIcon} + onclick={add_new_conversation}/> + </div> </div> <SearchBar /> </section> <ol class="p-2"> - {#each conversations as conversation} - <li><ConversationPreview {conversation} /></li> + {#each global.conversations as conversation (conversation.ID)} + <li> + <ConversationPreview {conversation} /> + </li> {:else} <NoConversation /> {/each}

@@ -42,4 +79,6 @@ </nav>

<section class="w-full h-full"> {@render children()} </section> + </div> + <div class="w-full h-4 border-2 border-sidebar text-xs px-1">{statusLineQueue[0]}</div> </main>
M frontend/src/routes/+page.sveltefrontend/src/routes/+page.svelte

@@ -7,26 +7,56 @@ import searchIcon from '$lib/assets/icons/search.svg?raw';

import contactIcon from '$lib/assets/icons/contact.svg?raw'; import attachIcon from '$lib/assets/icons/attach.svg?raw'; import sendmailIcon from '$lib/assets/icons/sendmail.svg?raw'; - - import { GetUserContact } from "$lib/wailsjs/go/main/App"; + import { goto } from "$app/navigation"; + import { GetUserContact, SendMail } from "$lib/wailsjs/go/main/App"; import { global } from "$lib/global.svelte.js"; const userContact = await GetUserContact(); + + let message = $state(""); + let highlight = $derived(message.trim() !== ""); + + async function handleSubmit(event) { + if (event) event.preventDefault(); + const recipients = global.conversation?.Recipients; + if (!recipients || recipients.length === 0) return; + if (!message.trim()) return; + global.conversation = await SendMail( + global.fromAddress, + global.conversation, + message, + ); + message = ""; + } + + async function edit_user_contact() { + global.contact = await GetUserContact(); + goto("/contact"); + } + function manage_contacts() { + goto("/contacts"); + } </script> <section class="flex flex-col w-full h-full"> - <section class="bg-header w-full p-2 flex justify-between"> - <EnvelopeControl fromContact={userContact} - toContact={global.contact} /> - <div class="flex gap-2"> - <ControlButton icon={searchIcon} /> - <ControlButton icon={contactIcon} /> - </div> - </section> - <ConversationView /> - <section class="flex p-2 gap-2 items-end"> - <ControlButton icon={attachIcon} /> - <MessageBar /> - <ControlButton icon={sendmailIcon} /> - </section> + {#if global.conversations.length > 0} + <section class="bg-header w-full p-2 flex justify-between"> + <EnvelopeControl conversation={global.conversation} /> + <div class="flex gap-2"> + <ControlButton icon={searchIcon} /> + <ControlButton icon={contactIcon} onclick={manage_contacts} /> + </div> + </section> + <ConversationView conversation={global.conversation} /> + <form onsubmit={handleSubmit} class="flex p-2 gap-2 items-end"> + <ControlButton icon={attachIcon} /> + <MessageBar bind:value={message} onsubmit={handleSubmit} /> + <ControlButton type="submit" icon={sendmailIcon} {highlight} /> + </form> + {:else} + <section class="bg-header w-full p-2 flex justify-end"> + <ControlButton icon={contactIcon} onclick={edit_user_contact} /> + </section> + <h1 class="text-center my-auto font-bold text-7xl">Welcome to<br/>EMessage</h1> + {/if} </section>
M frontend/src/routes/contact/+page.sveltefrontend/src/routes/contact/+page.svelte

@@ -1,11 +1,33 @@

<script lang="ts"> import AddField from "$lib/components/AddField.svelte"; import GenericInput from "$lib/components/GenericInput.svelte"; + import DisplayImage from "$lib/components/DisplayImage.svelte"; import { goto } from "$app/navigation"; - import { SaveContact } from "$lib/wailsjs/go/main/App"; + import { SaveContact, SaveContactImage, PickContactImage, GetContactImage } from "$lib/wailsjs/go/main/App"; import { global } from "$lib/global.svelte.js"; let showJob = $derived(global.contact.Job !== "") + let imageDataUri = $state(""); + let pendingImageBase64 = $state(""); + + $inspect(imageDataUri, pendingImageBase64); + + $effect(async () => { + if (global.contact.ID) { + const uri = await GetContactImage(global.contact); + if (uri) imageDataUri = uri; + } + }); + + async function handle_image_select() { + alert("Sorry I just haven't implemented contact photos."); + return; + const result = await PickContactImage(global.contact); + if (result) { + imageDataUri = result.dataURI; + pendingImageBase64 = result.base64; + } + } async function preventDefault(event) { event.preventDefault();

@@ -19,9 +41,14 @@ await SaveContact(global.contact).catch(err => {

alert(err); return; }); + if (pendingImageBase64) await SaveContactImage(pendingImageBase64); goto("/"); } + if (!global.contact.Emails || global.contact.Emails.length == 0) { + add_email(); + } + function add_email() { global.contact.Emails = global.contact.Emails || []; global.contact.Emails.push({});

@@ -48,8 +75,16 @@ global.contact.Other.push({});

} </script> -<form class=" h-full w-full p-4 overflow-scroll flex flex-col" onsubmit={preventDefault}> +<form class="h-full w-full p-4 overflow-scroll flex flex-col" onsubmit={preventDefault}> + <div class="flex justify-between"> + <h1 class="page-header">Editing Contact</h1> + <a class="text-button" href="/contacts">Manage Contacts</a> + </div> <section class="flex flex-col gap-8 text-xs w-1/2"> + <button type="button" onclick={handle_image_select} class="mb-4 focus:outline-none cursor-pointer text-button"> + Set contact Photo + <DisplayImage src={imageDataUri} /> + </button> <fieldset class="field-group"> <fieldset> <label>First Name <GenericInput bind:value={global.contact.Firstname} placeholder="Mailey" /></label>
A frontend/src/routes/contacts/+page.svelte

@@ -0,0 +1,59 @@

+<script lang="ts"> + import { goto } from "$app/navigation"; + import { NewContact, GetAllContactsSorted } from "$lib/wailsjs/go/main/App"; + import { global } from "$lib/global.svelte.js"; + + if (global.activeContacts.length) { + global.contacts = global.activeContacts; + global.activeContacts = []; + } else { + global.contacts = await GetAllContactsSorted(); + } + + async function new_contact () { + global.contact = await NewContact(); + goto("/contact"); + } + function edit_contact(contact) { + return function () { + global.contact = contact; + goto("/contact"); + } + } +</script> + +<div class="h-full w-full p-4 overflow-scroll flex flex-col"> + <div class="flex justify-between"> + <h1 class="page-header">Contacts</h1> + <button class="text-button" onclick={new_contact}>Add New Contact</button> + </div> + <ol class="w-full"> + {#each global.contacts as contact} + <li class="mt-4 w-full flex justify-between border-b-1 border-primary"> + <div class="flex flex-col"> + <h4 class="text-sm"> + {#if contact.ID == 1} + <span class="text-input">(YOU)</span> + {/if} + {contact.Firstname} {contact.Lastname} + {#if contact.Association} + ({contact.Association}) + {/if} + </h4> + <span class="min-h-8 text-xs text-input"> + {#each contact.Emails as email, index} + {#if index !== 0} + , + {/if} + {email.Value} ({email.Key}) + {/each} + </span> + </div> + <div> + <button class="text-button" + onclick={edit_contact(contact)}>Edit</button> + </div> + </li> + {/each} + </ol> +</div>
M frontend/src/routes/layout.cssfrontend/src/routes/layout.css

@@ -1,11 +1,5 @@

@import 'tailwindcss'; -select { - text-decoration: underline; - cursor: pointer; - -webkit-appearance: none; -} - @theme { --color-red-50: oklch(97.1% 0.013 17.38); --color-red-100: oklch(93.6% 0.032 17.717);

@@ -301,7 +295,19 @@ --color-sidebar: var(--color-zinc-200);

--color-header: var(--color-zinc-100); --color-control: var(--color-neutral-400); --color-input: var(--color-neutral-300); - --color-button: var(--color-slate-400) + --color-button: var(--color-slate-400); + --color-selected: var(--color-rose-500); + --color-inverted: var(--color-neutral-100); + --color-error: var(--color-red-500); + + --color-emessage-message: var(--color-purple-500); + --color-external-message: var(--color-slate-900); +} + +select { + border-bottom: solid 1px var(--color-primary); + cursor: pointer; + -webkit-appearance: none; } button, input[type="submit"] {

@@ -326,3 +332,7 @@ }

.field-group:empty { display: none; } + +.page-header { + padding: 8px 0; +}
M frontend/src/routes/mailaccount/+page.sveltefrontend/src/routes/mailaccount/+page.svelte

@@ -1,13 +1,13 @@

<script lang="ts"> import GenericInput from "$lib/components/GenericInput.svelte"; import { goto } from "$app/navigation"; - import { GetMailConnections, SaveMailConnection, EstablishMailConnections } from "$lib/wailsjs/go/main/App"; + import { GetUserContact, GetMailConnections, SaveMailConnection, EstablishMailConnections } from "$lib/wailsjs/go/main/App"; import { global } from "$lib/global.svelte.js"; const connections = await GetMailConnections(); - function edit_contact_card() { - global.contact = userContact; + async function edit_contact_card() { + global.contact = await GetUserContact(); goto("/contact"); }
M frontend/src/routes/settings/+page.sveltefrontend/src/routes/settings/+page.svelte

@@ -1,5 +1,6 @@

-<div class="p-4"> +<div class="flex flex-col p-4"> <h1>Settings</h1> <a class="text-button" href="/mailaccount">Configure Mail Accounts</a> + <a class="text-button" href="/contacts">Manage Contacts</a> </div>
M go.modgo.mod

@@ -1,12 +1,13 @@

-module ElectronicMessage +module emessage.machinetele.com -go 1.23.0 +go 1.25.0 require ( github.com/adrg/xdg v0.5.3 github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-smtp v0.24.0 + github.com/google/uuid v1.6.0 github.com/wailsapp/wails/v2 v2.12.0 github.com/zalando/go-keyring v0.2.8 gorm.io/driver/sqlite v1.6.0

@@ -20,7 +21,6 @@ github.com/danieljoos/wincred v1.2.3 // indirect

github.com/emersion/go-message v0.18.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jinzhu/inflection v1.0.0 // indirect

@@ -43,10 +43,10 @@ github.com/valyala/bytebufferpool v1.0.0 // indirect

github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect - golang.org/x/crypto v0.33.0 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect ) // replace github.com/wailsapp/wails/v2 v2.12.0 => /home/robertismo/go/pkg/mod
M go.sumgo.sum

@@ -86,8 +86,8 @@ github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=

github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=

@@ -95,8 +95,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=

golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

@@ -111,8 +111,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=

@@ -122,8 +122,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
A images.go

@@ -0,0 +1,124 @@

+package main + +import ( + "encoding/base64" + "errors" + "fmt" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/wailsapp/wails/v2/pkg/runtime" + "gorm.io/gorm" +) + +type DisplayImage struct { + gorm.Model + MIMEType string + Content []byte +} + +type ImageResponse struct { + DataURI string + Base64 string +} + +func (app *App) PickContactImage(contact Contact) (*ImageResponse, error) { + filePath, err := runtime.OpenFileDialog(app.ctx, runtime.OpenDialogOptions{ + Title: "Select Contact Photo", + Filters: []runtime.FileFilter{ + { + DisplayName: "Images (*.png;*.jpg;*.jpeg;*.gif)", + Pattern: "*.png;*.jpg;*.jpeg;*.gif", + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("file dialog failed: %w", err) + } + if filePath == "" { + return nil, nil + } + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + ext := strings.ToLower(filepath.Ext(filePath)) + mimeType := mime.TypeByExtension(ext) + if mimeType == "" { + mimeType = "image/png" + } + + base64Str := base64.StdEncoding.EncodeToString(data) + dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Str) + + return &ImageResponse{DataURI: dataURI, Base64: base64Str}, nil +} + +func (app *App) GetContactImage(contact Contact) (*ImageResponse, error) { + if contact.ID == 0 { + return nil, nil + } + + var img DisplayImage + if err := app.DB.Where("id = ?", contact.DisplayImageID).First(&img).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + + if len(img.Content) == 0 { + return nil, nil + } + + encoded := base64.StdEncoding.EncodeToString(img.Content) + dataURI := fmt.Sprintf("data:%s;base64,%s", img.MIMEType, encoded) + + return &ImageResponse{ + DataURI: dataURI, + Base64: encoded, + }, nil +} + +func (app *App) SaveContactImage(contactID uint, base64Str string) error { + if base64Str == "" { + return nil + } + + data, err := base64.StdEncoding.DecodeString(base64Str) + if err != nil { + return fmt.Errorf("invalid base64 image data: %w", err) + } + + mimeType := http.DetectContentType(data) + + var contact Contact + if err := app.DB.First(&contact, contactID).Error; err != nil { + return fmt.Errorf("contact %d not found: %w", contactID, err) + } + + img := DisplayImage{ + MIMEType: mimeType, + Content: data, + } + if contact.DisplayImageID != 0 { + img.Model = gorm.Model{ID: contact.DisplayImageID} + } + + if err := app.DB.Session(&gorm.Session{FullSaveAssociations: true}). + Save(&img).Error; err != nil { + return fmt.Errorf("failed to save image: %w", err) + } + + contact.DisplayImageID = img.ID + if err := app.DB.Save(&contact).Error; err != nil { + return fmt.Errorf("failed to link image to contact: %w", err) + } + + return nil +}
A logger.go

@@ -0,0 +1,31 @@

+package main + +import ( + "context" + "fmt" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + + +type LogMessage struct { + context context.Context + message string +} + +func (app *App) Log(format string, a ...any) *LogMessage { + log := LogMessage{context: app.ctx} + log.message = fmt.Sprintf(format, a...) + return &log +} + +func (log *LogMessage) Stdout() *LogMessage { + fmt.Printf("[%s] %s\n", time.Now().Format(time.DateTime), log.message) + return log +} + +func (log *LogMessage) StatusLine() *LogMessage { + runtime.EventsEmit(log.context, "status", log.message) + return log +}
M mailaccount.gomailaccount.go

@@ -1,9 +1,12 @@

package main import ( - "log" + "errors" + "time" + "sync" "strconv" - + + "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp"

@@ -11,6 +14,8 @@ "github.com/zalando/go-keyring"

"gorm.io/gorm" ) +var ImapSyncMutex sync.Mutex + type MailAccountConnection struct { gorm.Model Email string `gorm:"uniqueIndex"`

@@ -26,7 +31,11 @@ SmtpClient *smtp.Client `gorm:"-:all"`

SmtpSasl *sasl.Client `gorm:"-:all"` } -var mailConnections = make(map[string]MailAccountConnection) +var ( + mailConnections = make(map[string]MailAccountConnection) + AppMailbox = "APPLICATION.machinetelecom.emessage" + SourceMailboxes = []string{"INBOX", "Sent", "Spam", "Junk"} +) func (app *App) GetMailConnections() ([]MailAccountConnection, error) { userContact := app.GetUserContact()

@@ -67,6 +76,14 @@

return connections, nil } +func (app *App) GetActiveMailEmails() []string { + emails := make([]string, 0) + for email := range mailConnections { + emails = append(emails, email) + } + return emails +} + func (app *App) SaveMailConnection(conn MailAccountConnection) error { keyring.Set( app.GetMailPasswordKeyringService(),

@@ -89,9 +106,13 @@ }

} func (app *App) EstablishMailConnections() { + ImapSyncMutex.Lock() + defer ImapSyncMutex.Unlock() _, _ = app.GetMailConnections() for key, conn := range mailConnections { - log.Printf("Attempting to connect to %s...\n", conn.Email) + app.Log("Attempting to connect to %s...", conn.Email). + Stdout(). + StatusLine() var err error if conn.ImapClient != nil {

@@ -119,15 +140,19 @@ break

} if err != nil { RemoveMailConnection(conn.Email) - log.Print(err.Error()) + app.Log(err.Error()). + Stdout() continue } if err := conn.ImapClient.Login(conn.ImapUsername, conn.Password).Wait(); err != nil { RemoveMailConnection(conn.Email) - log.Print(err.Error()) + app.Log(err.Error()). + Stdout() continue } + + go app.keepIMAPAlive(key) if conn.SmtpClient != nil { conn.SmtpClient.Close()

@@ -157,21 +182,33 @@ break

} if err != nil { RemoveMailConnection(conn.Email) - log.Print(err.Error()) + app.Log(err.Error()). + Stdout() continue } auth := sasl.NewPlainClient("", conn.SmtpUsername, conn.Password) if err := conn.SmtpClient.Auth(auth); err != nil { RemoveMailConnection(conn.Email) - log.Print(err.Error()) + app.Log(err.Error()). + Stdout() continue } conn.SmtpSasl = &auth mailConnections[key] = conn - log.Printf("Successfully connected %s!\n", conn.Email) + app.Log("Successfully connected %s!", conn.Email). + Stdout(). + StatusLine() } + app.EnsureAppMailboxExists() + go func () { + if err := app.syncMessages(); err != nil { + app.Log("Sync Error: %s", err.Error()). + Stdout(). + StatusLine() + } + }() } func CloseMailConnections() {

@@ -179,3 +216,185 @@ for key, _ := range mailConnections {

RemoveMailConnection(key) } } + +func (conn *MailAccountConnection) EnsureMailboxExists(mailboxName string) error { + if conn.ImapClient == nil { + return errors.New("IMAP client is nil") + } + _, err := conn.ImapClient.Status(mailboxName, &imap.StatusOptions{UIDValidity: true}).Wait() + if err != nil { + if err := conn.ImapClient.Create(mailboxName, nil).Wait(); err != nil { + return err + } + } + return nil +} + +func (app *App) EnsureAppMailboxExists() { + for email, conn := range mailConnections { + if err := conn.EnsureMailboxExists(AppMailbox); err != nil { + app.Log("Failed to ensure app mailbox for %s: %v", email, err). + Stdout(). + StatusLine() + } + } +} + +func (app *App) organizeMailboxesForAccount(conn MailAccountConnection) error { + for _, mailbox := range SourceMailboxes { + if _, err := conn.ImapClient.Select(mailbox, nil).Wait(); err != nil { + continue + } + + criteria := &imap.SearchCriteria{ + Header: []imap.SearchCriteriaHeaderField{{ + Key: "FE-Application", + Value: "emessage", + }}, + } + searchData, err := conn.ImapClient.UIDSearch(criteria, nil).Wait() + if err != nil { + return err + } + uids := searchData.AllUIDs() + if len(uids) == 0 { + continue + } + + uidSet := imap.UIDSetNum(uids...) + if _, err := conn.ImapClient.Move(uidSet, AppMailbox).Wait(); err != nil { + return err + } + } + + return nil +} +func (app *App) syncMailbox(email EmailAddress, conn MailAccountConnection) error { + if err := app.organizeMailboxesForAccount(conn); err != nil { + return err + } + + messages, err := app.fetchFilteredMessages(conn) + if err != nil { + return err + } + if len(messages) == 0 { + return nil + } + + app.Log("Found %d messages...", len(messages)).Stdout().StatusLine() + + var maxUID uint32 + for _, buf := range messages { + msg, err := app.parseMessage(buf) + if err != nil { + continue + } + app.Log("Processing %s...", msg.ServerMessageID).Stdout() + + recipients := collectRecipients(&msg) + + conv, err := app.findOrCreateConversation(recipients, email) + if err != nil { + app.Log(err.Error()). + Stdout() + continue + } + + msg.ConversationID = conv.ID + + sender, err := app.resolveSender(msg.From) + if err != nil { + app.Log(err.Error()). + Stdout(). + StatusLine() + continue + } + msg.SenderID = sender.ID + + if err := app.DB.Create(&msg).Error; err != nil { + app.Log(err.Error()). + Stdout(). + StatusLine() + continue + } + + if !msg.Seen { + app.DB.Model(&conv).Update("IsUnread", true) + } + + if msg.UID > maxUID { + maxUID = msg.UID + } + } + + if maxUID > 0 { + if err := app.setLastSynced(email, maxUID); err != nil { + return err + } + } + + return nil +} + +func (app *App) syncMessages() error { + if !ImapSyncMutex.TryLock() { + return nil + } + defer ImapSyncMutex.Unlock() + app.Log("Syncing..."). + Stdout(). + StatusLine() + for emailStr, conn := range mailConnections { + var emailAddr EmailAddress + if err := app.DB.Where("value = ?", emailStr).First(&emailAddr).Error; err != nil { + app.Log("Email %s not found in DB: %v", emailStr, err). + Stdout(). + StatusLine() + continue + } + + if err := app.syncMailbox(emailAddr, conn); err != nil { + app.Log("Mailbox Error: %s: %v", emailStr, err). + Stdout(). + StatusLine() + continue + } + } + app.Log("Syncing done."). + Stdout(). + StatusLine() + + go func() { + budget := 10 + for { + amount, err := app.PopulateMissingMessageContents(budget) + if err != nil || amount < budget { + break + } + time.Sleep(20 * time.Millisecond) + } + }() + + + + return nil +} + +func (app *App) keepIMAPAlive(conn string) { + for { + select { + case <-time.After(5 * time.Minute): + if connection, ok := mailConnections[conn]; ok { + ImapSyncMutex.Lock() + err := connection.ImapClient.Noop().Wait() + ImapSyncMutex.Unlock() + if err != nil { + app.Log("IMAP Noop failed: %v", err).Stdout() + go app.EstablishMailConnections() + return + } + } + } + } +}
M main.gomain.go

@@ -1,8 +1,9 @@

+//go:build eclient + package main import ( "embed" - "log" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options"

@@ -21,9 +22,11 @@

func main() { app := NewApp(AppConfig{ Title: "EMessage", + Version: EMessageVersionHistory[0], }) - log.Printf("Running %s...\n", app.GetTitle()) + app.Log("Running %s...", app.GetTitle()). + Stdout() err := wails.Run(&options.App{ Title: app.GetTitle(), Width: ApplicationWidth,

@@ -34,7 +37,8 @@ AssetServer: &assetserver.Options{Assets: assets},

BackgroundColour: &options.RGBA{R: 255, G: 255, B: 255, A: 255}, Bind: []interface{}{app}, }) - log.Print("Exited.") + app.Log("Exited."). + Stdout() if err != nil { println("Error:", err.Error())
A release.go

@@ -0,0 +1,12 @@

+//go:build erelease + +package main + +import () + +var ( + EMessageRemoteServerAddress = "https://emessage.machinetele.com" + EMessageVersionHistory = []string{ + "v0.0.0", + } +)
A server.go

@@ -0,0 +1,22 @@

+//go:build eserver + +package main + +import ( + "log" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var db *gorm.DB + +func initDB() { + var err error + db, err = gorm.Open(sqlite.Open("data.db"), &gorm.Config{}) + if err != nil { + log.Fatalf("failed to connect database: %v", err) + } +} + +func main() {}