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
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Formatters
5
+ # Renders Models::IcsEvent objects as an RFC 5545 calendar for subscribing from
6
+ # Calendar.app and friends. The VEVENT layout follows the feed outlook-cli served,
7
+ # so a subscription keeps showing invitees, responses and Teams join buttons.
8
+ module IcsFormat
9
+ PRODID = '-//ericboehs//teems//EN'
10
+ PARTSTAT = { 'accepted' => 'ACCEPTED', 'organizer' => 'ACCEPTED', 'declined' => 'DECLINED',
11
+ 'tentativelyAccepted' => 'TENTATIVE' }.freeze
12
+ ROLE = { 'optional' => 'OPT-PARTICIPANT', 'resource' => 'NON-PARTICIPANT' }.freeze
13
+
14
+ module_function
15
+
16
+ def calendar(events, name:, stamp: Time.now)
17
+ dtstamp = utc(stamp)
18
+ vevents = events.select(&:dated?).flat_map { |event| vevent(event, dtstamp) }
19
+ lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', "PRODID:#{PRODID}", 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH',
20
+ "X-WR-CALNAME:#{escape(name)}", *vevents, 'END:VCALENDAR']
21
+ "#{lines.map { |line| fold(line) }.join("\r\n")}\r\n"
22
+ end
23
+
24
+ def vevent(event, dtstamp)
25
+ ['BEGIN:VEVENT', "UID:#{uri(event.uid)}", "DTSTAMP:#{dtstamp}", *times(event),
26
+ "SUMMARY:#{escape(event.subject)}", *optional_text('LOCATION', event.location),
27
+ *people(event), *optional_text('DESCRIPTION', event.description), *join_lines(event.join_url),
28
+ *status_lines(event), 'END:VEVENT']
29
+ end
30
+
31
+ def times(event) = ["DTSTART#{time_value(event.starts)}", "DTEND#{time_value(event.ends)}"]
32
+
33
+ # All-day events have Dates, others UTC Times
34
+ def time_value(value) = value.is_a?(Date) ? ";VALUE=DATE:#{value.strftime('%Y%m%d')}" : ":#{utc(value)}"
35
+
36
+ def people(event)
37
+ organizer = Hash(event.organizer)
38
+ email = mail_address(organizer)
39
+ lines = email ? ["ORGANIZER;CN=#{common_name(organizer, email)}:mailto:#{email}"] : []
40
+ lines + event.attendees.filter_map { |attendee| attendee_line(attendee) }
41
+ end
42
+
43
+ def attendee_line(attendee)
44
+ email = mail_address(attendee)
45
+ return unless email
46
+
47
+ role = ROLE.fetch(attendee[:type].to_s, 'REQ-PARTICIPANT')
48
+ partstat = PARTSTAT.fetch(attendee[:response].to_s, 'NEEDS-ACTION')
49
+ "ATTENDEE;CN=#{common_name(attendee, email)};ROLE=#{role};PARTSTAT=#{partstat};RSVP=TRUE:mailto:#{email}"
50
+ end
51
+
52
+ # An address that makes a valid mailto: URI, or nil. Exchange-internal X.500 addresses
53
+ # (/O=EXCHANGELABS/...) have no @ and are left out with the people who have them.
54
+ def mail_address(person)
55
+ email = uri(person[:email]).strip
56
+ email.include?('@') ? email : nil
57
+ end
58
+
59
+ def common_name(person, email)
60
+ name = person[:name].to_s.strip
61
+ param(name.empty? ? email : name)
62
+ end
63
+
64
+ # URI values are not text-escaped. URL is what most clients show; CONFERENCE and the
65
+ # Microsoft property are what Calendar.app and Outlook look for to offer a Join button.
66
+ def join_lines(join_url)
67
+ url = uri(join_url)
68
+ return [] if url.empty?
69
+
70
+ ["URL;VALUE=URI:#{url}", %(CONFERENCE;VALUE=URI;FEATURE=AUDIO,VIDEO;LABEL="Microsoft Teams":#{url}),
71
+ "X-MICROSOFT-SKYPETEAMSMEETINGURL:#{url}"]
72
+ end
73
+
74
+ def status_lines(event)
75
+ [*('STATUS:CANCELLED' if event.cancelled?), *('TRANSP:TRANSPARENT' if event.free?)]
76
+ end
77
+
78
+ def optional_text(name, text) = text.to_s.strip.empty? ? [] : ["#{name}:#{escape(text)}"]
79
+
80
+ # Control characters RFC 5545 allows in no value: all but HTAB, once line breaks are handled
81
+ CONTROLS = /[\x00-\x08\x0A-\x1F\x7F]/
82
+
83
+ # RFC 5545 TEXT escaping
84
+ def escape(text)
85
+ text.to_s.gsub(/[\\;,]/) { |char| "\\#{char}" }.gsub(/\r\n|\r|\n/, '\\n').gsub(CONTROLS, '')
86
+ end
87
+
88
+ # UIDs, URIs and addresses are not escaped, so a line break in one would start a new
89
+ # property: control characters are dropped
90
+ def uri(value) = value.to_s.gsub(CONTROLS, '')
91
+
92
+ # Parameter values are quoted when they hold a separator; quotes themselves cannot appear
93
+ def param(value)
94
+ text = value.to_s.delete('"').gsub(/[\r\n]+/, ' ').gsub(CONTROLS, '')
95
+ text.match?(/[;:,]/) ? %("#{text}") : text
96
+ end
97
+
98
+ def utc(time) = time.utc.strftime('%Y%m%dT%H%M%SZ')
99
+
100
+ # Lines longer than 75 octets continue on lines starting with a space, never splitting a character
101
+ def fold(line)
102
+ parts = []
103
+ line.each_char do |char|
104
+ current = parts.last
105
+ fits = current && current.bytesize + char.bytesize <= (parts.one? ? 75 : 74)
106
+ fits ? current << char : parts << char
107
+ end
108
+ parts.join("\r\n ")
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Formatters
5
+ # Cleans the hrefs listed in a mail links footer. SafeLinks wrappers are unwrapped and
6
+ # click tracking is dropped (utm_* everywhere, plus SharePoint and Teams noise on
7
+ # SharePoint hosts). Every other query parameter is kept with its original encoding.
8
+ module LinkCleaner
9
+ SAFELINKS_HOSTS = %w[.safelinks.protection.outlook.com .safelinks.protection.office365.us].freeze
10
+ SHAREPOINT_HOSTS = %w[.sharepoint.com .sharepoint.us].freeze
11
+ # ASP.NET reads query keys case-insensitively, so these are matched that way too
12
+ SHAREPOINT_NOISE = %w[xsdata sdata ovuser clickparams teamscid ct linkopentime or].freeze
13
+ # Not web pages: mail and phone links, inline images, scripts
14
+ SKIP_SCHEMES = %w[mailto: tel: cid: javascript: data: sms: callto: about:].freeze
15
+ URL_PARTS = %r{\A(?<head>[a-z][a-z0-9+.-]*://(?<host>[^/?#]*)[^?#]*)(?:\?(?<query>[^#]*))?(?<frag>#.*)?\z}im
16
+
17
+ module_function
18
+
19
+ def skip?(href)
20
+ href.empty? || href.start_with?('#') || href.downcase.start_with?(*SKIP_SCHEMES)
21
+ end
22
+
23
+ def clean(href)
24
+ url = unwrap_safelinks(href.gsub(/[\t\r\n]/, '').strip)
25
+ drop_tracking(url).gsub(' ', '%20')
26
+ end
27
+
28
+ # SafeLinks wraps the real URL in its url param, sometimes more than once when a
29
+ # message has been forwarded around
30
+ def unwrap_safelinks(url)
31
+ 5.times do
32
+ inner = safelinks_target(url)
33
+ break unless inner
34
+
35
+ url = inner
36
+ end
37
+ url
38
+ end
39
+
40
+ def safelinks_target(url)
41
+ parts = URL_PARTS.match(url)
42
+ return unless parts && host_of(parts).end_with?(*SAFELINKS_HOSTS)
43
+
44
+ URI.decode_www_form(parts[:query].to_s).assoc('url')&.last&.strip
45
+ rescue ArgumentError
46
+ nil
47
+ end
48
+
49
+ def drop_tracking(url)
50
+ parts = URL_PARTS.match(url)
51
+ query = parts&.[](:query)
52
+ return url if query.to_s.empty?
53
+
54
+ pairs = query.split('&', -1)
55
+ noise = host_of(parts).end_with?(*SHAREPOINT_HOSTS) ? SHAREPOINT_NOISE : []
56
+ kept = pairs.reject { |pair| tracking_param?(pair, noise) }
57
+ kept.length == pairs.length ? url : rebuild(parts, kept)
58
+ end
59
+
60
+ def tracking_param?(pair, noise)
61
+ name = unescape(pair.split('=', 2).first.to_s).downcase
62
+ pair.empty? || name.start_with?('utm_') || noise.include?(name)
63
+ end
64
+
65
+ def rebuild(parts, kept)
66
+ query = kept.empty? ? '' : "?#{kept.join('&')}"
67
+ "#{parts[:head]}#{query}#{parts[:frag]}"
68
+ end
69
+
70
+ # Link text that is a URL, or just repeats the start of this one, adds nothing.
71
+ # Compared without scheme, leading www. and trailing slashes.
72
+ def label(text, url, max:)
73
+ text = text.delete("\u200b\ufeff").gsub(/[[:space:]]+/, ' ').strip
74
+ return '' if text.empty? || text.match?(%r{\A(https?://|www\.)}i) || prefix_of_url?(text, url)
75
+
76
+ text.length > max ? "#{text[0, max - 3].rstrip}..." : text
77
+ end
78
+
79
+ def prefix_of_url?(text, url)
80
+ bare_text = bare(text)
81
+ bare_text.match?(%r{[./]}) && bare(url).start_with?(bare_text)
82
+ end
83
+
84
+ def bare(text) = text.downcase.sub(%r{\A([a-z][a-z0-9+.-]*://)?(www\.)?}, '').sub(%r{/+\z}, '')
85
+
86
+ def host_of(parts) = parts[:host].sub(/\A.*@/, '').sub(/:\d*\z/, '').downcase
87
+
88
+ def unescape(text) = text.gsub(/%(\h\h)/) { Regexp.last_match(1).hex.chr }
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Formatters
5
+ # Collects the anchors of an HTML body, one entry per cleaned URL, numbered by first
6
+ # appearance. A later anchor for the same URL supplies the label when the first had none.
7
+ class LinkCollector
8
+ LABEL_MAX = 80
9
+
10
+ def initialize
11
+ @links = {}
12
+ # Each URL's footer number, kept when it is first added
13
+ @numbers = {}
14
+ @current = nil
15
+ end
16
+
17
+ def links = @links.to_a
18
+
19
+ def empty? = @links.empty?
20
+
21
+ def capturing? = @current.is_a?(Hash)
22
+
23
+ def start(href)
24
+ finish
25
+ @current = { href: href, text: +'', alt: [] }
26
+ end
27
+
28
+ def add_text(text)
29
+ @current[:text] << text if @current
30
+ end
31
+
32
+ def add_alt(alt)
33
+ @current[:alt] << alt if @current && !alt.empty?
34
+ end
35
+
36
+ # Ends the current anchor; returns its footer number, or nil when it is not listed
37
+ def finish
38
+ current = @current
39
+ return unless current
40
+
41
+ @current = nil
42
+ register(current[:href].strip, anchor_text(current))
43
+ end
44
+
45
+ def footer
46
+ return '' if empty?
47
+
48
+ lines = links.each.with_index(1).map do |(url, label), number|
49
+ label.empty? ? "[#{number}] #{url}" : "[#{number}] #{label} <#{url}>"
50
+ end
51
+ ['Links:', *lines].join("\n")
52
+ end
53
+
54
+ private
55
+
56
+ def anchor_text(current)
57
+ text = current[:text]
58
+ text.strip.empty? ? current[:alt].join(' ') : text
59
+ end
60
+
61
+ def register(href, text)
62
+ return if LinkCleaner.skip?(href)
63
+
64
+ url = LinkCleaner.clean(href)
65
+ @links[url] = LinkCleaner.label(text, url, max: LABEL_MAX) if @links.fetch(url, '').empty?
66
+ @numbers[url] ||= @numbers.size + 1
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Formatters
5
+ # Date styles shared by mail output
6
+ module MailDates
7
+ module_function
8
+
9
+ # The style Outlook for Mac printed, kept for `read` headers and synced Markdown
10
+ def long(time) = time ? time.getlocal.strftime('%A, %B %-d, %Y at %-I:%M:%S %p') : ''
11
+
12
+ def short(time) = time ? time.getlocal.strftime('%Y-%m-%d %H:%M') : ''
13
+
14
+ def iso(time) = time ? time.getlocal.iso8601 : ''
15
+ end
16
+
17
+ # Terminal output for `teems mail` (TSV rows come from the models)
18
+ class MailFormatter
19
+ FROM_WIDTH = 24
20
+ SUBJECT_WIDTH = 70
21
+
22
+ def initialize(output:)
23
+ @output = output
24
+ end
25
+
26
+ # id, received (local), from, subject, read flag
27
+ def message_rows(messages) = messages.map { |message| message_row(message) }
28
+
29
+ # message_rows plus each message's folder, which lines up after the read/unread column
30
+ def thread_rows(conversation)
31
+ conversation.messages.map do |message|
32
+ padding = message.read? ? ' ' : ' '
33
+ "#{message_row(message)}#{padding}#{@output.gray(conversation.folder(message).to_s)}"
34
+ end
35
+ end
36
+
37
+ def folder_rows(folders)
38
+ width = folders.map { |folder| folder.path.length }.push(6).max
39
+ header = @output.bold("#{'FOLDER'.ljust(width)} #{'UNREAD'.rjust(6)} #{'TOTAL'.rjust(7)}")
40
+ [header, *folders.collect { |folder| folder_row(folder, width) }]
41
+ end
42
+
43
+ private
44
+
45
+ def message_row(message)
46
+ short_id, received, from, subject = message.list_columns
47
+ from = FormatUtils.truncate(from, FROM_WIDTH - 3).ljust(FROM_WIDTH)
48
+ row = "#{@output.cyan(short_id)} #{received} #{from} #{FormatUtils.truncate(subject, SUBJECT_WIDTH)}"
49
+ row = message.read? ? "#{row} #{@output.gray('read')}" : @output.bold("#{row} unread")
50
+ message.flagged ? "#{row} #{@output.yellow('flagged')}" : row
51
+ end
52
+
53
+ def folder_row(folder, width)
54
+ "#{folder.path.ljust(width)} #{folder.unread_count.to_s.rjust(6)} #{folder.total_count.to_s.rjust(7)}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Formatters
5
+ # Renders a synced mail message as Markdown with YAML frontmatter. The fields and layout
6
+ # follow the files outlook-cli wrote to ~/Mail, so md2maildir and search indexes keep working.
7
+ class MailMarkdown
8
+ SLUG_MAX = 80
9
+
10
+ def self.filename(message)
11
+ date = message.received_at&.getlocal&.strftime('%Y-%m-%d') || 'unknown-date'
12
+ slug = message.subject.unicode_normalize(:nfkc).gsub(/[^[:alnum:]]+/, '-')[0, SLUG_MAX].gsub(/\A-+|-+\z/, '')
13
+ "#{date}-#{slug.empty? ? 'no-subject' : slug}-#{message.short_id}.md"
14
+ end
15
+
16
+ def self.render(message, folder:, attachments: []) = new(message, folder, attachments).render
17
+
18
+ def initialize(message, folder, attachments)
19
+ @message = message
20
+ @folder = folder
21
+ @attachments = attachments
22
+ end
23
+
24
+ def render
25
+ [frontmatter, '', "# #{@message.subject}", '', "**From:** #{@message.from_display}",
26
+ "**To:** #{addresses(@message.to)}", "**Date:** #{MailDates.long(@message.received_at)}",
27
+ '', '---', '', *protection_lines, "#{@message.text_body}\n"].join("\n")
28
+ end
29
+
30
+ private
31
+
32
+ def frontmatter
33
+ fields = { 'id' => @message.id, **quoted_fields, 'status' => @message.status.downcase,
34
+ 'attachments' => quote(@attachments.join(', ')),
35
+ 'internet_message_id' => quote(@message.internet_message_id), **protection_field }
36
+ ['---', *fields.map { |key, value| "#{key}: #{value}" }, '---'].join("\n")
37
+ end
38
+
39
+ def quoted_fields
40
+ received = @message.received_at
41
+ fields = { 'subject' => @message.subject, 'from' => @message.from_display, 'to' => addresses(@message.to),
42
+ 'cc' => addresses(@message.cc), 'date' => MailDates.long(received),
43
+ 'received' => MailDates.iso(received), 'folder' => @folder,
44
+ 'categories' => @message.categories.join(', ') }
45
+ fields.transform_values { |value| quote(value) }
46
+ end
47
+
48
+ # Protected mail only, so files of plain mail stay as outlook-cli wrote them
49
+ def protection_field
50
+ kind = @message.protection
51
+ kind ? { 'protection' => quote(kind) } : {}
52
+ end
53
+
54
+ # A note above the body of protected mail, whose body is empty or Microsoft's placeholder;
55
+ # Purview mail's links to Outlook on the web, which opens it
56
+ def protection_lines
57
+ note = @message.protection_note
58
+ return [] unless note
59
+
60
+ link = @message.protection == 'purview' && @message.web_link
61
+ [link ? "> #{note}: <#{link}>" : "> #{note}", '']
62
+ end
63
+
64
+ def addresses(list) = list.map { |entry| entry[:address] }.reject(&:empty?).join(', ')
65
+
66
+ # JSON strings are valid YAML double-quoted scalars
67
+ def quote(value) = JSON.generate(value.to_s)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Formatters
5
+ # `teems mail rules` output: a table, TSV, or JSON, each with MailRuleText's description
6
+ class MailRuleFormatter
7
+ NAME_WIDTH = 30
8
+
9
+ # folder_names: Graph folder ID => path, to name where a rule moves mail
10
+ def initialize(output:, folder_names: {})
11
+ @output = output
12
+ @text = MailRuleText.new(folder_names)
13
+ end
14
+
15
+ def describe(rule) = @text.describe(rule)
16
+
17
+ def rows(rules)
18
+ width = name_width(rules)
19
+ rules.map { |rule| row(rule, width) }
20
+ end
21
+
22
+ def tsv_rows(rules) = rules.map { |rule| rule.tsv_row(describe(rule)) }
23
+
24
+ def json(rules) = rules.map { |rule| rule.to_json_hash(describe(rule)) }
25
+
26
+ private
27
+
28
+ def name_width(rules) = rules.map { |rule| rule.name.length }.max.to_i.clamp(..NAME_WIDTH)
29
+
30
+ def row(rule, width)
31
+ number, state, name = rule.columns
32
+ "#{number.to_s.rjust(3)} #{state_text(state)} #{name[0, width].ljust(width)} #{describe(rule)}"
33
+ end
34
+
35
+ def state_text(state)
36
+ padded = state.ljust(5)
37
+ case state
38
+ when 'on' then @output.green(padded)
39
+ when 'off' then @output.gray(padded)
40
+ else @output.red(padded)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Formatters
5
+ # One line saying what an Inbox rule does, e.g.
6
+ # from jira@example.com, except subject has "urgent" -> move to Inbox/Jira, stop
7
+ # Keys this does not know are spelled out from their Graph names, so nothing is hidden.
8
+ class MailRuleText
9
+ LABELS = {
10
+ 'fromAddresses' => 'from', 'sentToAddresses' => 'to', 'subjectContains' => 'subject has',
11
+ 'bodyContains' => 'body has', 'bodyOrSubjectContains' => 'subject or body has',
12
+ 'senderContains' => 'sender has', 'recipientContains' => 'recipient has',
13
+ 'headerContains' => 'header has', 'moveToFolder' => 'move to', 'copyToFolder' => 'copy to',
14
+ 'assignCategories' => 'categories', 'markImportance' => 'importance',
15
+ 'forwardTo' => 'FORWARD to', 'forwardAsAttachmentTo' => 'FORWARD as attachment to',
16
+ 'redirectTo' => 'REDIRECT to'
17
+ }.freeze
18
+ # Conditions and actions that are simply on
19
+ SWITCHES = { 'markAsRead' => 'mark read', 'stopProcessingRules' => 'stop',
20
+ 'delete' => 'delete', 'permanentDelete' => 'PERMANENTLY delete',
21
+ 'isMeetingRequest' => 'meeting requests', 'hasAttachments' => 'with attachments' }.freeze
22
+ FOLDER_ACTIONS = %w[moveToFolder copyToFolder].freeze
23
+
24
+ # folder_names: Graph folder ID => path, to name a rule's destination
25
+ def initialize(folder_names = {})
26
+ @folder_names = folder_names
27
+ end
28
+
29
+ def describe(rule)
30
+ exceptions = phrases(rule.exceptions)
31
+ tests = phrases(rule.conditions).join(', ')
32
+ tests = 'all mail' if tests.empty?
33
+ tests += ", except #{exceptions.join(', ')}" if exceptions.any?
34
+ "#{tests} -> #{phrases(rule.actions).join(', ')}"
35
+ end
36
+
37
+ private
38
+
39
+ def phrases(settings) = settings.filter_map { |key, value| phrase(key, value) }
40
+
41
+ def phrase(key, value)
42
+ return if [false, nil, []].include?(value)
43
+
44
+ switch = value == true
45
+ label = (switch ? SWITCHES : LABELS).fetch(key) { spelled(key) }
46
+ switch ? label : "#{label} #{values_text(key, value)}"
47
+ end
48
+
49
+ def values_text(key, value) = Array(value).map { |item| item_text(key, item) }.join(', ')
50
+
51
+ def item_text(key, item)
52
+ return @folder_names.fetch(item, 'another folder') if FOLDER_ACTIONS.include?(key)
53
+ return recipient_text(item) if item.is_a?(Hash)
54
+
55
+ key.end_with?('Contains') ? %("#{item}") : item.to_s
56
+ end
57
+
58
+ def recipient_text(item)
59
+ address = item['emailAddress'] || item
60
+ address.values_at('address', 'name').compact.first.to_s
61
+ end
62
+
63
+ # isAutomaticReply -> is automatic reply
64
+ def spelled(key) = key.gsub(/[A-Z]/) { |letter| " #{letter.downcase}" }
65
+ end
66
+ end
67
+ end
@@ -22,6 +22,14 @@ module Teems
22
22
  end
23
23
  end
24
24
 
25
+ # Stands in for stdout or stderr once its reader has gone away (see Output#to_stdout)
26
+ module NullIO
27
+ def self.puts(*) = nil
28
+ def self.print(*) = nil
29
+ def self.flush = self
30
+ def self.tty? = false
31
+ end
32
+
25
33
  # Terminal output with ANSI color support
26
34
  class Output
27
35
  include OutputColors
@@ -52,23 +60,34 @@ module Teems
52
60
  def tty? = @io.tty?
53
61
 
54
62
  def puts(message = '')
55
- write_unless_quiet { @io.puts(message) }
63
+ write_unless_quiet { |io| io.puts(message) }
56
64
  end
57
65
 
58
66
  def print(message)
59
- write_unless_quiet { @io.print(message) }
67
+ write_unless_quiet { |io| io.print(message) }
60
68
  end
61
69
 
62
70
  def flush
63
- @io.flush
71
+ to_stdout(&:flush)
64
72
  end
65
73
 
66
74
  def error(message)
67
- @err.puts(colorize("#{red('Error:')} #{message}"))
75
+ to_stderr { |err| err.puts(colorize("#{red('Error:')} #{message}")) }
68
76
  end
69
77
 
70
78
  def warn(message)
71
- write_unless_quiet { @err.puts(colorize("#{yellow('Warning:')} #{message}")) }
79
+ note("#{yellow('Warning:')} #{message}")
80
+ end
81
+
82
+ # A question for the person at the terminal: on stderr, so piped stdout stays data, and
83
+ # shown under --quiet too, since an unseen question would just hang (stderr is unbuffered)
84
+ def prompt(message)
85
+ to_stderr { |err| err.print(message) }
86
+ end
87
+
88
+ # Commentary that should not mix with data on stdout (shown unless --quiet)
89
+ def note(message)
90
+ to_stderr { |err| err.puts(colorize(message)) } unless quiet?
72
91
  end
73
92
 
74
93
  def success(message)
@@ -82,13 +101,31 @@ module Teems
82
101
  def debug(message)
83
102
  return unless verbose?
84
103
 
85
- @err.puts(colorize("#{gray('[debug]')} #{message}"))
104
+ to_stderr { |err| err.puts(colorize("#{gray('[debug]')} #{message}")) }
86
105
  end
87
106
 
88
107
  private
89
108
 
90
- def write_unless_quiet
91
- yield unless quiet?
109
+ def write_unless_quiet(&)
110
+ to_stdout(&) unless quiet?
111
+ end
112
+
113
+ # A reader that stops early (`teems ... | head`) is not an error: drop the
114
+ # rest of stdout and let the command finish quietly. Raising instead would
115
+ # surface as "Unexpected error: Broken pipe", or be caught by a per-item
116
+ # rescue and reported as a failed chat or download.
117
+ def to_stdout
118
+ yield @io
119
+ rescue Errno::EPIPE
120
+ @io = NullIO
121
+ end
122
+
123
+ # The same for stderr, which `teems ... 2>&1 | head` closes along with stdout. A
124
+ # warning that raised here would also escape the per-item rescues that report it.
125
+ def to_stderr
126
+ yield @err
127
+ rescue Errno::EPIPE
128
+ @err = NullIO
92
129
  end
93
130
 
94
131
  def wrap(color, text)
@@ -30,18 +30,22 @@ module Teems
30
30
  end
31
31
 
32
32
  def self.from_ngmsg(data)
33
- thread_props = data['threadProperties'] || {}
33
+ id = data['id']
34
34
  props = data['properties'] || {}
35
35
  new(
36
- id: data['id'],
37
- topic: thread_props['topic'] || thread_props['spaceThreadTopic'],
38
- chat_type: normalize_chat_type(thread_props['threadType']),
39
- created_at: parse_time(thread_props['createdat']),
36
+ id: id,
40
37
  last_updated: parse_time(props['lastimreceivedtime']),
38
+ **ngmsg_thread(id, data['threadProperties'] || {}),
41
39
  **ngmsg_status(props, data['lastMessage'])
42
40
  )
43
41
  end
44
42
 
43
+ def self.ngmsg_thread(id, thread_props)
44
+ { topic: thread_props['topic'] || thread_props['spaceThreadTopic'],
45
+ chat_type: ngmsg_chat_type(id, thread_props),
46
+ created_at: parse_time(thread_props['createdat']) }
47
+ end
48
+
45
49
  def self.ngmsg_status(props, last_message)
46
50
  horizon = props['consumptionhorizon']
47
51
  last_msg_id = last_message&.dig('id')
@@ -52,6 +56,13 @@ module Teems
52
56
  }
53
57
  end
54
58
 
59
+ # ng.msg reports 1:1 chats with threadType 'chat', like group chats. They are marked by
60
+ # productThreadType 'OneToOneChat' and a "19:<user>_<user>@unq.gbl.spaces" ID.
61
+ def self.ngmsg_chat_type(id, thread_props)
62
+ one_on_one = thread_props['productThreadType'] == 'OneToOneChat' || id.to_s.end_with?('@unq.gbl.spaces')
63
+ one_on_one ? 'oneOnOne' : normalize_chat_type(thread_props['threadType'])
64
+ end
65
+
55
66
  # Normalize ng.msg threadType to a consistent chat type.
56
67
  # ng.msg uses: 'chat' (group), 'meeting', 'topic' (channel), 'space'
57
68
  # Normalized to: 'group', 'meeting', 'channel', 'space'
@@ -79,6 +90,9 @@ module Teems
79
90
 
80
91
  def chat_type_label = CHAT_TYPE_LABELS.fetch(chat_type, chat_type)
81
92
 
93
+ # What sync uses to name and place the chat's directory
94
+ def sync_identity = { chat_id: id, display_name: display_name, chat_type: chat_type }
95
+
82
96
  def unread? = unread
83
97
  def favorite? = favorite
84
98
  def pinned? = pinned