diff options
| -rw-r--r-- | controllers/mail.go | 17 | ||||
| -rw-r--r-- | database/migrate.go | 1 | ||||
| -rw-r--r-- | go.mod | 14 | ||||
| -rw-r--r-- | go.sum | 25 | ||||
| -rw-r--r-- | lain/main.go | 6 | ||||
| -rw-r--r-- | models/email.go | 53 | ||||
| -rw-r--r-- | processors/preferences.go | 23 | ||||
| -rw-r--r-- | processors/processors.go | 1 | ||||
| -rw-r--r-- | repository/email.go | 284 | ||||
| -rw-r--r-- | repository/preferences.go | 10 | ||||
| -rw-r--r-- | types/email.go | 35 | ||||
| -rw-r--r-- | utils/email/messages.go | 230 | ||||
| -rw-r--r-- | utils/storage/minio.go | 170 |
13 files changed, 867 insertions, 2 deletions
diff --git a/controllers/mail.go b/controllers/mail.go index da2adf2..36166ce 100644 --- a/controllers/mail.go +++ b/controllers/mail.go @@ -1,6 +1,7 @@ package controllers import ( + "lain/models" "lain/repository" "lain/session" "lain/utils/meta" @@ -20,10 +21,23 @@ func Mailbox(context *fiber.Ctx) error { return InternalServerError(context, err) } + prefs := context.Locals("Preferences").(*models.Preferences) + folders := repository.GetFolders(email, folderPath) displayName := repository.GetFolderDisplayName(email, folderPath) - emails := []fiber.Map{} + page := context.QueryInt("page", 1) + if page < 1 { + page = 1 + } + + limit := prefs.EmailsPerPage + offset := (page - 1) * limit + + emails, err := repository.GetEmails(email, folderPath, limit, offset) + if err != nil { + emails = []fiber.Map{} + } meta.SetPageTitle(context, displayName) @@ -31,5 +45,6 @@ func Mailbox(context *fiber.Ctx) error { "Folders": folders, "Emails": emails, "Email": nil, + "Page": page, }) } diff --git a/database/migrate.go b/database/migrate.go index a968a83..3ea96c1 100644 --- a/database/migrate.go +++ b/database/migrate.go @@ -6,6 +6,7 @@ func migrate() error { err := DB.AutoMigrate( &models.Preferences{}, &models.Folder{}, + &models.Email{}, ) return err } @@ -4,11 +4,13 @@ go 1.25.5 require ( github.com/emersion/go-imap v1.2.1 + github.com/emersion/go-message v0.15.0 github.com/flosch/pongo2/v6 v6.0.0 github.com/gofiber/fiber/v2 v2.52.10 github.com/gofiber/storage/postgres/v3 v3.3.1 github.com/gofiber/template/django/v3 v3.1.14 github.com/joho/godotenv v1.5.1 + github.com/minio/minio-go/v7 v7.0.97 gorm.io/datatypes v1.2.7 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 @@ -17,7 +19,10 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect + github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/gofiber/template v1.8.3 // indirect github.com/gofiber/utils v1.1.0 // indirect @@ -29,17 +34,26 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.18.1 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/minio/crc64nvme v1.1.0 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.3.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect golang.org/x/crypto v0.45.0 // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/mysql v1.5.6 // indirect ) @@ -31,18 +31,24 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= +github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY= github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU= github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -84,6 +90,11 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= @@ -103,6 +114,12 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= +github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q= +github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ= +github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= @@ -123,6 +140,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -133,6 +152,8 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA= github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -146,6 +167,8 @@ github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+ github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ= +github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= +github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= @@ -170,6 +193,8 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/lain/main.go b/lain/main.go index b659cd0..4ba7b75 100644 --- a/lain/main.go +++ b/lain/main.go @@ -8,6 +8,7 @@ import ( "lain/router" "lain/tags" "lain/utils/env" + "lain/utils/storage" "log" "github.com/gofiber/fiber/v2" @@ -23,6 +24,11 @@ func main() { log.Println("Warning: AppSecret is set to a default value which is not secure. Please set a strong random secret in your APP_SECRET environment variable or .env file.") } + if err := storage.InitMinIO(); err != nil { + log.Fatalf("Failed to initialize MinIO: %v", err) + } + log.Println("MinIO initialized successfully") + tags.Initialize() engine := django.New("./templates", ".django") engine.Reload(config.Server.DevMode) diff --git a/models/email.go b/models/email.go new file mode 100644 index 0000000..84474b7 --- /dev/null +++ b/models/email.go @@ -0,0 +1,53 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type Email struct { + gorm.Model + UserEmail string + FolderID uint + Folder Folder `gorm:"foreignKey:FolderID"` + + UID uint32 `gorm:"index"` + MessageID string `gorm:"index"` + + From string + FromName string + To string + CC string + BCC string + ReplyTo string + + Subject string + Date time.Time `gorm:"index"` + + BodyText string `gorm:"type:text"` + BodyHTML string `gorm:"type:text"` + Snippet string + + IsRead bool `gorm:"default:false;index"` + IsFlagged bool `gorm:"default:false;index"` + IsAnswered bool `gorm:"default:false"` + IsDraft bool `gorm:"default:false"` + HasAttachment bool `gorm:"default:false;index"` + + Size int64 + InReplyTo string + References string +} + +type Attachment struct { + gorm.Model + EmailID uint + Email Email `gorm:"foreignKey:EmailID"` + + Filename string + ContentType string + Size int64 + ContentID string + MinIOPath string `gorm:"index"` +} diff --git a/processors/preferences.go b/processors/preferences.go new file mode 100644 index 0000000..77bf3e1 --- /dev/null +++ b/processors/preferences.go @@ -0,0 +1,23 @@ +package processors + +import ( + "lain/repository" + "lain/session" + "lain/utils/auth" + + "github.com/gofiber/fiber/v2" +) + +func preferences(ctx *fiber.Ctx) error { + if auth.IsAuthenticated(ctx) { + email, err := session.GetSessionEmail(ctx) + if err == nil { + prefs, err := repository.GetPreferencesByEmail(email) + if err == nil { + ctx.Locals("Preferences", prefs) + } + } + } + + return ctx.Next() +} diff --git a/processors/processors.go b/processors/processors.go index d56cbde..20292a2 100644 --- a/processors/processors.go +++ b/processors/processors.go @@ -5,4 +5,5 @@ import "github.com/gofiber/fiber/v2" func Initialize(app *fiber.App) { app.Use(metadata) app.Use(request) + app.Use(preferences) } diff --git a/repository/email.go b/repository/email.go new file mode 100644 index 0000000..9203e40 --- /dev/null +++ b/repository/email.go @@ -0,0 +1,284 @@ +package repository + +import ( + "fmt" + "lain/database" + "lain/models" + "lain/utils/crypto" + "lain/utils/email" + "lain/utils/storage" + "net/url" + "strings" + + "github.com/gofiber/fiber/v2" +) + +func GetEmails(userEmail, folderPath string, limit int, offset int) ([]fiber.Map, error) { + // Decode URL-encoded path (e.g., "inbox/lets%20encrypt" -> "inbox/lets encrypt") + decodedPath, _ := url.QueryUnescape(folderPath) + + var folder models.Folder + err := database.DB.Where("user_email = ? AND LOWER(imap_name) = ?", userEmail, strings.ToLower(decodedPath)).First(&folder).Error + if err != nil { + return nil, fmt.Errorf("folder not found: %w", err) + } + + var count int64 + database.DB.Model(&models.Email{}).Where("user_email = ? AND folder_id = ?", userEmail, folder.ID).Count(&count) + + // Always sync if no emails exist + if count == 0 { + if err := syncEmails(userEmail, folder.ID, folder.IMAPName); err != nil { + // Log the error but continue to show UI + fmt.Printf("Failed to sync emails for folder %s: %v\n", folder.IMAPName, err) + } + // Recount after sync + database.DB.Model(&models.Email{}).Where("user_email = ? AND folder_id = ?", userEmail, folder.ID).Count(&count) + } + + var messages []models.Email + err = database.DB.Where("user_email = ? AND folder_id = ?", userEmail, folder.ID). + Order("date DESC"). + Limit(limit). + Offset(offset). + Find(&messages).Error + + if err != nil { + return nil, fmt.Errorf("failed to fetch emails: %w", err) + } + + var emailMaps []fiber.Map + for _, message := range messages { + emailMaps = append(emailMaps, fiber.Map{ + "ID": message.ID, + "UID": message.UID, + "From": message.From, + "FromName": message.FromName, + "Subject": message.Subject, + "Date": message.Date, + "Snippet": message.Snippet, + "IsRead": message.IsRead, + "IsFlagged": message.IsFlagged, + "HasAttachment": message.HasAttachment, + }) + } + + return emailMaps, nil +} + +func GetEmail(userEmail string, emailID uint) (*models.Email, error) { + var message models.Email + err := database.DB.Preload("Folder").Where("user_email = ? AND id = ?", userEmail, emailID).First(&message).Error + if err != nil { + return nil, fmt.Errorf("email not found: %w", err) + } + + return &message, nil +} + +func GetAttachments(emailID uint) ([]models.Attachment, error) { + var attachments []models.Attachment + err := database.DB.Where("email_id = ?", emailID).Find(&attachments).Error + if err != nil { + return nil, fmt.Errorf("failed to fetch attachments: %w", err) + } + + return attachments, nil +} + +func MarkEmailAsRead(userEmail string, emailID uint) error { + var message models.Email + err := database.DB.Preload("Folder").Where("user_email = ? AND id = ?", userEmail, emailID).First(&message).Error + if err != nil { + return fmt.Errorf("email not found: %w", err) + } + + if message.IsRead { + return nil + } + + var prefs models.Preferences + if err := database.DB.Where("email = ?", userEmail).First(&prefs).Error; err != nil { + return err + } + + password, err := crypto.Decrypt(prefs.Authorization) + if err != nil { + return err + } + + client, err := email.ConnectIMAP(userEmail, password) + if err != nil { + return err + } + defer email.DisconnectIMAP(client) + + if err := email.MarkAsRead(client, message.Folder.IMAPName, message.UID); err != nil { + return err + } + + message.IsRead = true + if err := database.DB.Save(&message).Error; err != nil { + return fmt.Errorf("failed to update email: %w", err) + } + + return nil +} + +func ToggleEmailFlag(userEmail string, emailID uint) error { + var message models.Email + err := database.DB.Preload("Folder").Where("user_email = ? AND id = ?", userEmail, emailID).First(&message).Error + if err != nil { + return fmt.Errorf("email not found: %w", err) + } + + var prefs models.Preferences + if err := database.DB.Where("email = ?", userEmail).First(&prefs).Error; err != nil { + return err + } + + password, err := crypto.Decrypt(prefs.Authorization) + if err != nil { + return err + } + + client, err := email.ConnectIMAP(userEmail, password) + if err != nil { + return err + } + defer email.DisconnectIMAP(client) + + if err := email.ToggleFlag(client, message.Folder.IMAPName, message.UID, message.IsFlagged); err != nil { + return err + } + + message.IsFlagged = !message.IsFlagged + if err := database.DB.Save(&message).Error; err != nil { + return fmt.Errorf("failed to update email: %w", err) + } + + return nil +} + +func syncEmails(userEmail string, folderID uint, folderPath string) error { + var prefs models.Preferences + if err := database.DB.Where("email = ?", userEmail).First(&prefs).Error; err != nil { + return err + } + + password, err := crypto.Decrypt(prefs.Authorization) + if err != nil { + return err + } + + client, err := email.ConnectIMAP(userEmail, password) + if err != nil { + return err + } + defer email.DisconnectIMAP(client) + + messages, err := email.FetchMessages(client, folderPath, 50) + if err != nil { + return fmt.Errorf("failed to fetch messages: %w", err) + } + + for _, msg := range messages { + var existingMessage models.Email + result := database.DB.Where("user_email = ? AND folder_id = ? AND uid = ?", userEmail, folderID, msg.UID).First(&existingMessage) + + if result.Error == nil { + continue + } + + snippet := generateSnippet(msg.BodyText, msg.BodyHTML) + + message := models.Email{ + UserEmail: userEmail, + FolderID: folderID, + UID: msg.UID, + MessageID: msg.MessageID, + From: msg.From, + FromName: msg.FromName, + To: strings.Join(msg.To, ", "), + CC: strings.Join(msg.CC, ", "), + BCC: strings.Join(msg.BCC, ", "), + ReplyTo: strings.Join(msg.ReplyTo, ", "), + Subject: msg.Subject, + Date: msg.Date, + BodyText: msg.BodyText, + BodyHTML: msg.BodyHTML, + Snippet: snippet, + Size: int64(msg.Size), + InReplyTo: msg.InReplyTo, + IsRead: msg.IsRead, + IsFlagged: msg.IsFlagged, + IsAnswered: msg.IsAnswered, + IsDraft: msg.IsDraft, + HasAttachment: msg.HasAttachment, + } + + if err := database.DB.Create(&message).Error; err != nil { + continue + } + + for _, att := range msg.Attachments { + path, err := storage.UploadAttachment(userEmail, message.ID, att.Filename, att.Data, att.ContentType) + if err != nil { + continue + } + + attachment := models.Attachment{ + EmailID: message.ID, + Filename: att.Filename, + ContentType: att.ContentType, + Size: int64(len(att.Data)), + MinIOPath: path, + } + + database.DB.Create(&attachment) + } + } + + return nil +} + +func generateSnippet(bodyText, bodyHTML string) string { + text := bodyText + if text == "" && bodyHTML != "" { + text = stripHTML(bodyHTML) + } + + text = strings.TrimSpace(text) + if len(text) > 150 { + text = text[:150] + "..." + } + + return text +} + +func stripHTML(html string) string { + text := html + text = strings.ReplaceAll(text, "<br>", "\n") + text = strings.ReplaceAll(text, "<br/>", "\n") + text = strings.ReplaceAll(text, "<br />", "\n") + text = strings.ReplaceAll(text, "</p>", "\n\n") + text = strings.ReplaceAll(text, "</div>", "\n") + + inTag := false + var result strings.Builder + for _, char := range text { + if char == '<' { + inTag = true + continue + } + if char == '>' { + inTag = false + continue + } + if !inTag { + result.WriteRune(char) + } + } + + return strings.TrimSpace(result.String()) +} diff --git a/repository/preferences.go b/repository/preferences.go index 2619152..2542923 100644 --- a/repository/preferences.go +++ b/repository/preferences.go @@ -26,6 +26,16 @@ func GetPreferences(formData types.LoginForm) (*models.Preferences, error) { return &preferences, nil } +func GetPreferencesByEmail(email string) (*models.Preferences, error) { + var preferences models.Preferences + + if err := database.DB.Where("email = ?", email).First(&preferences).Error; err != nil { + return nil, err + } + + return &preferences, nil +} + func CreateDefaultPreferences(formData types.LoginForm) (*models.Preferences, error) { preferences := models.Preferences{ Email: formData.Email, diff --git a/types/email.go b/types/email.go index 1d30a9b..e217c6f 100644 --- a/types/email.go +++ b/types/email.go @@ -1,6 +1,10 @@ package types -import "github.com/emersion/go-imap/client" +import ( + "time" + + "github.com/emersion/go-imap/client" +) type EmailClient struct { *client.Client @@ -15,3 +19,32 @@ type FolderIconVariant struct { Open string Close string } + +type EmailMessage struct { + UID uint32 + MessageID string + From string + FromName string + To []string + CC []string + BCC []string + ReplyTo []string + Subject string + Date time.Time + BodyText string + BodyHTML string + Size uint32 + InReplyTo string + IsRead bool + IsFlagged bool + IsAnswered bool + IsDraft bool + HasAttachment bool + Attachments []EmailAttachment +} + +type EmailAttachment struct { + Filename string + ContentType string + Data []byte +} diff --git a/utils/email/messages.go b/utils/email/messages.go new file mode 100644 index 0000000..cedbec5 --- /dev/null +++ b/utils/email/messages.go @@ -0,0 +1,230 @@ +package email + +import ( + "fmt" + "io" + "lain/types" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message/mail" +) + +func SelectFolder(client *types.EmailClient, folderName string) (*imap.MailboxStatus, error) { + mbox, err := client.Select(folderName, false) + if err != nil { + return nil, fmt.Errorf("failed to select folder: %w", err) + } + return mbox, nil +} + +func FetchMessages(client *types.EmailClient, folderName string, limit uint32) ([]*types.EmailMessage, error) { + mbox, err := SelectFolder(client, folderName) + if err != nil { + return nil, err + } + + if mbox.Messages == 0 { + return []*types.EmailMessage{}, nil + } + + from := uint32(1) + to := mbox.Messages + if mbox.Messages > limit { + from = mbox.Messages - limit + 1 + } + + seqset := new(imap.SeqSet) + seqset.AddRange(from, to) + + messages := make(chan *imap.Message, 10) + done := make(chan error, 1) + + section := &imap.BodySectionName{} + items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid, imap.FetchRFC822Size, section.FetchItem()} + + go func() { + done <- client.Fetch(seqset, items, messages) + }() + + var result []*types.EmailMessage + for msg := range messages { + if msg == nil { + continue + } + + emailMsg, err := parseMessage(msg) + if err != nil { + continue + } + + result = append(result, emailMsg) + } + + if err := <-done; err != nil { + return nil, fmt.Errorf("failed to fetch messages: %w", err) + } + + return result, nil +} + +func parseMessage(msg *imap.Message) (*types.EmailMessage, error) { + if msg.Envelope == nil { + return nil, fmt.Errorf("message envelope is nil") + } + + section := &imap.BodySectionName{} + bodyReader := msg.GetBody(section) + if bodyReader == nil { + return nil, fmt.Errorf("message body is nil") + } + + mr, err := mail.CreateReader(bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create mail reader: %w", err) + } + + var bodyText, bodyHTML string + var attachments []types.EmailAttachment + + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + continue + } + + switch h := part.Header.(type) { + case *mail.InlineHeader: + contentType, _, _ := h.ContentType() + body, _ := io.ReadAll(part.Body) + + if contentType == "text/plain" { + bodyText = string(body) + } else if contentType == "text/html" { + bodyHTML = string(body) + } + + case *mail.AttachmentHeader: + filename, _ := h.Filename() + contentType, _, _ := h.ContentType() + data, _ := io.ReadAll(part.Body) + + attachments = append(attachments, types.EmailAttachment{ + Filename: filename, + ContentType: contentType, + Data: data, + }) + } + } + + var fromAddr, fromName string + if len(msg.Envelope.From) > 0 { + fromAddr = msg.Envelope.From[0].MailboxName + "@" + msg.Envelope.From[0].HostName + fromName = msg.Envelope.From[0].PersonalName + } + + var toList []string + for _, addr := range msg.Envelope.To { + toList = append(toList, addr.MailboxName+"@"+addr.HostName) + } + + var ccList []string + for _, addr := range msg.Envelope.Cc { + ccList = append(ccList, addr.MailboxName+"@"+addr.HostName) + } + + var bccList []string + for _, addr := range msg.Envelope.Bcc { + bccList = append(bccList, addr.MailboxName+"@"+addr.HostName) + } + + var replyToList []string + for _, addr := range msg.Envelope.ReplyTo { + replyToList = append(replyToList, addr.MailboxName+"@"+addr.HostName) + } + + isRead := false + isFlagged := false + isAnswered := false + isDraft := false + + for _, flag := range msg.Flags { + switch flag { + case imap.SeenFlag: + isRead = true + case imap.FlaggedFlag: + isFlagged = true + case imap.AnsweredFlag: + isAnswered = true + case imap.DraftFlag: + isDraft = true + } + } + + return &types.EmailMessage{ + UID: msg.Uid, + MessageID: msg.Envelope.MessageId, + From: fromAddr, + FromName: fromName, + To: toList, + CC: ccList, + BCC: bccList, + ReplyTo: replyToList, + Subject: msg.Envelope.Subject, + Date: msg.Envelope.Date, + BodyText: bodyText, + BodyHTML: bodyHTML, + Size: msg.Size, + InReplyTo: msg.Envelope.InReplyTo, + IsRead: isRead, + IsFlagged: isFlagged, + IsAnswered: isAnswered, + IsDraft: isDraft, + HasAttachment: len(attachments) > 0, + Attachments: attachments, + }, nil +} + +func MarkAsRead(client *types.EmailClient, folderName string, uid uint32) error { + if _, err := SelectFolder(client, folderName); err != nil { + return err + } + + seqSet := new(imap.SeqSet) + seqSet.AddNum(uid) + + item := imap.FormatFlagsOp(imap.AddFlags, true) + flags := []interface{}{imap.SeenFlag} + + if err := client.UidStore(seqSet, item, flags, nil); err != nil { + return fmt.Errorf("failed to mark as read: %w", err) + } + + return nil +} + +func ToggleFlag(client *types.EmailClient, folderName string, uid uint32, isFlagged bool) error { + if _, err := SelectFolder(client, folderName); err != nil { + return err + } + + seqSet := new(imap.SeqSet) + seqSet.AddNum(uid) + + var item imap.StoreItem + if isFlagged { + item = imap.FormatFlagsOp(imap.RemoveFlags, true) + } else { + item = imap.FormatFlagsOp(imap.AddFlags, true) + } + + flags := []interface{}{imap.FlaggedFlag} + + if err := client.UidStore(seqSet, item, flags, nil); err != nil { + return fmt.Errorf("failed to toggle flag: %w", err) + } + + return nil +} diff --git a/utils/storage/minio.go b/utils/storage/minio.go new file mode 100644 index 0000000..0d6e364 --- /dev/null +++ b/utils/storage/minio.go @@ -0,0 +1,170 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + "io" + "lain/config" + "path/filepath" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +var minioClient *minio.Client + +func InitMinIO() error { + client, err := minio.New(config.MinIO.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(config.MinIO.AccessKey, config.MinIO.SecretKey, ""), + Secure: config.MinIO.UseSSL, + }) + if err != nil { + return fmt.Errorf("failed to create minio client: %w", err) + } + + ctx := context.Background() + exists, err := client.BucketExists(ctx, config.MinIO.BucketName) + if err != nil { + return fmt.Errorf("failed to check bucket existence: %w", err) + } + + if !exists { + err = client.MakeBucket(ctx, config.MinIO.BucketName, minio.MakeBucketOptions{}) + if err != nil { + return fmt.Errorf("failed to create bucket: %w", err) + } + } + + minioClient = client + return nil +} + +func UploadAttachment(userEmail string, emailID uint, filename string, data []byte, contentType string) (string, error) { + if minioClient == nil { + return "", fmt.Errorf("minio client not initialized") + } + + path := fmt.Sprintf("attachments/%s/%d/%s", userEmail, emailID, filename) + + ctx := context.Background() + + _, err := minioClient.PutObject( + ctx, + config.MinIO.BucketName, + path, + bytes.NewReader(data), + int64(len(data)), + minio.PutObjectOptions{ + ContentType: contentType, + }, + ) + + if err != nil { + return "", fmt.Errorf("failed to upload attachment: %w", err) + } + + return path, nil +} + +func DownloadAttachment(path string) ([]byte, error) { + if minioClient == nil { + return nil, fmt.Errorf("minio client not initialized") + } + + ctx := context.Background() + + object, err := minioClient.GetObject(ctx, config.MinIO.BucketName, path, minio.GetObjectOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get attachment object: %w", err) + } + defer object.Close() + + data, err := io.ReadAll(object) + if err != nil { + return nil, fmt.Errorf("failed to read attachment data: %w", err) + } + + return data, nil +} + +func DeleteAttachment(path string) error { + if minioClient == nil { + return fmt.Errorf("minio client not initialized") + } + + ctx := context.Background() + + err := minioClient.RemoveObject(ctx, config.MinIO.BucketName, path, minio.RemoveObjectOptions{}) + if err != nil { + return fmt.Errorf("failed to delete attachment: %w", err) + } + + return nil +} + +func DeleteAttachmentsByEmail(userEmail string, emailID uint) error { + if minioClient == nil { + return fmt.Errorf("minio client not initialized") + } + + ctx := context.Background() + prefix := fmt.Sprintf("attachments/%s/%d/", userEmail, emailID) + + objectCh := minioClient.ListObjects(ctx, config.MinIO.BucketName, minio.ListObjectsOptions{ + Prefix: prefix, + Recursive: true, + }) + + for object := range objectCh { + if object.Err != nil { + return fmt.Errorf("failed to list attachments: %w", object.Err) + } + + err := minioClient.RemoveObject(ctx, config.MinIO.BucketName, object.Key, minio.RemoveObjectOptions{}) + if err != nil { + return fmt.Errorf("failed to delete attachment %s: %w", object.Key, err) + } + } + + return nil +} + +func GetAttachmentURL(path string, expiryDuration time.Duration) (string, error) { + if minioClient == nil { + return "", fmt.Errorf("minio client not initialized") + } + + ctx := context.Background() + + url, err := minioClient.PresignedGetObject(ctx, config.MinIO.BucketName, path, expiryDuration, nil) + if err != nil { + return "", fmt.Errorf("failed to generate presigned url: %w", err) + } + + return url.String(), nil +} + +func GetAttachmentFilename(path string) string { + return filepath.Base(path) +} + +func AttachmentExists(path string) (bool, error) { + if minioClient == nil { + return false, fmt.Errorf("minio client not initialized") + } + + ctx := context.Background() + + _, err := minioClient.StatObject(ctx, config.MinIO.BucketName, path, minio.StatObjectOptions{}) + if err != nil { + errResponse := minio.ToErrorResponse(err) + if errResponse.Code == "NoSuchKey" { + return false, nil + } + return false, fmt.Errorf("failed to check attachment existence: %w", err) + } + + return true, nil +} |
