teems 0.3.2 → 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 +78 -0
- data/README.md +84 -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/meeting.rb +4 -3
- data/lib/teems/commands/messages.rb +51 -8
- data/lib/teems/commands/sync.rb +45 -11
- data/lib/teems/commands/sync_folders.rb +118 -0
- data/lib/teems/commands/transcripts.rb +205 -137
- data/lib/teems/formatters/format_utils.rb +26 -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/markdown_formatter.rb +10 -1
- data/lib/teems/formatters/message_formatter.rb +7 -2
- data/lib/teems/formatters/output.rb +45 -8
- data/lib/teems/formatters/transcript_markdown.rb +16 -12
- 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/inline_image.rb +83 -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/models/message.rb +7 -4
- data/lib/teems/models/parsing.rb +5 -0
- data/lib/teems/runner.rb +11 -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/inline_image_downloader.rb +115 -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_engine.rb +13 -7
- data/lib/teems/services/sync_images.rb +55 -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/subprocess.rb +2 -0
- data/lib/teems/support/time_parsing.rb +9 -1
- data/lib/teems/version.rb +1 -1
- data/lib/teems.rb +67 -2
- metadata +51 -1
|
@@ -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/models/message.rb
CHANGED
|
@@ -7,10 +7,13 @@ module Teems
|
|
|
7
7
|
Message = Data.define(
|
|
8
8
|
:id, :sender_id, :sender_name, :content, :created_at,
|
|
9
9
|
:message_type, :reply_to_id, :reactions, :attachments, :importance,
|
|
10
|
-
:edited, :mentions
|
|
10
|
+
:edited, :mentions, :images
|
|
11
11
|
) do
|
|
12
12
|
extend Parsing
|
|
13
13
|
|
|
14
|
+
# `images` (inline AMS images) defaults to empty so callers can omit it
|
|
15
|
+
def initialize(images: [], **attrs) = super
|
|
16
|
+
|
|
14
17
|
def self.from_api(data)
|
|
15
18
|
if data['message'] then from_teams_internal_api(data)
|
|
16
19
|
elsif data['imdisplayname'] || data['messagetype'] then from_ng_msg_api(data)
|
|
@@ -27,7 +30,7 @@ module Teems
|
|
|
27
30
|
{
|
|
28
31
|
id: data['id'], sender_id: data['from'],
|
|
29
32
|
sender_name: data['imdisplayname'] || data['fromDisplayNameInToken'] || 'Unknown',
|
|
30
|
-
|
|
33
|
+
**html_body_attrs(data['content']),
|
|
31
34
|
created_at: parse_time(data['composetime'] || data['originalarrivaltime']),
|
|
32
35
|
message_type: data['messagetype'],
|
|
33
36
|
**ng_msg_extras(data, props)
|
|
@@ -57,7 +60,7 @@ module Teems
|
|
|
57
60
|
new(
|
|
58
61
|
id: data['id'], sender_id: msg['from'],
|
|
59
62
|
sender_name: msg['imDisplayName'] || msg['fromDisplayNameInToken'] || 'Unknown',
|
|
60
|
-
|
|
63
|
+
**html_body_attrs(msg['content']),
|
|
61
64
|
created_at: parse_time(msg['composeTime'] || data['latestMessageTime']),
|
|
62
65
|
message_type: msg['type'], reply_to_id: nil, reactions: [],
|
|
63
66
|
attachments: parse_files_json(msg.dig('properties', 'files')),
|
|
@@ -74,7 +77,7 @@ module Teems
|
|
|
74
77
|
id: data['id'],
|
|
75
78
|
sender_id: data.dig('from', 'user', 'id') || data.dig('from', 'application', 'id'),
|
|
76
79
|
sender_name: extract_sender_name(data),
|
|
77
|
-
|
|
80
|
+
**html_body_attrs(data.dig('body', 'content')),
|
|
78
81
|
created_at: parse_time(data['createdDateTime']),
|
|
79
82
|
**graph_extras(data)
|
|
80
83
|
}
|
data/lib/teems/models/parsing.rb
CHANGED
|
@@ -13,6 +13,11 @@ module Teems
|
|
|
13
13
|
CGI.unescapeHTML(html.gsub(/<[^>]+>/, ' ')).gsub(' ', ' ').gsub(/\s+/, ' ').strip
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Plain-text content plus the inline images that strip_html would discard
|
|
17
|
+
def html_body_attrs(html)
|
|
18
|
+
{ content: strip_html(html || ''), images: InlineImage.parse_html(html) }
|
|
19
|
+
end
|
|
20
|
+
|
|
16
21
|
def parse_time(time_str)
|
|
17
22
|
return nil unless time_str
|
|
18
23
|
|
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
|
|
@@ -72,6 +78,11 @@ module Teems
|
|
|
72
78
|
Services::TokenRefresher.new(token_store: @token_store, output: @output)
|
|
73
79
|
end
|
|
74
80
|
|
|
81
|
+
# Downloader for inline message images; reads the current skype token on each request
|
|
82
|
+
def inline_image_downloader
|
|
83
|
+
Services::InlineImageDownloader.new(token_provider: -> { account.skype_token })
|
|
84
|
+
end
|
|
85
|
+
|
|
75
86
|
# Attempt to refresh the skype_token
|
|
76
87
|
def refresh_tokens
|
|
77
88
|
token_refresher.refresh
|
|
@@ -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,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Services
|
|
5
|
+
# Error responses from AMS, mapped so the caller can fall back or refresh tokens
|
|
6
|
+
module InlineImageErrors
|
|
7
|
+
# The requested AMS view does not exist for this object (try another view)
|
|
8
|
+
class ViewUnavailable < ApiError; end
|
|
9
|
+
|
|
10
|
+
MISSING_VIEW_CODES = %w[400 404 410].freeze
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def raise_failure(response)
|
|
15
|
+
code = response.code
|
|
16
|
+
status = code.to_i
|
|
17
|
+
raise ApiError.new('Invalid token or session expired', status_code: 401) if status == 401
|
|
18
|
+
raise ViewUnavailable.new("HTTP #{code}", status_code: status) if MISSING_VIEW_CODES.include?(code)
|
|
19
|
+
|
|
20
|
+
raise ApiError.new("HTTP #{code}", status_code: status)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def untrusted_host!(uri)
|
|
24
|
+
raise Error, "Refusing to send Teams credentials to untrusted image host: #{uri.host}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Identifies image bytes by their magic number
|
|
29
|
+
module ImageSignature
|
|
30
|
+
SIGNATURES = { 'png' => "\x89PNG".b, 'jpg' => "\xFF\xD8\xFF".b, 'gif' => 'GIF8'.b }.freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def extension_for(body)
|
|
35
|
+
bytes = body.to_s.b
|
|
36
|
+
found = SIGNATURES.find { |_ext, magic| bytes.start_with?(magic) }&.first
|
|
37
|
+
found || ('webp' if bytes.start_with?('RIFF') && bytes.byteslice(8, 4) == 'WEBP')
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Fetches inline message images from Teams' AMS object store. AMS accepts the
|
|
42
|
+
# skype token as `Authorization: skype_token <token>` (Bearer tokens get a 401).
|
|
43
|
+
# Credentials are only sent over HTTPS to Microsoft Teams/Skype hosts.
|
|
44
|
+
class InlineImageDownloader
|
|
45
|
+
include InlineImageErrors
|
|
46
|
+
|
|
47
|
+
# Downloaded image bytes, the file extension they imply, and the URL that served them
|
|
48
|
+
Result = Data.define(:body, :extension, :url)
|
|
49
|
+
|
|
50
|
+
TRUSTED_HOST = /(?:\A|\.)(?:asm\.skype\.com|teams\.microsoft\.com|teams\.microsoft\.us)\z/i
|
|
51
|
+
MAX_REDIRECTS = 3
|
|
52
|
+
TIMEOUTS = { open_timeout: 10, read_timeout: 60 }.freeze
|
|
53
|
+
|
|
54
|
+
# token_provider is called per request so a refreshed skype token is picked up
|
|
55
|
+
def initialize(token_provider:, http_client: nil)
|
|
56
|
+
@token_provider = token_provider
|
|
57
|
+
@http_client = http_client
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Prefers the original upload and falls back to the `src` preview when that view is missing
|
|
61
|
+
def fetch(image)
|
|
62
|
+
full_size = image.full_size_url
|
|
63
|
+
preview = image.url
|
|
64
|
+
fetch_url(full_size)
|
|
65
|
+
rescue ViewUnavailable
|
|
66
|
+
raise if full_size == preview
|
|
67
|
+
|
|
68
|
+
fetch_url(preview)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.trusted?(uri) = uri.is_a?(URI::HTTPS) && TRUSTED_HOST.match?(uri.host.to_s)
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def fetch_url(url)
|
|
76
|
+
uri = URI(url)
|
|
77
|
+
untrusted_host!(uri) unless self.class.trusted?(uri)
|
|
78
|
+
|
|
79
|
+
follow(uri, MAX_REDIRECTS)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def follow(uri, redirects_left)
|
|
83
|
+
response = http_get(uri)
|
|
84
|
+
case response
|
|
85
|
+
when Net::HTTPSuccess then build_result(response.body, uri)
|
|
86
|
+
when Net::HTTPRedirection then follow_redirect(uri, response['location'], redirects_left)
|
|
87
|
+
else raise_failure(response)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def follow_redirect(uri, location, redirects_left)
|
|
92
|
+
raise Error, 'Too many redirects' unless redirects_left.positive? && location
|
|
93
|
+
|
|
94
|
+
follow(uri + location, redirects_left - 1)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_result(body, uri)
|
|
98
|
+
extension = ImageSignature.extension_for(body)
|
|
99
|
+
raise Error, 'Response was not an image' unless extension
|
|
100
|
+
|
|
101
|
+
Result.new(body: body.to_s, extension: extension, url: uri.to_s)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Redirect targets outside Teams/Skype (e.g. a signed CDN URL) get no credentials
|
|
105
|
+
def http_get(uri)
|
|
106
|
+
headers = self.class.trusted?(uri) ? { 'Authorization' => "skype_token #{@token_provider.call}" } : {}
|
|
107
|
+
return @http_client.call(uri, headers) if @http_client
|
|
108
|
+
|
|
109
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, **TIMEOUTS) do |http|
|
|
110
|
+
http.request(Net::HTTP::Get.new(uri, headers))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
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
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Services
|
|
5
|
+
# Remembers full Graph message IDs for the short IDs `teems mail list` and `search` print,
|
|
6
|
+
# so `teems mail read <short id>` works afterwards. Kept in the XDG cache dir.
|
|
7
|
+
class MailIdCache
|
|
8
|
+
MAX_ENTRIES = 2000
|
|
9
|
+
|
|
10
|
+
def initialize(paths: Support::XdgPaths.new)
|
|
11
|
+
@path = paths.cache_file('mail-ids.json')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def remember(messages)
|
|
15
|
+
return if messages.empty?
|
|
16
|
+
|
|
17
|
+
# Listed IDs move to the end, so the ones in use are the last evicted
|
|
18
|
+
listed = messages.to_h { |message| [message.short_id, message.id] }
|
|
19
|
+
ids = load.except(*listed.keys).merge(listed)
|
|
20
|
+
FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
|
|
21
|
+
File.write(@path, JSON.generate(ids.to_a.last(MAX_ENTRIES).to_h))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Full ID for a short ID or unique prefix of one (4+ hex digits); full IDs pass through
|
|
25
|
+
def resolve(token)
|
|
26
|
+
prefix = self.class.short_prefix(token)
|
|
27
|
+
return token.to_s.strip unless prefix
|
|
28
|
+
|
|
29
|
+
matches = ids_starting_with(prefix)
|
|
30
|
+
matches.one? ? matches.first : nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Lowercased short ID (or prefix) for a token that looks like one, else nil
|
|
34
|
+
def self.short_prefix(token)
|
|
35
|
+
stripped = token.to_s.strip
|
|
36
|
+
stripped.match?(/\A\h{4,16}\z/) ? stripped.downcase : nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def ids_starting_with(prefix) = load.select { |short, _id| short.start_with?(prefix) }.values.uniq
|
|
42
|
+
|
|
43
|
+
def load
|
|
44
|
+
data = File.file?(@path) ? JSON.parse(File.read(@path)) : {}
|
|
45
|
+
data.is_a?(Hash) ? data : {}
|
|
46
|
+
rescue JSON::ParserError
|
|
47
|
+
{}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|