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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -0
  3. data/README.md +77 -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/sync.rb +15 -8
  25. data/lib/teems/commands/sync_folders.rb +118 -0
  26. data/lib/teems/formatters/format_utils.rb +23 -2
  27. data/lib/teems/formatters/html_entities.rb +49 -0
  28. data/lib/teems/formatters/html_text.rb +181 -0
  29. data/lib/teems/formatters/ics_format.rb +112 -0
  30. data/lib/teems/formatters/link_cleaner.rb +91 -0
  31. data/lib/teems/formatters/link_collector.rb +70 -0
  32. data/lib/teems/formatters/mail_formatter.rb +58 -0
  33. data/lib/teems/formatters/mail_markdown.rb +70 -0
  34. data/lib/teems/formatters/mail_rule_formatter.rb +45 -0
  35. data/lib/teems/formatters/mail_rule_text.rb +67 -0
  36. data/lib/teems/formatters/output.rb +45 -8
  37. data/lib/teems/models/chat.rb +19 -5
  38. data/lib/teems/models/event.rb +1 -23
  39. data/lib/teems/models/event_display.rb +40 -0
  40. data/lib/teems/models/ics_event.rb +60 -0
  41. data/lib/teems/models/mail_conversation.rb +62 -0
  42. data/lib/teems/models/mail_encryption.rb +41 -0
  43. data/lib/teems/models/mail_folder.rb +44 -0
  44. data/lib/teems/models/mail_message.rb +152 -0
  45. data/lib/teems/models/mail_rule.rb +38 -0
  46. data/lib/teems/runner.rb +6 -0
  47. data/lib/teems/services/api_client.rb +23 -11
  48. data/lib/teems/services/browser.rb +17 -0
  49. data/lib/teems/services/clipboard.rb +17 -0
  50. data/lib/teems/services/mail_folders.rb +76 -0
  51. data/lib/teems/services/mail_id_cache.rb +51 -0
  52. data/lib/teems/services/mail_legacy_files.rb +96 -0
  53. data/lib/teems/services/mail_sync_attachments.rb +103 -0
  54. data/lib/teems/services/mail_sync_engine.rb +98 -0
  55. data/lib/teems/services/mail_sync_folder.rb +142 -0
  56. data/lib/teems/services/mail_sync_store.rb +116 -0
  57. data/lib/teems/services/smime_content.rb +36 -0
  58. data/lib/teems/services/smime_decryptor.rb +36 -0
  59. data/lib/teems/services/sync_dir_naming.rb +16 -10
  60. data/lib/teems/services/sync_dir_ownership.rb +111 -0
  61. data/lib/teems/services/sync_dir_planning.rb +130 -0
  62. data/lib/teems/services/sync_store.rb +20 -43
  63. data/lib/teems/services/token_extractor.rb +4 -0
  64. data/lib/teems/support/control_chars.rb +19 -0
  65. data/lib/teems/support/mime_header.rb +61 -0
  66. data/lib/teems/support/mime_part.rb +97 -0
  67. data/lib/teems/support/signed_data.rb +23 -0
  68. data/lib/teems/support/time_parsing.rb +9 -1
  69. data/lib/teems/version.rb +1 -1
  70. data/lib/teems.rb +63 -2
  71. metadata +48 -1
@@ -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
- 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,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