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,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