package internal import ( "database/sql" "fmt" "log/slog" "os" "regexp" "strings" "sync" "time" _ "github.com/mattn/go-sqlite3" ) var db *sql.DB // userDBs stores per-user database connections (keyed by user ID) var userDBs = make(map[string]*sql.DB) var userDBsMutex sync.RWMutex // truncateString truncates a string to maxLen characters for logging func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." } // SanitizeUsername converts a username to a safe filesystem name func SanitizeUsername(username string) string { // Convert to lowercase safe := strings.ToLower(username) // Replace spaces and special characters with underscores reg := regexp.MustCompile(`[^a-z0-9]+`) safe = reg.ReplaceAllString(safe, "_") // Remove leading/trailing underscores safe = strings.Trim(safe, "_") // Ensure it's not empty if safe == "" { safe = "user" } return safe } func InitDB(filepath string) error { var err error db, err = sql.Open("sqlite3", filepath) if err != nil { return err } if err = db.Ping(); err != nil { return err } createTableSQL := ` -- Unified table for SMS messages, MMS messages, and call logs -- record_type: 1 = SMS, 2 = MMS, 3 = call CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, record_type INTEGER NOT NULL DEFAULT 1, address TEXT NOT NULL, body TEXT, type INTEGER NOT NULL, date INTEGER NOT NULL, read INTEGER DEFAULT 0, thread_id INTEGER, subject TEXT, media_type TEXT, media_data BLOB, protocol INTEGER, status INTEGER, service_center TEXT, sub_id INTEGER, contact_name TEXT, sender TEXT, content_type TEXT, read_report INTEGER, read_status INTEGER, message_id TEXT, message_size INTEGER, message_type INTEGER, sim_slot INTEGER, addresses TEXT, duration INTEGER, presentation INTEGER, subscription_id TEXT ); CREATE INDEX IF NOT EXISTS idx_address ON messages(address); CREATE INDEX IF NOT EXISTS idx_date ON messages(date); CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id); CREATE INDEX IF NOT EXISTS idx_record_type ON messages(record_type); CREATE INDEX IF NOT EXISTS idx_record_type_date ON messages(record_type, date); -- Create unique constraints for idempotent imports -- record_type differentiates SMS (1), MMS (2), and calls (3) CREATE UNIQUE INDEX IF NOT EXISTS idx_message_unique ON messages(record_type, address, date, type, COALESCE(body, ''), COALESCE(content_type, ''), COALESCE(message_id, ''), COALESCE(duration, 0)); -- Create FTS5 virtual table for full-text search of messages CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( message_id UNINDEXED, address UNINDEXED, body, contact_name UNINDEXED, date UNINDEXED, content='messages', content_rowid='id' ); -- Create triggers to keep FTS table in sync CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date) VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date); END; CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date) VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date); END; CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date) VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date); INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date) VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date); END; ` _, err = db.Exec(createTableSQL) if err != nil { return err } slog.Info("Database initialized successfully") return nil } // InitUserDB initializes a database for a specific user func InitUserDB(userID string, filepath string) error { userDB, err := sql.Open("sqlite3", filepath) if err != nil { return err } if err = userDB.Ping(); err != nil { return err } createTableSQL := ` -- Unified table for SMS messages, MMS messages, and call logs -- record_type: 1 = SMS, 2 = MMS, 3 = call CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, record_type INTEGER NOT NULL DEFAULT 1, address TEXT NOT NULL, body TEXT, type INTEGER NOT NULL, date INTEGER NOT NULL, read INTEGER DEFAULT 0, thread_id INTEGER, subject TEXT, media_type TEXT, media_data BLOB, protocol INTEGER, status INTEGER, service_center TEXT, sub_id INTEGER, contact_name TEXT, sender TEXT, content_type TEXT, read_report INTEGER, read_status INTEGER, message_id TEXT, message_size INTEGER, message_type INTEGER, sim_slot INTEGER, addresses TEXT, duration INTEGER, presentation INTEGER, subscription_id TEXT ); CREATE INDEX IF NOT EXISTS idx_address ON messages(address); CREATE INDEX IF NOT EXISTS idx_date ON messages(date); CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id); CREATE INDEX IF NOT EXISTS idx_record_type ON messages(record_type); CREATE INDEX IF NOT EXISTS idx_record_type_date ON messages(record_type, date); -- record_type differentiates SMS (1), MMS (2), and calls (3) CREATE UNIQUE INDEX IF NOT EXISTS idx_message_unique ON messages(record_type, address, date, type, COALESCE(body, ''), COALESCE(content_type, ''), COALESCE(message_id, ''), COALESCE(duration, 0)); CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( message_id UNINDEXED, address UNINDEXED, body, contact_name UNINDEXED, date UNINDEXED, content='messages', content_rowid='id' ); CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date) VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date); END; CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date) VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date); END; CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date) VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date); INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date) VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date); END; ` _, err = userDB.Exec(createTableSQL) if err != nil { return err } // Store in map userDBsMutex.Lock() userDBs[userID] = userDB userDBsMutex.Unlock() slog.Info("User database initialized", "user_id", userID, "path", filepath) return nil } // GetUserDB retrieves the database connection for a specific user func GetUserDB(userID string, username string) (*sql.DB, error) { userDBsMutex.RLock() defer userDBsMutex.RUnlock() userDB, exists := userDBs[userID] if !exists { // Try to open the database if it exists dbPathPrefix := os.Getenv("DB_PATH_PREFIX") if dbPathPrefix == "" { dbPathPrefix = "." } // Use UUID as database filename instead of sanitized username filepath := fmt.Sprintf("%s/sbv_%s.db", dbPathPrefix, userID) if _, err := os.Stat(filepath); err == nil { // Database file exists, try to open it userDBsMutex.RUnlock() if err := InitUserDB(userID, filepath); err != nil { userDBsMutex.RLock() return nil, fmt.Errorf("failed to open user database: %w", err) } userDBsMutex.RLock() userDB = userDBs[userID] } else { return nil, fmt.Errorf("user database not found for user %s", username) } } return userDB, nil } func InsertMessage(userDB *sql.DB, msg *Message) error { // Convert addresses slice to JSON string var addressesJSON string if len(msg.Addresses) > 0 { addresses := strings.Join(msg.Addresses, ",") addressesJSON = addresses } // Determine record type: 1 = SMS, 2 = MMS // MMS messages have ContentType set (e.g., 'application/vnd.wap.multipart.related') // SMS messages do not have ContentType recordType := 1 // Default to SMS if msg.ContentType != "" { recordType = 2 // MMS } query := ` INSERT INTO messages ( record_type, address, body, type, date, read, thread_id, subject, media_type, media_data, protocol, status, service_center, sub_id, contact_name, sender, content_type, read_report, read_status, message_id, message_size, message_type, sim_slot, addresses ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING ` result, err := userDB.Exec(query, recordType, // record_type: 1 = SMS, 2 = MMS msg.Address, msg.Body, msg.Type, msg.Date.Unix(), msg.Read, msg.ThreadID, msg.Subject, msg.MediaType, msg.MediaData, msg.Protocol, msg.Status, msg.ServiceCenter, msg.SubID, msg.ContactName, msg.Sender, msg.ContentType, msg.ReadReport, msg.ReadStatus, msg.MessageID, msg.MessageSize, msg.MessageType, msg.SimSlot, addressesJSON, ) if err != nil { slog.Debug("InsertMessage: Error inserting message", "error", err) return err } id, err := result.LastInsertId() if err != nil { return err } msg.ID = id return nil } func InsertCallLog(userDB *sql.DB, call *CallLog) error { query := ` INSERT INTO messages (record_type, address, type, date, duration, presentation, subscription_id, contact_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING ` result, err := userDB.Exec(query, 3, // record_type: 3 = call call.Number, call.Type, call.Date.Unix(), call.Duration, call.Presentation, call.SubscriptionID, call.ContactName, ) if err != nil { return err } id, err := result.LastInsertId() if err != nil { return err } call.ID = id return nil } // InsertCallLogBatch inserts multiple call logs in a single transaction for better performance func InsertCallLogBatch(userDB *sql.DB, calls []CallLog) error { if len(calls) == 0 { return nil } tx, err := userDB.Begin() if err != nil { return err } defer tx.Rollback() stmt, err := tx.Prepare(` INSERT INTO messages (record_type, address, type, date, duration, presentation, subscription_id, contact_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING `) if err != nil { return err } defer stmt.Close() for i := range calls { _, err := stmt.Exec( 3, // record_type: 3 = call calls[i].Number, calls[i].Type, calls[i].Date.Unix(), calls[i].Duration, calls[i].Presentation, calls[i].SubscriptionID, calls[i].ContactName, ) if err != nil { return err } } return tx.Commit() } func GetConversations(userDB *sql.DB, startDate, endDate *time.Time) ([]Conversation, error) { // Build a query that groups all activity (messages and calls) by address query := ` SELECT address, MAX(COALESCE(contact_name, '')) as contact_name, ( SELECT COALESCE(subject, '') FROM messages m2 WHERE m2.address = messages.address AND m2.subject IS NOT NULL AND m2.subject != '' ORDER BY date DESC LIMIT 1 ) as subject, ( SELECT CASE WHEN record_type = 1 THEN body -- SMS WHEN record_type = 2 THEN body -- MMS WHEN record_type = 3 AND type = 1 THEN 'Incoming call' WHEN record_type = 3 AND type = 2 THEN 'Outgoing call' WHEN record_type = 3 AND type = 3 THEN 'Missed call' WHEN record_type = 3 AND type = 4 THEN 'Voicemail' WHEN record_type = 3 AND type = 5 THEN 'Rejected call' WHEN record_type = 3 AND type = 6 THEN 'Refused call' ELSE 'Call' END FROM messages m3 WHERE m3.address = messages.address ORDER BY date DESC LIMIT 1 ) as last_message, MAX(date) as last_date, COUNT(*) as activity_count FROM messages WHERE 1=1 ` args := []interface{}{} if startDate != nil { query += " AND date >= ?" args = append(args, startDate.Unix()) } if endDate != nil { query += " AND date <= ?" args = append(args, endDate.Unix()) } query += ` GROUP BY address ORDER BY last_date DESC ` rows, err := userDB.Query(query, args...) if err != nil { return nil, err } defer rows.Close() conversations := []Conversation{} for rows.Next() { var c Conversation var lastDateUnix int64 var subject sql.NullString err := rows.Scan(&c.Address, &c.ContactName, &subject, &c.LastMessage, &lastDateUnix, &c.MessageCount) if err != nil { return nil, err } c.LastDate = time.Unix(lastDateUnix, 0) c.Subject = subject.String c.Type = "conversation" // Changed from "message" or "call" to indicate it's a merged conversation conversations = append(conversations, c) } return conversations, nil } func formatCallType(callType int) string { switch callType { case 1: return "Incoming call" case 2: return "Outgoing call" case 3: return "Missed call" case 4: return "Voicemail" case 5: return "Rejected call" case 6: return "Refused call" default: return "Call" } } func GetMessages(userDB *sql.DB, address string, startDate, endDate *time.Time) ([]Message, error) { query := ` SELECT id, address, body, type, date, read, thread_id, COALESCE(subject, ''), COALESCE(media_type, ''), COALESCE(media_data, ''), COALESCE(protocol, 0), COALESCE(status, 0), COALESCE(service_center, ''), COALESCE(sub_id, 0), COALESCE(contact_name, ''), COALESCE(sender, ''), COALESCE(content_type, ''), COALESCE(read_report, 0), COALESCE(read_status, 0), COALESCE(message_id, ''), COALESCE(message_size, 0), COALESCE(message_type, 0), COALESCE(sim_slot, 0), COALESCE(addresses, '') FROM messages WHERE record_type IN (1, 2) AND address = ? -- 1 = SMS, 2 = MMS ` args := []interface{}{address} if startDate != nil { query += " AND date >= ?" args = append(args, startDate.Unix()) } if endDate != nil { query += " AND date <= ?" args = append(args, endDate.Unix()) } query += " ORDER BY date ASC" slog.Debug("GetMessages: executing query", "address", address) slog.Debug("GetMessages: SQL query", "query", query) slog.Debug("GetMessages: query arguments", "args", args) rows, err := userDB.Query(query, args...) if err != nil { slog.Debug("GetMessages: Query error", "error", err) return nil, err } defer rows.Close() messages := []Message{} for rows.Next() { var m Message var dateUnix int64 var readInt int var addressesStr string err := rows.Scan(&m.ID, &m.Address, &m.Body, &m.Type, &dateUnix, &readInt, &m.ThreadID, &m.Subject, &m.MediaType, &m.MediaData, &m.Protocol, &m.Status, &m.ServiceCenter, &m.SubID, &m.ContactName, &m.Sender, &m.ContentType, &m.ReadReport, &m.ReadStatus, &m.MessageID, &m.MessageSize, &m.MessageType, &m.SimSlot, &addressesStr) if err != nil { return nil, err } m.Date = time.Unix(dateUnix, 0) m.Read = readInt == 1 // Parse addresses from comma-separated string if addressesStr != "" { m.Addresses = strings.Split(addressesStr, ",") } // Don't load media data - it will be fetched on demand via /api/media // Clear MediaData to save memory in response m.MediaData = nil slog.Debug("GetMessages: Message", "id", m.ID, "address", m.Address, "media_type", m.MediaType, "body", truncateString(m.Body, 50)) messages = append(messages, m) } slog.Debug("GetMessages: Returning messages", "count", len(messages), "address", address) return messages, nil } func GetCallLogs(userDB *sql.DB, number string, startDate, endDate *time.Time) ([]CallLog, error) { query := ` SELECT id, address, duration, date, type, COALESCE(presentation, 0), COALESCE(subscription_id, ''), COALESCE(contact_name, '') FROM messages WHERE record_type = 3 AND address = ? -- 3 = call ` args := []interface{}{number} if startDate != nil { query += " AND date >= ?" args = append(args, startDate.Unix()) } if endDate != nil { query += " AND date <= ?" args = append(args, endDate.Unix()) } query += " ORDER BY date ASC" rows, err := userDB.Query(query, args...) if err != nil { return nil, err } defer rows.Close() calls := []CallLog{} for rows.Next() { var c CallLog var dateUnix int64 err := rows.Scan(&c.ID, &c.Number, &c.Duration, &dateUnix, &c.Type, &c.Presentation, &c.SubscriptionID, &c.ContactName) if err != nil { return nil, err } c.Date = time.Unix(dateUnix, 0) calls = append(calls, c) } return calls, nil } func GetActivity(userDB *sql.DB, startDate, endDate *time.Time, limit, offset int) ([]ActivityItem, error) { return GetActivityByAddress(userDB, "", startDate, endDate, limit, offset) } func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time, limit, offset int) ([]ActivityItem, error) { var activities []ActivityItem // Query from unified table query := ` SELECT record_type, date, address, COALESCE(contact_name, '') as contact_name, id, body, type, read, thread_id, COALESCE(subject, ''), COALESCE(media_type, ''), COALESCE(media_data, ''), COALESCE(protocol, 0), COALESCE(status, 0), COALESCE(service_center, ''), COALESCE(sub_id, 0), COALESCE(content_type, ''), COALESCE(read_report, 0), COALESCE(read_status, 0), COALESCE(message_id, ''), COALESCE(message_size, 0), COALESCE(message_type, 0), COALESCE(sim_slot, 0), COALESCE(addresses, ''), COALESCE(duration, 0), COALESCE(presentation, 0), COALESCE(subscription_id, ''), COALESCE(sender, '') FROM messages WHERE 1=1 ` args := []interface{}{} if address != "" { query += " AND address = ?" args = append(args, address) } if startDate != nil { query += " AND date >= ?" args = append(args, startDate.Unix()) } if endDate != nil { query += " AND date <= ?" args = append(args, endDate.Unix()) } query += " ORDER BY date ASC LIMIT ? OFFSET ?" args = append(args, limit, offset) slog.Debug("GetActivityByAddress: executing query", "address", address, "limit", limit, "offset", offset) slog.Debug("GetActivityByAddress: SQL query", "query", query) slog.Debug("GetActivityByAddress: query arguments", "args", args) rows, err := userDB.Query(query, args...) if err != nil { slog.Debug("GetActivityByAddress: Query error", "error", err) return nil, err } defer rows.Close() for rows.Next() { var recordType int64 var dateUnix int64 var address, contactName string // Shared fields var id sql.NullInt64 var itemType sql.NullInt64 // type field - used for both message type and call type // Message fields var body, subject, mediaType, serviceCenter, contentType, messageID, subscriptionID, addressesStr, sender sql.NullString var readInt, threadID, protocol, status, subID, readReport, readStatus, messageSize, messageTypeField, simSlot sql.NullInt64 var mediaData []byte // Call fields var duration, presentation sql.NullInt64 err := rows.Scan(&recordType, &dateUnix, &address, &contactName, &id, &body, &itemType, &readInt, &threadID, &subject, &mediaType, &mediaData, &protocol, &status, &serviceCenter, &subID, &contentType, &readReport, &readStatus, &messageID, &messageSize, &messageTypeField, &simSlot, &addressesStr, &duration, &presentation, &subscriptionID, &sender) if err != nil { return nil, err } var activityTypeStr string if recordType == 1 || recordType == 2 { // 1 = SMS, 2 = MMS activityTypeStr = "message" } else if recordType == 3 { // 3 = call activityTypeStr = "call" } activity := ActivityItem{ Type: activityTypeStr, Date: time.Unix(dateUnix, 0), Address: address, ContactName: contactName, } if (recordType == 1 || recordType == 2) && id.Valid { // Handle SMS (1) and MMS (2) msg := &Message{ ID: id.Int64, Address: address, Body: body.String, Date: time.Unix(dateUnix, 0), ThreadID: int(threadID.Int64), Subject: subject.String, MediaType: mediaType.String, MediaData: mediaData, Protocol: int(protocol.Int64), Status: int(status.Int64), ServiceCenter: serviceCenter.String, SubID: int(subID.Int64), ContactName: contactName, ContentType: contentType.String, ReadReport: int(readReport.Int64), ReadStatus: int(readStatus.Int64), MessageID: messageID.String, MessageSize: int(messageSize.Int64), MessageType: int(messageTypeField.Int64), SimSlot: int(simSlot.Int64), Sender: sender.String, } if itemType.Valid { msg.Type = int(itemType.Int64) } if readInt.Valid { msg.Read = readInt.Int64 == 1 } // Parse addresses from comma-separated string slog.Debug("GetActivityByAddress: addressesStr raw", "id", id.Int64, "valid", addressesStr.Valid, "value", addressesStr.String) if addressesStr.Valid && addressesStr.String != "" { msg.Addresses = strings.Split(addressesStr.String, ",") slog.Debug("GetActivityByAddress: addresses split result", "id", id.Int64, "count", len(msg.Addresses), "values", msg.Addresses) } else if strings.Contains(address, ",") { // Fallback: If addresses field is empty but address contains commas, // this is a group conversation - parse the address field msg.Addresses = strings.Split(address, ",") slog.Debug("GetActivityByAddress: addresses from address field", "id", id.Int64, "count", len(msg.Addresses), "values", msg.Addresses) } // Don't load media data - it will be fetched on demand via /api/media // Clear MediaData to save memory in response msg.MediaData = nil slog.Debug("GetActivityByAddress: Message", "id", msg.ID, "address", msg.Address, "type", msg.Type, "sender", msg.Sender, "addresses", msg.Addresses, "media_type", msg.MediaType, "body", truncateString(msg.Body, 50)) activity.Message = msg } else if recordType == 3 && id.Valid { // Handle calls (3) call := &CallLog{ ID: id.Int64, Number: address, Duration: int(duration.Int64), Date: time.Unix(dateUnix, 0), Type: int(itemType.Int64), Presentation: int(presentation.Int64), SubscriptionID: subscriptionID.String, ContactName: contactName, } slog.Debug("GetActivityByAddress: Call", "id", call.ID, "number", call.Number, "type", call.Type, "duration", call.Duration) activity.Call = call } activities = append(activities, activity) } slog.Debug("GetActivityByAddress: Returning activities", "count", len(activities), "address", address) return activities, nil } func GetMessageMedia(userDB *sql.DB, messageID string) ([]byte, string, error) { query := ` SELECT COALESCE(media_data, ''), COALESCE(media_type, '') FROM messages WHERE id = ? AND record_type IN (1, 2) -- 1 = SMS, 2 = MMS ` slog.Debug("GetMessageMedia: Fetching media", "message_id", messageID) slog.Debug("GetMessageMedia: SQL query", "query", query) var mediaData []byte var mediaType string err := userDB.QueryRow(query, messageID).Scan(&mediaData, &mediaType) if err != nil { slog.Debug("GetMessageMedia: Error scanning row", "message_id", messageID, "error", err) return nil, "", err } slog.Debug("GetMessageMedia: Found media", "media_type", mediaType, "data_length", len(mediaData), "message_id", messageID) if len(mediaData) == 0 || mediaType == "" { slog.Debug("GetMessageMedia: No media found", "message_id", messageID) return nil, "", fmt.Errorf("no media found") } // Convert HEIC to JPEG if needed if isHEICContentType(mediaType) { convertedData, err := convertHEICtoJPEG(mediaData) if err != nil { slog.Error("Failed to convert HEIC to JPEG", "message_id", messageID, "error", err) // Return original if conversion fails return mediaData, mediaType, nil } return convertedData, "image/jpeg", nil } // Convert unsupported video formats (3GP, etc.) to MP4 if needed if needsVideoConversion(mediaType) { slog.Info("Converting video to MP4", "from_type", mediaType, "message_id", messageID) convertedData, err := convertVideoToMP4(mediaData) if err != nil { slog.Error("Failed to convert video to MP4", "message_id", messageID, "error", err) // Return original if conversion fails return mediaData, mediaType, nil } slog.Info("Successfully converted video to MP4", "message_id", messageID) return convertedData, "video/mp4", nil } return mediaData, mediaType, nil } func GetDateRange(userDB *sql.DB) (time.Time, time.Time, error) { var minDate, maxDate int64 // Get min/max from unified messages table query := "SELECT MIN(date), MAX(date) FROM messages" var min, max sql.NullInt64 err := userDB.QueryRow(query).Scan(&min, &max) if err != nil && err != sql.ErrNoRows { return time.Time{}, time.Time{}, err } if !min.Valid || !max.Valid { return time.Time{}, time.Time{}, fmt.Errorf("no data available") } minDate = min.Int64 maxDate = max.Int64 return time.Unix(minDate, 0), time.Unix(maxDate, 0), nil } // SearchResult represents a message search result type SearchResult struct { MessageID int64 `json:"message_id"` Address string `json:"address"` ContactName string `json:"contact_name"` Body string `json:"body"` Date time.Time `json:"date"` Snippet string `json:"snippet"` } // SearchMessages performs full-text search on message contents func SearchMessages(userDB *sql.DB, query string, limit int) ([]SearchResult, error) { if query == "" { return []SearchResult{}, nil } sqlQuery := ` SELECT m.id, m.address, COALESCE(m.contact_name, ''), m.body, m.date, snippet(messages_fts, 2, '', '', '...', 50) as snippet FROM messages_fts JOIN messages m ON messages_fts.rowid = m.id WHERE messages_fts MATCH ? ORDER BY rank LIMIT ? ` rows, err := userDB.Query(sqlQuery, query, limit) if err != nil { return nil, err } defer rows.Close() results := []SearchResult{} for rows.Next() { var r SearchResult var dateUnix int64 err := rows.Scan(&r.MessageID, &r.Address, &r.ContactName, &r.Body, &dateUnix, &r.Snippet) if err != nil { return nil, err } r.Date = time.Unix(dateUnix, 0) results = append(results, r) } return results, nil }