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,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Formatters
5
+ # Tag groups used by HtmlText
6
+ module HtmlTags
7
+ # Removed with their contents before conversion
8
+ DROPPED = %r{<(head|style|script|title|xml)\b[^>]*>.*?</\1\s*>}im
9
+ COMMENTS = /<!--.*?-->/m
10
+ TOKENS = /<[^>]*>|<|[^<]+/m
11
+ BLOCKS = %w[address article blockquote center dd div dl dt footer form h1 h2 h3 h4 h5 h6 header
12
+ li ol section table tbody thead tfoot tr ul].freeze
13
+
14
+ module_function
15
+
16
+ # [name, closing?] for a tag token, or nil for anything that is not a tag
17
+ def parse(token)
18
+ match = token.match(%r{\A<\s*(/?)\s*([a-zA-Z][a-zA-Z0-9:-]*)})
19
+ match && [match[2].downcase, !match[1].empty?]
20
+ end
21
+
22
+ def attribute(token, name)
23
+ match = token.match(/\s#{name}\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i)
24
+ match && HtmlEntities.decode(match.captures.compact.first)
25
+ end
26
+ end
27
+
28
+ # Accumulates converted text, tracking line starts so block boundaries never stack up
29
+ # more newlines than asked for and indentation from source HTML never leaks in
30
+ class TextBuffer
31
+ ZERO_WIDTH = /[\u200b\u200c\u200d\u2060\ufeff\u034f\u00ad]/
32
+
33
+ def initialize
34
+ @out = +''
35
+ end
36
+
37
+ def <<(text)
38
+ @out << text
39
+ self
40
+ end
41
+
42
+ # Inline text with source whitespace collapsed; << keeps it as-is
43
+ def add_text(text)
44
+ collapsed = text.gsub(/[ \t\r\n\f]+/, ' ')
45
+ self << (line_start? ? collapsed.lstrip : collapsed)
46
+ end
47
+
48
+ def line_break
49
+ trim_line_end
50
+ self << "\n"
51
+ end
52
+
53
+ # At least count newlines at the end, without adding to ones already there
54
+ def ensure_newlines(count)
55
+ return self if @out.empty?
56
+
57
+ trim_line_end
58
+ missing = count - trailing_newlines(count)
59
+ missing.positive? ? self << ("\n" * missing) : self
60
+ end
61
+
62
+ def space
63
+ @out.end_with?(' ', "\n") || @out.empty? ? self : self << ' '
64
+ end
65
+
66
+ def line_start? = @out.empty? || @out.end_with?("\n")
67
+
68
+ def to_s
69
+ text = @out.tr("\u00a0", ' ').gsub(ZERO_WIDTH, '').gsub(/[ \t]+$/, '')
70
+ text.gsub(/\n{3,}/, "\n\n").sub(/\A\n+/, '').rstrip
71
+ end
72
+
73
+ private
74
+
75
+ # Both helpers look only at the end of the buffer. An end-anchored regex such as
76
+ # /[ \t]+\z/ is scanned from the start, which made long messages quadratic.
77
+ def trim_line_end
78
+ @out.chop! while @out.end_with?(' ', "\t")
79
+ end
80
+
81
+ # Newlines already at the end, counting no further than limit
82
+ def trailing_newlines(limit) = (1..limit).count { |run| @out.end_with?("\n" * run) }
83
+ end
84
+
85
+ # Converts an HTML mail body to plain text. Each anchor's text stays inline followed by
86
+ # a [N] marker, and the URLs are listed once each in a numbered "Links:" footer
87
+ # (SafeLinks unwrapped, click tracking dropped, mailto:/tel:/cid: skipped).
88
+ class HtmlText
89
+ # Converted text and its "Links:" footer ('' when there are no links)
90
+ Result = Data.define(:text, :footer) do
91
+ # Text with the footer appended after a blank line, or the text alone when there are no links
92
+ def to_s = footer.empty? ? text : "#{text}\n\n#{footer}"
93
+ end
94
+
95
+ OPEN_TAGS = {
96
+ 'a' => :open_anchor, 'br' => :line_break, 'p' => :open_paragraph, 'li' => :open_list_item,
97
+ 'img' => :image, 'hr' => :rule, 'pre' => :open_pre, 'td' => :cell, 'th' => :cell
98
+ }.freeze
99
+ CLOSE_TAGS = { 'a' => :close_anchor, 'br' => :line_break, 'p' => :close_paragraph, 'pre' => :close_pre }.freeze
100
+
101
+ def self.convert(html) = new.convert(html.to_s)
102
+
103
+ def initialize
104
+ @buffer = TextBuffer.new
105
+ @links = LinkCollector.new
106
+ # Preformatted text, and whether the open <p> is one of Outlook's one-line paragraphs
107
+ @state = { pre: false, outlook_paragraph: false }
108
+ end
109
+
110
+ def convert(html)
111
+ tokens(html).each { |token| token.start_with?('<') ? tag(token) : text(token) }
112
+ close_anchor
113
+ Result.new(text: @buffer.to_s, footer: @links.footer)
114
+ end
115
+
116
+ private
117
+
118
+ def tokens(html)
119
+ Support::ControlChars.strip(html.scrub('')).gsub(HtmlTags::COMMENTS, '').gsub(HtmlTags::DROPPED, '').scan(HtmlTags::TOKENS)
120
+ end
121
+
122
+ def tag(token)
123
+ name, closing = HtmlTags.parse(token)
124
+ return text(token) unless name
125
+
126
+ handler = (closing ? CLOSE_TAGS : OPEN_TAGS)[name]
127
+ return send(handler, token) if handler
128
+
129
+ @buffer.ensure_newlines(1) if HtmlTags::BLOCKS.include?(name)
130
+ end
131
+
132
+ def text(token)
133
+ chunk = HtmlEntities.decode(token)
134
+ @state[:pre] ? @buffer << chunk : @buffer.add_text(chunk)
135
+ @links.add_text(chunk)
136
+ end
137
+
138
+ def open_anchor(token)
139
+ href = HtmlTags.attribute(token, 'href')
140
+ href ? @links.start(href) : close_anchor
141
+ end
142
+
143
+ def close_anchor(_token = nil)
144
+ number = @links.finish
145
+ @buffer.space << "[#{number}]" if number
146
+ end
147
+
148
+ def line_break(_token) = @buffer.line_break
149
+
150
+ # Outlook writes every line as its own <p class=MsoNormal>, with &nbsp; paragraphs for
151
+ # blank lines, so those end with one newline; other paragraphs get a blank line.
152
+ def open_paragraph(token)
153
+ @state[:outlook_paragraph] = HtmlTags.attribute(token, 'class').to_s.match?(/\bMso/i)
154
+ @buffer.ensure_newlines(1)
155
+ end
156
+
157
+ def close_paragraph(_token) = @buffer.ensure_newlines(@state[:outlook_paragraph] ? 1 : 2)
158
+
159
+ def open_list_item(_token) = @buffer.ensure_newlines(1) << '- '
160
+
161
+ def image(token)
162
+ @links.add_alt(HtmlTags.attribute(token, 'alt').to_s) if @links.capturing?
163
+ end
164
+
165
+ def rule(_token) = @buffer.ensure_newlines(1) << "#{'_' * 32}\n"
166
+
167
+ def open_pre(_token) = pre(true)
168
+
169
+ def close_pre(_token) = pre(false)
170
+
171
+ def pre(enabled)
172
+ @buffer.ensure_newlines(1)
173
+ @state[:pre] = enabled
174
+ end
175
+
176
+ def cell(_token)
177
+ @buffer.space unless @buffer.line_start?
178
+ end
179
+ end
180
+ end
181
+ end
@@ -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