internal/db/mailbox.go (view raw)
1package db
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "log/slog"
9
10 "postern/internal/model"
11)
12
13func (db *DB) GetUserMailbox(ctx context.Context, userID int, mailboxName string) (model.Mailbox, error) {
14 mailboxes, err := db.GetUserMailboxes(ctx, userID)
15 if err != nil {
16 return model.Mailbox{}, err
17 }
18
19 for _, mb := range mailboxes {
20 if mb.Name == mailboxName {
21 return mb, nil
22 }
23 }
24 return model.Mailbox{}, ErrMailboxNotFound
25}
26
27// GetUserMailboxes returns all existing mailboxes with their calculated IMAP status values.
28func (db *DB) GetUserMailboxes(ctx context.Context, userID int) ([]model.Mailbox, error) {
29 // This query safely aggregates counts using subqueries, preventing
30 // Cartesian products if a message has multiple flags.
31 query := `
32 SELECT
33 mb.id,
34 mb.name,
35 mb.special_use,
36 mb.uidnext,
37 mb.uidvalidity,
38 mb.highest_modseq,
39 (s.id IS NOT NULL) AS is_subscribed,
40 (SELECT COUNT(*) FROM mailbox_messages mm WHERE mm.mailbox_id = mb.id AND mm.expunged_modseq IS NULL) AS num_messages,
41 (SELECT COALESCE(SUM(m.size), 0) FROM mailbox_messages mm JOIN messages m ON mm.message_id = m.id WHERE mm.mailbox_id = mb.id AND mm.expunged_modseq IS NULL) AS size,
42 (SELECT COUNT(*) FROM mailbox_messages mm WHERE mm.mailbox_id = mb.id AND mm.expunged_modseq IS NULL AND NOT EXISTS (SELECT 1 FROM message_flags mf WHERE mf.mailbox_id = mm.mailbox_id AND mf.uid = mm.uid AND mf.flag = '\Seen')) AS num_unseen,
43 (SELECT COUNT(*) FROM mailbox_messages mm WHERE mm.mailbox_id = mb.id AND mm.expunged_modseq IS NULL AND EXISTS (SELECT 1 FROM message_flags mf WHERE mf.mailbox_id = mm.mailbox_id AND mf.uid = mm.uid AND mf.flag = '\Deleted')) AS num_deleted
44 FROM mailboxes mb
45 LEFT JOIN subscriptions s ON mb.id = s.mailbox_id
46 WHERE mb.user_id = ?
47 `
48
49 rows, err := db.read.QueryContext(ctx, query, userID)
50 if err != nil {
51 return nil, err
52 }
53 defer func(rows *sql.Rows) {
54 _ = rows.Close()
55 }(rows)
56
57 var mailboxes []model.Mailbox
58 for rows.Next() {
59 var mb model.Mailbox
60 var specialUse sql.NullString
61
62 err := rows.Scan(
63 &mb.ID,
64 &mb.Name,
65 &specialUse,
66 &mb.UIDNext,
67 &mb.UIDValidity,
68 &mb.HighestModSeq,
69 &mb.IsSubscribed,
70 &mb.NumMessages,
71 &mb.Size,
72 &mb.NumUnseen,
73 &mb.NumDeleted,
74 )
75 if err != nil {
76 return nil, err
77 }
78
79 if specialUse.Valid {
80 mb.SpecialUse = new(specialUse.String)
81 }
82 mailboxes = append(mailboxes, mb)
83 }
84
85 return mailboxes, rows.Err()
86}
87
88func (db *DB) CreateMailbox(ctx context.Context, userID int, mailbox string) error {
89 tx, err := db.write.BeginTx(ctx, nil)
90 if err != nil {
91 return fmt.Errorf("beginning transaction: %w", err)
92 }
93 defer txRollback(tx)
94
95 nextUID, err := nextUIDValidity(tx)
96 if err != nil {
97 return err
98 }
99
100 _, err = tx.ExecContext(ctx, `
101 INSERT INTO mailboxes
102 (user_id, name, uidvalidity, uidnext, highest_modseq)
103 VALUES
104 (?, ?, ?, 1, 0)`,
105 userID, mailbox, nextUID,
106 )
107 if err != nil {
108 return fmt.Errorf("creating mailbox %q: %w", mailbox, err)
109 }
110
111 return tx.Commit()
112}
113
114func (db *DB) DeleteMailbox(ctx context.Context, userID int, mailbox string) error {
115 _, err := db.write.ExecContext(ctx, `DELETE FROM mailboxes WHERE user_id = ? AND name = ?`, userID, mailbox)
116 if err != nil {
117 return fmt.Errorf("deleting mailbox %q: %w", mailbox, err)
118 }
119
120 return nil
121}
122
123func (db *DB) RenameMailbox(ctx context.Context, userID int, oldName, newName string) error {
124 tx, err := db.write.BeginTx(ctx, nil)
125 if err != nil {
126 return fmt.Errorf("beginning transaction: %w", err)
127 }
128 defer txRollback(tx)
129
130 _, err = tx.ExecContext(ctx, `
131 UPDATE mailboxes
132 SET name = ? || SUBSTR(name, LENGTH(?)+1)
133 WHERE user_id = ?
134 AND (name = ? OR name LIKE ? ESCAPE '\')
135`, newName, oldName, userID, oldName, escapeLike(oldName)+"/%")
136 if err != nil {
137 return fmt.Errorf("renaming mailbox %q: %v", oldName, err.Error())
138 }
139
140 return tx.Commit()
141}
142
143// GetMailboxID returns the mailbox ID und UID Validity for the given mailbox name, scoped to the user ID
144func (db *DB) GetMailboxID(ctx context.Context, userID int, mailbox string) (int, uint32, error) {
145 var id int
146 var uidValidity uint32
147 err := db.read.QueryRowContext(ctx, "SELECT id, uidvalidity FROM mailboxes WHERE name = ? AND user_id = ?", mailbox, userID).Scan(&id, &uidValidity)
148 if errors.Is(err, sql.ErrNoRows) {
149 return 0, 0, ErrMailboxNotFound
150 }
151 return id, uidValidity, err
152}
153
154// CreateAndMoveAllMessagesFromINBOXToMailbox is special handling for RENAME of mailbox INBOX
155func (db *DB) CreateAndMoveAllMessagesFromINBOXToMailbox(ctx context.Context, userID int, toMailbox string) error {
156 tx, err := db.write.BeginTx(ctx, nil)
157 if err != nil {
158 return err
159 }
160 defer func(tx *sql.Tx) {
161 err := tx.Rollback()
162 if err != nil {
163 slog.Error("failed to rollback transaction", "error", err.Error())
164 }
165 }(tx)
166
167 // Rename INBOX -> toMailbox
168 _, err = tx.ExecContext(ctx, `UPDATE mailboxes SET name = ? WHERE user_id = ? AND name = 'INBOX'`, toMailbox, userID)
169 if err != nil {
170 return err
171 }
172
173 // Create new INBOX mailbox
174 nextUID, err := nextUIDValidity(tx)
175 if err != nil {
176 return err
177 }
178
179 _, err = tx.ExecContext(ctx, `
180 INSERT INTO mailboxes
181 (user_id, name, uidvalidity, uidnext, highest_modseq)
182 VALUES
183 (?, 'INBOX', ?, 1, 0)`,
184 userID, nextUID,
185 )
186 if err != nil {
187 return err
188 }
189
190 return tx.Commit()
191}
192
193// ListSubscribed fulfills IMAP LIST (SUBSCRIBED).
194// It returns ONLY mailboxes that are subscribed to. The boolean map value
195// indicates if the mailbox still exists (true) or was deleted (false).
196func (db *DB) ListSubscribed(ctx context.Context, userID int) (map[model.Mailbox]bool, error) {
197 // INNER JOIN ensures we only get existing mailboxes the user is actually subscribed to.
198 // The UNION ALL handles mailboxes that were deleted (mailbox_id IS NULL)
199 // but still have a subscription record.
200 query := `
201 SELECT mb.name, mb.special_use, 1 AS mb_exists
202 FROM subscriptions s
203 INNER JOIN mailboxes mb ON s.mailbox_id = mb.id
204 WHERE s.user_id = ?
205
206 UNION ALL
207
208 SELECT s.mailbox_name AS name, NULL AS special_use, 0 AS mb_exists
209 FROM subscriptions s
210 WHERE s.user_id = ? AND s.mailbox_id IS NULL
211 `
212
213 rows, err := db.read.QueryContext(ctx, query, userID, userID)
214 if err != nil {
215 return nil, err
216 }
217 defer func(rows *sql.Rows) {
218 _ = rows.Close()
219 }(rows)
220
221 out := make(map[model.Mailbox]bool)
222 for rows.Next() {
223 var mb model.Mailbox
224 var exists bool
225 var specialUse sql.NullString
226
227 if err := rows.Scan(&mb.Name, &specialUse, &exists); err != nil {
228 return nil, err
229 }
230
231 if specialUse.Valid {
232 mb.SpecialUse = new(specialUse.String)
233 }
234
235 mb.IsSubscribed = true
236 out[mb] = exists
237 }
238
239 return out, rows.Err()
240}
241
242func (db *DB) UIDToServerSeq(ctx context.Context, mailboxID int, uid uint32) (uint32, error) {
243 var seq uint32
244 err := db.read.QueryRowContext(ctx, `
245 SELECT COUNT(*)
246 FROM mailbox_messages
247 WHERE mailbox_id = ? AND expunged_modseq IS NULL AND uid <= ?
248 `, mailboxID, uid).Scan(&seq)
249 return seq, err // 0 means "no live message at or below this uid"
250}
251
252func (db *DB) ServerSeqToUID(ctx context.Context, mailboxID int, seq uint32) (uint32, error) {
253 var uid uint32
254 err := db.read.QueryRowContext(ctx, `
255 SELECT uid FROM mailbox_messages
256 WHERE mailbox_id = ? AND expunged_modseq IS NULL
257 ORDER BY uid LIMIT 1 OFFSET ?
258 `, mailboxID, seq-1).Scan(&uid) // OFFSET is 0-based, seqnum 1-based
259 return uid, err
260}
261
262// MailboxMessageCount returns the number of live (non-expunged) messages in the
263// mailbox: the server-view EXISTS value, which is what RFC 9051 binds "*" to.
264func (db *DB) MailboxMessageCount(ctx context.Context, mailboxID int) (uint32, error) {
265 var count uint32
266 err := db.read.QueryRowContext(ctx, `
267 SELECT COUNT(*) FROM mailbox_messages
268 WHERE mailbox_id = ? AND expunged_modseq IS NULL
269 `, mailboxID).Scan(&count)
270 return count, err
271}