summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--controllers/mail.go17
-rw-r--r--database/migrate.go1
-rw-r--r--go.mod14
-rw-r--r--go.sum25
-rw-r--r--lain/main.go6
-rw-r--r--models/email.go53
-rw-r--r--processors/preferences.go23
-rw-r--r--processors/processors.go1
-rw-r--r--repository/email.go284
-rw-r--r--repository/preferences.go10
-rw-r--r--types/email.go35
-rw-r--r--utils/email/messages.go230
-rw-r--r--utils/storage/minio.go170
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
}
diff --git a/go.mod b/go.mod
index 6b693e1..2b7e1dd 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index f3f7658..c980f8c 100644
--- a/go.sum
+++ b/go.sum
@@ -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
+}