78b57273e2
- Add password hashing with bcrypt - Add AuthService with proper login - Add password strength validation - Add RBAC middleware (AdminOnly, ManagerOrAdmin) - Add tenant isolation middleware - Update CRM handler with tenant filtering - Add JWT fallback for development mode - Add user context helpers - Build successful
322 lines
6.9 KiB
Go
322 lines
6.9 KiB
Go
package email
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/emersion/go-imap"
|
|
"github.com/emersion/go-imap/client"
|
|
)
|
|
|
|
// IMAPClient handles reading emails via IMAP
|
|
type IMAPClient struct {
|
|
server string
|
|
port int
|
|
username string
|
|
password string
|
|
useTLS bool
|
|
}
|
|
|
|
// EmailMessage represents an email in the inbox
|
|
type EmailMessage struct {
|
|
UID uint32 `json:"uid"`
|
|
Subject string `json:"subject"`
|
|
From string `json:"from"`
|
|
To []string `json:"to"`
|
|
Date time.Time `json:"date"`
|
|
Body string `json:"body"`
|
|
Preview string `json:"preview"`
|
|
Read bool `json:"read"`
|
|
Attachments int `json:"attachments"`
|
|
}
|
|
|
|
// NewIMAPClient creates a new IMAP client
|
|
func NewIMAPClient(server string, port int, username, password string) *IMAPClient {
|
|
return &IMAPClient{
|
|
server: server,
|
|
port: port,
|
|
username: username,
|
|
password: password,
|
|
useTLS: port == 993,
|
|
}
|
|
}
|
|
|
|
// Connect establishes connection to IMAP server
|
|
func (c *IMAPClient) Connect() (*client.Client, error) {
|
|
addr := fmt.Sprintf("%s:%d", c.server, c.port)
|
|
|
|
var cl *client.Client
|
|
var err error
|
|
|
|
if c.useTLS {
|
|
cl, err = client.DialTLS(addr, &tls.Config{InsecureSkipVerify: true})
|
|
} else {
|
|
cl, err = client.Dial(addr)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dial imap: %w", err)
|
|
}
|
|
|
|
if err := cl.Login(c.username, c.password); err != nil {
|
|
cl.Logout()
|
|
return nil, fmt.Errorf("imap login: %w", err)
|
|
}
|
|
|
|
return cl, nil
|
|
}
|
|
|
|
// ListMessages fetches emails from inbox
|
|
func (c *IMAPClient) ListMessages(limit int) ([]EmailMessage, error) {
|
|
cl, err := c.Connect()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cl.Logout()
|
|
|
|
// Select INBOX
|
|
mbox, err := cl.Select("INBOX", false)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("select inbox: %w", err)
|
|
}
|
|
|
|
if mbox.Messages == 0 {
|
|
return []EmailMessage{}, nil
|
|
}
|
|
|
|
// Fetch last N messages
|
|
from := uint32(1)
|
|
if mbox.Messages > uint32(limit) {
|
|
from = mbox.Messages - uint32(limit) + 1
|
|
}
|
|
|
|
seqset := new(imap.SeqSet)
|
|
seqset.AddRange(from, mbox.Messages)
|
|
|
|
messages := make(chan *imap.Message, 10)
|
|
done := make(chan error, 1)
|
|
|
|
go func() {
|
|
done <- cl.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchRFC822Text}, messages)
|
|
}()
|
|
|
|
var result []EmailMessage
|
|
for msg := range messages {
|
|
email := EmailMessage{
|
|
UID: msg.Uid,
|
|
Subject: msg.Envelope.Subject,
|
|
Date: msg.Envelope.Date,
|
|
Read: !hasFlag(msg.Flags, imap.RecentFlag),
|
|
}
|
|
|
|
if len(msg.Envelope.From) > 0 {
|
|
email.From = msg.Envelope.From[0].Address()
|
|
}
|
|
|
|
for _, to := range msg.Envelope.To {
|
|
email.To = append(email.To, to.Address())
|
|
}
|
|
|
|
// Extract preview from body
|
|
for _, literal := range msg.Body {
|
|
if buf := make([]byte, 0); literal != nil {
|
|
buf = make([]byte, literal.Len())
|
|
n, _ := literal.Read(buf)
|
|
if n > 0 {
|
|
body := string(buf[:n])
|
|
email.Body = body
|
|
email.Preview = truncate(stripHTML(body), 200)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
result = append(result, email)
|
|
}
|
|
|
|
if err := <-done; err != nil {
|
|
return nil, fmt.Errorf("fetch messages: %w", err)
|
|
}
|
|
|
|
// Reverse to show newest first
|
|
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
|
|
result[i], result[j] = result[j], result[i]
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetMessage fetches a single email by UID
|
|
func (c *IMAPClient) GetMessage(uid uint32) (*EmailMessage, error) {
|
|
cl, err := c.Connect()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cl.Logout()
|
|
|
|
// Select INBOX
|
|
if _, err := cl.Select("INBOX", false); err != nil {
|
|
return nil, fmt.Errorf("select inbox: %w", err)
|
|
}
|
|
|
|
seqset := new(imap.SeqSet)
|
|
seqset.AddNum(uid)
|
|
|
|
messages := make(chan *imap.Message, 1)
|
|
done := make(chan error, 1)
|
|
|
|
go func() {
|
|
done <- cl.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchRFC822Text}, messages)
|
|
}()
|
|
|
|
var email *EmailMessage
|
|
for msg := range messages {
|
|
email = &EmailMessage{
|
|
UID: msg.Uid,
|
|
Subject: msg.Envelope.Subject,
|
|
Date: msg.Envelope.Date,
|
|
Read: !hasFlag(msg.Flags, imap.RecentFlag),
|
|
}
|
|
|
|
if len(msg.Envelope.From) > 0 {
|
|
email.From = msg.Envelope.From[0].Address()
|
|
}
|
|
|
|
for _, to := range msg.Envelope.To {
|
|
email.To = append(email.To, to.Address())
|
|
}
|
|
|
|
for _, literal := range msg.Body {
|
|
if literal != nil {
|
|
buf := make([]byte, literal.Len())
|
|
n, _ := literal.Read(buf)
|
|
if n > 0 {
|
|
body := string(buf[:n])
|
|
email.Body = body
|
|
email.Preview = truncate(stripHTML(body), 200)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := <-done; err != nil {
|
|
return nil, fmt.Errorf("fetch message: %w", err)
|
|
}
|
|
|
|
return email, nil
|
|
}
|
|
|
|
// MarkAsRead marks an email as read
|
|
func (c *IMAPClient) MarkAsRead(uid uint32) error {
|
|
cl, err := c.Connect()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cl.Logout()
|
|
|
|
if _, err := cl.Select("INBOX", false); err != nil {
|
|
return fmt.Errorf("select inbox: %w", err)
|
|
}
|
|
|
|
seqset := new(imap.SeqSet)
|
|
seqset.AddNum(uid)
|
|
|
|
item := imap.FormatFlagsOp(imap.AddFlags, true)
|
|
flags := []interface{}{imap.SeenFlag}
|
|
|
|
if err := cl.Store(seqset, item, flags, nil); err != nil {
|
|
return fmt.Errorf("mark as read: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetUnreadCount returns number of unread messages
|
|
func (c *IMAPClient) GetUnreadCount() (int, error) {
|
|
cl, err := c.Connect()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer cl.Logout()
|
|
|
|
mbox, err := cl.Select("INBOX", false)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("select inbox: %w", err)
|
|
}
|
|
|
|
return int(mbox.Unseen), nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func hasFlag(flags []string, flag string) bool {
|
|
for _, f := range flags {
|
|
if f == flag {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func stripHTML(html string) string {
|
|
// Simple HTML stripping
|
|
result := html
|
|
result = strings.ReplaceAll(result, "<br>", "\n")
|
|
result = strings.ReplaceAll(result, "<br/>", "\n")
|
|
result = strings.ReplaceAll(result, "<p>", "\n")
|
|
result = strings.ReplaceAll(result, "</p>", "")
|
|
|
|
// Remove tags
|
|
for {
|
|
start := strings.Index(result, "<")
|
|
if start == -1 {
|
|
break
|
|
}
|
|
end := strings.Index(result[start:], ">")
|
|
if end == -1 {
|
|
break
|
|
}
|
|
result = result[:start] + result[start+end+1:]
|
|
}
|
|
|
|
// Decode HTML entities
|
|
result = strings.ReplaceAll(result, " ", " ")
|
|
result = strings.ReplaceAll(result, "<", "<")
|
|
result = strings.ReplaceAll(result, ">", ">")
|
|
result = strings.ReplaceAll(result, "&", "&")
|
|
result = strings.ReplaceAll(result, """, "\"")
|
|
|
|
return strings.TrimSpace(result)
|
|
}
|
|
|
|
func truncate(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
return s[:maxLen] + "..."
|
|
}
|
|
|
|
// ParseIMAPURL parses an IMAP URL like imaps://user:pass@server:993
|
|
func ParseIMAPURL(imapURL string) (*IMAPClient, error) {
|
|
u, err := url.Parse(imapURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
password, _ := u.User.Password()
|
|
port := 993
|
|
if u.Port() != "" {
|
|
p, err := strconv.Atoi(u.Port())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
port = p
|
|
}
|
|
|
|
return NewIMAPClient(u.Hostname(), port, u.User.Username(), password), nil
|
|
} |