teems 0.3.3 → 0.8.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 +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +77 -0
- data/bin/teems +8 -1
- data/lib/teems/api/calendar.rb +45 -14
- data/lib/teems/api/client.rb +19 -4
- data/lib/teems/api/mail.rb +132 -0
- data/lib/teems/api/mail_actions.rb +22 -0
- data/lib/teems/api/mail_rules.rb +24 -0
- data/lib/teems/cli.rb +2 -0
- data/lib/teems/commands/base.rb +31 -7
- data/lib/teems/commands/cal.rb +109 -28
- data/lib/teems/commands/cal_ics.rb +47 -0
- data/lib/teems/commands/confirmation.rb +19 -0
- data/lib/teems/commands/help.rb +4 -0
- data/lib/teems/commands/mail.rb +207 -0
- data/lib/teems/commands/mail_decrypting.rb +46 -0
- data/lib/teems/commands/mail_help.rb +85 -0
- data/lib/teems/commands/mail_rules.rb +201 -0
- data/lib/teems/commands/mail_sync.rb +92 -0
- data/lib/teems/commands/mail_thread.rb +87 -0
- data/lib/teems/commands/mail_triage.rb +241 -0
- data/lib/teems/commands/meet.rb +159 -0
- data/lib/teems/commands/sync.rb +15 -8
- data/lib/teems/commands/sync_folders.rb +118 -0
- data/lib/teems/formatters/format_utils.rb +23 -2
- data/lib/teems/formatters/html_entities.rb +49 -0
- data/lib/teems/formatters/html_text.rb +181 -0
- data/lib/teems/formatters/ics_format.rb +112 -0
- data/lib/teems/formatters/link_cleaner.rb +91 -0
- data/lib/teems/formatters/link_collector.rb +70 -0
- data/lib/teems/formatters/mail_formatter.rb +58 -0
- data/lib/teems/formatters/mail_markdown.rb +70 -0
- data/lib/teems/formatters/mail_rule_formatter.rb +45 -0
- data/lib/teems/formatters/mail_rule_text.rb +67 -0
- data/lib/teems/formatters/output.rb +45 -8
- data/lib/teems/models/chat.rb +19 -5
- data/lib/teems/models/event.rb +1 -23
- data/lib/teems/models/event_display.rb +40 -0
- data/lib/teems/models/ics_event.rb +60 -0
- data/lib/teems/models/mail_conversation.rb +62 -0
- data/lib/teems/models/mail_encryption.rb +41 -0
- data/lib/teems/models/mail_folder.rb +44 -0
- data/lib/teems/models/mail_message.rb +152 -0
- data/lib/teems/models/mail_rule.rb +38 -0
- data/lib/teems/runner.rb +6 -0
- data/lib/teems/services/api_client.rb +23 -11
- data/lib/teems/services/browser.rb +17 -0
- data/lib/teems/services/clipboard.rb +17 -0
- data/lib/teems/services/mail_folders.rb +76 -0
- data/lib/teems/services/mail_id_cache.rb +51 -0
- data/lib/teems/services/mail_legacy_files.rb +96 -0
- data/lib/teems/services/mail_sync_attachments.rb +103 -0
- data/lib/teems/services/mail_sync_engine.rb +98 -0
- data/lib/teems/services/mail_sync_folder.rb +142 -0
- data/lib/teems/services/mail_sync_store.rb +116 -0
- data/lib/teems/services/smime_content.rb +36 -0
- data/lib/teems/services/smime_decryptor.rb +36 -0
- data/lib/teems/services/sync_dir_naming.rb +16 -10
- data/lib/teems/services/sync_dir_ownership.rb +111 -0
- data/lib/teems/services/sync_dir_planning.rb +130 -0
- data/lib/teems/services/sync_store.rb +20 -43
- data/lib/teems/services/token_extractor.rb +4 -0
- data/lib/teems/support/control_chars.rb +19 -0
- data/lib/teems/support/mime_header.rb +61 -0
- data/lib/teems/support/mime_part.rb +97 -0
- data/lib/teems/support/signed_data.rb +23 -0
- data/lib/teems/support/time_parsing.rb +9 -1
- data/lib/teems/version.rb +1 -1
- data/lib/teems.rb +63 -2
- metadata +48 -1
data/lib/teems/models/event.rb
CHANGED
|
@@ -24,6 +24,7 @@ module Teems
|
|
|
24
24
|
:event_type
|
|
25
25
|
) do
|
|
26
26
|
extend Parsing
|
|
27
|
+
include EventDisplay
|
|
27
28
|
|
|
28
29
|
def self.from_api(data)
|
|
29
30
|
new(**event_attrs(data))
|
|
@@ -90,29 +91,6 @@ module Teems
|
|
|
90
91
|
def cancelled? = is_cancelled
|
|
91
92
|
def recurring? = %w[occurrence exception].include?(event_type)
|
|
92
93
|
|
|
93
|
-
def time_range_display
|
|
94
|
-
return 'ALL DAY' if all_day?
|
|
95
|
-
return '' unless start_time && end_time
|
|
96
|
-
|
|
97
|
-
"#{start_time.strftime('%H:%M')}-#{end_time.strftime('%H:%M')}"
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def date_display
|
|
101
|
-
if all_day?
|
|
102
|
-
"#{start_time&.strftime('%Y-%m-%d')} (all day)"
|
|
103
|
-
elsif start_time && end_time
|
|
104
|
-
"#{start_time.strftime('%Y-%m-%d %H:%M')}-#{end_time.strftime('%H:%M')}"
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def create_summary_lines
|
|
109
|
-
lines = []
|
|
110
|
-
lines << date_display if date_display
|
|
111
|
-
lines << "Location: #{location}" if location && !location.empty?
|
|
112
|
-
lines << "Teams link: #{online_meeting_url}" if online_meeting_url
|
|
113
|
-
lines
|
|
114
|
-
end
|
|
115
|
-
|
|
116
94
|
def required_attendees = attendees.select { |att| att[:type] == 'required' }
|
|
117
95
|
def optional_attendees = attendees.select { |att| att[:type] == 'optional' }
|
|
118
96
|
def accepted_attendees = attendees.select { |att| att[:response] == 'accepted' }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
# Date and time labels for an Event (expects start_time, end_time, all_day?, location, online_meeting_url)
|
|
6
|
+
module EventDisplay
|
|
7
|
+
def time_range_display
|
|
8
|
+
return 'ALL DAY' if all_day?
|
|
9
|
+
return '' unless start_time && end_time
|
|
10
|
+
|
|
11
|
+
"#{start_time.strftime('%H:%M')}-#{end_time.strftime('%H:%M')}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def date_display
|
|
15
|
+
if all_day?
|
|
16
|
+
"#{all_day_span} (all day)"
|
|
17
|
+
elsif start_time && end_time
|
|
18
|
+
"#{start_time.strftime('%Y-%m-%d %H:%M')}-#{end_time.strftime('%H:%M')}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Graph ends all-day events at midnight after the last day, so a span is "first to last"
|
|
23
|
+
def all_day_span
|
|
24
|
+
first_day = start_time&.to_date
|
|
25
|
+
last_day = end_time&.to_date&.prev_day
|
|
26
|
+
return first_day.to_s unless first_day && last_day && last_day > first_day
|
|
27
|
+
|
|
28
|
+
"#{first_day} to #{last_day}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def create_summary_lines
|
|
32
|
+
lines = []
|
|
33
|
+
lines << date_display if date_display
|
|
34
|
+
lines << "Location: #{location}" if location && !location.empty?
|
|
35
|
+
lines << "Teams link: #{online_meeting_url}" if online_meeting_url
|
|
36
|
+
lines
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
# A calendarView event as `teems cal ics` exports it. Built from Graph data requested
|
|
6
|
+
# with Prefer: outlook.timezone="UTC", so timed events carry UTC times and all-day
|
|
7
|
+
# events carry their (floating) dates.
|
|
8
|
+
IcsEvent = Data.define(:uid, :subject, :starts, :ends, :all_day, :location, :organizer, :attendees,
|
|
9
|
+
:body, :join_url, :status) do
|
|
10
|
+
def self.from_api(data)
|
|
11
|
+
new(uid: data['iCalUId'] || data['id'], subject: data['subject'].to_s, **time_attrs(data),
|
|
12
|
+
location: data.dig('location', 'displayName').to_s, organizer: Event.parse_organizer(data['organizer']),
|
|
13
|
+
attendees: Event.parse_attendees(data['attendees']), body: data['body'] || {},
|
|
14
|
+
join_url: data.dig('onlineMeeting', 'joinUrl').to_s, status: status_attrs(data))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Dates for all-day events (Graph sends their midnights without a zone), UTC Times otherwise
|
|
18
|
+
def self.time_attrs(data)
|
|
19
|
+
all_day = data['isAllDay'] || false
|
|
20
|
+
parser = all_day ? :graph_date : :graph_utc
|
|
21
|
+
{ starts: send(parser, data['start']), ends: send(parser, data['end']), all_day: all_day }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.graph_date(value) = graph_stamp(value) { |stamp| Date.parse(stamp) }
|
|
25
|
+
|
|
26
|
+
def self.graph_utc(value) = graph_stamp(value) { |stamp| Time.parse("#{stamp.delete_suffix('Z')}Z").utc }
|
|
27
|
+
|
|
28
|
+
def self.graph_stamp(value)
|
|
29
|
+
stamp = Hash(value)['dateTime']
|
|
30
|
+
stamp && yield(stamp)
|
|
31
|
+
rescue ArgumentError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.status_attrs(data) = { cancelled: data['isCancelled'] || false, show_as: data['showAs'].to_s }
|
|
36
|
+
|
|
37
|
+
def dated? = [starts, ends].all?
|
|
38
|
+
|
|
39
|
+
def cancelled? = status[:cancelled]
|
|
40
|
+
|
|
41
|
+
def free? = status[:show_as] == 'free'
|
|
42
|
+
|
|
43
|
+
# Plain-text body, links listed at the end
|
|
44
|
+
def description
|
|
45
|
+
content = body['content'].to_s
|
|
46
|
+
body['contentType'] == 'html' ? Formatters::HtmlText.convert(content).to_s : content.strip
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# True when one of addresses organizes the event or has accepted it, so it is already
|
|
50
|
+
# on that address's own calendar. An invitation alone does not count.
|
|
51
|
+
def involves?(addresses)
|
|
52
|
+
return false if addresses.empty?
|
|
53
|
+
|
|
54
|
+
accepted = attendees.select { |attendee| attendee[:response] == 'accepted' }
|
|
55
|
+
[Hash(organizer)[:email], *accepted.map { |attendee| attendee[:email] }]
|
|
56
|
+
.any? { |email| addresses.include?(email.to_s.downcase) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
# A conversation's messages (oldest first), Graph folder ID => path for naming the folder
|
|
6
|
+
# each one sits in (a thread spans the Inbox, Sent Items, Archive and so on), message ID =>
|
|
7
|
+
# attachment names once with_attachment_names has fetched them, and message ID => how a
|
|
8
|
+
# decrypted message was opened (see MailEncryption)
|
|
9
|
+
MailConversation = Data.define(:messages, :folders, :attachments, :encryptions) do
|
|
10
|
+
def initialize(messages:, folders:, attachments: {}, encryptions: {}) = super
|
|
11
|
+
|
|
12
|
+
def folder(message) = folders[message.folder_id]
|
|
13
|
+
|
|
14
|
+
def with_attachment_names(api)
|
|
15
|
+
with(attachments: messages.to_h { |message| [message.id, message.attachment_names_from(api)] })
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Marks these messages S/MIME encrypted and not opened; the envelope is not listed as a file
|
|
19
|
+
def with_locked(message_ids)
|
|
20
|
+
no_files = message_ids.to_h { |id| [id, []] }
|
|
21
|
+
locked = no_files.transform_values { { type: 'smime', decrypted: false } }
|
|
22
|
+
with(encryptions: encryptions.merge(locked), attachments: attachments.merge(no_files))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# opened: message ID => Services::SmimeContent::Result for each message decrypted
|
|
26
|
+
def with_decrypted(opened)
|
|
27
|
+
with(messages: messages.map { |message| opened[message.id]&.apply_to(message) || message },
|
|
28
|
+
attachments: attachments.merge(opened.transform_values(&:attachment_names)),
|
|
29
|
+
encryptions: encryptions.merge(opened.transform_values { |content| MailEncryption.decrypted(content) }))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Each message's JSON (from the block) with its folder path
|
|
33
|
+
def json = messages.map { |message| yield(message).merge(folder: folder(message)) }
|
|
34
|
+
|
|
35
|
+
# As `read --json` gives each message, plus its folder
|
|
36
|
+
def full_json
|
|
37
|
+
json do |message|
|
|
38
|
+
encryption = encryption(message)
|
|
39
|
+
hash = message.full_json_hash(attachments: names(message))
|
|
40
|
+
encryption ? hash.merge(encryption: encryption) : hash
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# As `read` prints each message, with its folder (and how it was protected) before the ID
|
|
45
|
+
def texts = messages.map { |message| message.to_text(attachments: names(message), notes: notes(message)) }
|
|
46
|
+
|
|
47
|
+
# list's tab-separated columns plus the folder path
|
|
48
|
+
def tsv_rows = messages.map { |message| "#{message.tsv_row}\t#{folder(message).to_s.tr("\t", ' ')}" }
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def names(message) = attachments.fetch(message.id, [])
|
|
53
|
+
|
|
54
|
+
def encryption(message) = message.encryption_in(encryptions)
|
|
55
|
+
|
|
56
|
+
def notes(message)
|
|
57
|
+
protection = MailEncryption.notes(encryption(message), web_link: message.web_link)
|
|
58
|
+
{ 'Folder' => folder(message) }.compact.merge(protection)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
# How protected mail was shown, as { type: 'smime' | 'purview', decrypted:, signed: } (signed
|
|
6
|
+
# only once decrypted), and the header lines `read` and `thread --full` print for it. Purview
|
|
7
|
+
# mail gets its Outlook on the web link, where Exchange decrypts it for you.
|
|
8
|
+
module MailEncryption
|
|
9
|
+
LOCKED_HINT = 'S/MIME encrypted; read it with --decrypt (macOS asks for your PIV PIN)'
|
|
10
|
+
|
|
11
|
+
# 'purview' (rights-managed, Content-Class rpmsg.message), 'smime' (an S/MIME envelope),
|
|
12
|
+
# or nil, from a message's internet headers. Drafts have none, and Exchange wraps most
|
|
13
|
+
# S/MIME mail as TNEF (see envelope?), so nil is not proof of plain mail.
|
|
14
|
+
def self.from_headers(headers)
|
|
15
|
+
fields = Array(headers).to_h { |header| [header['name'].to_s.downcase, header['value'].to_s.downcase] }
|
|
16
|
+
return 'purview' if fields['content-class'].to_s.start_with?('rpmsg.message')
|
|
17
|
+
|
|
18
|
+
type, params = Support::MimeHeader.field(fields['content-type'])
|
|
19
|
+
'smime' if Support::MimePart::PKCS7_TYPES.include?(type) && params['smime-type'] != 'signed-data'
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.notes(encryption, web_link: nil)
|
|
23
|
+
return {} unless encryption
|
|
24
|
+
|
|
25
|
+
type = encryption[:type]
|
|
26
|
+
return { 'Encryption' => MailMessage::PROTECTION_NOTES[type], 'Open' => web_link }.compact if type == 'purview'
|
|
27
|
+
return { 'Encryption' => LOCKED_HINT } unless encryption[:decrypted]
|
|
28
|
+
|
|
29
|
+
{ 'Encryption' => 'S/MIME, decrypted', 'Signature' => encryption[:signed] ? 'Signed (not checked)' : '' }
|
|
30
|
+
.reject { |_name, value| value.empty? }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.decrypted(content) = { type: 'smime', decrypted: true, signed: content.signed }
|
|
34
|
+
|
|
35
|
+
# An S/MIME envelope (smime.p7m) among a message's Graph attachments
|
|
36
|
+
def self.envelope?(attachment)
|
|
37
|
+
Support::MimePart::PKCS7_TYPES.include?(Support::MimeHeader.field(attachment['contentType']).first)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
# A mail folder from Microsoft Graph (/me/mailFolders); path joins parent names with "/"
|
|
6
|
+
MailFolder = Data.define(:id, :name, :path, :unread_count, :total_count, :child_count) do
|
|
7
|
+
def self.from_api(data, parent_path: nil)
|
|
8
|
+
name = data['displayName'].to_s
|
|
9
|
+
new(id: data['id'], name: name, path: [parent_path, name].compact.join('/'),
|
|
10
|
+
unread_count: data['unreadItemCount'].to_i, total_count: data['totalItemCount'].to_i,
|
|
11
|
+
child_count: data['childFolderCount'].to_i)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The folder whose full path is name, else the one whose display name is (both ignore
|
|
15
|
+
# case), or nil. A display name several folders share raises rather than guessing.
|
|
16
|
+
def self.find_named(folders, name)
|
|
17
|
+
matches = matching(folders, name)
|
|
18
|
+
return matches.first unless matches.length > 1
|
|
19
|
+
|
|
20
|
+
raise Error, "Mail folder name is ambiguous: #{name} matches #{matches.map(&:path).join(', ')} " \
|
|
21
|
+
'(use the full path)'
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.matching(folders, name)
|
|
25
|
+
wanted = name.strip
|
|
26
|
+
candidates = folders.select { |folder| folder.named?(wanted) }
|
|
27
|
+
by_path = candidates.select { |folder| folder.path.casecmp?(wanted) }
|
|
28
|
+
by_path.empty? ? candidates : by_path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_json_hash = to_h
|
|
32
|
+
|
|
33
|
+
def tsv_row = [path, unread_count, total_count].join("\t")
|
|
34
|
+
|
|
35
|
+
# Matches a display name or full path, ignoring case
|
|
36
|
+
def named?(wanted) = [path, name].any? { |candidate| candidate.casecmp?(wanted) }
|
|
37
|
+
|
|
38
|
+
def id_and_path = [id, path]
|
|
39
|
+
|
|
40
|
+
# [id, path] to list this folder's children with, or nil when it has none
|
|
41
|
+
def child_lookup = child_count.positive? ? [id, path] : nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
# A mail message from Microsoft Graph (/me/messages). Text a sender controls is kept free
|
|
6
|
+
# of terminal control characters (see Support::ControlChars), whichever way it is shown.
|
|
7
|
+
MailMessage = Data.define(
|
|
8
|
+
:id, :subject, :from, :to, :cc, :received_at, :is_read, :has_attachments, :flagged,
|
|
9
|
+
:categories, :body_type, :body, :internet_message_id, :web_link, :protection, :folder_id
|
|
10
|
+
) do
|
|
11
|
+
extend Parsing
|
|
12
|
+
|
|
13
|
+
def self.from_api(data)
|
|
14
|
+
new(**header_attrs(data), **body_attrs(data))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.header_attrs(data)
|
|
18
|
+
{ id: data['id'], subject: clean(data['subject']), from: recipient(data['from']),
|
|
19
|
+
to: recipients(data['toRecipients']), cc: recipients(data['ccRecipients']),
|
|
20
|
+
received_at: parse_time(data['receivedDateTime']), is_read: data['isRead'] == true,
|
|
21
|
+
has_attachments: data['hasAttachments'] == true, flagged: Hash(data['flag'])['flagStatus'] == 'flagged' }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.body_attrs(data)
|
|
25
|
+
body = data['body'] || {}
|
|
26
|
+
{ categories: Array(data['categories']).map { |category| clean(category) },
|
|
27
|
+
body_type: body['contentType'].to_s.downcase, body: clean(body['content']),
|
|
28
|
+
internet_message_id: data['internetMessageId'], web_link: data['webLink'],
|
|
29
|
+
protection: MailEncryption.from_headers(data['internetMessageHeaders']), folder_id: data['parentFolderId'] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.recipient(data)
|
|
33
|
+
email = Hash(data)['emailAddress'] || {}
|
|
34
|
+
{ name: clean(email['name']), address: clean(email['address']) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.clean(text) = Support::ControlChars.strip(text)
|
|
38
|
+
|
|
39
|
+
def self.recipients(list) = Array(list).map { |entry| recipient(entry) }
|
|
40
|
+
|
|
41
|
+
# Names of the attachments a person attached (inline images left out)
|
|
42
|
+
def self.attachment_names(attachments)
|
|
43
|
+
attachments.reject { |item| item['isInline'] }.map { |item| clean(item['name']) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Short handle shown in lists; `teems mail read` accepts it after a list or search
|
|
47
|
+
def short_id = self.class.short_id(id)
|
|
48
|
+
|
|
49
|
+
def self.short_id(id) = Digest::SHA256.hexdigest(id.to_s)[0, 8]
|
|
50
|
+
|
|
51
|
+
def read? = is_read
|
|
52
|
+
def status = is_read ? 'Read' : 'Unread'
|
|
53
|
+
def html? = body_type == 'html'
|
|
54
|
+
|
|
55
|
+
# What to show instead of a protected message's body, or nil for plain mail
|
|
56
|
+
def protection_note = self.class::PROTECTION_NOTES[protection]
|
|
57
|
+
|
|
58
|
+
# { type:, decrypted: false } for protected mail, as `read --json` has it (see MailEncryption); else nil
|
|
59
|
+
def encryption = protection && { type: protection, decrypted: false }
|
|
60
|
+
|
|
61
|
+
# How this message was shown when overrides (message ID => encryption) say, else #encryption
|
|
62
|
+
def encryption_in(overrides) = overrides.fetch(id) { encryption }
|
|
63
|
+
|
|
64
|
+
# Names of attached files, fetched through api only if there are any
|
|
65
|
+
def attachment_names_from(api) = files? ? self.class.attachment_names(api.attachments(id)) : []
|
|
66
|
+
|
|
67
|
+
# An S/MIME message's one attachment is its envelope, not a file
|
|
68
|
+
def files? = has_attachments && protection != 'smime'
|
|
69
|
+
|
|
70
|
+
# True for S/MIME encrypted mail, which Graph returns with no body (the envelope is an attachment)
|
|
71
|
+
def bodyless? = body.strip.empty?
|
|
72
|
+
|
|
73
|
+
# Graph says it has attachments, or it may hide an S/MIME envelope
|
|
74
|
+
def attachments_to_list? = has_attachments || envelope_candidate?
|
|
75
|
+
|
|
76
|
+
# Worth looking for an S/MIME envelope: no body, and not Purview. Exchange often wraps
|
|
77
|
+
# encrypted mail as TNEF, so neither its headers nor hasAttachments give it away.
|
|
78
|
+
def envelope_candidate? = bodyless? && protection != 'purview'
|
|
79
|
+
|
|
80
|
+
def from_label = self.class.label(from)
|
|
81
|
+
def from_display = self.class.display(from)
|
|
82
|
+
def to_display = to.map { |entry| self.class.display(entry) }.join(', ')
|
|
83
|
+
def cc_display = cc.map { |entry| self.class.display(entry) }.join(', ')
|
|
84
|
+
|
|
85
|
+
def self.label(entry)
|
|
86
|
+
name = entry[:name]
|
|
87
|
+
name.empty? ? entry[:address] : name
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.display(entry)
|
|
91
|
+
name, address = entry.values_at(:name, :address)
|
|
92
|
+
return address if name.empty? || name == address
|
|
93
|
+
return name if address.empty?
|
|
94
|
+
|
|
95
|
+
"#{name} <#{address}>"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Plain-text body with a links footer; plain-text messages come back unchanged
|
|
99
|
+
def text_body
|
|
100
|
+
return Formatters::HtmlText.convert(body) if html?
|
|
101
|
+
|
|
102
|
+
Formatters::HtmlText::Result.new(text: body.gsub("\r\n", "\n").rstrip, footer: '')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# short id, received (local), sender, one-line subject
|
|
106
|
+
def list_columns
|
|
107
|
+
[short_id, Formatters::MailDates.short(received_at), from_label, subject.tr("\r\n", ' ')]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Tab-separated: full id, received (ISO 8601 local), sender address, subject, Read/Unread
|
|
111
|
+
def tsv_row
|
|
112
|
+
[id, Formatters::MailDates.iso(received_at), from[:address], subject, status]
|
|
113
|
+
.map { |field| field.to_s.tr("\t\r\n", ' ') }.join("\t")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Header lines for `teems mail read`, in Outlook's order; Cc, Categories and Attachments
|
|
117
|
+
# only when present. notes (e.g. Encryption) go just before the ID.
|
|
118
|
+
def headers(attachments: [], notes: {})
|
|
119
|
+
cc_line = cc_display
|
|
120
|
+
names = attachments.join(', ')
|
|
121
|
+
{ 'Subject' => subject, 'From' => from_display, 'To' => to_display, 'Cc' => cc_line,
|
|
122
|
+
'Date' => Formatters::MailDates.long(received_at), 'Categories' => categories.join(', '),
|
|
123
|
+
'Status' => status, 'Attachments' => names, **notes, 'ID' => id }
|
|
124
|
+
.reject { |name, value| value.empty? && %w[Cc Categories Attachments].include?(name) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# `teems mail read` output: header block, blank line, plain-text body with links at the end
|
|
128
|
+
def to_text(attachments: [], notes: {})
|
|
129
|
+
lines = headers(attachments: attachments, notes: notes).map { |name, value| "#{name}: #{value}" }
|
|
130
|
+
[*lines, '', text_body.to_s].join("\n")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# JSON for `teems mail read --json`: summary plus plain-text body and links
|
|
134
|
+
def full_json_hash(attachments: [])
|
|
135
|
+
text = text_body
|
|
136
|
+
to_json_hash.merge(body: text.text, links: text.footer, attachments: attachments)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def to_json_hash
|
|
140
|
+
{ id: id, short_id: short_id, subject: subject, from: from, to: to, cc: cc,
|
|
141
|
+
received_at: received_at&.iso8601, is_read: is_read, has_attachments: has_attachments, flagged: flagged,
|
|
142
|
+
categories: categories, internet_message_id: internet_message_id, web_link: web_link }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Constants set inside Data.define's block would land on Models, so they go here
|
|
147
|
+
MailMessage::PROTECTION_NOTES = {
|
|
148
|
+
'purview' => 'Protected by Purview; open in Outlook on the web',
|
|
149
|
+
'smime' => 'S/MIME encrypted; read it with teems mail read --decrypt'
|
|
150
|
+
}.freeze
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
# An Inbox rule from Microsoft Graph. conditions, exceptions and actions keep Graph's shape
|
|
6
|
+
# (camelCase keys) because they are passed back unchanged and described as they are.
|
|
7
|
+
MailRule = Data.define(:id, :name, :sequence, :enabled, :has_error, :conditions, :exceptions, :actions) do
|
|
8
|
+
def self.from_api(data)
|
|
9
|
+
new(id: data['id'], name: Support::ControlChars.strip(data['displayName'].to_s),
|
|
10
|
+
sequence: data['sequence'].to_i, enabled: data['isEnabled'] == true, has_error: data['hasError'] == true,
|
|
11
|
+
conditions: data['conditions'] || {}, exceptions: data['exceptions'] || {},
|
|
12
|
+
actions: data['actions'] || {})
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Rules that send mail on; teems lists them but will not turn them on
|
|
16
|
+
def forwards? = actions.keys.intersect?(MAIL_RULE_SENDING_ACTIONS)
|
|
17
|
+
|
|
18
|
+
def folder_ids = actions.values_at('moveToFolder', 'copyToFolder').compact
|
|
19
|
+
|
|
20
|
+
def state
|
|
21
|
+
return 'error' if has_error
|
|
22
|
+
|
|
23
|
+
enabled ? 'on' : 'off'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def columns = [sequence, state, name]
|
|
27
|
+
|
|
28
|
+
def tsv_row(description) = [id, sequence, state, name, description].join("\t")
|
|
29
|
+
|
|
30
|
+
def to_json_hash(description)
|
|
31
|
+
{ id: id, name: name, sequence: sequence, enabled: enabled, has_error: has_error,
|
|
32
|
+
description: description, conditions: conditions, exceptions: exceptions, actions: actions }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
MAIL_RULE_SENDING_ACTIONS = %w[forwardTo forwardAsAttachmentTo redirectTo].freeze
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/teems/runner.rb
CHANGED
|
@@ -8,6 +8,12 @@ module Teems
|
|
|
8
8
|
def chats_api = Api::Chats.new(api_client, account)
|
|
9
9
|
def messages_api = Api::Messages.new(api_client, account)
|
|
10
10
|
def calendar_api = Api::Calendar.new(api_client, account)
|
|
11
|
+
def mail_api = Api::Mail.new(api_client, account)
|
|
12
|
+
def mail_actions = Api::MailActions.new(api_client, account)
|
|
13
|
+
def mail_rules_api = Api::MailRules.new(api_client, account)
|
|
14
|
+
def smime_decryptor = @smime_decryptor ||= Services::SmimeDecryptor.new
|
|
15
|
+
def browser = @browser ||= Services::Browser.new
|
|
16
|
+
def clipboard = @clipboard ||= Services::Clipboard.new
|
|
11
17
|
def users_api = Api::Users.new(api_client, account)
|
|
12
18
|
def files_api = Api::Files.new(api_client, account)
|
|
13
19
|
end
|
|
@@ -47,20 +47,22 @@ module Teems
|
|
|
47
47
|
|
|
48
48
|
# POST/PATCH request methods, extracted to manage class size
|
|
49
49
|
module WriteRequests
|
|
50
|
+
# options: account:, and optionally body: (sent as JSON) and headers:
|
|
50
51
|
def post(endpoint_key, path, **options)
|
|
51
|
-
|
|
52
|
-
body = options.delete(:body)
|
|
53
|
-
req = Net::HTTP::Post.new(URI("#{resolve_endpoint(endpoint_key)}#{path}"))
|
|
54
|
-
apply_auth(req, account, endpoint_key)
|
|
55
|
-
req.body = JSON.generate(body) if body
|
|
56
|
-
run_request(path, get_http_for_endpoint(endpoint_key)) { |http| http.request(req) }
|
|
52
|
+
send_body(Net::HTTP::Post, options.merge(endpoint: endpoint_key, path: path))
|
|
57
53
|
end
|
|
58
54
|
|
|
59
55
|
def patch(endpoint_key, path, **options)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
send_body(Net::HTTP::Patch, options.merge(endpoint: endpoint_key, path: path))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def send_body(request_class, request)
|
|
62
|
+
endpoint_key, path, body = request.values_at(:endpoint, :path, :body)
|
|
63
|
+
req = request_class.new(URI("#{resolve_endpoint(endpoint_key)}#{path}"))
|
|
64
|
+
apply_headers(req, request.fetch(:headers, {}))
|
|
65
|
+
apply_auth(req, request[:account], endpoint_key)
|
|
64
66
|
req.body = JSON.generate(body) if body
|
|
65
67
|
run_request(path, get_http_for_endpoint(endpoint_key)) { |http| http.request(req) }
|
|
66
68
|
end
|
|
@@ -83,10 +85,20 @@ module Teems
|
|
|
83
85
|
when Net::HTTPTooManyRequests then raise_rate_limit(response)
|
|
84
86
|
else
|
|
85
87
|
code = response.code
|
|
86
|
-
raise ApiError.new("HTTP #{code}: #{response.message}",
|
|
88
|
+
raise ApiError.new("HTTP #{code}: #{response.message}",
|
|
89
|
+
status_code: code.to_i, error_code: error_code(response.body))
|
|
87
90
|
end
|
|
88
91
|
end
|
|
89
92
|
|
|
93
|
+
# Graph explains a 4xx as {"error": {"code": ...}}; other services may not
|
|
94
|
+
def error_code(body)
|
|
95
|
+
text = body.to_s
|
|
96
|
+
error = JSON.parse(text)['error'] if text.start_with?('{')
|
|
97
|
+
error['code'] if error.is_a?(Hash)
|
|
98
|
+
rescue JSON::ParserError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
90
102
|
def raise_rate_limit(response)
|
|
91
103
|
retry_after = response['Retry-After']
|
|
92
104
|
suffix = retry_after ? "retry after #{retry_after} seconds" : 'please wait and try again'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Services
|
|
5
|
+
# Opens a URL in the default browser with macOS's `open`
|
|
6
|
+
class Browser
|
|
7
|
+
def open(url)
|
|
8
|
+
_out, errors, status = Support::Subprocess.capture3('open', url)
|
|
9
|
+
raise Error, "Could not open the browser: #{errors.strip}" unless status.success?
|
|
10
|
+
|
|
11
|
+
url
|
|
12
|
+
rescue Errno::ENOENT
|
|
13
|
+
raise Error, 'Opening a browser needs the macOS open command'
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Services
|
|
5
|
+
# Copies text to the macOS clipboard with pbcopy
|
|
6
|
+
class Clipboard
|
|
7
|
+
def copy(text)
|
|
8
|
+
_out, errors, status = Support::Subprocess.capture3('pbcopy', stdin_data: text)
|
|
9
|
+
raise Error, "Could not copy to the clipboard: #{errors.strip}" unless status.success?
|
|
10
|
+
|
|
11
|
+
text
|
|
12
|
+
rescue Errno::ENOENT
|
|
13
|
+
raise Error, 'Copying needs the macOS pbcopy command'
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Services
|
|
5
|
+
# Resolves mail folder names ("Inbox", "Sent Items", "Projects/EERT") to Graph folders.
|
|
6
|
+
# Well-known folders resolve without listing the mailbox; other names match a folder's
|
|
7
|
+
# display name or full path, case-insensitively.
|
|
8
|
+
class MailFolders
|
|
9
|
+
WELL_KNOWN = {
|
|
10
|
+
'inbox' => 'inbox', 'archive' => 'archive', 'drafts' => 'drafts', 'outbox' => 'outbox',
|
|
11
|
+
'sent' => 'sentitems', 'sent items' => 'sentitems', 'sentitems' => 'sentitems',
|
|
12
|
+
'deleted' => 'deleteditems', 'deleted items' => 'deleteditems', 'deleteditems' => 'deleteditems',
|
|
13
|
+
'trash' => 'deleteditems', 'junk' => 'junkemail', 'junk email' => 'junkemail', 'junkemail' => 'junkemail'
|
|
14
|
+
}.freeze
|
|
15
|
+
# Graph folder IDs are long base64url strings; nothing a person names a folder looks like one
|
|
16
|
+
FOLDER_ID = /\A[A-Za-z0-9_=-]{60,}\z/
|
|
17
|
+
|
|
18
|
+
def initialize(api)
|
|
19
|
+
@api = api
|
|
20
|
+
@all = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Every folder, depth first, with "/"-joined paths
|
|
24
|
+
def all
|
|
25
|
+
@all ||= walk(@api.folders, nil)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# ID usable in a /me/mailFolders/{id} path, without a lookup where possible
|
|
29
|
+
def resolve_id(name)
|
|
30
|
+
well_known = well_known(name)
|
|
31
|
+
return well_known if well_known
|
|
32
|
+
return name if name.match?(FOLDER_ID)
|
|
33
|
+
|
|
34
|
+
resolve(name).id
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Graph folder ID => path, for naming folders other responses refer to by ID
|
|
38
|
+
def paths_by_id = all.to_h(&:id_and_path)
|
|
39
|
+
|
|
40
|
+
def resolve(name)
|
|
41
|
+
known = well_known(name)
|
|
42
|
+
return fetch(known) if known
|
|
43
|
+
return by_id(name) if name.match?(FOLDER_ID)
|
|
44
|
+
|
|
45
|
+
find(name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def well_known(name) = WELL_KNOWN[name.to_s.strip.downcase]
|
|
51
|
+
|
|
52
|
+
def fetch(id) = Models::MailFolder.from_api(@api.folder(id))
|
|
53
|
+
|
|
54
|
+
# A folder fetched by ID alone has no parent path (and so a different sync directory
|
|
55
|
+
# than by name), so look for it in the tree first
|
|
56
|
+
def by_id(id) = all.find { |folder| folder.id == id } || fetch(id)
|
|
57
|
+
|
|
58
|
+
def find(name)
|
|
59
|
+
Models::MailFolder.find_named(all, name) or
|
|
60
|
+
raise Error, "Mail folder not found: #{name} (run: teems mail folders)"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def walk(items, parent_path)
|
|
64
|
+
items.flat_map do |data|
|
|
65
|
+
folder = Models::MailFolder.from_api(data, parent_path: parent_path)
|
|
66
|
+
[folder, *children(folder)]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def children(folder)
|
|
71
|
+
parent_id, path = folder.child_lookup
|
|
72
|
+
parent_id ? walk(@api.folders(parent_id: parent_id), path) : []
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|