heathrow 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +58 -0
  3. data/README.md +205 -0
  4. data/bin/heathrow +42 -0
  5. data/bin/heathrowd +283 -0
  6. data/docs/ARCHITECTURE.md +1172 -0
  7. data/docs/DATABASE_SCHEMA.md +685 -0
  8. data/docs/DEVELOPMENT_WORKFLOW.md +867 -0
  9. data/docs/DISCORD_SETUP.md +142 -0
  10. data/docs/GMAIL_OAUTH_SETUP.md +120 -0
  11. data/docs/PLUGIN_SYSTEM.md +1370 -0
  12. data/docs/PROJECT_PLAN.md +1022 -0
  13. data/docs/README.md +417 -0
  14. data/docs/REDDIT_SETUP.md +174 -0
  15. data/docs/REPLY_FORWARD.md +182 -0
  16. data/docs/WHATSAPP_TELEGRAM_SETUP.md +306 -0
  17. data/heathrow.gemspec +34 -0
  18. data/heathrowd.service +21 -0
  19. data/img/heathrow.svg +95 -0
  20. data/img/rss_threaded.png +0 -0
  21. data/img/sources.png +0 -0
  22. data/lib/heathrow/address_book.rb +42 -0
  23. data/lib/heathrow/config.rb +332 -0
  24. data/lib/heathrow/database.rb +731 -0
  25. data/lib/heathrow/database_new.rb +392 -0
  26. data/lib/heathrow/event_bus.rb +175 -0
  27. data/lib/heathrow/logger.rb +122 -0
  28. data/lib/heathrow/message.rb +176 -0
  29. data/lib/heathrow/message_composer.rb +399 -0
  30. data/lib/heathrow/message_organizer.rb +774 -0
  31. data/lib/heathrow/migrations/001_initial_schema.rb +248 -0
  32. data/lib/heathrow/notmuch.rb +45 -0
  33. data/lib/heathrow/oauth2_smtp.rb +254 -0
  34. data/lib/heathrow/plugin/base.rb +212 -0
  35. data/lib/heathrow/plugin_manager.rb +141 -0
  36. data/lib/heathrow/poller.rb +93 -0
  37. data/lib/heathrow/smtp_sender.rb +204 -0
  38. data/lib/heathrow/source.rb +39 -0
  39. data/lib/heathrow/sources/base.rb +74 -0
  40. data/lib/heathrow/sources/discord.rb +357 -0
  41. data/lib/heathrow/sources/gmail.rb +294 -0
  42. data/lib/heathrow/sources/imap.rb +198 -0
  43. data/lib/heathrow/sources/instagram.rb +307 -0
  44. data/lib/heathrow/sources/instagram_fetch.py +101 -0
  45. data/lib/heathrow/sources/instagram_send.py +55 -0
  46. data/lib/heathrow/sources/instagram_send_marionette.py +104 -0
  47. data/lib/heathrow/sources/maildir.rb +606 -0
  48. data/lib/heathrow/sources/messenger.rb +212 -0
  49. data/lib/heathrow/sources/messenger_fetch.js +297 -0
  50. data/lib/heathrow/sources/messenger_fetch_marionette.py +138 -0
  51. data/lib/heathrow/sources/messenger_send.js +32 -0
  52. data/lib/heathrow/sources/messenger_send.py +100 -0
  53. data/lib/heathrow/sources/reddit.rb +461 -0
  54. data/lib/heathrow/sources/rss.rb +299 -0
  55. data/lib/heathrow/sources/slack.rb +375 -0
  56. data/lib/heathrow/sources/source_manager.rb +328 -0
  57. data/lib/heathrow/sources/telegram.rb +498 -0
  58. data/lib/heathrow/sources/webpage.rb +207 -0
  59. data/lib/heathrow/sources/weechat.rb +479 -0
  60. data/lib/heathrow/sources/whatsapp.rb +474 -0
  61. data/lib/heathrow/ui/application.rb +8098 -0
  62. data/lib/heathrow/ui/navigation.rb +8 -0
  63. data/lib/heathrow/ui/panes.rb +8 -0
  64. data/lib/heathrow/ui/source_wizard.rb +567 -0
  65. data/lib/heathrow/ui/threaded_view.rb +780 -0
  66. data/lib/heathrow/ui/views.rb +8 -0
  67. data/lib/heathrow/version.rb +3 -0
  68. data/lib/heathrow/wizards/discord_wizard.rb +193 -0
  69. data/lib/heathrow/wizards/slack_wizard.rb +140 -0
  70. data/lib/heathrow.rb +55 -0
  71. metadata +147 -0
@@ -0,0 +1,731 @@
1
+ require 'sqlite3'
2
+ require 'time'
3
+ require 'json'
4
+
5
+ module Heathrow
6
+ class Database
7
+ attr_reader :db
8
+
9
+ SCHEMA_VERSION = 1
10
+
11
+ def initialize(db_path = HEATHROW_DB)
12
+ @db_path = db_path
13
+ @db = SQLite3::Database.new(@db_path)
14
+ @db.results_as_hash = true
15
+ @db.execute("PRAGMA journal_mode=WAL")
16
+ @db.execute("PRAGMA busy_timeout=5000")
17
+ @mutex = Mutex.new
18
+ setup_schema
19
+ run_migrations
20
+ end
21
+
22
+ def setup_schema
23
+ # Schema version tracking
24
+ @db.execute <<-SQL
25
+ CREATE TABLE IF NOT EXISTS schema_version (
26
+ version INTEGER PRIMARY KEY,
27
+ applied_at INTEGER NOT NULL
28
+ )
29
+ SQL
30
+ # Main messages table (following DATABASE_SCHEMA.md spec)
31
+ @db.execute <<-SQL
32
+ CREATE TABLE IF NOT EXISTS messages (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
34
+ source_id INTEGER NOT NULL,
35
+ external_id TEXT NOT NULL,
36
+ thread_id TEXT,
37
+ parent_id INTEGER,
38
+
39
+ sender TEXT NOT NULL,
40
+ sender_name TEXT,
41
+
42
+ recipients TEXT NOT NULL,
43
+ cc TEXT,
44
+ bcc TEXT,
45
+
46
+ subject TEXT,
47
+ content TEXT NOT NULL,
48
+ html_content TEXT,
49
+
50
+ timestamp INTEGER NOT NULL,
51
+ received_at INTEGER NOT NULL,
52
+ read INTEGER DEFAULT 0,
53
+ starred INTEGER DEFAULT 0,
54
+ archived INTEGER DEFAULT 0,
55
+
56
+ labels TEXT,
57
+ attachments TEXT,
58
+ metadata TEXT,
59
+
60
+ UNIQUE(source_id, external_id),
61
+ FOREIGN KEY(source_id) REFERENCES sources(id) ON DELETE CASCADE,
62
+ FOREIGN KEY(parent_id) REFERENCES messages(id) ON DELETE SET NULL
63
+ )
64
+ SQL
65
+
66
+ # Indexes for performance
67
+ @db.execute "CREATE INDEX IF NOT EXISTS idx_messages_source ON messages(source_id)"
68
+ @db.execute "CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp DESC)"
69
+ @db.execute "CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id)"
70
+ @db.execute "CREATE INDEX IF NOT EXISTS idx_messages_read ON messages(read)"
71
+ @db.execute "CREATE INDEX IF NOT EXISTS idx_messages_read_timestamp ON messages(read, timestamp DESC)"
72
+ @db.execute "CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender)"
73
+
74
+ # Sources table (configured communication sources)
75
+ @db.execute <<-SQL
76
+ CREATE TABLE IF NOT EXISTS sources (
77
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
78
+ name TEXT NOT NULL UNIQUE,
79
+ plugin_type TEXT NOT NULL,
80
+ enabled INTEGER DEFAULT 1,
81
+
82
+ config TEXT NOT NULL,
83
+ capabilities TEXT NOT NULL,
84
+
85
+ last_sync INTEGER,
86
+ last_error TEXT,
87
+
88
+ message_count INTEGER DEFAULT 0,
89
+ created_at INTEGER NOT NULL,
90
+ updated_at INTEGER NOT NULL
91
+ )
92
+ SQL
93
+
94
+ @db.execute "CREATE INDEX IF NOT EXISTS idx_sources_enabled ON sources(enabled)"
95
+ @db.execute "CREATE INDEX IF NOT EXISTS idx_sources_plugin_type ON sources(plugin_type)"
96
+
97
+ # Views table (user-defined filtered views)
98
+ @db.execute <<-SQL
99
+ CREATE TABLE IF NOT EXISTS views (
100
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
101
+ name TEXT NOT NULL UNIQUE,
102
+ key_binding TEXT UNIQUE,
103
+
104
+ filters TEXT NOT NULL,
105
+
106
+ sort_order TEXT DEFAULT 'timestamp DESC',
107
+ is_remainder INTEGER DEFAULT 0,
108
+
109
+ show_count INTEGER DEFAULT 1,
110
+ color INTEGER,
111
+ icon TEXT,
112
+
113
+ created_at INTEGER NOT NULL,
114
+ updated_at INTEGER NOT NULL
115
+ )
116
+ SQL
117
+
118
+ @db.execute "CREATE INDEX IF NOT EXISTS idx_views_key_binding ON views(key_binding)"
119
+
120
+ # Additional tables from spec
121
+ create_additional_tables
122
+ create_default_views
123
+ end
124
+
125
+ def create_additional_tables
126
+ # Contacts table
127
+ @db.execute <<-SQL
128
+ CREATE TABLE IF NOT EXISTS contacts (
129
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
130
+ name TEXT NOT NULL,
131
+ primary_email TEXT,
132
+ identities TEXT,
133
+ phone TEXT,
134
+ avatar_url TEXT,
135
+ tags TEXT,
136
+ notes TEXT,
137
+ message_count INTEGER DEFAULT 0,
138
+ last_contact INTEGER,
139
+ created_at INTEGER NOT NULL,
140
+ updated_at INTEGER NOT NULL
141
+ )
142
+ SQL
143
+
144
+ @db.execute "CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(primary_email)"
145
+ @db.execute "CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name)"
146
+
147
+ # Drafts table
148
+ @db.execute <<-SQL
149
+ CREATE TABLE IF NOT EXISTS drafts (
150
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
151
+ source_id INTEGER,
152
+ reply_to_id INTEGER,
153
+ recipients TEXT NOT NULL,
154
+ cc TEXT,
155
+ bcc TEXT,
156
+ subject TEXT,
157
+ content TEXT NOT NULL,
158
+ attachments TEXT,
159
+ created_at INTEGER NOT NULL,
160
+ updated_at INTEGER NOT NULL,
161
+ FOREIGN KEY(source_id) REFERENCES sources(id) ON DELETE SET NULL,
162
+ FOREIGN KEY(reply_to_id) REFERENCES messages(id) ON DELETE SET NULL
163
+ )
164
+ SQL
165
+
166
+ @db.execute "CREATE INDEX IF NOT EXISTS idx_drafts_updated ON drafts(updated_at DESC)"
167
+
168
+ # Filters table
169
+ @db.execute <<-SQL
170
+ CREATE TABLE IF NOT EXISTS filters (
171
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
172
+ name TEXT NOT NULL,
173
+ enabled INTEGER DEFAULT 1,
174
+ priority INTEGER DEFAULT 0,
175
+ conditions TEXT NOT NULL,
176
+ actions TEXT NOT NULL,
177
+ created_at INTEGER NOT NULL,
178
+ updated_at INTEGER NOT NULL
179
+ )
180
+ SQL
181
+
182
+ @db.execute "CREATE INDEX IF NOT EXISTS idx_filters_enabled ON filters(enabled)"
183
+ @db.execute "CREATE INDEX IF NOT EXISTS idx_filters_priority ON filters(priority DESC)"
184
+
185
+ # Settings table
186
+ @db.execute <<-SQL
187
+ CREATE TABLE IF NOT EXISTS settings (
188
+ key TEXT PRIMARY KEY,
189
+ value TEXT NOT NULL,
190
+ updated_at INTEGER NOT NULL
191
+ )
192
+ SQL
193
+ end
194
+
195
+ def run_migrations
196
+ current_version = @db.get_first_value("SELECT MAX(version) FROM schema_version") || 0
197
+
198
+ # Migration: add folder column for fast folder lookups
199
+ # Migration: add poll_interval and color columns to sources
200
+ source_cols = @db.execute("PRAGMA table_info(sources)").map { |c| c['name'] }
201
+ unless source_cols.include?('poll_interval')
202
+ @db.execute("ALTER TABLE sources ADD COLUMN poll_interval INTEGER DEFAULT 900")
203
+ # Maildir is fast local scan, default to 30s
204
+ @db.execute("UPDATE sources SET poll_interval = 30 WHERE plugin_type = 'maildir'")
205
+ end
206
+ unless source_cols.include?('color')
207
+ @db.execute("ALTER TABLE sources ADD COLUMN color TEXT")
208
+ end
209
+
210
+ cols = @db.execute("PRAGMA table_info(messages)").map { |c| c['name'] }
211
+ unless cols.include?('folder')
212
+ @db.execute("ALTER TABLE messages ADD COLUMN folder TEXT")
213
+ @db.execute("CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder)")
214
+ # Populate from labels JSON (first element)
215
+ @db.execute("UPDATE messages SET folder = json_extract(labels, '$[0]') WHERE labels IS NOT NULL AND labels != '[]'")
216
+ end
217
+
218
+ unless cols.include?('replied')
219
+ @db.execute("ALTER TABLE messages ADD COLUMN replied INTEGER DEFAULT 0")
220
+ end
221
+
222
+ # Postponed messages (drafts)
223
+ @db.execute <<-SQL
224
+ CREATE TABLE IF NOT EXISTS postponed (
225
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
226
+ source_id INTEGER,
227
+ data TEXT NOT NULL,
228
+ created_at INTEGER NOT NULL
229
+ )
230
+ SQL
231
+
232
+ if current_version < SCHEMA_VERSION
233
+ @db.transaction do
234
+ @db.execute("INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
235
+ [SCHEMA_VERSION, Time.now.to_i])
236
+ end
237
+ end
238
+ end
239
+
240
+ def create_default_views
241
+ # Check if default views exist
242
+ count = @db.get_first_value("SELECT COUNT(*) FROM views")
243
+ return if count && count > 0
244
+
245
+ now = Time.now.to_i
246
+
247
+ # Built-in views (A, N, * are hardcoded in the app, but stored for reference)
248
+ @db.execute("INSERT OR IGNORE INTO views (name, key_binding, filters, is_remainder, created_at, updated_at) VALUES ('All', 'A', '{\"rules\": []}', 0, ?, ?)", [now, now])
249
+ @db.execute("INSERT OR IGNORE INTO views (name, key_binding, filters, created_at, updated_at) VALUES ('Unread', 'N', '{\"rules\": [{\"field\": \"read\", \"op\": \"=\", \"value\": false}]}', ?, ?)", [now, now])
250
+ @db.execute("INSERT OR IGNORE INTO views (name, key_binding, filters, created_at, updated_at) VALUES ('Starred', '*', '{\"rules\": [{\"field\": \"starred\", \"op\": \"=\", \"value\": true}]}', ?, ?)", [now, now])
251
+
252
+ # User-configurable views are defined in heathrowrc via the `view` DSL
253
+ end
254
+
255
+ # Message operations
256
+ def insert_message(data)
257
+ # Support both hash and array formats for backward compatibility
258
+ if data.is_a?(Hash)
259
+ now = Time.now.to_i
260
+ folder = data[:labels].is_a?(Array) ? data[:labels].first : nil
261
+ @db.execute(
262
+ "INSERT INTO messages
263
+ (source_id, external_id, thread_id, parent_id, sender, sender_name,
264
+ recipients, cc, bcc, subject, content, html_content,
265
+ timestamp, received_at, read, starred, archived,
266
+ labels, attachments, metadata, folder, replied)
267
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
268
+ ON CONFLICT(source_id, external_id) DO UPDATE SET
269
+ subject = excluded.subject,
270
+ content = excluded.content,
271
+ html_content = excluded.html_content,
272
+ metadata = excluded.metadata,
273
+ attachments = excluded.attachments
274
+ -- Preserve read, starred, archived, replied (user modifications)",
275
+ [
276
+ data[:source_id],
277
+ data[:external_id],
278
+ data[:thread_id],
279
+ data[:parent_id],
280
+ data[:sender],
281
+ data[:sender_name],
282
+ data[:recipients].is_a?(Array) ? data[:recipients].to_json : data[:recipients],
283
+ data[:cc]&.to_json,
284
+ data[:bcc]&.to_json,
285
+ data[:subject],
286
+ data[:content],
287
+ data[:html_content],
288
+ data[:timestamp] || now,
289
+ data[:received_at] || now,
290
+ data[:read] ? 1 : 0,
291
+ data[:starred] ? 1 : 0,
292
+ data[:archived] ? 1 : 0,
293
+ data[:labels]&.to_json,
294
+ data[:attachments]&.to_json,
295
+ data[:metadata]&.to_json,
296
+ folder,
297
+ data[:replied] ? 1 : 0
298
+ ]
299
+ )
300
+ else
301
+ # Legacy array format - convert to new schema as best we can
302
+ source_id, source_type, external_id, sender, recipient, subject, content, raw_data, attachments, timestamp, is_read = data
303
+ now = Time.now.to_i
304
+ ts = timestamp.is_a?(Time) ? timestamp.to_i : (timestamp.is_a?(String) ? Time.parse(timestamp).to_i : timestamp)
305
+
306
+ @db.execute(
307
+ "INSERT INTO messages
308
+ (source_id, external_id, thread_id, parent_id, sender, sender_name,
309
+ recipients, cc, bcc, subject, content, html_content,
310
+ timestamp, received_at, read, starred, archived,
311
+ labels, attachments, metadata)
312
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
313
+ ON CONFLICT(source_id, external_id) DO UPDATE SET
314
+ subject = excluded.subject,
315
+ content = excluded.content,
316
+ html_content = excluded.html_content,
317
+ metadata = excluded.metadata,
318
+ attachments = excluded.attachments
319
+ -- Preserve read, starred, archived (user modifications)",
320
+ [
321
+ source_id,
322
+ external_id || "legacy-#{now}",
323
+ nil, nil,
324
+ sender || "unknown",
325
+ nil,
326
+ [recipient].compact.to_json,
327
+ nil, nil,
328
+ subject,
329
+ content || "",
330
+ nil,
331
+ ts || now,
332
+ now,
333
+ is_read ? 1 : 0,
334
+ 0, 0,
335
+ nil,
336
+ attachments,
337
+ raw_data
338
+ ]
339
+ )
340
+ end
341
+ end
342
+
343
+ def get_messages(filters = {}, limit = nil, offset = 0, light: false)
344
+ cols = if light
345
+ "id, source_id, external_id, thread_id, parent_id, sender, sender_name, recipients, subject, substr(content, 1, 200) as content, timestamp, received_at, read, starred, archived, labels, metadata, attachments, folder, replied"
346
+ else
347
+ "*"
348
+ end
349
+ query = "SELECT #{cols} FROM messages WHERE 1=1"
350
+ params = []
351
+
352
+ # Exclude archived/deleted messages by default
353
+ unless filters.key?(:archived)
354
+ query += " AND (archived = 0 OR archived IS NULL)"
355
+ end
356
+
357
+ if filters[:source_id]
358
+ query += " AND source_id = ?"
359
+ params << filters[:source_id]
360
+ end
361
+
362
+ # Handle sender pattern (supports regex via pipe separation)
363
+ if filters[:sender_pattern]
364
+ patterns = filters[:sender_pattern].split('|')
365
+ conditions = patterns.map { "sender LIKE ?" }.join(' OR ')
366
+ query += " AND (#{conditions})"
367
+ params += patterns.map { |p| "%#{p}%" }
368
+ end
369
+
370
+ # Handle subject pattern
371
+ if filters[:subject_pattern]
372
+ patterns = filters[:subject_pattern].split('|')
373
+ conditions = patterns.map { "subject LIKE ?" }.join(' OR ')
374
+ query += " AND (#{conditions})"
375
+ params += patterns.map { |p| "%#{p}%" }
376
+ end
377
+
378
+ # Handle content patterns (each can be a pattern with | for OR, separated by comma for AND)
379
+ if filters[:content_patterns]
380
+ filters[:content_patterns].each do |pattern_group|
381
+ if pattern_group.include?('|')
382
+ # OR logic within this group
383
+ or_patterns = pattern_group.split('|').map(&:strip)
384
+ conditions = or_patterns.map { "content LIKE ?" }.join(' OR ')
385
+ query += " AND (#{conditions})"
386
+ params += or_patterns.map { |p| "%#{p}%" }
387
+ else
388
+ # Simple keyword
389
+ query += " AND content LIKE ?"
390
+ params << "%#{pattern_group}%"
391
+ end
392
+ end
393
+ end
394
+
395
+ # Legacy support for old filter formats
396
+ if filters[:content_keywords]
397
+ filters[:content_keywords].each do |keyword|
398
+ query += " AND content LIKE ?"
399
+ params << "%#{keyword}%"
400
+ end
401
+ end
402
+
403
+ if filters[:content_regex]
404
+ query += " AND content LIKE ?"
405
+ params << "%#{filters[:content_regex]}%"
406
+ end
407
+
408
+ # Search across sender, subject, content (supports | for OR)
409
+ if filters[:search]
410
+ terms = filters[:search].split('|').map(&:strip).reject(&:empty?)
411
+ if terms.size == 1
412
+ query += " AND (sender LIKE ? OR subject LIKE ? OR content LIKE ? OR recipients LIKE ?)"
413
+ search_term = "%#{terms.first}%"
414
+ params += [search_term, search_term, search_term, search_term]
415
+ else
416
+ conditions = terms.map { |_t|
417
+ "(sender LIKE ? OR subject LIKE ? OR content LIKE ? OR recipients LIKE ?)"
418
+ }.join(' OR ')
419
+ query += " AND (#{conditions})"
420
+ terms.each { |t| term = "%#{t}%"; params += [term, term, term, term] }
421
+ end
422
+ end
423
+
424
+ # Support both old and new column names for read status
425
+ if filters[:is_read] != nil || filters[:read] != nil
426
+ query += " AND read = ?"
427
+ params << ((filters[:read] || filters[:is_read]) ? 1 : 0)
428
+ end
429
+
430
+ if filters[:starred] != nil
431
+ query += " AND starred = ?"
432
+ params << (filters[:starred] ? 1 : 0)
433
+ end
434
+
435
+ if filters[:archived] != nil
436
+ query += " AND archived = ?"
437
+ params << (filters[:archived] ? 1 : 0)
438
+ end
439
+
440
+ if filters[:maildir_folder]
441
+ query += " AND folder = ?"
442
+ params << filters[:maildir_folder]
443
+ end
444
+
445
+ if filters[:label]
446
+ # Match label anywhere in the JSON labels array
447
+ query += " AND labels LIKE ?"
448
+ params << "%\"#{filters[:label]}\"%"
449
+ end
450
+
451
+ query += " ORDER BY timestamp DESC"
452
+
453
+ if limit
454
+ query += " LIMIT ? OFFSET ?"
455
+ params += [limit, offset]
456
+ end
457
+
458
+ results = @db.execute(query, params)
459
+ results.map { |row| normalize_message_row(row) }
460
+ end
461
+
462
+ def get_message(id)
463
+ row = @db.execute("SELECT * FROM messages WHERE id = ?", [id]).first
464
+ return nil unless row
465
+ normalize_message_row(row)
466
+ end
467
+
468
+ def mark_as_read(message_id)
469
+ @db.execute("UPDATE messages SET read = 1 WHERE id = ?", message_id)
470
+ @db.changes > 0
471
+ end
472
+
473
+ # Bulk mark all unread messages as read, optionally filtered by folder.
474
+ # Returns array of [id, metadata_json] for maildir flag sync.
475
+ def mark_all_as_read(folder: nil)
476
+ if folder
477
+ rows = @db.execute(
478
+ "SELECT id, metadata FROM messages WHERE read = 0 AND folder >= ? AND folder < ?",
479
+ [folder, folder.chomp('.') + '/']
480
+ )
481
+ @db.execute(
482
+ "UPDATE messages SET read = 1 WHERE read = 0 AND folder >= ? AND folder < ?",
483
+ [folder, folder.chomp('.') + '/']
484
+ )
485
+ else
486
+ rows = @db.execute("SELECT id, metadata FROM messages WHERE read = 0")
487
+ @db.execute("UPDATE messages SET read = 1 WHERE read = 0")
488
+ end
489
+ rows
490
+ end
491
+
492
+ def mark_as_unread(message_id)
493
+ @db.execute("UPDATE messages SET read = 0 WHERE id = ?", message_id)
494
+ @db.changes > 0
495
+ end
496
+
497
+ def toggle_star(message_id)
498
+ @db.execute("UPDATE messages SET starred = NOT starred WHERE id = ?", message_id)
499
+ end
500
+
501
+ def delete_message(message_id)
502
+ @db.execute("DELETE FROM messages WHERE id = ?", message_id)
503
+ end
504
+
505
+ # Postponed messages (drafts)
506
+ def save_postponed(source_id, data)
507
+ @db.execute("INSERT INTO postponed (source_id, data, created_at) VALUES (?, ?, ?)",
508
+ [source_id, JSON.generate(data), Time.now.to_i])
509
+ end
510
+
511
+ def list_postponed
512
+ @db.execute("SELECT * FROM postponed ORDER BY created_at DESC")
513
+ end
514
+
515
+ def get_postponed(id)
516
+ @db.get_first_row("SELECT * FROM postponed WHERE id = ?", [id])
517
+ end
518
+
519
+ def delete_postponed(id)
520
+ @db.execute("DELETE FROM postponed WHERE id = ?", [id])
521
+ end
522
+
523
+ def postponed_count
524
+ @db.get_first_value("SELECT COUNT(*) FROM postponed") || 0
525
+ end
526
+
527
+ # Source operations
528
+ def add_source(name, plugin_type, config, capabilities = ["read"], enabled = true)
529
+ config_json = config.is_a?(Hash) ? config.to_json : config
530
+ capabilities_json = capabilities.is_a?(Array) ? capabilities.to_json : capabilities
531
+ now = Time.now.to_i
532
+
533
+ @db.execute <<-SQL, [name, plugin_type, enabled ? 1 : 0, config_json, capabilities_json, 0, now, now]
534
+ INSERT OR REPLACE INTO sources
535
+ (name, plugin_type, enabled, config, capabilities, message_count, created_at, updated_at)
536
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
537
+ SQL
538
+
539
+ @db.last_insert_row_id
540
+ end
541
+
542
+ def get_sources(enabled_only = true)
543
+ query = enabled_only ? "SELECT * FROM sources WHERE enabled = 1 ORDER BY id" : "SELECT * FROM sources ORDER BY id"
544
+ @db.execute(query).each { |s| normalize_source_row(s) }
545
+ end
546
+
547
+ def get_all_sources
548
+ get_sources(false)
549
+ end
550
+
551
+ # Batch query: returns { source_id => {count:, unread:} }
552
+ def get_source_stats
553
+ rows = @db.execute(
554
+ "SELECT source_id, COUNT(*) as cnt, SUM(CASE WHEN read = 0 THEN 1 ELSE 0 END) as unread
555
+ FROM messages WHERE archived = 0 OR archived IS NULL GROUP BY source_id"
556
+ )
557
+ stats = {}
558
+ rows.each { |r| stats[r['source_id']] = { count: r['cnt'], unread: r['unread'] } }
559
+ stats
560
+ end
561
+
562
+ # Returns { source_id => plugin_type } for all sources
563
+ def get_source_type_map
564
+ rows = @db.execute("SELECT id, plugin_type FROM sources")
565
+ rows.each_with_object({}) { |r, h| h[r['id']] = r['plugin_type'] }
566
+ end
567
+
568
+ def get_source_by_name(name)
569
+ source = @db.execute("SELECT * FROM sources WHERE name = ? LIMIT 1", name).first
570
+ source ? normalize_source_row(source) : nil
571
+ end
572
+
573
+ def get_source_by_id(id)
574
+ source = @db.execute("SELECT * FROM sources WHERE id = ? LIMIT 1", id).first
575
+ source ? normalize_source_row(source) : nil
576
+ end
577
+
578
+ def update_source(source_id, updates = {})
579
+ now = Time.now.to_i
580
+ updates.each do |key, value|
581
+ case key
582
+ when :config
583
+ value = value.to_json if value.is_a?(Hash)
584
+ @db.execute("UPDATE sources SET config = ?, updated_at = ? WHERE id = ?", [value, now, source_id])
585
+ when :last_sync
586
+ @db.execute("UPDATE sources SET last_sync = ?, updated_at = ? WHERE id = ?", [value, now, source_id])
587
+ when :last_error
588
+ @db.execute("UPDATE sources SET last_error = ?, updated_at = ? WHERE id = ?", [value, now, source_id])
589
+ when :enabled
590
+ @db.execute("UPDATE sources SET enabled = ?, updated_at = ? WHERE id = ?", [value ? 1 : 0, now, source_id])
591
+ end
592
+ end
593
+ end
594
+
595
+ def update_source_poll_time(source_id)
596
+ now = Time.now.to_i
597
+ @db.execute("UPDATE sources SET last_sync = ?, updated_at = ? WHERE id = ?", [now, now, source_id])
598
+ end
599
+
600
+ # View operations
601
+ def save_view(view_data)
602
+ now = Time.now.to_i
603
+ filters_json = view_data[:filters].is_a?(Hash) ? view_data[:filters].to_json : view_data[:filters]
604
+
605
+ if view_data[:id]
606
+ # Update existing view by id
607
+ @db.execute(
608
+ "UPDATE views SET name = ?, key_binding = ?, filters = ?, sort_order = ?, updated_at = ? WHERE id = ?",
609
+ [view_data[:name], view_data[:key_binding], filters_json,
610
+ view_data[:sort_order] || 'timestamp DESC', now, view_data[:id]]
611
+ )
612
+ elsif view_data[:key_binding]
613
+ # UPSERT by key_binding (for F1-F12 and 0-9)
614
+ existing = @db.get_first_row("SELECT id FROM views WHERE key_binding = ?", view_data[:key_binding])
615
+ if existing
616
+ @db.execute(
617
+ "UPDATE views SET name = ?, filters = ?, sort_order = ?, updated_at = ? WHERE key_binding = ?",
618
+ [view_data[:name], filters_json, view_data[:sort_order] || 'timestamp DESC', now, view_data[:key_binding]]
619
+ )
620
+ existing['id']
621
+ else
622
+ @db.execute(
623
+ "INSERT INTO views (name, key_binding, filters, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
624
+ [view_data[:name], view_data[:key_binding], filters_json,
625
+ view_data[:sort_order] || 'timestamp DESC', now, now]
626
+ )
627
+ @db.last_insert_row_id
628
+ end
629
+ else
630
+ # Insert new view
631
+ @db.execute(
632
+ "INSERT INTO views (name, key_binding, filters, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
633
+ [view_data[:name], view_data[:key_binding], filters_json,
634
+ view_data[:sort_order] || 'timestamp DESC', now, now]
635
+ )
636
+ @db.last_insert_row_id
637
+ end
638
+ end
639
+
640
+ def delete_view(view_id)
641
+ @db.execute("DELETE FROM views WHERE id = ?", view_id)
642
+ end
643
+
644
+ def get_view(view_id)
645
+ result = @db.get_first_row("SELECT * FROM views WHERE id = ?", view_id)
646
+ if result
647
+ result['filters'] = JSON.parse(result['filters']) if result['filters']
648
+ end
649
+ result
650
+ end
651
+
652
+ def get_all_views
653
+ views = @db.execute("SELECT * FROM views ORDER BY id")
654
+ views.each do |view|
655
+ view['filters'] = JSON.parse(view['filters']) if view['filters']
656
+ end
657
+ views
658
+ end
659
+
660
+ # Statistics
661
+ def get_stats
662
+ {
663
+ total_messages: @db.get_first_value("SELECT COUNT(*) FROM messages"),
664
+ unread_messages: @db.get_first_value("SELECT COUNT(*) FROM messages WHERE read = 0"),
665
+ starred_messages: @db.get_first_value("SELECT COUNT(*) FROM messages WHERE starred = 1"),
666
+ archived_messages: @db.get_first_value("SELECT COUNT(*) FROM messages WHERE archived = 1"),
667
+ total_sources: @db.get_first_value("SELECT COUNT(*) FROM sources"),
668
+ active_sources: @db.get_first_value("SELECT COUNT(*) FROM sources WHERE enabled = 1")
669
+ }
670
+ end
671
+
672
+ # Returns hash of { base_id => {id:, external_id:, read:, starred:} } for a folder
673
+ def get_folder_index(source_id, folder_name)
674
+ rows = @db.execute(
675
+ "SELECT id, external_id, read, starred, replied FROM messages WHERE source_id = ? AND folder = ?",
676
+ [source_id, folder_name]
677
+ )
678
+ index = {}
679
+ rows.each do |row|
680
+ base_id = row['external_id'].to_s.split(':2,', 2).first
681
+ index[base_id] = { id: row['id'], external_id: row['external_id'],
682
+ read: row['read'], starred: row['starred'], replied: row['replied'] }
683
+ end
684
+ index
685
+ end
686
+
687
+ # Bulk delete messages by their IDs
688
+ def delete_messages_by_ids(ids)
689
+ return if ids.empty?
690
+ placeholders = ids.map { '?' }.join(',')
691
+ @db.execute("DELETE FROM messages WHERE id IN (#{placeholders})", ids)
692
+ end
693
+
694
+ private
695
+
696
+ def normalize_message_row(row)
697
+ r = row.dup
698
+ %w[recipients cc bcc labels attachments metadata].each do |field|
699
+ r[field] = JSON.parse(r[field]) if r.key?(field) && r[field].is_a?(String)
700
+ end
701
+ r['is_read'] = r['read']
702
+ r['is_starred'] = r['starred']
703
+ r
704
+ rescue JSON::ParserError
705
+ row.dup
706
+ end
707
+
708
+ def normalize_source_row(source)
709
+ s = source.dup
710
+ s['config'] = JSON.parse(s['config']) if s['config'].is_a?(String)
711
+ s['capabilities'] = JSON.parse(s['capabilities']) if s['capabilities'].is_a?(String)
712
+ s
713
+ rescue JSON::ParserError
714
+ source.dup
715
+ end
716
+
717
+ public
718
+
719
+ def execute(query, *params)
720
+ @db.execute(query, params)
721
+ end
722
+
723
+ def transaction(&block)
724
+ @db.transaction(&block)
725
+ end
726
+
727
+ def close
728
+ @db.close if @db
729
+ end
730
+ end
731
+ end