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.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -0
  3. data/README.md +84 -0
  4. data/bin/teems +8 -1
  5. data/lib/teems/api/calendar.rb +45 -14
  6. data/lib/teems/api/client.rb +19 -4
  7. data/lib/teems/api/mail.rb +132 -0
  8. data/lib/teems/api/mail_actions.rb +22 -0
  9. data/lib/teems/api/mail_rules.rb +24 -0
  10. data/lib/teems/cli.rb +2 -0
  11. data/lib/teems/commands/base.rb +31 -7
  12. data/lib/teems/commands/cal.rb +109 -28
  13. data/lib/teems/commands/cal_ics.rb +47 -0
  14. data/lib/teems/commands/confirmation.rb +19 -0
  15. data/lib/teems/commands/help.rb +4 -0
  16. data/lib/teems/commands/mail.rb +207 -0
  17. data/lib/teems/commands/mail_decrypting.rb +46 -0
  18. data/lib/teems/commands/mail_help.rb +85 -0
  19. data/lib/teems/commands/mail_rules.rb +201 -0
  20. data/lib/teems/commands/mail_sync.rb +92 -0
  21. data/lib/teems/commands/mail_thread.rb +87 -0
  22. data/lib/teems/commands/mail_triage.rb +241 -0
  23. data/lib/teems/commands/meet.rb +159 -0
  24. data/lib/teems/commands/meeting.rb +4 -3
  25. data/lib/teems/commands/messages.rb +51 -8
  26. data/lib/teems/commands/sync.rb +45 -11
  27. data/lib/teems/commands/sync_folders.rb +118 -0
  28. data/lib/teems/commands/transcripts.rb +205 -137
  29. data/lib/teems/formatters/format_utils.rb +26 -2
  30. data/lib/teems/formatters/html_entities.rb +49 -0
  31. data/lib/teems/formatters/html_text.rb +181 -0
  32. data/lib/teems/formatters/ics_format.rb +112 -0
  33. data/lib/teems/formatters/link_cleaner.rb +91 -0
  34. data/lib/teems/formatters/link_collector.rb +70 -0
  35. data/lib/teems/formatters/mail_formatter.rb +58 -0
  36. data/lib/teems/formatters/mail_markdown.rb +70 -0
  37. data/lib/teems/formatters/mail_rule_formatter.rb +45 -0
  38. data/lib/teems/formatters/mail_rule_text.rb +67 -0
  39. data/lib/teems/formatters/markdown_formatter.rb +10 -1
  40. data/lib/teems/formatters/message_formatter.rb +7 -2
  41. data/lib/teems/formatters/output.rb +45 -8
  42. data/lib/teems/formatters/transcript_markdown.rb +16 -12
  43. data/lib/teems/models/chat.rb +19 -5
  44. data/lib/teems/models/event.rb +1 -23
  45. data/lib/teems/models/event_display.rb +40 -0
  46. data/lib/teems/models/ics_event.rb +60 -0
  47. data/lib/teems/models/inline_image.rb +83 -0
  48. data/lib/teems/models/mail_conversation.rb +62 -0
  49. data/lib/teems/models/mail_encryption.rb +41 -0
  50. data/lib/teems/models/mail_folder.rb +44 -0
  51. data/lib/teems/models/mail_message.rb +152 -0
  52. data/lib/teems/models/mail_rule.rb +38 -0
  53. data/lib/teems/models/message.rb +7 -4
  54. data/lib/teems/models/parsing.rb +5 -0
  55. data/lib/teems/runner.rb +11 -0
  56. data/lib/teems/services/api_client.rb +23 -11
  57. data/lib/teems/services/browser.rb +17 -0
  58. data/lib/teems/services/clipboard.rb +17 -0
  59. data/lib/teems/services/inline_image_downloader.rb +115 -0
  60. data/lib/teems/services/mail_folders.rb +76 -0
  61. data/lib/teems/services/mail_id_cache.rb +51 -0
  62. data/lib/teems/services/mail_legacy_files.rb +96 -0
  63. data/lib/teems/services/mail_sync_attachments.rb +103 -0
  64. data/lib/teems/services/mail_sync_engine.rb +98 -0
  65. data/lib/teems/services/mail_sync_folder.rb +142 -0
  66. data/lib/teems/services/mail_sync_store.rb +116 -0
  67. data/lib/teems/services/smime_content.rb +36 -0
  68. data/lib/teems/services/smime_decryptor.rb +36 -0
  69. data/lib/teems/services/sync_dir_naming.rb +16 -10
  70. data/lib/teems/services/sync_dir_ownership.rb +111 -0
  71. data/lib/teems/services/sync_dir_planning.rb +130 -0
  72. data/lib/teems/services/sync_engine.rb +13 -7
  73. data/lib/teems/services/sync_images.rb +55 -0
  74. data/lib/teems/services/sync_store.rb +20 -43
  75. data/lib/teems/services/token_extractor.rb +4 -0
  76. data/lib/teems/support/control_chars.rb +19 -0
  77. data/lib/teems/support/mime_header.rb +61 -0
  78. data/lib/teems/support/mime_part.rb +97 -0
  79. data/lib/teems/support/signed_data.rb +23 -0
  80. data/lib/teems/support/subprocess.rb +2 -0
  81. data/lib/teems/support/time_parsing.rb +9 -1
  82. data/lib/teems/version.rb +1 -1
  83. data/lib/teems.rb +67 -2
  84. 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
@@ -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
- content: strip_html(data['content'] || ''),
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
- content: strip_html(msg['content'] || ''),
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
- content: strip_html(data.dig('body', 'content') || ''),
80
+ **html_body_attrs(data.dig('body', 'content')),
78
81
  created_at: parse_time(data['createdDateTime']),
79
82
  **graph_extras(data)
80
83
  }
@@ -13,6 +13,11 @@ module Teems
13
13
  CGI.unescapeHTML(html.gsub(/<[^>]+>/, ' ')).gsub('&nbsp;', ' ').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
- account = options.delete(:account)
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
- account = options.delete(:account)
61
- body = options.delete(:body)
62
- req = Net::HTTP::Patch.new(URI("#{resolve_endpoint(endpoint_key)}#{path}"))
63
- apply_auth(req, account, endpoint_key)
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}", status_code: code.to_i)
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