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,606 @@
|
|
|
1
|
+
require 'mail'
|
|
2
|
+
require 'time'
|
|
3
|
+
require 'timeout'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require_relative '../plugin/base'
|
|
6
|
+
|
|
7
|
+
module Heathrow
|
|
8
|
+
module Sources
|
|
9
|
+
# Maildir++ source - Read emails from local Maildir format directories
|
|
10
|
+
#
|
|
11
|
+
# Supports Maildir++ subfolder format where subfolders are dot-prefixed
|
|
12
|
+
# directories (e.g., .Personal, .Work.Archive) each containing
|
|
13
|
+
# their own cur/new/tmp structure.
|
|
14
|
+
#
|
|
15
|
+
# Configuration:
|
|
16
|
+
# {
|
|
17
|
+
# "maildir_path": "/home/user/Maildir",
|
|
18
|
+
# "max_age_days": 30,
|
|
19
|
+
# "include_folders": ["Geir", "AA"], # Optional whitelist
|
|
20
|
+
# "exclude_folders": ["Trash", "Spam"] # Optional blacklist
|
|
21
|
+
# }
|
|
22
|
+
#
|
|
23
|
+
class Maildir < Heathrow::Plugin::Base
|
|
24
|
+
def initialize(source, logger: nil, event_bus: nil)
|
|
25
|
+
super(source, logger: logger, event_bus: event_bus)
|
|
26
|
+
@maildir_path = @config['maildir_path'] || File.join(Dir.home, 'Maildir')
|
|
27
|
+
@max_age_days = @config['max_age_days']
|
|
28
|
+
@include_folders = @config['include_folders']
|
|
29
|
+
@exclude_folders = @config['exclude_folders']
|
|
30
|
+
@capabilities = ['read', 'send']
|
|
31
|
+
|
|
32
|
+
validate_maildir_path!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Discover all Maildir++ folders
|
|
36
|
+
def discover_folders
|
|
37
|
+
folders = [{ name: 'INBOX', path: @maildir_path }]
|
|
38
|
+
Dir.glob(File.join(@maildir_path, '.*')).sort.each do |dir|
|
|
39
|
+
basename = File.basename(dir)
|
|
40
|
+
next if basename == '.' || basename == '..'
|
|
41
|
+
next unless File.directory?(dir)
|
|
42
|
+
next unless File.directory?(File.join(dir, 'cur')) || File.directory?(File.join(dir, 'new'))
|
|
43
|
+
folder_name = basename.sub(/^\./, '')
|
|
44
|
+
folders << { name: folder_name, path: dir }
|
|
45
|
+
end
|
|
46
|
+
folders
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# List all folder names
|
|
50
|
+
def list_folders
|
|
51
|
+
discover_folders.map { |f| f[:name] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fetch_messages
|
|
55
|
+
messages = []
|
|
56
|
+
folders = apply_folder_filters(discover_folders)
|
|
57
|
+
|
|
58
|
+
log_info("Scanning #{folders.size} Maildir++ folders", path: @maildir_path)
|
|
59
|
+
|
|
60
|
+
folders.each do |folder|
|
|
61
|
+
['cur', 'new'].each do |subdir|
|
|
62
|
+
folder_path = File.join(folder[:path], subdir)
|
|
63
|
+
next unless Dir.exist?(folder_path)
|
|
64
|
+
|
|
65
|
+
Dir.glob(File.join(folder_path, '*')).each do |file_path|
|
|
66
|
+
next if File.directory?(file_path)
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
msg = parse_maildir_file(file_path, folder[:name])
|
|
70
|
+
messages << msg if msg && (!@max_age_days || msg[:timestamp] > cutoff_time)
|
|
71
|
+
rescue => e
|
|
72
|
+
log_error("Error parsing Maildir file #{file_path}", e)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
log_info("Fetched #{messages.size} messages from #{folders.size} folders", path: @maildir_path)
|
|
79
|
+
publish_event('maildir.fetched', count: messages.size, path: @maildir_path)
|
|
80
|
+
|
|
81
|
+
messages
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def setup_wizard
|
|
85
|
+
[
|
|
86
|
+
{
|
|
87
|
+
key: 'maildir_path',
|
|
88
|
+
prompt: 'Enter path to your Maildir folder:',
|
|
89
|
+
type: 'text',
|
|
90
|
+
default: File.join(Dir.home, 'Maildir'),
|
|
91
|
+
required: true
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: 'max_age_days',
|
|
95
|
+
prompt: 'Only sync messages from last N days (blank for all):',
|
|
96
|
+
type: 'number',
|
|
97
|
+
required: false
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_config
|
|
103
|
+
unless @config['maildir_path']
|
|
104
|
+
return [false, "maildir_path is required"]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
unless Dir.exist?(@config['maildir_path'])
|
|
108
|
+
return [false, "Maildir directory does not exist: #{@config['maildir_path']}"]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
[true, nil]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Incremental sync: compare disk files with DB for a single folder
|
|
115
|
+
def sync_folder(db, source_id, folder_name, folder_path)
|
|
116
|
+
# 1. List all files on disk (cur/ + new/)
|
|
117
|
+
disk_files = {} # base_id => full_path
|
|
118
|
+
['cur', 'new'].each do |subdir|
|
|
119
|
+
dir = File.join(folder_path, subdir)
|
|
120
|
+
next unless Dir.exist?(dir)
|
|
121
|
+
Dir.foreach(dir) do |f|
|
|
122
|
+
next if f.start_with?('.')
|
|
123
|
+
path = File.join(dir, f)
|
|
124
|
+
next if File.directory?(path)
|
|
125
|
+
base_id = f.split(':2,', 2).first
|
|
126
|
+
disk_files[base_id] = path
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# 2. Get DB index for this folder
|
|
131
|
+
db_index = db.get_folder_index(source_id, folder_name)
|
|
132
|
+
|
|
133
|
+
new_base_ids = disk_files.keys - db_index.keys
|
|
134
|
+
deleted_base_ids = db_index.keys - disk_files.keys
|
|
135
|
+
changed_base_ids = disk_files.keys & db_index.keys
|
|
136
|
+
|
|
137
|
+
# Skip if nothing changed (return false)
|
|
138
|
+
return false if new_base_ids.empty? && deleted_base_ids.empty? && changed_base_ids.all? { |bid|
|
|
139
|
+
flags = self.class.parse_maildir_flags(disk_files[bid])
|
|
140
|
+
db_row = db_index[bid]
|
|
141
|
+
flags[:seen] == (db_row[:read] == 1) &&
|
|
142
|
+
flags[:flagged] == (db_row[:starred] == 1) &&
|
|
143
|
+
flags[:replied] == (db_row[:replied] == 1) &&
|
|
144
|
+
File.basename(disk_files[bid]) == db_row[:external_id]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
structural_change = !new_base_ids.empty? || !deleted_base_ids.empty?
|
|
148
|
+
|
|
149
|
+
# Batch all writes in one transaction (single lock acquisition)
|
|
150
|
+
db.transaction do
|
|
151
|
+
# 3. New files (on disk, not in DB) — parse and insert
|
|
152
|
+
new_base_ids.each do |base_id|
|
|
153
|
+
begin
|
|
154
|
+
msg = parse_maildir_file(disk_files[base_id], folder_name)
|
|
155
|
+
next unless msg
|
|
156
|
+
msg[:source_id] = source_id
|
|
157
|
+
db.insert_message(msg)
|
|
158
|
+
rescue => e
|
|
159
|
+
log_error("Error parsing new Maildir file #{disk_files[base_id]}", e)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# 4. Deleted files (in DB, not on disk) — remove
|
|
164
|
+
unless deleted_base_ids.empty?
|
|
165
|
+
ids_to_delete = deleted_base_ids.map { |bid| db_index[bid][:id] }
|
|
166
|
+
db.delete_messages_by_ids(ids_to_delete)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# 5. Flag changes (both exist, but flags differ)
|
|
170
|
+
changed_base_ids.each do |base_id|
|
|
171
|
+
flags = self.class.parse_maildir_flags(disk_files[base_id])
|
|
172
|
+
db_row = db_index[base_id]
|
|
173
|
+
if flags[:seen] != (db_row[:read] == 1) || flags[:flagged] != (db_row[:starred] == 1) || flags[:replied] != (db_row[:replied] == 1)
|
|
174
|
+
db.execute("UPDATE messages SET read = ?, starred = ?, replied = ? WHERE id = ?",
|
|
175
|
+
flags[:seen] ? 1 : 0, flags[:flagged] ? 1 : 0, flags[:replied] ? 1 : 0, db_row[:id])
|
|
176
|
+
end
|
|
177
|
+
# Update external_id and metadata if filename changed
|
|
178
|
+
current_filename = File.basename(disk_files[base_id])
|
|
179
|
+
if current_filename != db_row[:external_id]
|
|
180
|
+
begin
|
|
181
|
+
db.execute("UPDATE messages SET external_id = ? WHERE id = ?",
|
|
182
|
+
current_filename, db_row[:id])
|
|
183
|
+
db.execute("UPDATE messages SET metadata = json_set(metadata, '$.maildir_file', ?) WHERE id = ?",
|
|
184
|
+
disk_files[base_id], db_row[:id])
|
|
185
|
+
rescue SQLite3::ConstraintException
|
|
186
|
+
# Duplicate — skip
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
# Only return true for new/deleted messages (structural changes).
|
|
192
|
+
# Flag-only changes are already reflected in the UI and don't need
|
|
193
|
+
# a view refresh that could shift the selected index.
|
|
194
|
+
structural_change
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Full sync across all folders (with include/exclude filters).
|
|
198
|
+
# Returns true if any folder had changes.
|
|
199
|
+
# Yields between folders so caller can pause/abort.
|
|
200
|
+
def sync_all(db, source_id)
|
|
201
|
+
changed = false
|
|
202
|
+
apply_folder_filters(discover_folders).each do |f|
|
|
203
|
+
changed = true if sync_folder(db, source_id, f[:name], f[:path])
|
|
204
|
+
yield if block_given?
|
|
205
|
+
end
|
|
206
|
+
changed
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def health_check
|
|
210
|
+
begin
|
|
211
|
+
unless Dir.exist?(@maildir_path)
|
|
212
|
+
return [false, "Maildir directory not found: #{@maildir_path}"]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
folder_count = discover_folders.size
|
|
216
|
+
[true, "OK - #{@maildir_path} (#{folder_count} folders)"]
|
|
217
|
+
rescue => e
|
|
218
|
+
[false, e.message]
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Send an email by piping RFC822 message through the SMTP script.
|
|
223
|
+
# Returns { success: bool, message: string }
|
|
224
|
+
def send_message(to, subject, body, in_reply_to = nil,
|
|
225
|
+
from: nil, cc: nil, bcc: nil, reply_to: nil,
|
|
226
|
+
extra_headers: nil, smtp_command: nil, attachments: nil)
|
|
227
|
+
msg_from = from || @config['from']
|
|
228
|
+
return { success: false, message: "No From address configured" } unless msg_from
|
|
229
|
+
return { success: false, message: "No SMTP command configured" } unless smtp_command
|
|
230
|
+
|
|
231
|
+
from_addr = msg_from[/<([^>]+)>/, 1] || msg_from
|
|
232
|
+
|
|
233
|
+
# Build RFC822 email
|
|
234
|
+
msg_to = to; msg_cc = cc; msg_bcc = bcc
|
|
235
|
+
msg_subj = subject; msg_body = body; msg_reply_to = reply_to
|
|
236
|
+
msg_attachments = attachments
|
|
237
|
+
if msg_attachments && !msg_attachments.empty?
|
|
238
|
+
# Multipart message with attachments
|
|
239
|
+
mail = Mail.new do
|
|
240
|
+
from msg_from
|
|
241
|
+
to msg_to
|
|
242
|
+
cc msg_cc if msg_cc && !msg_cc.empty?
|
|
243
|
+
bcc msg_bcc if msg_bcc && !msg_bcc.empty?
|
|
244
|
+
reply_to msg_reply_to if msg_reply_to && !msg_reply_to.empty?
|
|
245
|
+
subject msg_subj
|
|
246
|
+
end
|
|
247
|
+
mail.text_part = Mail::Part.new do
|
|
248
|
+
content_type 'text/plain; charset=UTF-8'
|
|
249
|
+
body msg_body
|
|
250
|
+
end
|
|
251
|
+
msg_attachments.each do |filepath|
|
|
252
|
+
mail.add_file(filepath)
|
|
253
|
+
end
|
|
254
|
+
else
|
|
255
|
+
mail = Mail.new do
|
|
256
|
+
from msg_from
|
|
257
|
+
to msg_to
|
|
258
|
+
cc msg_cc if msg_cc && !msg_cc.empty?
|
|
259
|
+
bcc msg_bcc if msg_bcc && !msg_bcc.empty?
|
|
260
|
+
reply_to msg_reply_to if msg_reply_to && !msg_reply_to.empty?
|
|
261
|
+
subject msg_subj
|
|
262
|
+
content_type 'text/plain; charset=UTF-8'
|
|
263
|
+
body msg_body
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
mail.in_reply_to = in_reply_to if in_reply_to
|
|
267
|
+
mail.message_id = Mail::MessageIdField.new.message_id
|
|
268
|
+
|
|
269
|
+
if extra_headers
|
|
270
|
+
extra_headers.each { |k, v| mail[k] = v }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Suppress STDERR from mail gem and SMTP script (charset warnings etc.)
|
|
274
|
+
# Strip CR from CRLF — mail gem outputs RFC822 CRLF but Maildir expects LF
|
|
275
|
+
mail_str = suppress_stderr { mail.to_s }.gsub("\r\n", "\n")
|
|
276
|
+
|
|
277
|
+
# Collect all envelope recipients (bare email addresses for SMTP)
|
|
278
|
+
all_recipients = Array(to).flat_map { |r| r.split(',').map(&:strip) }
|
|
279
|
+
all_recipients += Array(cc).flat_map { |r| r.split(',').map(&:strip) } if cc
|
|
280
|
+
all_recipients += Array(bcc).flat_map { |r| r.split(',').map(&:strip) } if bcc
|
|
281
|
+
all_recipients.map! { |r| r[/<([^>]+)>/, 1] || r }
|
|
282
|
+
|
|
283
|
+
# Pipe through SMTP script (same interface as mutt's sendmail)
|
|
284
|
+
cmd_args = [smtp_command, '-f', from_addr, '--'] + all_recipients
|
|
285
|
+
stderr_output = ""
|
|
286
|
+
require 'open3'
|
|
287
|
+
status = nil
|
|
288
|
+
Open3.popen3(*cmd_args) do |stdin, stdout, stderr, wait_thr|
|
|
289
|
+
stdin.write(mail_str)
|
|
290
|
+
stdin.close
|
|
291
|
+
stderr_output = stderr.read
|
|
292
|
+
status = wait_thr.value
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
if status&.success?
|
|
296
|
+
save_to_sent(mail_str)
|
|
297
|
+
{ success: true, message: "Message sent to #{to}" }
|
|
298
|
+
else
|
|
299
|
+
err_detail = stderr_output.strip.lines.last(2).join(' ').strip
|
|
300
|
+
err_detail = "exit #{status&.exitstatus || '?'}" if err_detail.empty?
|
|
301
|
+
{ success: false, message: "SMTP failed: #{err_detail}" }
|
|
302
|
+
end
|
|
303
|
+
rescue => e
|
|
304
|
+
{ success: false, message: "Send failed: #{e.message}" }
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Parse Maildir flags from filename
|
|
308
|
+
# Returns hash: {seen: bool, flagged: bool, replied: bool, trashed: bool}
|
|
309
|
+
def self.parse_maildir_flags(filename)
|
|
310
|
+
basename = File.basename(filename)
|
|
311
|
+
flags = { seen: false, flagged: false, replied: false, trashed: false, draft: false, passed: false }
|
|
312
|
+
if basename.include?(':2,')
|
|
313
|
+
flag_str = basename.split(':2,', 2).last
|
|
314
|
+
flags[:draft] = flag_str.include?('D')
|
|
315
|
+
flags[:flagged] = flag_str.include?('F')
|
|
316
|
+
flags[:passed] = flag_str.include?('P')
|
|
317
|
+
flags[:replied] = flag_str.include?('R')
|
|
318
|
+
flags[:seen] = flag_str.include?('S')
|
|
319
|
+
flags[:trashed] = flag_str.include?('T')
|
|
320
|
+
end
|
|
321
|
+
flags
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Rename a Maildir file to add or remove a flag character
|
|
325
|
+
# Returns the new file path
|
|
326
|
+
def self.rename_with_flag(file_path, flag_char, add: true)
|
|
327
|
+
return file_path unless File.exist?(file_path)
|
|
328
|
+
|
|
329
|
+
dir = File.dirname(file_path)
|
|
330
|
+
basename = File.basename(file_path)
|
|
331
|
+
|
|
332
|
+
if basename.include?(':2,')
|
|
333
|
+
prefix, flags = basename.split(':2,', 2)
|
|
334
|
+
if add
|
|
335
|
+
flags = (flags.chars + [flag_char]).uniq.sort.join
|
|
336
|
+
else
|
|
337
|
+
flags = flags.delete(flag_char)
|
|
338
|
+
end
|
|
339
|
+
new_name = "#{prefix}:2,#{flags}"
|
|
340
|
+
else
|
|
341
|
+
new_name = add ? "#{basename}:2,#{flag_char}" : basename
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Maildir spec: messages with flags must live in cur/, not new/
|
|
345
|
+
target_dir = if dir.end_with?('/new')
|
|
346
|
+
dir.sub(/\/new\z/, '/cur')
|
|
347
|
+
else
|
|
348
|
+
dir
|
|
349
|
+
end
|
|
350
|
+
new_path = File.join(target_dir, new_name)
|
|
351
|
+
if file_path != new_path
|
|
352
|
+
File.rename(file_path, new_path)
|
|
353
|
+
end
|
|
354
|
+
new_path
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Sync read status to Maildir file (add/remove S flag)
|
|
358
|
+
def self.sync_read_flag(file_path, is_read)
|
|
359
|
+
rename_with_flag(file_path, 'S', add: is_read)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Sync star/flagged status to Maildir file (add/remove F flag)
|
|
363
|
+
def self.sync_flagged(file_path, is_flagged)
|
|
364
|
+
rename_with_flag(file_path, 'F', add: is_flagged)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Sync trashed status to Maildir file (add/remove T flag)
|
|
368
|
+
def self.sync_trashed(file_path)
|
|
369
|
+
rename_with_flag(file_path, 'T', add: true)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Move a message file to a different folder
|
|
373
|
+
# Returns new file path
|
|
374
|
+
def self.move_to_folder(file_path, maildir_root, dest_folder_name)
|
|
375
|
+
return nil unless File.exist?(file_path)
|
|
376
|
+
|
|
377
|
+
# Determine destination directory
|
|
378
|
+
if dest_folder_name == 'INBOX'
|
|
379
|
+
dest_dir = File.join(maildir_root, 'cur')
|
|
380
|
+
else
|
|
381
|
+
dest_dir = File.join(maildir_root, ".#{dest_folder_name}", 'cur')
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Create destination if needed
|
|
385
|
+
FileUtils.mkdir_p(dest_dir)
|
|
386
|
+
tmp_dir = File.join(File.dirname(dest_dir), 'tmp')
|
|
387
|
+
new_dir = File.join(File.dirname(dest_dir), 'new')
|
|
388
|
+
FileUtils.mkdir_p(tmp_dir)
|
|
389
|
+
FileUtils.mkdir_p(new_dir)
|
|
390
|
+
|
|
391
|
+
new_path = File.join(dest_dir, File.basename(file_path))
|
|
392
|
+
File.rename(file_path, new_path)
|
|
393
|
+
new_path
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
private
|
|
397
|
+
|
|
398
|
+
# Apply include/exclude folder filters
|
|
399
|
+
def apply_folder_filters(folders)
|
|
400
|
+
if @include_folders && !@include_folders.empty?
|
|
401
|
+
folders.select! { |f| f[:name] == 'INBOX' || @include_folders.any? { |inc| f[:name].start_with?(inc) } }
|
|
402
|
+
end
|
|
403
|
+
if @exclude_folders && !@exclude_folders.empty?
|
|
404
|
+
folders.reject! { |f| @exclude_folders.any? { |exc| f[:name].start_with?(exc) } }
|
|
405
|
+
end
|
|
406
|
+
folders
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def validate_maildir_path!
|
|
410
|
+
unless Dir.exist?(@maildir_path)
|
|
411
|
+
log_error("Maildir directory does not exist", path: @maildir_path)
|
|
412
|
+
raise "Maildir directory not found: #{@maildir_path}"
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def cutoff_time
|
|
417
|
+
return 0 unless @max_age_days
|
|
418
|
+
Time.now.to_i - (@max_age_days * 86400)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def parse_maildir_file(file_path, folder_name)
|
|
422
|
+
Timeout.timeout(5) do
|
|
423
|
+
_parse_maildir_file_inner(file_path, folder_name)
|
|
424
|
+
end
|
|
425
|
+
rescue Timeout::Error
|
|
426
|
+
log_error("Timeout parsing #{file_path}", nil)
|
|
427
|
+
nil
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def _parse_maildir_file_inner(file_path, folder_name)
|
|
431
|
+
raw_email = File.binread(file_path)
|
|
432
|
+
mail = Mail.new(raw_email)
|
|
433
|
+
|
|
434
|
+
external_id = File.basename(file_path)
|
|
435
|
+
|
|
436
|
+
# Parse flags from filename
|
|
437
|
+
flags = self.class.parse_maildir_flags(file_path)
|
|
438
|
+
is_read = flags[:seen]
|
|
439
|
+
is_starred = flags[:flagged]
|
|
440
|
+
|
|
441
|
+
# Extract recipients
|
|
442
|
+
recipients = []
|
|
443
|
+
recipients << mail.to if mail.to
|
|
444
|
+
recipients = recipients.flatten.compact
|
|
445
|
+
|
|
446
|
+
cc = mail.cc ? Array(mail.cc).flatten.compact : []
|
|
447
|
+
|
|
448
|
+
# Parse timestamp
|
|
449
|
+
timestamp = mail.date ? mail.date.to_time.to_i : File.mtime(file_path).to_i
|
|
450
|
+
|
|
451
|
+
# Extract sender info
|
|
452
|
+
sender = mail.from ? Array(mail.from).first : 'unknown'
|
|
453
|
+
sender_name = extract_sender_name(mail)
|
|
454
|
+
|
|
455
|
+
# Get message content
|
|
456
|
+
content = extract_content(mail)
|
|
457
|
+
html_content = extract_html_content(mail)
|
|
458
|
+
|
|
459
|
+
# Extract attachments info
|
|
460
|
+
attachments = extract_attachments(mail, file_path)
|
|
461
|
+
|
|
462
|
+
# Build metadata
|
|
463
|
+
metadata = {
|
|
464
|
+
'maildir_folder' => folder_name,
|
|
465
|
+
'maildir_file' => file_path,
|
|
466
|
+
'message_id' => mail.message_id,
|
|
467
|
+
'in_reply_to' => mail.in_reply_to,
|
|
468
|
+
'references' => mail.references
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
# Store folder name in labels for filtering
|
|
472
|
+
labels = [folder_name]
|
|
473
|
+
|
|
474
|
+
normalize_message(
|
|
475
|
+
external_id: external_id,
|
|
476
|
+
sender: sender,
|
|
477
|
+
sender_name: sender_name,
|
|
478
|
+
recipients: recipients,
|
|
479
|
+
cc: cc,
|
|
480
|
+
subject: mail.subject || '(no subject)',
|
|
481
|
+
content: content,
|
|
482
|
+
html_content: html_content,
|
|
483
|
+
timestamp: timestamp,
|
|
484
|
+
read: is_read,
|
|
485
|
+
starred: is_starred,
|
|
486
|
+
replied: flags[:replied],
|
|
487
|
+
attachments: attachments,
|
|
488
|
+
metadata: metadata,
|
|
489
|
+
labels: labels
|
|
490
|
+
)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def extract_sender_name(mail)
|
|
494
|
+
return nil unless mail.from
|
|
495
|
+
if mail[:from] && mail[:from].display_names.any?
|
|
496
|
+
mail[:from].display_names.first
|
|
497
|
+
else
|
|
498
|
+
nil
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def ensure_utf8(str, charset = nil)
|
|
503
|
+
return str unless str.is_a?(String)
|
|
504
|
+
return str if str.encoding == Encoding::UTF_8 && str.valid_encoding?
|
|
505
|
+
|
|
506
|
+
charset = charset&.strip&.downcase
|
|
507
|
+
# Map common charset names
|
|
508
|
+
enc = case charset
|
|
509
|
+
when nil, '', 'us-ascii', 'ascii' then Encoding::ISO_8859_1
|
|
510
|
+
when 'utf-8', 'utf8' then Encoding::UTF_8
|
|
511
|
+
when /iso-?8859-?1/i then Encoding::ISO_8859_1
|
|
512
|
+
when /iso-?8859-?15/i then Encoding::ISO_8859_15
|
|
513
|
+
when /windows-?1252/i, 'cp1252' then Encoding::Windows_1252
|
|
514
|
+
else
|
|
515
|
+
begin
|
|
516
|
+
Encoding.find(charset)
|
|
517
|
+
rescue ArgumentError
|
|
518
|
+
Encoding::ISO_8859_1
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
str.encode(Encoding::UTF_8, enc, invalid: :replace, undef: :replace, replace: '?')
|
|
523
|
+
rescue => e
|
|
524
|
+
str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '?')
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def extract_content(mail)
|
|
528
|
+
if mail.multipart?
|
|
529
|
+
text_part = mail.text_part
|
|
530
|
+
return ensure_utf8(text_part.decoded, text_part.charset) if text_part
|
|
531
|
+
|
|
532
|
+
html_part = mail.html_part
|
|
533
|
+
return strip_html(ensure_utf8(html_part.decoded, html_part.charset)) if html_part
|
|
534
|
+
|
|
535
|
+
ensure_utf8(mail.body.decoded, mail.charset)
|
|
536
|
+
else
|
|
537
|
+
ensure_utf8(mail.body.decoded, mail.charset)
|
|
538
|
+
end
|
|
539
|
+
rescue => e
|
|
540
|
+
log_error("Error extracting content", e)
|
|
541
|
+
"(Error reading message content)"
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def extract_html_content(mail)
|
|
545
|
+
if mail.multipart?
|
|
546
|
+
html_part = mail.html_part
|
|
547
|
+
html_part ? ensure_utf8(html_part.decoded, html_part.charset) : nil
|
|
548
|
+
elsif mail.content_type&.include?('text/html')
|
|
549
|
+
ensure_utf8(mail.body.decoded, mail.charset)
|
|
550
|
+
end
|
|
551
|
+
rescue => e
|
|
552
|
+
log_error("Error extracting HTML content", e)
|
|
553
|
+
nil
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def extract_attachments(mail, file_path)
|
|
557
|
+
return [] unless mail.attachments.any?
|
|
558
|
+
|
|
559
|
+
mail.attachments.map do |attachment|
|
|
560
|
+
{
|
|
561
|
+
'name' => attachment.filename,
|
|
562
|
+
'size' => attachment.body.decoded.bytesize,
|
|
563
|
+
'content_type' => attachment.content_type,
|
|
564
|
+
'source_file' => file_path
|
|
565
|
+
}
|
|
566
|
+
end
|
|
567
|
+
rescue => e
|
|
568
|
+
log_error("Error extracting attachments", e)
|
|
569
|
+
[]
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def save_to_sent(mail_str)
|
|
573
|
+
# Sent folder from RC config, with strftime expansion
|
|
574
|
+
pattern = Heathrow::Config.instance&.rc('sent_folder', 'Sent.%Y-%m') || 'Sent.%Y-%m'
|
|
575
|
+
folder_name = Time.now.strftime(pattern)
|
|
576
|
+
sent_base = File.join(@maildir_path, ".#{folder_name}")
|
|
577
|
+
sent_dir = File.join(sent_base, 'cur')
|
|
578
|
+
FileUtils.mkdir_p(sent_dir)
|
|
579
|
+
FileUtils.mkdir_p(File.join(sent_base, 'new'))
|
|
580
|
+
FileUtils.mkdir_p(File.join(sent_base, 'tmp'))
|
|
581
|
+
|
|
582
|
+
hostname = suppress_stderr { `hostname`.strip } rescue 'localhost'
|
|
583
|
+
unique_id = "#{Time.now.to_f}.#{$$}.#{hostname}"
|
|
584
|
+
filename = "#{unique_id}:2,S"
|
|
585
|
+
File.write(File.join(sent_dir, filename), mail_str)
|
|
586
|
+
rescue => e
|
|
587
|
+
log_error("Failed to save to Sent folder", e)
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def suppress_stderr
|
|
591
|
+
old_stderr = $stderr.dup
|
|
592
|
+
$stderr.reopen(File.open(File::NULL, 'w'))
|
|
593
|
+
yield
|
|
594
|
+
ensure
|
|
595
|
+
$stderr.reopen(old_stderr)
|
|
596
|
+
old_stderr.close
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def strip_html(html)
|
|
600
|
+
html.gsub(/<[^>]+>/, ' ')
|
|
601
|
+
.gsub(/\s+/, ' ')
|
|
602
|
+
.strip
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
end
|