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
data/lib/teems/commands/cal.rb
CHANGED
|
@@ -15,6 +15,7 @@ module Teems
|
|
|
15
15
|
teems cal tentative <N|hash> Tentatively accept event by # or hash
|
|
16
16
|
teems cal create "Title" [opts] Create a new event
|
|
17
17
|
teems cal delete <N|hash> Delete event by # or hash
|
|
18
|
+
teems cal ics [opts] Print events as an ICS calendar (for feeds)
|
|
18
19
|
|
|
19
20
|
OPTIONS:
|
|
20
21
|
--days N Show events for the next N days (default: 1)
|
|
@@ -29,12 +30,30 @@ module Teems
|
|
|
29
30
|
--json Output as JSON
|
|
30
31
|
-h, --help Show this help
|
|
31
32
|
|
|
33
|
+
ICS OPTIONS:
|
|
34
|
+
--all-calendars Every calendar (holidays, shared ones), not just the
|
|
35
|
+
default one. One that cannot be read is skipped with
|
|
36
|
+
a warning
|
|
37
|
+
--days N Days ahead to include (default: 60)
|
|
38
|
+
--days-back N Days back to include (default: 7)
|
|
39
|
+
--exclude-attendee ADDRS
|
|
40
|
+
Leave out events these addresses organize or have
|
|
41
|
+
accepted (comma separated), e.g. ones already on
|
|
42
|
+
another calendar you subscribe to
|
|
43
|
+
--name NAME Calendar name (default: "Outlook (teems)")
|
|
44
|
+
--no-login Never open Safari: renew expired tokens silently
|
|
45
|
+
from the cached sign-in, or fail (servers, launchd)
|
|
46
|
+
|
|
32
47
|
CREATE OPTIONS:
|
|
33
|
-
--start TIME Start time: "YYYY-MM-DD HH:MM",
|
|
34
|
-
"tomorrow HH:MM", or "HH:MM" (
|
|
48
|
+
--start TIME Start time: "now" (the default), "YYYY-MM-DD HH:MM",
|
|
49
|
+
"today HH:MM", "tomorrow HH:MM", or "HH:MM" (today)
|
|
35
50
|
--end TIME End time (default: start + 30 minutes)
|
|
36
51
|
--duration MIN Duration in minutes (alternative to --end)
|
|
37
|
-
--all-day Create an all-day event
|
|
52
|
+
--all-day Create an all-day event on --date (default: today)
|
|
53
|
+
With --all-day, --start YYYY-MM-DD is an alias for
|
|
54
|
+
--date, and --end YYYY-MM-DD is the last day
|
|
55
|
+
(inclusive) of a multi-day event. --duration and
|
|
56
|
+
timed --start/--end values are not allowed.
|
|
38
57
|
--location TEXT Event location
|
|
39
58
|
--body TEXT Event description (plain text)
|
|
40
59
|
--html TEXT Event description (HTML)
|
|
@@ -66,10 +85,16 @@ module Teems
|
|
|
66
85
|
teems cal accept a3f2 # Accept by hash prefix
|
|
67
86
|
teems cal decline 3 --comment "Out of office"
|
|
68
87
|
teems cal create "Standup" --start "tomorrow 09:00" --duration 15
|
|
69
|
-
teems cal create "Review" --start "2026-03-20 14:00" --teams
|
|
88
|
+
teems cal create "Review" --start "2026-03-20 14:00" --teams \\
|
|
70
89
|
--attendees alice@example.com,bob@example.com
|
|
90
|
+
teems cal create "Day off" --all-day --date 2026-03-20
|
|
91
|
+
teems cal create "PTO" --all-day --date 2026-03-23 --end 2026-03-24 \\
|
|
92
|
+
--show-as oof --no-reminder # One event spanning both days
|
|
71
93
|
teems cal delete 3 # Delete event #3
|
|
72
94
|
teems cal --json | jq ... # JSON output, no prompt
|
|
95
|
+
teems cal ics > work.ics # 7 days back through 60 ahead
|
|
96
|
+
teems cal ics --days 30 --days-back 0 --no-login
|
|
97
|
+
teems cal ics --all-calendars # Include holiday and shared calendars
|
|
73
98
|
HELP
|
|
74
99
|
|
|
75
100
|
# Date range computation for calendar queries
|
|
@@ -164,7 +189,7 @@ module Teems
|
|
|
164
189
|
SUBCOMMAND_PARSERS = {
|
|
165
190
|
'show' => :parse_show_subcommand, 'today' => :parse_today_subcommand,
|
|
166
191
|
'tomorrow' => :parse_tomorrow_subcommand, 'create' => :parse_create_subcommand,
|
|
167
|
-
'delete' => :parse_delete_subcommand
|
|
192
|
+
'delete' => :parse_delete_subcommand, 'ics' => :parse_ics_subcommand
|
|
168
193
|
}.freeze
|
|
169
194
|
|
|
170
195
|
private
|
|
@@ -205,6 +230,11 @@ module Teems
|
|
|
205
230
|
@options[:date] = (Date.today + 1).to_s
|
|
206
231
|
end
|
|
207
232
|
|
|
233
|
+
def parse_ics_subcommand(remaining)
|
|
234
|
+
@subcommand = 'ics'
|
|
235
|
+
remaining.shift
|
|
236
|
+
end
|
|
237
|
+
|
|
208
238
|
def parse_rsvp_subcommand(remaining)
|
|
209
239
|
action, event_arg = remaining.shift(2)
|
|
210
240
|
@subcommand = action
|
|
@@ -282,11 +312,6 @@ module Teems
|
|
|
282
312
|
resolve_event_id || 1
|
|
283
313
|
end
|
|
284
314
|
|
|
285
|
-
def api_error_result(prefix, err)
|
|
286
|
-
error("#{prefix}: #{err.message}")
|
|
287
|
-
1
|
|
288
|
-
end
|
|
289
|
-
|
|
290
315
|
def render_single_event(event_id)
|
|
291
316
|
event = fetch_event_for_display(event_id)
|
|
292
317
|
render_event_output(event)
|
|
@@ -563,7 +588,15 @@ module Teems
|
|
|
563
588
|
end
|
|
564
589
|
|
|
565
590
|
def validate_conflicting_flags
|
|
566
|
-
conflict_error('--teams', '--no-teams') if @options[:teams] && @options[:no_teams]
|
|
591
|
+
return conflict_error('--teams', '--no-teams') if @options[:teams] && @options[:no_teams]
|
|
592
|
+
|
|
593
|
+
validate_all_day_flags if @options[:all_day]
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def validate_all_day_flags
|
|
597
|
+
return conflict_error('--all-day', '--duration') if @options[:duration]
|
|
598
|
+
|
|
599
|
+
conflict_error('--date', '--start') if @options[:date] && @options[:start]
|
|
567
600
|
end
|
|
568
601
|
|
|
569
602
|
def conflict_error(flag_a, flag_b)
|
|
@@ -588,11 +621,66 @@ module Teems
|
|
|
588
621
|
end
|
|
589
622
|
end
|
|
590
623
|
|
|
624
|
+
# Resolves --all-day dates into Graph's all-day range, which runs from midnight on the
|
|
625
|
+
# first day to midnight after the last day. --end is inclusive, like `teems ooo --end`.
|
|
626
|
+
module CalAllDayTimes
|
|
627
|
+
include Support::TimeParsing
|
|
628
|
+
|
|
629
|
+
private
|
|
630
|
+
|
|
631
|
+
def resolve_all_day_times
|
|
632
|
+
first_day = all_day_first_day
|
|
633
|
+
return first_day if first_day.is_a?(Integer)
|
|
634
|
+
|
|
635
|
+
last_day = all_day_last_day(first_day)
|
|
636
|
+
return last_day if last_day.is_a?(Integer)
|
|
637
|
+
|
|
638
|
+
[midnight(first_day), midnight(last_day + 1)]
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def all_day_first_day
|
|
642
|
+
start = @options[:start]
|
|
643
|
+
return all_day_option_date('--start', start) if start
|
|
644
|
+
|
|
645
|
+
date = @options[:date] || Date.today.to_s
|
|
646
|
+
Date.parse(date)
|
|
647
|
+
rescue Date::Error
|
|
648
|
+
all_day_error("Invalid date: #{date}")
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def all_day_last_day(first_day)
|
|
652
|
+
raw = @options[:end]
|
|
653
|
+
return first_day unless raw
|
|
654
|
+
|
|
655
|
+
last_day = all_day_option_date('--end', raw)
|
|
656
|
+
return last_day if last_day.is_a?(Integer) || last_day >= first_day
|
|
657
|
+
|
|
658
|
+
all_day_error("--end #{last_day} is before the first day, #{first_day}")
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# --start and --end name calendar days here, so a time of day is almost certainly a mistake
|
|
662
|
+
def all_day_option_date(flag, raw)
|
|
663
|
+
return Date.parse(raw) unless parse_time_input(raw)
|
|
664
|
+
|
|
665
|
+
all_day_error("#{flag} must be a date (YYYY-MM-DD) with --all-day, not a time: #{raw}")
|
|
666
|
+
rescue Date::Error
|
|
667
|
+
all_day_error("Invalid #{flag} date: #{raw}. Use YYYY-MM-DD")
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
def all_day_error(message)
|
|
671
|
+
error(message)
|
|
672
|
+
1
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
def midnight(date) = date.strftime('%Y-%m-%dT00:00:00')
|
|
676
|
+
end
|
|
677
|
+
|
|
591
678
|
# Event creation subcommand
|
|
592
679
|
module CalCreateActions
|
|
593
680
|
include Support::TimeParsing
|
|
594
681
|
include CalEventFields
|
|
595
682
|
include CalCreateValidation
|
|
683
|
+
include CalAllDayTimes
|
|
596
684
|
|
|
597
685
|
private
|
|
598
686
|
|
|
@@ -623,14 +711,6 @@ module Teems
|
|
|
623
711
|
@options[:all_day] ? resolve_all_day_times : resolve_timed_event_times
|
|
624
712
|
end
|
|
625
713
|
|
|
626
|
-
def resolve_all_day_times
|
|
627
|
-
date = @options[:date] || Date.today.to_s
|
|
628
|
-
parsed = Date.parse(date)
|
|
629
|
-
[parsed.strftime('%Y-%m-%dT00:00:00'), (parsed + 1).strftime('%Y-%m-%dT00:00:00')]
|
|
630
|
-
rescue Date::Error
|
|
631
|
-
error("Invalid date: #{date}") || 1
|
|
632
|
-
end
|
|
633
|
-
|
|
634
714
|
def resolve_timed_event_times
|
|
635
715
|
start_time = validated_start_time
|
|
636
716
|
return start_time if start_time.is_a?(Integer)
|
|
@@ -641,18 +721,12 @@ module Teems
|
|
|
641
721
|
[format_time(start_time), format_time(end_time)]
|
|
642
722
|
end
|
|
643
723
|
|
|
724
|
+
# Now when --start is left out
|
|
644
725
|
def validated_start_time
|
|
645
|
-
start_input = @options[:start]
|
|
646
|
-
return missing_start_time_error unless start_input
|
|
647
|
-
|
|
726
|
+
start_input = @options[:start] || 'now'
|
|
648
727
|
parse_time_input(start_input) || (error("Invalid start time: #{start_input}") || 1)
|
|
649
728
|
end
|
|
650
729
|
|
|
651
|
-
def missing_start_time_error
|
|
652
|
-
error('Start time required. Use --start "YYYY-MM-DD HH:MM" or --start "today HH:MM"')
|
|
653
|
-
1
|
|
654
|
-
end
|
|
655
|
-
|
|
656
730
|
def compute_end_time(start_time)
|
|
657
731
|
@options[:end] ? parse_explicit_end_time : compute_end_from_duration(start_time)
|
|
658
732
|
end
|
|
@@ -735,7 +809,12 @@ module Teems
|
|
|
735
809
|
'--no-reminder' => ->(opts, _args) { opts[:no_reminder] = true },
|
|
736
810
|
'--no-rsvp' => ->(opts, _args) { opts[:no_rsvp] = true },
|
|
737
811
|
'--no-time-proposals' => ->(opts, _args) { opts[:no_time_proposals] = true },
|
|
738
|
-
'--hide-attendees' => ->(opts, _args) { opts[:hide_attendees] = true }
|
|
812
|
+
'--hide-attendees' => ->(opts, _args) { opts[:hide_attendees] = true },
|
|
813
|
+
'--days-back' => ->(opts, args) { opts[:days_back] = args.shift.to_i },
|
|
814
|
+
'--exclude-attendee' => ->(opts, args) { opts[:exclude_attendee] = args.shift },
|
|
815
|
+
'--name' => ->(opts, args) { opts[:name] = args.shift },
|
|
816
|
+
'--no-login' => ->(opts, _args) { opts[:no_login] = true },
|
|
817
|
+
'--all-calendars' => ->(opts, _args) { opts[:all_calendars] = true }
|
|
739
818
|
}.freeze
|
|
740
819
|
end
|
|
741
820
|
|
|
@@ -747,6 +826,7 @@ module Teems
|
|
|
747
826
|
include CalEventActions
|
|
748
827
|
include CalCreateActions
|
|
749
828
|
include CalInteractiveMode
|
|
829
|
+
include CalIcsExport
|
|
750
830
|
|
|
751
831
|
def initialize(args, runner:)
|
|
752
832
|
@options = {}
|
|
@@ -786,6 +866,7 @@ module Teems
|
|
|
786
866
|
when 'show' then show_event
|
|
787
867
|
when 'create' then create_event
|
|
788
868
|
when 'delete' then delete_event
|
|
869
|
+
when 'ics' then export_ics
|
|
789
870
|
when 'accept', 'decline', 'tentative' then rsvp_event
|
|
790
871
|
else list_events
|
|
791
872
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Commands
|
|
5
|
+
# `teems cal ics`: events from days-back days ago to days ahead as an ICS calendar,
|
|
6
|
+
# for a subscription server (outlook-cli's outlook-calendar-server) to serve
|
|
7
|
+
module CalIcsExport
|
|
8
|
+
ICS_DEFAULTS = { days: 60, days_back: 7, name: 'Outlook (teems)' }.freeze
|
|
9
|
+
DAY_SECONDS = 86_400
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def export_ics
|
|
14
|
+
now = Time.now
|
|
15
|
+
events = with_token_refresh { fetch_feed_events(now) }
|
|
16
|
+
excluded = excluded_addresses
|
|
17
|
+
kept = events.reject { |event| event.involves?(excluded) }
|
|
18
|
+
# The calendar is the command's result, so -q does not silence it
|
|
19
|
+
output.print(Formatters::IcsFormat.calendar(kept, name: ics_option(:name), stamp: now))
|
|
20
|
+
0
|
|
21
|
+
rescue ApiError => e
|
|
22
|
+
api_error_result('Failed to fetch calendar', e)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# With --all-calendars, a shared calendar that cannot be read is warned about and left
|
|
26
|
+
# out, so one calendar does not take down the feed
|
|
27
|
+
def fetch_feed_events(now)
|
|
28
|
+
api = runner.calendar_api
|
|
29
|
+
range = { from: ics_start(now), to: ics_end(now) }
|
|
30
|
+
return api.feed_events(**range) unless @options[:all_calendars]
|
|
31
|
+
|
|
32
|
+
api.feed_events(**range, views: api.calendar_views) do |name, error|
|
|
33
|
+
warn("Skipped calendar #{name.inspect}: #{error.message}")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ics_option(key) = @options[key] || ICS_DEFAULTS.fetch(key)
|
|
38
|
+
|
|
39
|
+
def ics_start(now) = now - (ics_option(:days_back) * DAY_SECONDS)
|
|
40
|
+
|
|
41
|
+
def ics_end(now) = now + (ics_option(:days) * DAY_SECONDS)
|
|
42
|
+
|
|
43
|
+
# Comma, semicolon or space separated, compared without case
|
|
44
|
+
def excluded_addresses = @options[:exclude_attendee].to_s.downcase.split(/[\s,;]+/).reject(&:empty?)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Commands
|
|
5
|
+
# Asking before a bulk or irreversible change. Without a terminal there is nobody to ask,
|
|
6
|
+
# so the caller has to pass --yes instead.
|
|
7
|
+
module Confirmation
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# question: "Archive 3 messages"; refusal: the error when there is no terminal
|
|
11
|
+
def confirm?(question, refusal:)
|
|
12
|
+
raise Error, refusal unless $stdin.tty?
|
|
13
|
+
|
|
14
|
+
output.prompt("#{question}? [y/N] ")
|
|
15
|
+
$stdin.gets.to_s.strip.downcase.start_with?('y')
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/teems/commands/help.rb
CHANGED
|
@@ -8,9 +8,11 @@ module Teems
|
|
|
8
8
|
['cal', 'List calendar events and view details'],
|
|
9
9
|
['channels', 'List joined teams and channels'],
|
|
10
10
|
['chats', 'List recent chats'],
|
|
11
|
+
['meet', 'Start a Teams meeting and print its join link'],
|
|
11
12
|
['meeting', 'View meeting details, chat, transcripts, and recordings'],
|
|
12
13
|
['transcripts', 'Sync saved meeting transcripts to this machine'],
|
|
13
14
|
['messages', 'Read messages from a channel or chat'],
|
|
15
|
+
['mail', 'Read Outlook mail: list, read, folders, search, sync'],
|
|
14
16
|
['sync', 'Sync chat history locally'],
|
|
15
17
|
['who', "Look up a user's profile"],
|
|
16
18
|
['org', 'Show org chart for a user'],
|
|
@@ -85,6 +87,8 @@ module Teems
|
|
|
85
87
|
teems chats List recent chats
|
|
86
88
|
teems messages <channel-id> Read messages from a channel
|
|
87
89
|
teems transcripts sync Download recent meeting transcripts
|
|
90
|
+
teems mail list --unread Unread messages in the Inbox
|
|
91
|
+
teems mail read 3f9a2c1b Read a message from the list
|
|
88
92
|
teems who Show your profile
|
|
89
93
|
teems who john Search for a user
|
|
90
94
|
teems org Show your org chart
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Commands
|
|
5
|
+
# list, read, folders, and search for `teems mail`
|
|
6
|
+
module MailReading
|
|
7
|
+
DEFAULT_COUNT = 20
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def list_messages
|
|
12
|
+
folder, count = list_args
|
|
13
|
+
return error('Count must be a positive number') unless count&.positive?
|
|
14
|
+
|
|
15
|
+
show_messages(mail_api.messages(folder: mail_folders.resolve_id(folder), count: count, only: only_filters))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# --unread and --flagged limit a listing (both: unread and flagged)
|
|
19
|
+
def only_filters = Api::Mail::FILTERS.keys.select { |key| @options[key] }
|
|
20
|
+
|
|
21
|
+
# [folder] [count]; a lone number is the count
|
|
22
|
+
def list_args
|
|
23
|
+
folder, count = positional_args.drop(1)
|
|
24
|
+
return ['Inbox', Integer(folder)] if !count && folder.to_s.match?(/\A\d+\z/)
|
|
25
|
+
|
|
26
|
+
[folder || 'Inbox', Integer(count || @options[:limit] || DEFAULT_COUNT, exception: false)]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def search_messages
|
|
30
|
+
query = positional_args[1]
|
|
31
|
+
return error('Usage: teems mail search <query> [folder]') if query.to_s.strip.empty?
|
|
32
|
+
|
|
33
|
+
count = (@options[:limit] || DEFAULT_COUNT).clamp(1, 1000)
|
|
34
|
+
show_messages(mail_api.search(query: query, count: count, folder: search_folder))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def search_folder
|
|
38
|
+
name = @options[:folders]&.last || positional_args[2]
|
|
39
|
+
name && mail_folders.resolve_id(name)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def show_messages(messages)
|
|
43
|
+
id_cache.remember(messages)
|
|
44
|
+
return json_result(messages.map(&:to_json_hash)) if @options[:json]
|
|
45
|
+
return print_lines(messages.map(&:tsv_row)) if @options[:tsv]
|
|
46
|
+
|
|
47
|
+
rows = mail_formatter.message_rows(messages)
|
|
48
|
+
rows.empty? ? print_lines([output.gray('No messages found')]) : print_lines(rows)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def read_message
|
|
52
|
+
token = positional_args[1]
|
|
53
|
+
return error('Usage: teems mail read <id>') unless token
|
|
54
|
+
|
|
55
|
+
show_message(mail_api.message(resolve_message_id(token)))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def show_message(message)
|
|
59
|
+
return show_purview(message) if message.protection == 'purview'
|
|
60
|
+
|
|
61
|
+
id = message.id
|
|
62
|
+
envelope = message.bodyless? && envelope_attachment(id)
|
|
63
|
+
return show_encrypted(message, envelope) if envelope
|
|
64
|
+
|
|
65
|
+
warn('Not S/MIME encrypted; nothing to decrypt') if @options[:decrypt]
|
|
66
|
+
return print_lines([message.body]) if @options[:html]
|
|
67
|
+
|
|
68
|
+
render_message(message, message.attachment_names_from(mail_api))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# encryption: nil for plain mail, else { type:, decrypted:, signed: } (see MailDecrypting)
|
|
72
|
+
def render_message(message, names, encryption = nil)
|
|
73
|
+
return print_lines([message.body]) if @options[:html]
|
|
74
|
+
|
|
75
|
+
if @options[:json]
|
|
76
|
+
return json_result(message.full_json_hash(attachments: names).merge(encryption ? { encryption: } : {}))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
notes = Models::MailEncryption.notes(encryption, web_link: message.web_link)
|
|
80
|
+
print_lines([message.to_text(attachments: names, notes: notes)])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Outlook on the web, which opens what teems cannot (Purview protected mail); --print
|
|
84
|
+
# prints the address instead
|
|
85
|
+
def open_message
|
|
86
|
+
token = positional_args[1] or raise Error, 'Usage: teems mail open <id> [--print]'
|
|
87
|
+
link = mail_api.web_link(resolve_message_id(token)) or raise Error, 'Graph gave no web link for that message'
|
|
88
|
+
return print_lines([link]) if @options[:print]
|
|
89
|
+
|
|
90
|
+
runner.browser.open(link)
|
|
91
|
+
success('Opened in Outlook on the web')
|
|
92
|
+
0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def list_folders
|
|
96
|
+
all = mail_folders.all
|
|
97
|
+
return json_result(all.map(&:to_json_hash)) if @options[:json]
|
|
98
|
+
return print_lines(all.map(&:tsv_row)) if @options[:tsv]
|
|
99
|
+
|
|
100
|
+
print_lines(mail_formatter.folder_rows(all))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def json_result(data)
|
|
104
|
+
output_json(data)
|
|
105
|
+
0
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Data output goes straight to stdout so --quiet only silences status messages
|
|
109
|
+
def print_lines(lines)
|
|
110
|
+
lines.each { |line| output.puts(line) }
|
|
111
|
+
0
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Outlook mail via Microsoft Graph: list, read, folders, search, local sync, and triage
|
|
116
|
+
# (read state, flags, and moves). Nothing sends mail.
|
|
117
|
+
class Mail < Base
|
|
118
|
+
include MailReading
|
|
119
|
+
include MailDecrypting
|
|
120
|
+
include MailThread
|
|
121
|
+
include MailSyncing
|
|
122
|
+
include MailTriage
|
|
123
|
+
include MailRules
|
|
124
|
+
include Confirmation
|
|
125
|
+
include Support::Timezone
|
|
126
|
+
|
|
127
|
+
HELP = MailHelp::TEXT
|
|
128
|
+
|
|
129
|
+
SUBCOMMANDS = {
|
|
130
|
+
'list' => :list_messages, 'recent' => :list_messages, 'read' => :read_message, 'thread' => :show_thread,
|
|
131
|
+
'open' => :open_message, 'folders' => :list_folders, 'search' => :search_messages, 'sync' => :sync_mail,
|
|
132
|
+
**MailTriage::ACTIONS.transform_values { :triage }, 'rules' => :mail_rules
|
|
133
|
+
}.freeze
|
|
134
|
+
|
|
135
|
+
FLAGS = { '--unread' => :unread, '--flagged' => :flagged, '--html' => :html, '--tsv' => :tsv,
|
|
136
|
+
'--attachments' => :attachments, '--no-login' => :no_login, '--dry-run' => :dry_run,
|
|
137
|
+
'--yes' => :yes, '-y' => :yes, '--decrypt' => :decrypt,
|
|
138
|
+
'--full' => :full, '--thread' => :thread, '--print' => :print, **MailRuleOptions::FLAGS }.freeze
|
|
139
|
+
VALUES = { '--since' => :since, '--dir' => :dir, '--folder' => :folders, '--search' => :search,
|
|
140
|
+
'--to' => :to, '--due' => :due, **MailRuleOptions::VALUES }.freeze
|
|
141
|
+
REPEATABLE = [:folders, *MailRuleOptions::REPEATABLE].freeze
|
|
142
|
+
|
|
143
|
+
def initialize(args, runner:)
|
|
144
|
+
@options = {}
|
|
145
|
+
super
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def execute
|
|
149
|
+
validation = validate_options
|
|
150
|
+
return validation if validation
|
|
151
|
+
|
|
152
|
+
handler = SUBCOMMANDS[positional_args.first]
|
|
153
|
+
return error("Usage: teems mail <#{SUBCOMMANDS.keys.join('|')}> (teems mail --help)") unless handler
|
|
154
|
+
return error('Rule options (--from, --move-to, ...) go with rules add only') if misplaced_rule_options?
|
|
155
|
+
|
|
156
|
+
run_authenticated(handler)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
protected
|
|
160
|
+
|
|
161
|
+
# No default -n: list and search show 20, a triage selection up to 1,000
|
|
162
|
+
def default_options = super.merge(limit: nil)
|
|
163
|
+
|
|
164
|
+
def handle_option(arg, pending)
|
|
165
|
+
return @options[FLAGS[arg]] = true if FLAGS.key?(arg)
|
|
166
|
+
|
|
167
|
+
key = VALUES[arg]
|
|
168
|
+
return super unless key
|
|
169
|
+
|
|
170
|
+
store_value(key, pending.shift.to_s)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# --folder repeats (sync takes several), as do rule conditions and categories; other
|
|
174
|
+
# values keep the last one given
|
|
175
|
+
def store_value(key, value)
|
|
176
|
+
REPEATABLE.include?(key) ? (@options[key] ||= []) << value : @options[key] = value
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def help_text = HELP
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def run_authenticated(handler)
|
|
184
|
+
auth = require_auth
|
|
185
|
+
return auth if auth
|
|
186
|
+
|
|
187
|
+
with_token_refresh { fresh_attempt(handler) }
|
|
188
|
+
rescue ApiError => e
|
|
189
|
+
api_error_result('Mail request failed', e)
|
|
190
|
+
rescue Teems::Error => e
|
|
191
|
+
error(e.message)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The folder tree is cached for one attempt: a retry after a token refresh starts over
|
|
195
|
+
# with the new token
|
|
196
|
+
def fresh_attempt(handler)
|
|
197
|
+
@mail_folders = nil
|
|
198
|
+
send(handler)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def mail_api = runner.mail_api
|
|
202
|
+
def mail_folders = @mail_folders ||= Services::MailFolders.new(mail_api)
|
|
203
|
+
def mail_formatter = Formatters::MailFormatter.new(output: output)
|
|
204
|
+
def id_cache = Services::MailIdCache.new
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Commands
|
|
5
|
+
# Protected mail for `teems mail read`. Graph hands S/MIME encrypted mail over with an empty
|
|
6
|
+
# body and its envelope as an smime.p7m attachment; --decrypt opens it with the PIV card
|
|
7
|
+
# (macOS asks for the PIN) and shows it like any other message. Purview (rights-managed)
|
|
8
|
+
# mail needs Microsoft's service, so it is only labelled.
|
|
9
|
+
module MailDecrypting
|
|
10
|
+
LOCKED_HINT = Models::MailEncryption::LOCKED_HINT
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def envelope_attachment(message_id)
|
|
15
|
+
mail_api.attachments(message_id).find { |item| Models::MailEncryption.envelope?(item) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def show_purview(message)
|
|
19
|
+
warn('--decrypt opens S/MIME mail only; Purview protected mail needs Outlook') if @options[:decrypt]
|
|
20
|
+
warn([message.protection_note, message.web_link].compact.join(': ')) if @options[:html]
|
|
21
|
+
render_message(message, message.attachment_names_from(mail_api), { type: 'purview', decrypted: false })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def show_encrypted(message, envelope)
|
|
25
|
+
return show_locked(message) unless @options[:decrypt]
|
|
26
|
+
|
|
27
|
+
content = decrypt_content(message.id, envelope)
|
|
28
|
+
render_message(content.apply_to(message), content.attachment_names, Models::MailEncryption.decrypted(content))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The message inside an envelope (an smime.p7m attachment), opened with the PIV card
|
|
32
|
+
def decrypt_content(message_id, envelope)
|
|
33
|
+
Services::SmimeContent.parse(runner.smime_decryptor.decrypt(envelope_bytes(message_id, envelope['id'])))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def show_locked(message)
|
|
37
|
+
warn(LOCKED_HINT) if @options[:html] # no header block to carry the hint
|
|
38
|
+
render_message(message, [], { type: 'smime', decrypted: false })
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def envelope_bytes(message_id, attachment_id)
|
|
42
|
+
mail_api.attachment(message_id, attachment_id)['contentBytes'].to_s.unpack1('m')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Commands
|
|
5
|
+
# `teems mail --help`
|
|
6
|
+
module MailHelp
|
|
7
|
+
TEXT = <<~HELP
|
|
8
|
+
teems mail - Read and triage Outlook mail via Microsoft Graph (never sends)
|
|
9
|
+
|
|
10
|
+
USAGE:
|
|
11
|
+
teems mail list [folder] [count] Newest messages (alias: recent)
|
|
12
|
+
teems mail read <id> [--html] [--decrypt] Headers, plain-text body, numbered links
|
|
13
|
+
teems mail thread <id> [--full [--decrypt]] The whole conversation, any folder, oldest first
|
|
14
|
+
teems mail open <id> [--print] Open in Outlook on the web (--print: print the link)
|
|
15
|
+
teems mail folders Folders with unread and total counts
|
|
16
|
+
teems mail search <query> [folder] Graph $search (KQL), e.g. "from:alice subject:report"
|
|
17
|
+
teems mail sync [folder...] [options] Incremental local copy (Graph delta)
|
|
18
|
+
|
|
19
|
+
TRIAGE:
|
|
20
|
+
teems mail mark-read <id>... Mark read (mark-unread: unread)
|
|
21
|
+
teems mail flag <id>... [--due DATE] Flag for follow-up (unflag: clear it)
|
|
22
|
+
teems mail archive <id>... Move to Archive
|
|
23
|
+
teems mail move <id>... --to FOLDER Move to a folder
|
|
24
|
+
teems mail delete <id>... Move to Deleted Items (never purges)
|
|
25
|
+
|
|
26
|
+
Instead of IDs, select messages with --search QUERY [--folder NAME] or
|
|
27
|
+
--folder NAME [--unread] [--flagged] (up to 1,000; -n for fewer). A selection
|
|
28
|
+
is confirmed first: answer the prompt, or pass --yes. --dry-run lists the
|
|
29
|
+
messages without changing them. --thread adds the rest of each named message's
|
|
30
|
+
conversation (not what sits in Sent Items, Drafts, Deleted Items, or Junk Email)
|
|
31
|
+
and is confirmed the same way.
|
|
32
|
+
|
|
33
|
+
RULES (Inbox rules, applied by Exchange as mail arrives):
|
|
34
|
+
teems mail rules List them in order (alias: rules list)
|
|
35
|
+
teems mail rules add <name> <conditions> <actions>
|
|
36
|
+
teems mail rules enable <rule> Turn on (disable: turn off)
|
|
37
|
+
teems mail rules delete <rule> Remove (asks first; --yes skips)
|
|
38
|
+
|
|
39
|
+
<rule> is a name, the number rules lists, or an ID (--tsv shows them).
|
|
40
|
+
Conditions (repeatable; a message must match each kind given):
|
|
41
|
+
--from ADDR --sent-to ADDR --subject TEXT --body TEXT
|
|
42
|
+
Actions: --move-to FOLDER --copy-to FOLDER --mark-read --category NAME
|
|
43
|
+
--importance low|normal|high --delete (to Deleted Items) --stop (skip later rules)
|
|
44
|
+
--disabled adds the rule turned off, and --dry-run shows it without adding it.
|
|
45
|
+
Rules never forward or redirect mail, and one that does is not turned on.
|
|
46
|
+
|
|
47
|
+
OPTIONS:
|
|
48
|
+
--unread list, triage --folder: only unread messages
|
|
49
|
+
--flagged list, triage --folder: only flagged messages
|
|
50
|
+
--html read: print the raw HTML body
|
|
51
|
+
--decrypt read, thread --full: open S/MIME encrypted mail with your PIV card (asks for the PIN)
|
|
52
|
+
--full thread: print every message as read does
|
|
53
|
+
--thread triage: act on the named messages' whole conversations
|
|
54
|
+
--folder NAME search: limit to a folder; sync: folder to sync (repeatable)
|
|
55
|
+
--search QUERY triage: act on the messages a KQL search finds
|
|
56
|
+
--to FOLDER move: destination folder
|
|
57
|
+
--due DATE flag: due date (YYYY-MM-DD, today, tomorrow, or a weekday)
|
|
58
|
+
--dry-run triage: list what would change, change nothing
|
|
59
|
+
-y, --yes triage: act on a selection without asking
|
|
60
|
+
--tsv list/search/folders/thread: tab-separated output with full IDs
|
|
61
|
+
-n, --limit N list: count (default 20); search: max results (default 20)
|
|
62
|
+
--json JSON output
|
|
63
|
+
|
|
64
|
+
SYNC OPTIONS:
|
|
65
|
+
--dir DIR Store root (default ~/.local/share/teems/sync/mail)
|
|
66
|
+
--since DAYS First sync of a folder: only mail from the last DAYS days,
|
|
67
|
+
or "all" (default 90). Later runs fetch only changes.
|
|
68
|
+
Graph caps a first sync limited by DAYS at 5,000 messages
|
|
69
|
+
per folder; use "all" for more.
|
|
70
|
+
--attachments Also download file attachments to <Folder>/attachments/
|
|
71
|
+
--no-login Never open Safari: renew expired tokens silently from the
|
|
72
|
+
cached sign-in, or fail (for launchd and cron)
|
|
73
|
+
|
|
74
|
+
Folders are names ("Inbox", "Sent Items", "Projects/EERT"), well-known names
|
|
75
|
+
(inbox, archive, sent, drafts, deleted, junk), or Graph folder IDs. IDs from
|
|
76
|
+
list and search are short handles; read and triage accept them or full Graph IDs.
|
|
77
|
+
|
|
78
|
+
Sync writes one Markdown file per message plus <Folder>/index.json, and keeps
|
|
79
|
+
delta links in ~/.local/state/teems/mail-sync.json. Syncing into a directory an
|
|
80
|
+
older outlook-cli sync filled (e.g. ~/Mail) adopts its files instead of
|
|
81
|
+
duplicating them. Removed messages keep their file; the index marks them.
|
|
82
|
+
HELP
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|