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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +77 -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/sync.rb +15 -8
- data/lib/teems/commands/sync_folders.rb +118 -0
- data/lib/teems/formatters/format_utils.rb +23 -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/output.rb +45 -8
- 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/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/runner.rb +6 -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/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_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/time_parsing.rb +9 -1
- data/lib/teems/version.rb +1 -1
- data/lib/teems.rb +63 -2
- 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 {
|
|
63
|
+
write_unless_quiet { |io| io.puts(message) }
|
|
56
64
|
end
|
|
57
65
|
|
|
58
66
|
def print(message)
|
|
59
|
-
write_unless_quiet {
|
|
67
|
+
write_unless_quiet { |io| io.print(message) }
|
|
60
68
|
end
|
|
61
69
|
|
|
62
70
|
def flush
|
|
63
|
-
|
|
71
|
+
to_stdout(&:flush)
|
|
64
72
|
end
|
|
65
73
|
|
|
66
74
|
def error(message)
|
|
67
|
-
|
|
75
|
+
to_stderr { |err| err.puts(colorize("#{red('Error:')} #{message}")) }
|
|
68
76
|
end
|
|
69
77
|
|
|
70
78
|
def warn(message)
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/teems/models/chat.rb
CHANGED
|
@@ -30,18 +30,22 @@ module Teems
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def self.from_ngmsg(data)
|
|
33
|
-
|
|
33
|
+
id = data['id']
|
|
34
34
|
props = data['properties'] || {}
|
|
35
35
|
new(
|
|
36
|
-
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
|