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.
- checksums.yaml +7 -0
- data/.gitignore +58 -0
- data/README.md +205 -0
- data/bin/heathrow +42 -0
- data/bin/heathrowd +283 -0
- data/docs/ARCHITECTURE.md +1172 -0
- data/docs/DATABASE_SCHEMA.md +685 -0
- data/docs/DEVELOPMENT_WORKFLOW.md +867 -0
- data/docs/DISCORD_SETUP.md +142 -0
- data/docs/GMAIL_OAUTH_SETUP.md +120 -0
- data/docs/PLUGIN_SYSTEM.md +1370 -0
- data/docs/PROJECT_PLAN.md +1022 -0
- data/docs/README.md +417 -0
- data/docs/REDDIT_SETUP.md +174 -0
- data/docs/REPLY_FORWARD.md +182 -0
- data/docs/WHATSAPP_TELEGRAM_SETUP.md +306 -0
- data/heathrow.gemspec +34 -0
- data/heathrowd.service +21 -0
- data/img/heathrow.svg +95 -0
- data/img/rss_threaded.png +0 -0
- data/img/sources.png +0 -0
- data/lib/heathrow/address_book.rb +42 -0
- data/lib/heathrow/config.rb +332 -0
- data/lib/heathrow/database.rb +731 -0
- data/lib/heathrow/database_new.rb +392 -0
- data/lib/heathrow/event_bus.rb +175 -0
- data/lib/heathrow/logger.rb +122 -0
- data/lib/heathrow/message.rb +176 -0
- data/lib/heathrow/message_composer.rb +399 -0
- data/lib/heathrow/message_organizer.rb +774 -0
- data/lib/heathrow/migrations/001_initial_schema.rb +248 -0
- data/lib/heathrow/notmuch.rb +45 -0
- data/lib/heathrow/oauth2_smtp.rb +254 -0
- data/lib/heathrow/plugin/base.rb +212 -0
- data/lib/heathrow/plugin_manager.rb +141 -0
- data/lib/heathrow/poller.rb +93 -0
- data/lib/heathrow/smtp_sender.rb +204 -0
- data/lib/heathrow/source.rb +39 -0
- data/lib/heathrow/sources/base.rb +74 -0
- data/lib/heathrow/sources/discord.rb +357 -0
- data/lib/heathrow/sources/gmail.rb +294 -0
- data/lib/heathrow/sources/imap.rb +198 -0
- data/lib/heathrow/sources/instagram.rb +307 -0
- data/lib/heathrow/sources/instagram_fetch.py +101 -0
- data/lib/heathrow/sources/instagram_send.py +55 -0
- data/lib/heathrow/sources/instagram_send_marionette.py +104 -0
- data/lib/heathrow/sources/maildir.rb +606 -0
- data/lib/heathrow/sources/messenger.rb +212 -0
- data/lib/heathrow/sources/messenger_fetch.js +297 -0
- data/lib/heathrow/sources/messenger_fetch_marionette.py +138 -0
- data/lib/heathrow/sources/messenger_send.js +32 -0
- data/lib/heathrow/sources/messenger_send.py +100 -0
- data/lib/heathrow/sources/reddit.rb +461 -0
- data/lib/heathrow/sources/rss.rb +299 -0
- data/lib/heathrow/sources/slack.rb +375 -0
- data/lib/heathrow/sources/source_manager.rb +328 -0
- data/lib/heathrow/sources/telegram.rb +498 -0
- data/lib/heathrow/sources/webpage.rb +207 -0
- data/lib/heathrow/sources/weechat.rb +479 -0
- data/lib/heathrow/sources/whatsapp.rb +474 -0
- data/lib/heathrow/ui/application.rb +8098 -0
- data/lib/heathrow/ui/navigation.rb +8 -0
- data/lib/heathrow/ui/panes.rb +8 -0
- data/lib/heathrow/ui/source_wizard.rb +567 -0
- data/lib/heathrow/ui/threaded_view.rb +780 -0
- data/lib/heathrow/ui/views.rb +8 -0
- data/lib/heathrow/version.rb +3 -0
- data/lib/heathrow/wizards/discord_wizard.rb +193 -0
- data/lib/heathrow/wizards/slack_wizard.rb +140 -0
- data/lib/heathrow.rb +55 -0
- 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
|