mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 08:36:01 +01:00
Generally Working app
This commit is contained in:
@@ -172,6 +172,8 @@ func (d *DB) Migrate() error {
|
||||
// Default: primary folder types sync by default, others don't.
|
||||
`ALTER TABLE folders ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE folders ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 1`,
|
||||
// Plaintext search index column — stores decrypted subject+from+preview for LIKE search.
|
||||
`ALTER TABLE messages ADD COLUMN search_text TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
for _, stmt := range alterStmts {
|
||||
d.sql.Exec(stmt) // ignore "duplicate column" errors intentionally
|
||||
@@ -763,19 +765,28 @@ func (d *DB) UpsertMessage(m *models.Message) error {
|
||||
bodyTextEnc, _ := d.enc.Encrypt(m.BodyText)
|
||||
bodyHTMLEnc, _ := d.enc.Encrypt(m.BodyHTML)
|
||||
|
||||
// Build plaintext search index: subject + from name + from email + first 200 chars of body
|
||||
preview := m.BodyText
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200]
|
||||
}
|
||||
searchText := strings.ToLower(m.Subject + " " + m.FromName + " " + m.FromEmail + " " + preview)
|
||||
|
||||
res, err := d.sql.Exec(`
|
||||
INSERT INTO messages
|
||||
(account_id, folder_id, remote_uid, thread_id, message_id,
|
||||
subject, from_name, from_email, to_list, cc_list, bcc_list, reply_to,
|
||||
body_text, body_html, date, is_read, is_starred, is_draft, has_attachment)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
body_text, body_html, date, is_read, is_starred, is_draft, has_attachment, search_text)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(account_id, folder_id, remote_uid) DO UPDATE SET
|
||||
is_read=excluded.is_read,
|
||||
is_starred=excluded.is_starred`,
|
||||
is_starred=excluded.is_starred,
|
||||
has_attachment=excluded.has_attachment,
|
||||
search_text=excluded.search_text`,
|
||||
m.AccountID, m.FolderID, m.RemoteUID, m.ThreadID, m.MessageID,
|
||||
subjectEnc, fromNameEnc, fromEmailEnc, toEnc, ccEnc, bccEnc, replyToEnc,
|
||||
bodyTextEnc, bodyHTMLEnc, m.Date,
|
||||
m.IsRead, m.IsStarred, m.IsDraft, m.HasAttachment,
|
||||
m.IsRead, m.IsStarred, m.IsDraft, m.HasAttachment, searchText,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -902,15 +913,15 @@ func (d *DB) ListMessages(userID int64, folderIDs []int64, accountID int64, page
|
||||
|
||||
func (d *DB) SearchMessages(userID int64, q string, page, pageSize int) (*models.PagedMessages, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
like := "%" + q + "%"
|
||||
args := []interface{}{userID, like, like, like, like, pageSize, offset}
|
||||
like := "%" + strings.ToLower(q) + "%"
|
||||
args := []interface{}{userID, like, pageSize, offset}
|
||||
|
||||
var total int
|
||||
d.sql.QueryRow(`
|
||||
SELECT COUNT(*) FROM messages m
|
||||
JOIN email_accounts a ON a.id=m.account_id
|
||||
WHERE a.user_id=? AND (m.subject LIKE ? OR m.from_email LIKE ? OR m.from_name LIKE ? OR m.body_text LIKE ?)`,
|
||||
userID, like, like, like, like,
|
||||
WHERE a.user_id=? AND m.search_text LIKE ?`,
|
||||
userID, like,
|
||||
).Scan(&total)
|
||||
|
||||
rows, err := d.sql.Query(`
|
||||
@@ -920,7 +931,7 @@ func (d *DB) SearchMessages(userID int64, q string, page, pageSize int) (*models
|
||||
FROM messages m
|
||||
JOIN email_accounts a ON a.id=m.account_id
|
||||
JOIN folders f ON f.id=m.folder_id
|
||||
WHERE a.user_id=? AND (m.subject LIKE ? OR m.from_email LIKE ? OR m.from_name LIKE ? OR m.body_text LIKE ?)
|
||||
WHERE a.user_id=? AND m.search_text LIKE ?
|
||||
ORDER BY m.date DESC LIMIT ? OFFSET ?`, args...,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -236,8 +236,8 @@ func (c *Client) ListMailboxes() ([]*imap.MailboxInfo, error) {
|
||||
return result, <-done
|
||||
}
|
||||
|
||||
// FetchMessages fetches messages received within the last `days` days.
|
||||
// Falls back to the most recent 200 if the server does not support SEARCH.
|
||||
// FetchMessages fetches messages from a mailbox.
|
||||
// If days <= 0, fetches ALL messages. Otherwise fetches messages since `days` days ago.
|
||||
func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Message, error) {
|
||||
mbox, err := c.imap.Select(mailboxName, true)
|
||||
if err != nil {
|
||||
@@ -247,15 +247,22 @@ func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Me
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
since := time.Now().AddDate(0, 0, -days)
|
||||
criteria := imap.NewSearchCriteria()
|
||||
criteria.Since = since
|
||||
var uids []uint32
|
||||
if days <= 0 {
|
||||
// Fetch ALL messages — empty criteria matches everything
|
||||
uids, err = c.imap.UidSearch(imap.NewSearchCriteria())
|
||||
} else {
|
||||
since := time.Now().AddDate(0, 0, -days)
|
||||
criteria := imap.NewSearchCriteria()
|
||||
criteria.Since = since
|
||||
uids, err = c.imap.UidSearch(criteria)
|
||||
}
|
||||
|
||||
uids, err := c.imap.Search(criteria)
|
||||
if err != nil || len(uids) == 0 {
|
||||
// Fallback: fetch last 500 by sequence number
|
||||
from := uint32(1)
|
||||
if mbox.Messages > 200 {
|
||||
from = mbox.Messages - 199
|
||||
if mbox.Messages > 500 {
|
||||
from = mbox.Messages - 499
|
||||
}
|
||||
seqSet := new(imap.SeqSet)
|
||||
seqSet.AddRange(from, mbox.Messages)
|
||||
@@ -266,7 +273,7 @@ func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Me
|
||||
for _, uid := range uids {
|
||||
seqSet.AddNum(uid)
|
||||
}
|
||||
return c.fetchBySeqSet(seqSet)
|
||||
return c.fetchByUIDSet(seqSet)
|
||||
}
|
||||
|
||||
func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
|
||||
@@ -296,6 +303,33 @@ func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, er
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// fetchByUIDSet fetches messages by UID set (used when UIDs are returned from UidSearch).
|
||||
func (c *Client) fetchByUIDSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
|
||||
items := []imap.FetchItem{
|
||||
imap.FetchUid, imap.FetchEnvelope,
|
||||
imap.FetchFlags, imap.FetchBodyStructure,
|
||||
imap.FetchRFC822,
|
||||
}
|
||||
|
||||
ch := make(chan *imap.Message, 64)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.imap.UidFetch(seqSet, items, ch) }()
|
||||
|
||||
var results []*gomailModels.Message
|
||||
for msg := range ch {
|
||||
m, err := parseIMAPMessage(msg, c.account)
|
||||
if err != nil {
|
||||
log.Printf("parse message uid=%d: %v", msg.Uid, err)
|
||||
continue
|
||||
}
|
||||
results = append(results, m)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
return results, fmt.Errorf("uid fetch: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*gomailModels.Message, error) {
|
||||
m := &gomailModels.Message{
|
||||
AccountID: account.ID,
|
||||
|
||||
@@ -592,18 +592,23 @@ func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) {
|
||||
go func() {
|
||||
uid, srcPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if err != nil || uid == 0 || account == nil {
|
||||
log.Printf("IMAP move: GetMessageIMAPInfo msg=%d err=%v uid=%d", messageID, err, uid)
|
||||
return
|
||||
}
|
||||
destFolder, err := h.db.GetFolderByID(req.FolderID)
|
||||
if err != nil || destFolder == nil {
|
||||
log.Printf("IMAP move: GetFolderByID folder=%d err=%v", req.FolderID, err)
|
||||
return
|
||||
}
|
||||
c, err := email.Connect(context.Background(), account)
|
||||
if err != nil {
|
||||
log.Printf("IMAP move: Connect account=%d err=%v", account.ID, err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
_ = c.MoveByUID(srcPath, destFolder.FullPath, uid)
|
||||
if err := c.MoveByUID(srcPath, destFolder.FullPath, uid); err != nil {
|
||||
log.Printf("IMAP move: MoveByUID uid=%d src=%s dst=%s err=%v", uid, srcPath, destFolder.FullPath, err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := h.db.MoveMessage(messageID, userID, req.FolderID); err != nil {
|
||||
@@ -621,14 +626,15 @@ func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
|
||||
go func() {
|
||||
uid, folderPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if err != nil || uid == 0 || account == nil {
|
||||
log.Printf("IMAP delete: GetMessageIMAPInfo msg=%d err=%v uid=%d", messageID, err, uid)
|
||||
return
|
||||
}
|
||||
c, err := email.Connect(context.Background(), account)
|
||||
if err != nil {
|
||||
log.Printf("IMAP delete: Connect account=%d err=%v", account.ID, err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
// Find trash folder name
|
||||
mailboxes, _ := c.ListMailboxes()
|
||||
var trashName string
|
||||
for _, mb := range mailboxes {
|
||||
@@ -637,7 +643,9 @@ func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = c.DeleteByUID(folderPath, uid, trashName)
|
||||
if err := c.DeleteByUID(folderPath, uid, trashName); err != nil {
|
||||
log.Printf("IMAP delete: DeleteByUID uid=%d folder=%s trash=%s err=%v", uid, folderPath, trashName, err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := h.db.DeleteMessage(messageID, userID); err != nil {
|
||||
@@ -815,15 +823,44 @@ func (h *APIHandler) GetMessageHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
"Subject": msg.Subject,
|
||||
"Date": msg.Date.Format("Mon, 02 Jan 2006 15:04:05 -0700"),
|
||||
}
|
||||
// Build a pseudo-raw header block for display
|
||||
var raw strings.Builder
|
||||
order := []string{"Date", "From", "To", "Cc", "Bcc", "Reply-To", "Subject", "Message-ID"}
|
||||
for _, k := range order {
|
||||
if v := headers[k]; v != "" {
|
||||
fmt.Fprintf(&raw, "%s: %s\r\n", k, v)
|
||||
|
||||
// Try to fetch real raw headers from IMAP server
|
||||
rawHeaders := ""
|
||||
uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if iErr == nil && uid != 0 && account != nil {
|
||||
if c, cErr := email.Connect(context.Background(), account); cErr == nil {
|
||||
defer c.Close()
|
||||
if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil {
|
||||
// Extract only the header section (before first blank line)
|
||||
rawStr := string(raw)
|
||||
if idx := strings.Index(rawStr, "\r\n\r\n"); idx != -1 {
|
||||
rawHeaders = rawStr[:idx+2]
|
||||
} else if idx := strings.Index(rawStr, "\n\n"); idx != -1 {
|
||||
rawHeaders = rawStr[:idx+1]
|
||||
} else {
|
||||
rawHeaders = rawStr
|
||||
}
|
||||
} else {
|
||||
log.Printf("FetchRawByUID for headers msg=%d: %v", messageID, rErr)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Connect for headers msg=%d: %v", messageID, cErr)
|
||||
}
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"headers": headers, "raw": raw.String()})
|
||||
|
||||
// Fallback: reconstruct from stored fields
|
||||
if rawHeaders == "" {
|
||||
var b strings.Builder
|
||||
order := []string{"Date", "From", "To", "Cc", "Bcc", "Reply-To", "Subject", "Message-ID"}
|
||||
for _, k := range order {
|
||||
if v := headers[k]; v != "" {
|
||||
fmt.Fprintf(&b, "%s: %s\r\n", k, v)
|
||||
}
|
||||
}
|
||||
rawHeaders = b.String()
|
||||
}
|
||||
|
||||
h.writeJSON(w, map[string]interface{}{"headers": headers, "raw": rawHeaders})
|
||||
}
|
||||
|
||||
func (h *APIHandler) StarredMessages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -95,7 +95,7 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
|
||||
defer c.Close()
|
||||
days := account.SyncDays
|
||||
if days <= 0 || account.SyncMode == "all" {
|
||||
days = 36500 // ~100 years = full mailbox
|
||||
days = 0 // 0 = fetch ALL via IMAP ALL criteria
|
||||
}
|
||||
messages, err := c.FetchMessages(folder.FullPath, days)
|
||||
if err != nil {
|
||||
@@ -170,7 +170,7 @@ func (s *Scheduler) doSync(account *models.EmailAccount) (int, error) {
|
||||
|
||||
days := account.SyncDays
|
||||
if days <= 0 || account.SyncMode == "all" {
|
||||
days = 36500 // ~100 years = full mailbox
|
||||
days = 0 // 0 = fetch ALL via IMAP ALL criteria
|
||||
}
|
||||
messages, err := c.FetchMessages(mb.Name, days)
|
||||
if err != nil {
|
||||
|
||||
@@ -188,8 +188,15 @@ async function openEditAccount(id) {
|
||||
document.getElementById('edit-imap-port').value=r.imap_port||993;
|
||||
document.getElementById('edit-smtp-host').value=r.smtp_host||'';
|
||||
document.getElementById('edit-smtp-port').value=r.smtp_port||587;
|
||||
document.getElementById('edit-sync-mode').value=r.sync_mode||'days';
|
||||
document.getElementById('edit-sync-days').value=r.sync_days||30;
|
||||
// Restore sync mode select: map stored days/mode back to a preset option
|
||||
const sel = document.getElementById('edit-sync-mode');
|
||||
if (r.sync_mode==='all' || !r.sync_days) {
|
||||
sel.value='all';
|
||||
} else {
|
||||
const presetMap={30:'preset-30',90:'preset-90',180:'preset-180',365:'preset-365',730:'preset-730',1825:'preset-1825'};
|
||||
sel.value = presetMap[r.sync_days] || 'days';
|
||||
}
|
||||
toggleSyncDaysField();
|
||||
const errEl=document.getElementById('edit-last-error'), connEl=document.getElementById('edit-conn-result');
|
||||
connEl.style.display='none';
|
||||
@@ -237,7 +244,7 @@ async function unhideFolder(folderId) {
|
||||
function toggleSyncDaysField() {
|
||||
const mode=document.getElementById('edit-sync-mode')?.value;
|
||||
const row=document.getElementById('edit-sync-days-row');
|
||||
if (row) row.style.display=(mode==='all')?'none':'flex';
|
||||
if (row) row.style.display=(mode==='days')?'flex':'none';
|
||||
}
|
||||
|
||||
async function testEditConnection() {
|
||||
@@ -260,11 +267,18 @@ async function saveAccountEdit() {
|
||||
smtp_host:document.getElementById('edit-smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('edit-smtp-port').value)||587};
|
||||
const pw=document.getElementById('edit-password').value;
|
||||
if (pw) body.password=pw;
|
||||
const modeVal = document.getElementById('edit-sync-mode').value;
|
||||
let syncMode='all', syncDays=0;
|
||||
if (modeVal==='days') {
|
||||
syncMode='days'; syncDays=parseInt(document.getElementById('edit-sync-days').value)||30;
|
||||
} else if (modeVal.startsWith('preset-')) {
|
||||
syncMode='days'; syncDays=parseInt(modeVal.replace('preset-',''));
|
||||
} // else 'all': syncMode='all', syncDays=0
|
||||
const [r1, r2] = await Promise.all([
|
||||
api('PUT','/accounts/'+id, body),
|
||||
api('PUT','/accounts/'+id+'/sync-settings',{
|
||||
sync_mode: document.getElementById('edit-sync-mode').value,
|
||||
sync_days: parseInt(document.getElementById('edit-sync-days').value)||30,
|
||||
sync_mode: syncMode,
|
||||
sync_days: syncDays,
|
||||
}),
|
||||
]);
|
||||
if (r1?.ok){toast('Account updated','success');closeModal('edit-account-modal');loadAccounts();}
|
||||
|
||||
@@ -234,14 +234,20 @@
|
||||
</div>
|
||||
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
|
||||
<div class="modal-field">
|
||||
<label>Import mode</label>
|
||||
<label>Email history to sync</label>
|
||||
<select id="edit-sync-mode" onchange="toggleSyncDaysField()" style="padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none">
|
||||
<option value="days">Last N days</option>
|
||||
<option value="all">Full mailbox (all email)</option>
|
||||
<option value="preset-30">Last 1 month</option>
|
||||
<option value="preset-90">Last 3 months</option>
|
||||
<option value="preset-180">Last 6 months</option>
|
||||
<option value="preset-365">Last 1 year</option>
|
||||
<option value="preset-730">Last 2 years</option>
|
||||
<option value="preset-1825">Last 5 years</option>
|
||||
<option value="all" selected>All emails (full mailbox)</option>
|
||||
<option value="days">Custom (days)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-row" id="edit-sync-days-row">
|
||||
<div class="modal-field"><label>Days to fetch</label><input type="number" id="edit-sync-days" value="30" min="1" max="3650"></div>
|
||||
<div class="modal-row" id="edit-sync-days-row" style="display:none">
|
||||
<div class="modal-field"><label>Custom days to fetch</label><input type="number" id="edit-sync-days" value="30" min="1" max="36500"></div>
|
||||
</div>
|
||||
<div id="edit-conn-result" class="test-result" style="display:none"></div>
|
||||
<div id="edit-last-error" style="display:none" class="alert error"></div>
|
||||
@@ -302,5 +308,5 @@
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/app.js?v=10"></script>
|
||||
<script src="/static/js/app.js?v=11"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}GoMail{{end}}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/gomail.css?v=10">
|
||||
<link rel="stylesheet" href="/static/css/gomail.css?v=11">
|
||||
{{block "head_extra" .}}{{end}}
|
||||
</head>
|
||||
<body class="{{block "body_class" .}}{{end}}">
|
||||
{{block "body" .}}{{end}}
|
||||
<script src="/static/js/gomail.js?v=10"></script>
|
||||
<script src="/static/js/gomail.js?v=11"></script>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user