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,248 @@
|
|
|
1
|
+
# Initial Heathrow database schema
|
|
2
|
+
# Migration version 1
|
|
3
|
+
|
|
4
|
+
module Heathrow
|
|
5
|
+
module Migrations
|
|
6
|
+
class InitialSchema
|
|
7
|
+
VERSION = 1
|
|
8
|
+
|
|
9
|
+
def self.up(db)
|
|
10
|
+
# Schema version tracking
|
|
11
|
+
db.exec <<-SQL
|
|
12
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
13
|
+
version INTEGER PRIMARY KEY,
|
|
14
|
+
applied_at INTEGER NOT NULL
|
|
15
|
+
);
|
|
16
|
+
SQL
|
|
17
|
+
|
|
18
|
+
# Messages table - normalized structure
|
|
19
|
+
db.exec <<-SQL
|
|
20
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
21
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
22
|
+
source_id INTEGER NOT NULL,
|
|
23
|
+
external_id TEXT NOT NULL,
|
|
24
|
+
thread_id TEXT,
|
|
25
|
+
parent_id INTEGER,
|
|
26
|
+
|
|
27
|
+
sender TEXT NOT NULL,
|
|
28
|
+
sender_name TEXT,
|
|
29
|
+
|
|
30
|
+
recipients TEXT NOT NULL,
|
|
31
|
+
cc TEXT,
|
|
32
|
+
bcc TEXT,
|
|
33
|
+
|
|
34
|
+
subject TEXT,
|
|
35
|
+
content TEXT NOT NULL,
|
|
36
|
+
html_content TEXT,
|
|
37
|
+
|
|
38
|
+
timestamp INTEGER NOT NULL,
|
|
39
|
+
received_at INTEGER NOT NULL,
|
|
40
|
+
read BOOLEAN DEFAULT 0,
|
|
41
|
+
starred BOOLEAN DEFAULT 0,
|
|
42
|
+
archived BOOLEAN DEFAULT 0,
|
|
43
|
+
|
|
44
|
+
labels TEXT,
|
|
45
|
+
attachments TEXT,
|
|
46
|
+
metadata TEXT,
|
|
47
|
+
|
|
48
|
+
UNIQUE(source_id, external_id),
|
|
49
|
+
FOREIGN KEY(source_id) REFERENCES sources(id) ON DELETE CASCADE,
|
|
50
|
+
FOREIGN KEY(parent_id) REFERENCES messages(id) ON DELETE SET NULL
|
|
51
|
+
);
|
|
52
|
+
SQL
|
|
53
|
+
|
|
54
|
+
# Indices for performance
|
|
55
|
+
db.exec "CREATE INDEX IF NOT EXISTS idx_messages_source ON messages(source_id)"
|
|
56
|
+
db.exec "CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp DESC)"
|
|
57
|
+
db.exec "CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id)"
|
|
58
|
+
db.exec "CREATE INDEX IF NOT EXISTS idx_messages_read ON messages(read)"
|
|
59
|
+
db.exec "CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender)"
|
|
60
|
+
|
|
61
|
+
# Full-text search
|
|
62
|
+
db.exec <<-SQL
|
|
63
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
64
|
+
subject,
|
|
65
|
+
content,
|
|
66
|
+
sender,
|
|
67
|
+
content='messages',
|
|
68
|
+
content_rowid='id'
|
|
69
|
+
);
|
|
70
|
+
SQL
|
|
71
|
+
|
|
72
|
+
# FTS triggers
|
|
73
|
+
db.exec <<-SQL
|
|
74
|
+
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
|
75
|
+
INSERT INTO messages_fts(rowid, subject, content, sender)
|
|
76
|
+
VALUES (new.id, new.subject, new.content, new.sender);
|
|
77
|
+
END;
|
|
78
|
+
SQL
|
|
79
|
+
|
|
80
|
+
db.exec <<-SQL
|
|
81
|
+
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
|
82
|
+
DELETE FROM messages_fts WHERE rowid = old.id;
|
|
83
|
+
END;
|
|
84
|
+
SQL
|
|
85
|
+
|
|
86
|
+
db.exec <<-SQL
|
|
87
|
+
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
|
|
88
|
+
UPDATE messages_fts
|
|
89
|
+
SET subject = new.subject, content = new.content, sender = new.sender
|
|
90
|
+
WHERE rowid = new.id;
|
|
91
|
+
END;
|
|
92
|
+
SQL
|
|
93
|
+
|
|
94
|
+
# Sources table
|
|
95
|
+
db.exec <<-SQL
|
|
96
|
+
CREATE TABLE IF NOT EXISTS sources (
|
|
97
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
98
|
+
name TEXT NOT NULL UNIQUE,
|
|
99
|
+
plugin_type TEXT NOT NULL,
|
|
100
|
+
enabled BOOLEAN DEFAULT 1,
|
|
101
|
+
|
|
102
|
+
config TEXT NOT NULL,
|
|
103
|
+
capabilities TEXT NOT NULL,
|
|
104
|
+
|
|
105
|
+
last_sync INTEGER,
|
|
106
|
+
last_error TEXT,
|
|
107
|
+
|
|
108
|
+
message_count INTEGER DEFAULT 0,
|
|
109
|
+
created_at INTEGER NOT NULL,
|
|
110
|
+
updated_at INTEGER NOT NULL
|
|
111
|
+
);
|
|
112
|
+
SQL
|
|
113
|
+
|
|
114
|
+
db.exec "CREATE INDEX IF NOT EXISTS idx_sources_enabled ON sources(enabled)"
|
|
115
|
+
db.exec "CREATE INDEX IF NOT EXISTS idx_sources_plugin_type ON sources(plugin_type)"
|
|
116
|
+
|
|
117
|
+
# Views table
|
|
118
|
+
db.exec <<-SQL
|
|
119
|
+
CREATE TABLE IF NOT EXISTS views (
|
|
120
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
121
|
+
name TEXT NOT NULL UNIQUE,
|
|
122
|
+
key_binding TEXT UNIQUE,
|
|
123
|
+
|
|
124
|
+
filters TEXT NOT NULL,
|
|
125
|
+
|
|
126
|
+
sort_order TEXT DEFAULT 'timestamp DESC',
|
|
127
|
+
is_remainder BOOLEAN DEFAULT 0,
|
|
128
|
+
|
|
129
|
+
show_count BOOLEAN DEFAULT 1,
|
|
130
|
+
color INTEGER,
|
|
131
|
+
icon TEXT,
|
|
132
|
+
|
|
133
|
+
created_at INTEGER NOT NULL,
|
|
134
|
+
updated_at INTEGER NOT NULL
|
|
135
|
+
);
|
|
136
|
+
SQL
|
|
137
|
+
|
|
138
|
+
db.exec "CREATE INDEX IF NOT EXISTS idx_views_key_binding ON views(key_binding)"
|
|
139
|
+
|
|
140
|
+
# Contacts table
|
|
141
|
+
db.exec <<-SQL
|
|
142
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
143
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
144
|
+
name TEXT NOT NULL,
|
|
145
|
+
primary_email TEXT,
|
|
146
|
+
|
|
147
|
+
identities TEXT,
|
|
148
|
+
|
|
149
|
+
phone TEXT,
|
|
150
|
+
avatar_url TEXT,
|
|
151
|
+
|
|
152
|
+
tags TEXT,
|
|
153
|
+
notes TEXT,
|
|
154
|
+
|
|
155
|
+
message_count INTEGER DEFAULT 0,
|
|
156
|
+
last_contact INTEGER,
|
|
157
|
+
|
|
158
|
+
created_at INTEGER NOT NULL,
|
|
159
|
+
updated_at INTEGER NOT NULL
|
|
160
|
+
);
|
|
161
|
+
SQL
|
|
162
|
+
|
|
163
|
+
db.exec "CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(primary_email)"
|
|
164
|
+
db.exec "CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name)"
|
|
165
|
+
|
|
166
|
+
# Drafts table
|
|
167
|
+
db.exec <<-SQL
|
|
168
|
+
CREATE TABLE IF NOT EXISTS drafts (
|
|
169
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
170
|
+
source_id INTEGER,
|
|
171
|
+
reply_to_id INTEGER,
|
|
172
|
+
|
|
173
|
+
recipients TEXT NOT NULL,
|
|
174
|
+
cc TEXT,
|
|
175
|
+
bcc TEXT,
|
|
176
|
+
subject TEXT,
|
|
177
|
+
content TEXT NOT NULL,
|
|
178
|
+
attachments TEXT,
|
|
179
|
+
|
|
180
|
+
created_at INTEGER NOT NULL,
|
|
181
|
+
updated_at INTEGER NOT NULL,
|
|
182
|
+
|
|
183
|
+
FOREIGN KEY(source_id) REFERENCES sources(id) ON DELETE SET NULL,
|
|
184
|
+
FOREIGN KEY(reply_to_id) REFERENCES messages(id) ON DELETE SET NULL
|
|
185
|
+
);
|
|
186
|
+
SQL
|
|
187
|
+
|
|
188
|
+
db.exec "CREATE INDEX IF NOT EXISTS idx_drafts_updated ON drafts(updated_at DESC)"
|
|
189
|
+
|
|
190
|
+
# Filters table
|
|
191
|
+
db.exec <<-SQL
|
|
192
|
+
CREATE TABLE IF NOT EXISTS filters (
|
|
193
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
194
|
+
name TEXT NOT NULL,
|
|
195
|
+
enabled BOOLEAN DEFAULT 1,
|
|
196
|
+
priority INTEGER DEFAULT 0,
|
|
197
|
+
|
|
198
|
+
conditions TEXT NOT NULL,
|
|
199
|
+
actions TEXT NOT NULL,
|
|
200
|
+
|
|
201
|
+
created_at INTEGER NOT NULL,
|
|
202
|
+
updated_at INTEGER NOT NULL
|
|
203
|
+
);
|
|
204
|
+
SQL
|
|
205
|
+
|
|
206
|
+
db.exec "CREATE INDEX IF NOT EXISTS idx_filters_enabled ON filters(enabled)"
|
|
207
|
+
db.exec "CREATE INDEX IF NOT EXISTS idx_filters_priority ON filters(priority DESC)"
|
|
208
|
+
|
|
209
|
+
# Settings table
|
|
210
|
+
db.exec <<-SQL
|
|
211
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
212
|
+
key TEXT PRIMARY KEY,
|
|
213
|
+
value TEXT NOT NULL,
|
|
214
|
+
updated_at INTEGER NOT NULL
|
|
215
|
+
);
|
|
216
|
+
SQL
|
|
217
|
+
|
|
218
|
+
# Record migration
|
|
219
|
+
db.exec "INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
|
220
|
+
[VERSION, Time.now.to_i]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def self.down(db)
|
|
224
|
+
# Reverse migration (drop all tables)
|
|
225
|
+
db.exec "DROP TABLE IF EXISTS settings"
|
|
226
|
+
db.exec "DROP TABLE IF EXISTS filters"
|
|
227
|
+
db.exec "DROP TABLE IF EXISTS drafts"
|
|
228
|
+
db.exec "DROP TABLE IF EXISTS contacts"
|
|
229
|
+
db.exec "DROP TABLE IF EXISTS views"
|
|
230
|
+
db.exec "DROP TABLE IF EXISTS sources"
|
|
231
|
+
|
|
232
|
+
# Drop FTS triggers
|
|
233
|
+
db.exec "DROP TRIGGER IF EXISTS messages_au"
|
|
234
|
+
db.exec "DROP TRIGGER IF EXISTS messages_ad"
|
|
235
|
+
db.exec "DROP TRIGGER IF EXISTS messages_ai"
|
|
236
|
+
|
|
237
|
+
# Drop FTS table
|
|
238
|
+
db.exec "DROP TABLE IF EXISTS messages_fts"
|
|
239
|
+
|
|
240
|
+
# Drop messages table
|
|
241
|
+
db.exec "DROP TABLE IF EXISTS messages"
|
|
242
|
+
|
|
243
|
+
# Remove migration record
|
|
244
|
+
db.exec "DELETE FROM schema_version WHERE version = ?", [VERSION]
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'shellwords'
|
|
3
|
+
|
|
4
|
+
module Heathrow
|
|
5
|
+
class Notmuch
|
|
6
|
+
NOTMUCH_BIN = '/usr/bin/notmuch'
|
|
7
|
+
|
|
8
|
+
def self.available?
|
|
9
|
+
File.executable?(NOTMUCH_BIN)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Search for messages matching query, returns array of file paths
|
|
13
|
+
def self.search_files(query)
|
|
14
|
+
return [] unless available?
|
|
15
|
+
cmd = "#{NOTMUCH_BIN} search --output=files #{Shellwords.escape(query)}"
|
|
16
|
+
output = `#{cmd} 2>/dev/null`
|
|
17
|
+
output.split("\n").reject(&:empty?)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Search for messages, returns structured results
|
|
21
|
+
def self.search(query, limit: 50)
|
|
22
|
+
return [] unless available?
|
|
23
|
+
cmd = "#{NOTMUCH_BIN} search --format=json --limit=#{limit} #{Shellwords.escape(query)}"
|
|
24
|
+
output = `#{cmd} 2>/dev/null`
|
|
25
|
+
JSON.parse(output)
|
|
26
|
+
rescue JSON::ParserError
|
|
27
|
+
[]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get thread containing a message
|
|
31
|
+
def self.thread(message_id)
|
|
32
|
+
return [] unless available?
|
|
33
|
+
cmd = "#{NOTMUCH_BIN} search --output=files --format=text #{Shellwords.escape("thread:{id:#{message_id}}")}"
|
|
34
|
+
output = `#{cmd} 2>/dev/null`
|
|
35
|
+
output.split("\n").reject(&:empty?)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Count results for a query
|
|
39
|
+
def self.count(query)
|
|
40
|
+
return 0 unless available?
|
|
41
|
+
cmd = "#{NOTMUCH_BIN} count #{Shellwords.escape(query)}"
|
|
42
|
+
`#{cmd} 2>/dev/null`.strip.to_i
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'net/smtp'
|
|
5
|
+
require 'mail'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'base64'
|
|
8
|
+
require 'open3'
|
|
9
|
+
|
|
10
|
+
# Monkey patch Net::SMTP to add xoauth2 support
|
|
11
|
+
module Net
|
|
12
|
+
class SMTP
|
|
13
|
+
unless method_defined?(:auth_xoauth2)
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def auth_xoauth2(user, secret)
|
|
17
|
+
auth_string = "user=#{user}\1auth=Bearer #{secret}\1\1"
|
|
18
|
+
res = critical {
|
|
19
|
+
send("AUTH XOAUTH2 #{Base64.strict_encode64(auth_string)}", true)
|
|
20
|
+
recv_response()
|
|
21
|
+
}
|
|
22
|
+
check_auth_response res
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module Heathrow
|
|
29
|
+
# OAuth2 SMTP module - integrated version of gmail_smtp
|
|
30
|
+
# Handles OAuth2 authentication for Gmail and compatible services
|
|
31
|
+
class OAuth2Smtp
|
|
32
|
+
attr_reader :from_address, :log_file
|
|
33
|
+
|
|
34
|
+
def initialize(from_address = nil, config = {})
|
|
35
|
+
cfg = Heathrow::Config.instance
|
|
36
|
+
@safe_dir = config['safe_dir'] || cfg&.rc('safe_dir', File.join(Dir.home, '.heathrow', 'mail'))
|
|
37
|
+
@default_email = config['default_email'] || cfg&.rc('default_email', '')
|
|
38
|
+
@oauth2_script = config['oauth2_script'] || cfg&.rc('oauth2_script', File.expand_path('~/bin/oauth2.py'))
|
|
39
|
+
@log_file_path = File.join(@safe_dir, '.smtp.log')
|
|
40
|
+
@from_address = from_address || @default_email
|
|
41
|
+
@log_file = File.open(@log_file_path, 'a') if File.exist?(@safe_dir) && File.writable?(@safe_dir)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Send email using OAuth2 authentication
|
|
45
|
+
def send(mail_object, recipients = nil)
|
|
46
|
+
begin
|
|
47
|
+
log "Mail to send: SUBJECT: #{mail_object.subject}"
|
|
48
|
+
log "FROM: #{from_address}"
|
|
49
|
+
|
|
50
|
+
# Extract recipients from mail object if not provided
|
|
51
|
+
if recipients.nil?
|
|
52
|
+
recipients = []
|
|
53
|
+
recipients += Array(mail_object.to) if mail_object.to
|
|
54
|
+
recipients += Array(mail_object.cc) if mail_object.cc
|
|
55
|
+
recipients.uniq!
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
log "TO: #{recipients.inspect}"
|
|
59
|
+
|
|
60
|
+
# Get OAuth2 access token
|
|
61
|
+
token = get_oauth2_token
|
|
62
|
+
|
|
63
|
+
unless token
|
|
64
|
+
error_msg = "Failed to get OAuth2 token for #{from_address}"
|
|
65
|
+
log error_msg
|
|
66
|
+
return { success: false, message: error_msg }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
log "Token obtained for #{from_address}"
|
|
70
|
+
|
|
71
|
+
# Send via Gmail SMTP with OAuth2
|
|
72
|
+
send_via_smtp(mail_object, token, recipients)
|
|
73
|
+
|
|
74
|
+
rescue => e
|
|
75
|
+
error_msg = "OAuth2 SMTP error: #{e.message}"
|
|
76
|
+
log error_msg
|
|
77
|
+
{ success: false, message: error_msg }
|
|
78
|
+
ensure
|
|
79
|
+
@log_file&.close
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Get OAuth2 access token using stored credentials
|
|
84
|
+
def get_oauth2_token
|
|
85
|
+
# Find the correct credential files
|
|
86
|
+
json_file = find_credential_file('.json')
|
|
87
|
+
txt_file = find_credential_file('.txt')
|
|
88
|
+
|
|
89
|
+
unless json_file && txt_file
|
|
90
|
+
log "Credential files not found for #{from_address}"
|
|
91
|
+
return nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
log "Using #{json_file}"
|
|
95
|
+
|
|
96
|
+
begin
|
|
97
|
+
# Parse the JSON credentials
|
|
98
|
+
credentials = JSON.parse(File.read(json_file))
|
|
99
|
+
client_id = credentials.dig('web', 'client_id') || credentials.dig('installed', 'client_id')
|
|
100
|
+
client_secret = credentials.dig('web', 'client_secret') || credentials.dig('installed', 'client_secret')
|
|
101
|
+
|
|
102
|
+
# Read the refresh token
|
|
103
|
+
refresh_token = File.read(txt_file).strip
|
|
104
|
+
|
|
105
|
+
# Call oauth2.py to get access token
|
|
106
|
+
if File.exist?(@oauth2_script)
|
|
107
|
+
get_token_via_script(client_id, client_secret, refresh_token)
|
|
108
|
+
else
|
|
109
|
+
# Fallback to direct API call if script not available
|
|
110
|
+
get_token_via_api(client_id, client_secret, refresh_token)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
rescue => e
|
|
114
|
+
log "Error getting token: #{e.message}"
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Find credential file for the email address
|
|
122
|
+
def find_credential_file(extension)
|
|
123
|
+
# Try exact email match first
|
|
124
|
+
file = File.join(@safe_dir, "#{from_address}#{extension}")
|
|
125
|
+
return file if File.exist?(file)
|
|
126
|
+
|
|
127
|
+
# Try without domain for aliases
|
|
128
|
+
username = from_address.split('@').first
|
|
129
|
+
file = File.join(@safe_dir, "#{username}#{extension}")
|
|
130
|
+
return file if File.exist?(file)
|
|
131
|
+
|
|
132
|
+
# Try default email
|
|
133
|
+
file = File.join(@safe_dir, "#{@default_email}#{extension}")
|
|
134
|
+
return file if File.exist?(file)
|
|
135
|
+
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Get token using oauth2.py script
|
|
140
|
+
def get_token_via_script(client_id, client_secret, refresh_token)
|
|
141
|
+
cmd = [
|
|
142
|
+
@oauth2_script,
|
|
143
|
+
'--generate_oauth2_token',
|
|
144
|
+
"--client_id=#{client_id}",
|
|
145
|
+
"--client_secret=#{client_secret}",
|
|
146
|
+
"--refresh_token=#{refresh_token}"
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
log "Getting token for #{from_address}"
|
|
150
|
+
|
|
151
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
152
|
+
|
|
153
|
+
if status.success?
|
|
154
|
+
# Extract token from output
|
|
155
|
+
if stdout =~ /Access Token:\s*(\S+)/
|
|
156
|
+
token = $1
|
|
157
|
+
log "Token obtained for #{from_address}"
|
|
158
|
+
return token
|
|
159
|
+
end
|
|
160
|
+
else
|
|
161
|
+
log "oauth2.py error: #{stderr}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Get token via direct Google API call (fallback)
|
|
168
|
+
def get_token_via_api(client_id, client_secret, refresh_token)
|
|
169
|
+
require 'net/http'
|
|
170
|
+
require 'uri'
|
|
171
|
+
|
|
172
|
+
uri = URI('https://oauth2.googleapis.com/token')
|
|
173
|
+
|
|
174
|
+
params = {
|
|
175
|
+
'client_id' => client_id,
|
|
176
|
+
'client_secret' => client_secret,
|
|
177
|
+
'refresh_token' => refresh_token,
|
|
178
|
+
'grant_type' => 'refresh_token'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
response = Net::HTTP.post_form(uri, params)
|
|
182
|
+
|
|
183
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
184
|
+
data = JSON.parse(response.body)
|
|
185
|
+
token = data['access_token']
|
|
186
|
+
log "Token obtained via API for #{from_address}"
|
|
187
|
+
return token
|
|
188
|
+
else
|
|
189
|
+
log "Token API error: #{response.body}"
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Send email via SMTP with OAuth2
|
|
195
|
+
def send_via_smtp(mail_object, token, recipients)
|
|
196
|
+
smtp = Net::SMTP.new('smtp.gmail.com', 587)
|
|
197
|
+
smtp.enable_starttls_auto
|
|
198
|
+
|
|
199
|
+
log "Sending email"
|
|
200
|
+
|
|
201
|
+
# Extract email address for authentication
|
|
202
|
+
auth_email = from_address.split(/[<>]/).find { |s| s.include?('@') } || from_address
|
|
203
|
+
|
|
204
|
+
# Extract bare email addresses for SMTP envelope
|
|
205
|
+
bare_from = auth_email
|
|
206
|
+
bare_recipients = Array(recipients).map { |r| r[/<([^>]+)>/, 1] || r.strip }
|
|
207
|
+
|
|
208
|
+
smtp.start('gmail.com', auth_email, token, :xoauth2) do |smtp_conn|
|
|
209
|
+
smtp_conn.send_message(
|
|
210
|
+
mail_object.to_s,
|
|
211
|
+
bare_from,
|
|
212
|
+
bare_recipients
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
log "Email sent"
|
|
217
|
+
|
|
218
|
+
{ success: true, message: "Message sent via OAuth2" }
|
|
219
|
+
|
|
220
|
+
rescue => e
|
|
221
|
+
error_msg = "SMTP error: #{e.message}"
|
|
222
|
+
log error_msg
|
|
223
|
+
{ success: false, message: error_msg }
|
|
224
|
+
ensure
|
|
225
|
+
smtp&.finish rescue nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Log message with timestamp
|
|
229
|
+
def log(message)
|
|
230
|
+
return unless @log_file
|
|
231
|
+
|
|
232
|
+
@log_file.puts "#{Time.now.utc} #{message}"
|
|
233
|
+
@log_file.flush
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Class method for easy sending
|
|
237
|
+
def self.send_message(from, to, subject, body, in_reply_to = nil)
|
|
238
|
+
mail = Mail.new do
|
|
239
|
+
from from
|
|
240
|
+
to to
|
|
241
|
+
subject subject
|
|
242
|
+
body body
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
if in_reply_to
|
|
246
|
+
mail['In-Reply-To'] = in_reply_to
|
|
247
|
+
mail['References'] = in_reply_to
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
oauth2 = new(from)
|
|
251
|
+
oauth2.send(mail)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|