teems 0.3.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -0
- data/README.md +84 -0
- data/bin/teems +8 -1
- data/lib/teems/api/calendar.rb +45 -14
- data/lib/teems/api/client.rb +19 -4
- data/lib/teems/api/mail.rb +132 -0
- data/lib/teems/api/mail_actions.rb +22 -0
- data/lib/teems/api/mail_rules.rb +24 -0
- data/lib/teems/cli.rb +2 -0
- data/lib/teems/commands/base.rb +31 -7
- data/lib/teems/commands/cal.rb +109 -28
- data/lib/teems/commands/cal_ics.rb +47 -0
- data/lib/teems/commands/confirmation.rb +19 -0
- data/lib/teems/commands/help.rb +4 -0
- data/lib/teems/commands/mail.rb +207 -0
- data/lib/teems/commands/mail_decrypting.rb +46 -0
- data/lib/teems/commands/mail_help.rb +85 -0
- data/lib/teems/commands/mail_rules.rb +201 -0
- data/lib/teems/commands/mail_sync.rb +92 -0
- data/lib/teems/commands/mail_thread.rb +87 -0
- data/lib/teems/commands/mail_triage.rb +241 -0
- data/lib/teems/commands/meet.rb +159 -0
- data/lib/teems/commands/meeting.rb +4 -3
- data/lib/teems/commands/messages.rb +51 -8
- data/lib/teems/commands/sync.rb +45 -11
- data/lib/teems/commands/sync_folders.rb +118 -0
- data/lib/teems/commands/transcripts.rb +205 -137
- data/lib/teems/formatters/format_utils.rb +26 -2
- data/lib/teems/formatters/html_entities.rb +49 -0
- data/lib/teems/formatters/html_text.rb +181 -0
- data/lib/teems/formatters/ics_format.rb +112 -0
- data/lib/teems/formatters/link_cleaner.rb +91 -0
- data/lib/teems/formatters/link_collector.rb +70 -0
- data/lib/teems/formatters/mail_formatter.rb +58 -0
- data/lib/teems/formatters/mail_markdown.rb +70 -0
- data/lib/teems/formatters/mail_rule_formatter.rb +45 -0
- data/lib/teems/formatters/mail_rule_text.rb +67 -0
- data/lib/teems/formatters/markdown_formatter.rb +10 -1
- data/lib/teems/formatters/message_formatter.rb +7 -2
- data/lib/teems/formatters/output.rb +45 -8
- data/lib/teems/formatters/transcript_markdown.rb +16 -12
- data/lib/teems/models/chat.rb +19 -5
- data/lib/teems/models/event.rb +1 -23
- data/lib/teems/models/event_display.rb +40 -0
- data/lib/teems/models/ics_event.rb +60 -0
- data/lib/teems/models/inline_image.rb +83 -0
- data/lib/teems/models/mail_conversation.rb +62 -0
- data/lib/teems/models/mail_encryption.rb +41 -0
- data/lib/teems/models/mail_folder.rb +44 -0
- data/lib/teems/models/mail_message.rb +152 -0
- data/lib/teems/models/mail_rule.rb +38 -0
- data/lib/teems/models/message.rb +7 -4
- data/lib/teems/models/parsing.rb +5 -0
- data/lib/teems/runner.rb +11 -0
- data/lib/teems/services/api_client.rb +23 -11
- data/lib/teems/services/browser.rb +17 -0
- data/lib/teems/services/clipboard.rb +17 -0
- data/lib/teems/services/inline_image_downloader.rb +115 -0
- data/lib/teems/services/mail_folders.rb +76 -0
- data/lib/teems/services/mail_id_cache.rb +51 -0
- data/lib/teems/services/mail_legacy_files.rb +96 -0
- data/lib/teems/services/mail_sync_attachments.rb +103 -0
- data/lib/teems/services/mail_sync_engine.rb +98 -0
- data/lib/teems/services/mail_sync_folder.rb +142 -0
- data/lib/teems/services/mail_sync_store.rb +116 -0
- data/lib/teems/services/smime_content.rb +36 -0
- data/lib/teems/services/smime_decryptor.rb +36 -0
- data/lib/teems/services/sync_dir_naming.rb +16 -10
- data/lib/teems/services/sync_dir_ownership.rb +111 -0
- data/lib/teems/services/sync_dir_planning.rb +130 -0
- data/lib/teems/services/sync_engine.rb +13 -7
- data/lib/teems/services/sync_images.rb +55 -0
- data/lib/teems/services/sync_store.rb +20 -43
- data/lib/teems/services/token_extractor.rb +4 -0
- data/lib/teems/support/control_chars.rb +19 -0
- data/lib/teems/support/mime_header.rb +61 -0
- data/lib/teems/support/mime_part.rb +97 -0
- data/lib/teems/support/signed_data.rb +23 -0
- data/lib/teems/support/subprocess.rb +2 -0
- data/lib/teems/support/time_parsing.rb +9 -1
- data/lib/teems/version.rb +1 -1
- data/lib/teems.rb +67 -2
- metadata +51 -1
|
@@ -0,0 +1,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 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
|