teems 0.3.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -0
  3. data/README.md +77 -0
  4. data/bin/teems +8 -1
  5. data/lib/teems/api/calendar.rb +45 -14
  6. data/lib/teems/api/client.rb +19 -4
  7. data/lib/teems/api/mail.rb +132 -0
  8. data/lib/teems/api/mail_actions.rb +22 -0
  9. data/lib/teems/api/mail_rules.rb +24 -0
  10. data/lib/teems/cli.rb +2 -0
  11. data/lib/teems/commands/base.rb +31 -7
  12. data/lib/teems/commands/cal.rb +109 -28
  13. data/lib/teems/commands/cal_ics.rb +47 -0
  14. data/lib/teems/commands/confirmation.rb +19 -0
  15. data/lib/teems/commands/help.rb +4 -0
  16. data/lib/teems/commands/mail.rb +207 -0
  17. data/lib/teems/commands/mail_decrypting.rb +46 -0
  18. data/lib/teems/commands/mail_help.rb +85 -0
  19. data/lib/teems/commands/mail_rules.rb +201 -0
  20. data/lib/teems/commands/mail_sync.rb +92 -0
  21. data/lib/teems/commands/mail_thread.rb +87 -0
  22. data/lib/teems/commands/mail_triage.rb +241 -0
  23. data/lib/teems/commands/meet.rb +159 -0
  24. data/lib/teems/commands/sync.rb +15 -8
  25. data/lib/teems/commands/sync_folders.rb +118 -0
  26. data/lib/teems/formatters/format_utils.rb +23 -2
  27. data/lib/teems/formatters/html_entities.rb +49 -0
  28. data/lib/teems/formatters/html_text.rb +181 -0
  29. data/lib/teems/formatters/ics_format.rb +112 -0
  30. data/lib/teems/formatters/link_cleaner.rb +91 -0
  31. data/lib/teems/formatters/link_collector.rb +70 -0
  32. data/lib/teems/formatters/mail_formatter.rb +58 -0
  33. data/lib/teems/formatters/mail_markdown.rb +70 -0
  34. data/lib/teems/formatters/mail_rule_formatter.rb +45 -0
  35. data/lib/teems/formatters/mail_rule_text.rb +67 -0
  36. data/lib/teems/formatters/output.rb +45 -8
  37. data/lib/teems/models/chat.rb +19 -5
  38. data/lib/teems/models/event.rb +1 -23
  39. data/lib/teems/models/event_display.rb +40 -0
  40. data/lib/teems/models/ics_event.rb +60 -0
  41. data/lib/teems/models/mail_conversation.rb +62 -0
  42. data/lib/teems/models/mail_encryption.rb +41 -0
  43. data/lib/teems/models/mail_folder.rb +44 -0
  44. data/lib/teems/models/mail_message.rb +152 -0
  45. data/lib/teems/models/mail_rule.rb +38 -0
  46. data/lib/teems/runner.rb +6 -0
  47. data/lib/teems/services/api_client.rb +23 -11
  48. data/lib/teems/services/browser.rb +17 -0
  49. data/lib/teems/services/clipboard.rb +17 -0
  50. data/lib/teems/services/mail_folders.rb +76 -0
  51. data/lib/teems/services/mail_id_cache.rb +51 -0
  52. data/lib/teems/services/mail_legacy_files.rb +96 -0
  53. data/lib/teems/services/mail_sync_attachments.rb +103 -0
  54. data/lib/teems/services/mail_sync_engine.rb +98 -0
  55. data/lib/teems/services/mail_sync_folder.rb +142 -0
  56. data/lib/teems/services/mail_sync_store.rb +116 -0
  57. data/lib/teems/services/smime_content.rb +36 -0
  58. data/lib/teems/services/smime_decryptor.rb +36 -0
  59. data/lib/teems/services/sync_dir_naming.rb +16 -10
  60. data/lib/teems/services/sync_dir_ownership.rb +111 -0
  61. data/lib/teems/services/sync_dir_planning.rb +130 -0
  62. data/lib/teems/services/sync_store.rb +20 -43
  63. data/lib/teems/services/token_extractor.rb +4 -0
  64. data/lib/teems/support/control_chars.rb +19 -0
  65. data/lib/teems/support/mime_header.rb +61 -0
  66. data/lib/teems/support/mime_part.rb +97 -0
  67. data/lib/teems/support/signed_data.rb +23 -0
  68. data/lib/teems/support/time_parsing.rb +9 -1
  69. data/lib/teems/version.rb +1 -1
  70. data/lib/teems.rb +63 -2
  71. metadata +48 -1
@@ -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", "today HH:MM",
34
- "tomorrow HH:MM", or "HH:MM" (assumes today)
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 (use with --date)
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
@@ -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