teems 0.3.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -0
  3. data/README.md +84 -0
  4. data/bin/teems +8 -1
  5. data/lib/teems/api/calendar.rb +45 -14
  6. data/lib/teems/api/client.rb +19 -4
  7. data/lib/teems/api/mail.rb +132 -0
  8. data/lib/teems/api/mail_actions.rb +22 -0
  9. data/lib/teems/api/mail_rules.rb +24 -0
  10. data/lib/teems/cli.rb +2 -0
  11. data/lib/teems/commands/base.rb +31 -7
  12. data/lib/teems/commands/cal.rb +109 -28
  13. data/lib/teems/commands/cal_ics.rb +47 -0
  14. data/lib/teems/commands/confirmation.rb +19 -0
  15. data/lib/teems/commands/help.rb +4 -0
  16. data/lib/teems/commands/mail.rb +207 -0
  17. data/lib/teems/commands/mail_decrypting.rb +46 -0
  18. data/lib/teems/commands/mail_help.rb +85 -0
  19. data/lib/teems/commands/mail_rules.rb +201 -0
  20. data/lib/teems/commands/mail_sync.rb +92 -0
  21. data/lib/teems/commands/mail_thread.rb +87 -0
  22. data/lib/teems/commands/mail_triage.rb +241 -0
  23. data/lib/teems/commands/meet.rb +159 -0
  24. data/lib/teems/commands/meeting.rb +4 -3
  25. data/lib/teems/commands/messages.rb +51 -8
  26. data/lib/teems/commands/sync.rb +45 -11
  27. data/lib/teems/commands/sync_folders.rb +118 -0
  28. data/lib/teems/commands/transcripts.rb +205 -137
  29. data/lib/teems/formatters/format_utils.rb +26 -2
  30. data/lib/teems/formatters/html_entities.rb +49 -0
  31. data/lib/teems/formatters/html_text.rb +181 -0
  32. data/lib/teems/formatters/ics_format.rb +112 -0
  33. data/lib/teems/formatters/link_cleaner.rb +91 -0
  34. data/lib/teems/formatters/link_collector.rb +70 -0
  35. data/lib/teems/formatters/mail_formatter.rb +58 -0
  36. data/lib/teems/formatters/mail_markdown.rb +70 -0
  37. data/lib/teems/formatters/mail_rule_formatter.rb +45 -0
  38. data/lib/teems/formatters/mail_rule_text.rb +67 -0
  39. data/lib/teems/formatters/markdown_formatter.rb +10 -1
  40. data/lib/teems/formatters/message_formatter.rb +7 -2
  41. data/lib/teems/formatters/output.rb +45 -8
  42. data/lib/teems/formatters/transcript_markdown.rb +16 -12
  43. data/lib/teems/models/chat.rb +19 -5
  44. data/lib/teems/models/event.rb +1 -23
  45. data/lib/teems/models/event_display.rb +40 -0
  46. data/lib/teems/models/ics_event.rb +60 -0
  47. data/lib/teems/models/inline_image.rb +83 -0
  48. data/lib/teems/models/mail_conversation.rb +62 -0
  49. data/lib/teems/models/mail_encryption.rb +41 -0
  50. data/lib/teems/models/mail_folder.rb +44 -0
  51. data/lib/teems/models/mail_message.rb +152 -0
  52. data/lib/teems/models/mail_rule.rb +38 -0
  53. data/lib/teems/models/message.rb +7 -4
  54. data/lib/teems/models/parsing.rb +5 -0
  55. data/lib/teems/runner.rb +11 -0
  56. data/lib/teems/services/api_client.rb +23 -11
  57. data/lib/teems/services/browser.rb +17 -0
  58. data/lib/teems/services/clipboard.rb +17 -0
  59. data/lib/teems/services/inline_image_downloader.rb +115 -0
  60. data/lib/teems/services/mail_folders.rb +76 -0
  61. data/lib/teems/services/mail_id_cache.rb +51 -0
  62. data/lib/teems/services/mail_legacy_files.rb +96 -0
  63. data/lib/teems/services/mail_sync_attachments.rb +103 -0
  64. data/lib/teems/services/mail_sync_engine.rb +98 -0
  65. data/lib/teems/services/mail_sync_folder.rb +142 -0
  66. data/lib/teems/services/mail_sync_store.rb +116 -0
  67. data/lib/teems/services/smime_content.rb +36 -0
  68. data/lib/teems/services/smime_decryptor.rb +36 -0
  69. data/lib/teems/services/sync_dir_naming.rb +16 -10
  70. data/lib/teems/services/sync_dir_ownership.rb +111 -0
  71. data/lib/teems/services/sync_dir_planning.rb +130 -0
  72. data/lib/teems/services/sync_engine.rb +13 -7
  73. data/lib/teems/services/sync_images.rb +55 -0
  74. data/lib/teems/services/sync_store.rb +20 -43
  75. data/lib/teems/services/token_extractor.rb +4 -0
  76. data/lib/teems/support/control_chars.rb +19 -0
  77. data/lib/teems/support/mime_header.rb +61 -0
  78. data/lib/teems/support/mime_part.rb +97 -0
  79. data/lib/teems/support/signed_data.rb +23 -0
  80. data/lib/teems/support/subprocess.rb +2 -0
  81. data/lib/teems/support/time_parsing.rb +9 -1
  82. data/lib/teems/version.rb +1 -1
  83. data/lib/teems.rb +67 -2
  84. metadata +51 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdc6ea71a9d23f39629fd24d240992925cca1482b53b69df377a84870d66367f
4
- data.tar.gz: 1d563de02f05c8a120130e3e792b508bbf4d1b2467937951a63f2a3efbb19410
3
+ metadata.gz: 85e10318e8ea5814ce7caeec5186ec86589ac7cbc6a1400e5174cdbd7a4112af
4
+ data.tar.gz: 0bab10db1f970e7d45ca782ae8ac9d52ef866b5691be6105c2be0d0f89c251be
5
5
  SHA512:
6
- metadata.gz: 8c903fdae8e9946614e093ab4370e74abc03f7fab0d0a76509da02d07814e0083382f4a8f46e0e9131dd1eb3b16f0c36c5eeb96bb381778679bb92028574ad13
7
- data.tar.gz: b3e6ff9fa9ec3adb9708a3ab97b30929d53a820e9d6239daaf4d359cee179c32448b306a7ac3ab26f336aa3c723402868a1206711637af791a974aa0e3f0e832
6
+ metadata.gz: 79a9bbeb61fa5b1dd8f7fe7ce73d300694bdc53fdf263c64c067ae78a6e4d8fa530e4b518f86b1c7d2af9a401c6a4f85a6f16ab8f94eb9f53f2c73273fbbc56e
7
+ data.tar.gz: 6fb30e2bf57862725981303e3818448e666c6aa1a0ecc0e1e81063e818d401ef010adcf45dfd575fbd600a9add0356b02552bd063d01dce8d5507e16390a939a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,83 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0] - 2026-10-28
4
+
5
+ ### Added
6
+ - `teems meet [minutes]` puts a Teams meeting on your calendar, starting now for 30 minutes unless `--at` and the minutes say otherwise, and prints its join link alone on stdout. `--title` names it, it opens the link here to join, since people from organizations yours does not trust wait in the lobby until a member of yours joins (`--no-open` skips that), `--copy` copies the link, and `--json` gives the event. `--with` invites people after asking, since Exchange emails each an invitation (`--yes` skips the question).
7
+ - `teems cal create --start now`, and `--start` may be left out for now.
8
+
9
+ ## [0.7.0] - 2026-10-28
10
+
11
+ ### Added
12
+ - `teems mail thread <id>` lists every message in a message's conversation, from any folder, oldest first, with the folder each sits in (`--tsv`, `--json`). `--full` prints them all as `read` does, Purview and S/MIME labels included, and `--full --decrypt` opens the S/MIME messages with the PIV card on the way.
13
+ - `--thread` on the triage commands (`mark-read`, `flag`, `archive`, `move`, `delete` and the rest) takes in the rest of each named message's conversation, skipping messages in Sent Items, Drafts, Deleted Items and Junk Email unless named. It is confirmed like a selection, and `--dry-run` lists it.
14
+
15
+ - `teems mail open <id>` opens a message in Outlook on the web, which can show Purview protected mail; `--print` prints the link instead.
16
+
17
+ ### Changed
18
+ - Purview protected mail now reads "Protected by Purview; open in Outlook on the web", and `read`, `thread --full` and synced Markdown give its Outlook on the web link (an `Open:` line in the headers), where Exchange decrypts it.
19
+
20
+ ### Fixed
21
+ - `teems mail sync` now labels S/MIME encrypted mail that Exchange wraps as TNEF, which is most of it: its headers do not say S/MIME and Graph reports no attachments, so sync lists the attachments of a message with no body and looks for an `smime.p7m` envelope. The envelope is not listed or saved as a file. Files synced before keep their old content until the message changes.
22
+
23
+ ## [0.6.0] - 2026-10-27
24
+
25
+ ### Added
26
+ - `teems mail rules` lists Inbox rules with what each does, and `rules add`, `enable`, `disable` and `delete` manage them. A new rule takes conditions (`--from`, `--sent-to`, `--subject`, `--body`) and actions (`--move-to`, `--copy-to`, `--mark-read`, `--category`, `--importance`, `--delete`, `--stop`), and `--dry-run` shows it first. Rules never forward or redirect mail, and a forwarding rule made elsewhere is not turned on.
27
+ - `teems mail read --decrypt` opens S/MIME encrypted mail with the PIV card (or any keychain identity) through macOS's `security cms -D`, which asks for the PIN. The decrypted message shows like any other: HTML or text body, links, attachment names, and a note when it was also signed (the signature is unwrapped, not checked). Plaintext never touches the disk. Without `--decrypt`, an encrypted message is labelled instead of showing an empty body.
28
+ - Purview (rights-managed) mail is labelled "Protected by Purview; open in Outlook" in `teems mail read` (`encryption` in `--json`), and synced Markdown of protected mail gets that note above the body plus a `protection:` frontmatter field. Graph's `hasAttachments` is unreliable for such mail, so protection is read from the internet headers, which makes a full sync download more.
29
+
30
+ ### Changed
31
+ - `teems mail read` leaves out the `Categories:` line when a message has none.
32
+
33
+ ## [0.5.0] - 2026-10-26
34
+
35
+ ### Added
36
+ - `teems mail` triage: `mark-read`, `mark-unread`, `flag` (with `--due YYYY-MM-DD`, `today`, `tomorrow` or a weekday), `unflag`, `archive`, `move --to FOLDER`, and `delete` (to Deleted Items; nothing is purged). They take message IDs, or a selection: `--search QUERY` (optionally in `--folder`) or `--folder NAME` with `--unread`/`--flagged`, up to 1,000 messages. A selection asks before changing anything (`--yes` skips the question, and without a terminal is required), and `--dry-run` lists the messages instead. One message failing does not stop the rest. Nothing sends mail.
37
+ - `teems mail list --flagged` lists flagged messages, and the table marks them.
38
+
39
+ ## [0.4.0] - 2026-10-20
40
+
41
+ ### Added
42
+ - `teems mail`, read-only Outlook mail over Microsoft Graph with the same tokens as the rest of teems. `list [folder] [count]` (alias `recent`) shows the newest messages, with `--unread` for unread only. `read <id>` prints headers and a plain-text body with a numbered list of links (tracking redirects such as Safe Links unwrapped), or the raw HTML with `--html`. `folders` shows unread and total counts, and `search <query>` runs a KQL `$search`, optionally limited to one folder. List and search IDs are short handles that `read` accepts. Reading a message does not mark it read.
43
+ - `teems mail sync [folder...]` keeps one Markdown file per message plus an `index.json` per folder, fetching only changes after the first run (Graph delta). It is safe to run from launchd: `--no-login` never opens Safari, a lock prevents overlapping runs, and the index is only rewritten when something changed. Messages that leave a folder keep their file and get `removed_at` in the index. Files written by the AppleScript outlook-cli sync in the same directory are adopted, matched on received time, subject and sender, instead of being duplicated. `--attachments` also downloads file attachments.
44
+ - `teems cal ics` prints the calendar as an iCalendar feed (default 7 days back through 60 ahead; `--days`, `--days-back`), with attendees, responses, descriptions and Teams join links. `--exclude-attendee` leaves out events given addresses organize or have accepted, `--name` sets the calendar name, and `--no-login` keeps it from opening Safari. `--all-calendars` adds the user's other calendars; one that refuses access (free/busy rights only, owner gone) is skipped with a warning, but the default calendar never is. This replaces the outlook-cli calendar feed, which stopped updating when Outlook lost EWS.
45
+
46
+ ### Fixed
47
+ - `teems sync` no longer merges different chats into one folder. Folder names used to identify generic chats ("Group Chat", "1:1 Chat") by the first 20 characters of the chat ID, which every 1:1 chat with the same person starts with, and named chats by their title alone. New chats, and chats whose title or type changes, now get a folder name ending with the chat's full ID. A name already used by another chat is never handed out, and a name that would still clash (IDs differing only in letter case on a case-insensitive filesystem) falls back to the ID plus a hash.
48
+ - 1:1 chats are classified as 1:1 chats (`dms/`, "1:1 Chat") instead of group chats.
49
+ - Piping a command into something that stops reading early (`teems help | head`) exits quietly instead of printing "Unexpected error: Broken pipe" and logging it. The same holds when stderr's reader is gone.
50
+ - Started without a locale (launchd, `env -i`), teems read its files as US-ASCII and failed on any non-ASCII name it had saved, and arguments with accents matched nothing. It now reads files and arguments as UTF-8 regardless of locale.
51
+
52
+ ### Changed
53
+ - Existing chat folders keep their names when only one chat uses them, so links into the sync folder keep working. 1:1 chats an older version filed under `groups/` move to `dms/`. `teems sync --migrate-dirs` renames every folder to the full-ID naming and writes an old to new map to `sync/dir-maps/`.
54
+ - `teems sync` detaches folders that an older version gave to more than one chat that has synced into them. The folders and their files are left untouched (the path is recorded as `legacy_shared_dir` in `sync_state.json`). Each affected chat gets its own folder and re-fetches history back to the oldest message in the old folder, or `--since` days if that is earlier. `teems sync --chat ID` only detaches that chat. Chats that never synced don't count toward a folder being shared.
55
+ - `teems sync` lists each folder move as old → new, warns when the new folder already exists and the old one is left in place, and keeps listing old shared folders while they are still on disk. `teems sync --dry-run` previews every folder move and detach without changing anything.
56
+
57
+ ## [0.3.4] - 2026-10-05
58
+
59
+ ### Added
60
+ - `teems cal create --all-day --end YYYY-MM-DD` creates one multi-day all-day event. `--end` is the last day, inclusive (like `teems ooo --end`), so `--date 2026-11-12 --end 2026-11-13` covers both days. Leaving out `--end` still creates a single-day event.
61
+ - `teems cal create --all-day --start YYYY-MM-DD` works as an alias for `--date`.
62
+
63
+ ### Changed
64
+ - `teems cal create --all-day` now rejects options it used to silently ignore: `--duration`, an `--end` before the first day, a time of day in `--start` or `--end`, and `--date` combined with `--start`.
65
+ - The created-event summary shows the date range of a multi-day all-day event (`2026-11-12 to 2026-11-13 (all day)`).
66
+
67
+ ### Fixed
68
+ - `teems cal --help` examples that continue onto a second line render as two lines instead of being run together.
69
+
70
+ ## [0.3.3] - 2026-10-02
71
+
72
+ ### Added
73
+ - Inline images (screenshots pasted into a message) are no longer dropped when message HTML is stripped. `teems messages` lists them under the message, and `--json` includes an `images` array with the AMS URL, full-size URL, alt text, and dimensions.
74
+ - `teems messages --download` saves inline images alongside file attachments, fetching the original-resolution AMS view (falling back to the message's preview URL) with the skype token. The token is only sent to Teams/AMS hosts over HTTPS and is dropped on redirects elsewhere.
75
+ - `teems sync` records inline images in `messages.json` and renders `[image: alt (WxH)]` placeholders in `messages.md`. `teems sync --images` downloads them into each chat's `images/` directory, links them from `messages.md`, and backfills already-synced chats.
76
+
77
+ ### Fixed
78
+ - `teems messages <teams-url>` for a message in a group or 1:1 chat no longer fails with "Failed to fetch message"; those chats have no reply threads, so the replies endpoint's 404 now means no replies.
79
+ - `teems messages --json <teams-url>` no longer crashes with "no implicit conversion from nil to integer" after printing the thread.
80
+
3
81
  ## [0.3.2] - 2026-09-29
4
82
 
5
83
  ### Added
data/README.md CHANGED
@@ -47,17 +47,98 @@ teems cal show 3 # View details for event #3
47
47
  teems cal accept 3 # Accept event #3
48
48
  teems cal decline 3 # Decline event #3
49
49
  teems cal create "Standup" --start "tomorrow 09:00" --attendees alice@example.com
50
+ teems cal create "PTO" --all-day --date 2026-11-12 --end 2026-11-13 --show-as oof
51
+ teems cal create "Pairing" --teams # Starts now (the default), 30 minutes
50
52
  teems cal delete 3 # Delete event #3
51
53
  ```
52
54
 
55
+ `--start` defaults to now and takes `now` too; the end defaults to 30 minutes later.
56
+
57
+ #### Instant Teams meetings
58
+
59
+ ```bash
60
+ teems meet # A Teams meeting now, 30 minutes; prints the link and joins here
61
+ teems meet 60 --title "Pairing" # An hour
62
+ teems meet --copy # Also copy the link
63
+ teems meet --no-open | pbcopy # Just the link
64
+ teems meet --at 14:30 --with alice@example.com # Asks before inviting (--yes skips)
65
+ ```
66
+
67
+ `teems meet` puts a Teams meeting on your calendar and prints its join link alone on stdout (`teems meet | pbcopy` works; `--json` gives the event too). With nobody invited, nothing is sent; `--with` invites people, and since Exchange emails each an invitation it asks first. It also opens the link here to join (`--no-open` skips that), because people from organizations yours does not trust wait in the lobby until a member of yours joins: setting "Everyone" to bypass the lobby in Meeting options does not change that, and neither does inviting them. Once you are in you can admit them, and the meeting usually carries on after you leave.
68
+
69
+ All-day events cover `--date` (or `--start YYYY-MM-DD`; default today). Add `--end YYYY-MM-DD` for a multi-day event: like `teems ooo --end`, it is the last day of the event, inclusive. The example above is one event covering November 12 and 13.
70
+
71
+ #### ICS feed
72
+
73
+ ```bash
74
+ teems cal ics > work.ics # 7 days back through 60 days ahead
75
+ teems cal ics --days 30 --days-back 0 # A different window
76
+ teems cal ics --exclude-attendee me@other.example --name "Work" --no-login
77
+ ```
78
+
79
+ `teems cal ics` prints your calendar as an iCalendar file, for a local server that Calendar.app subscribes to. Every occurrence of a recurring meeting is its own event. Events keep their invitees, responses and description, and Teams meetings carry their join link (`URL`, `CONFERENCE`) so Calendar.app shows a Join button. Cancelled meetings are marked `STATUS:CANCELLED` and free time is `TRANSP:TRANSPARENT`. It reads your default calendar; `--all-calendars` adds the others (holidays, shared calendars), keeping an event that is on two of them once and skipping, with a warning, a shared calendar you cannot read. `--exclude-attendee` leaves out events those addresses organize or have accepted, such as ones already on another calendar you subscribe to. `--no-login` never opens Safari: expired tokens are renewed silently from the cached sign-in session when possible, and otherwise the command fails, which is what you want from launchd or a server.
80
+
81
+ ### Mail
82
+
83
+ ```bash
84
+ teems mail list # 20 newest Inbox messages (alias: recent)
85
+ teems mail list Archive 50 --unread # Folder and count, unread only
86
+ teems mail read 3fa2c1 # Headers, plain-text body, numbered links
87
+ teems mail read 3fa2c1 --html # Raw HTML body
88
+ teems mail read 3fa2c1 --decrypt # S/MIME encrypted mail, opened with your PIV card
89
+ teems mail thread 3fa2c1 # Its whole conversation, any folder, oldest first
90
+ teems mail thread 3fa2c1 --full # Every message in it, as read prints them
91
+ teems mail thread 3fa2c1 --full --decrypt # Opening the S/MIME ones with your PIV card
92
+ teems mail open 3fa2c1 # In Outlook on the web (--print prints the link)
93
+ teems mail folders # Unread and total counts
94
+ teems mail search "from:alice subject:report" --folder Inbox
95
+ teems mail sync Inbox "Sent Items" --since 30
96
+ teems mail list --flagged # Flagged messages
97
+ ```
98
+
99
+ Triage changes a message's read state or flag, or moves it. Name messages by ID, or select them with `--search` (optionally within `--folder`) or `--folder` with `--unread`/`--flagged`:
100
+
101
+ ```bash
102
+ teems mail mark-read 3fa2c1 9b0e44 # mark-unread for the reverse
103
+ teems mail flag 3fa2c1 --due friday # unflag clears it
104
+ teems mail archive 3fa2c1
105
+ teems mail move 3fa2c1 --to Projects/EERT
106
+ teems mail delete 3fa2c1 # To Deleted Items, never purged
107
+ teems mail archive 3fa2c1 --thread # The whole conversation (asks first)
108
+ teems mail archive --search "from:jira" --dry-run # List what would move
109
+ teems mail mark-read --folder Inbox --unread --yes
110
+ ```
111
+
112
+ teems never sends mail, and reading a message does not mark it read. Message IDs in `list` and `search` output are short handles that `read` and the triage commands accept (`--tsv` prints the full Graph IDs). `search` and `--search` use Graph's KQL `$search`. A selection is confirmed before anything changes (or pass `--yes`) and stops at 1,000 messages (`-n` for fewer). `delete` moves messages to Deleted Items, as Outlook does. `thread` finds a conversation's messages in every folder and names the folder each sits in. `--thread` takes in the rest of each named message's conversation, except what sits in Sent Items, Drafts, Deleted Items or Junk Email, so archiving a thread leaves your replies and deleted mail where they are. It counts as a selection: confirmed first, or listed with `--dry-run`.
113
+
114
+ Inbox rules, which Exchange applies as mail arrives:
115
+
116
+ ```bash
117
+ teems mail rules # In order: number, on/off, name, what it does
118
+ teems mail rules add Jira --from jira@example.com --move-to Projects/Jira --mark-read --stop
119
+ teems mail rules add Builds --subject "build failed" --category Red --dry-run
120
+ teems mail rules disable Jira # By name, number, or ID; enable turns it on
121
+ teems mail rules delete Jira # Asks first; --yes skips
122
+ ```
123
+
124
+ A new rule needs at least one condition (`--from`, `--sent-to`, `--subject`, `--body`, each repeatable) and one action (`--move-to`, `--copy-to`, `--mark-read`, `--category`, `--importance`, `--delete` to Deleted Items, `--stop`). It goes last in order, and `--disabled` adds it turned off. teems never creates a rule that forwards or redirects mail, and won't turn on an existing one that does.
125
+
126
+ S/MIME encrypted mail reaches Graph with an empty body, so `read` labels it (`Encryption:` in the headers, `encryption` in `--json`) instead of showing nothing. `--decrypt` opens it with macOS's `security cms -D`, which finds the matching key in your keychain or on your PIV card (the current key management key or a retired one) and asks for the PIN in the system dialog. The ciphertext and plaintext travel over pipes, never to disk, and `sync` never decrypts. `thread --full --decrypt` opens every S/MIME message in a conversation the same way; one that none of your keys opens is shown locked, with a warning, and the rest still print. A signed message inside is unwrapped but its signature is not checked. Mail protected by Microsoft Purview (`message.rpmsg`, "Do Not Forward") uses Microsoft's rights management service rather than certificates, so `read` labels it "Protected by Purview; open in Outlook on the web" and adds an `Open:` line with the message's Outlook on the web link, where Exchange decrypts it for you; `teems mail open <id>` goes straight there. Synced copies of protected mail carry the same note above the body and a `protection:` field (`purview` or `smime`) in the frontmatter.
127
+
128
+ `teems mail sync` keeps a Markdown copy of each message in `~/.local/share/teems/sync/mail/<Folder>/` (or `--dir`) with an `index.json` per folder. The first run of a folder fetches `--since` days (default 90, or `all`); later runs fetch only what changed, using Graph delta links stored in `~/.local/state/teems/mail-sync.json`. Messages that leave the folder keep their file and are marked `removed_at` in the index. `--attachments` also saves file attachments. A lock file keeps overlapping runs (launchd) from colliding. Syncing into a directory filled by the older AppleScript-based outlook-cli sync adopts its files instead of writing duplicates.
129
+
53
130
  ### Messages
54
131
 
55
132
  ```bash
56
133
  teems messages <chat-id> # Read from a chat
57
134
  teems messages <channel-id> -t <team-id> # Read from a channel
58
135
  teems messages <chat-id> -n 50 # Show more messages
136
+ teems messages <chat-id> --json # JSON, including inline image refs
137
+ teems messages <chat-id> --download # Save attachments and pasted images
59
138
  ```
60
139
 
140
+ Screenshots pasted into a message are listed under it (`🖼️ image (881x179)`) and included in `--json` as `images` (AMS `url`, `full_size_url`, `alt`, `width`, `height`). `--download` saves the original-resolution image to the downloads directory as `image-<hash>-<n>.<ext>`, using your Teams skype token. The token is only sent to Teams/AMS hosts over HTTPS.
141
+
61
142
  ### Channels and Chats
62
143
 
63
144
  ```bash
@@ -151,8 +232,11 @@ teems activity # Show activity feed
151
232
 
152
233
  ```bash
153
234
  teems sync # Sync chat history locally
235
+ teems sync --images # Also save pasted images beside each chat
154
236
  ```
155
237
 
238
+ Each chat gets `messages.md`, `messages.json`, and `chat_metadata.json` under `~/.local/share/teems/sync/chats/`. Inline images are recorded in `messages.json` and rendered in `messages.md` as `[image: alt (WxH)]`. With `--images`, they are downloaded to the chat's `images/` directory (named by AMS object id, so each is fetched once) and `messages.md` links the saved copy instead. `--images` also rewrites already-synced chats so images in older messages are backfilled.
239
+
156
240
  ## Global Options
157
241
 
158
242
  | Option | Description |
data/bin/teems CHANGED
@@ -1,7 +1,14 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # launchd and `env -i` start teems without a locale, where Ruby reads files (tokens, sync
5
+ # indexes, legacy mail) as US-ASCII and fails on the first accented name, and passes
6
+ # arguments as raw bytes, so "Événements" matches no folder. Everything teems reads, writes
7
+ # and is given is UTF-8.
8
+ Encoding.default_external = Encoding::UTF_8
9
+ args = ARGV.map { |arg| arg.dup.force_encoding(Encoding::UTF_8) }
10
+
4
11
  $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
5
12
  require 'teems'
6
13
 
7
- exit Teems::CLI.new(ARGV).run
14
+ exit Teems::CLI.new(args).run
@@ -16,6 +16,14 @@ module Teems
16
16
  responseStatus sensitivity
17
17
  ].join(',').freeze
18
18
 
19
+ # The default calendar's view; #feed_events never skips it
20
+ DEFAULT_VIEW = '/v1.0/me/calendarView'
21
+ FEED_HEADERS = { 'Prefer' => 'outlook.timezone="UTC", IdType="ImmutableId"' }.freeze
22
+ FEED_SELECT = %w[
23
+ id iCalUId subject start end location isAllDay organizer attendees
24
+ body onlineMeeting showAs isCancelled
25
+ ].join(',').freeze
26
+
19
27
  # List events in a date range using CalendarView
20
28
  def list_events(time_range:, top: 50)
21
29
  params = calendar_view_params(time_range[:start_dt], time_range[:end_dt], top)
@@ -23,6 +31,29 @@ module Teems
23
31
  paginate_events('/v1.0/me/calendarView', params: params, headers: headers)
24
32
  end
25
33
 
34
+ # Every event (each occurrence of a series on its own) between two Times, for `teems cal ics`,
35
+ # from the default calendar or the given views (calendarView path => calendar name, see
36
+ # #calendar_views). Times come back in UTC and IDs are immutable, so UIDs stay stable
37
+ # between exports. An event on two calendars is kept once. Given a block, a calendar
38
+ # other than the default one that refuses its events (403 or 404: free/busy rights only,
39
+ # or its owner left) is yielded by name with the error and left out, instead of failing
40
+ # the whole feed.
41
+ def feed_events(from:, to:, views: { DEFAULT_VIEW => 'Calendar' }, &unreadable)
42
+ params = { 'startDateTime' => from.utc.iso8601, 'endDateTime' => to.utc.iso8601,
43
+ '$select' => FEED_SELECT, '$orderby' => 'start/dateTime', '$top' => 250 }
44
+ views.flat_map { |path, name| view_events(path, name, params, &unreadable) }.uniq(&:uid)
45
+ end
46
+
47
+ # calendarView path => name for every calendar (holidays, shared ones). The default
48
+ # calendar comes first, as DEFAULT_VIEW, so its copy of an event on two calendars is the
49
+ # one kept and it is never skipped.
50
+ def calendar_views
51
+ params = { '$select' => 'id,name,isDefaultCalendar' }
52
+ collect_pages(get('/v1.0/me/calendars', params: params))
53
+ .sort_by { |calendar| calendar['isDefaultCalendar'] ? 0 : 1 }
54
+ .to_h { |calendar| [calendar_view_path(calendar), calendar['name'].to_s] }
55
+ end
56
+
26
57
  # Get a single event by ID with full details
27
58
  def get_event(event_id:, timezone:)
28
59
  encoded_id = URI.encode_www_form_component(event_id)
@@ -67,27 +98,27 @@ module Teems
67
98
  end
68
99
 
69
100
  def paginate_events(path, params:, headers:)
70
- events = []
71
- response = get(path, params: params, headers: headers)
72
- collect_paginated_events(events, response, headers)
101
+ collect_pages(get(path, params: params, headers: headers), headers: headers).map { |data| Models::Event.from_api(data) }
73
102
  end
74
103
 
75
- def collect_paginated_events(events, response, headers)
76
- loop do
77
- events.concat(parse_events(response))
78
- next_link = response['@odata.nextLink']
79
- break events unless next_link
104
+ def calendar_view_path(calendar)
105
+ return DEFAULT_VIEW if calendar['isDefaultCalendar']
80
106
 
81
- response = get(next_link, headers: headers)
82
- end
107
+ "/v1.0/me/calendars/#{URI.encode_www_form_component(calendar['id'])}/calendarView"
83
108
  end
84
109
 
85
- def timezone_header(timezone)
86
- { 'Prefer' => "outlook.timezone=\"#{timezone}\"" }
110
+ def view_events(path, name, params)
111
+ collect_pages(get(path, params: params, headers: FEED_HEADERS), headers: FEED_HEADERS)
112
+ .map { |data| Models::IcsEvent.from_api(data) }
113
+ rescue ApiError => e
114
+ raise unless block_given? && path != DEFAULT_VIEW && (e.forbidden? || e.not_found?)
115
+
116
+ yield name, e
117
+ []
87
118
  end
88
119
 
89
- def parse_events(response)
90
- (response['value'] || []).map { |data| Models::Event.from_api(data) }
120
+ def timezone_header(timezone)
121
+ { 'Prefer' => "outlook.timezone=\"#{timezone}\"" }
91
122
  end
92
123
  end
93
124
  end
@@ -19,12 +19,13 @@ module Teems
19
19
  @api.get(endpoint, path, account: @account, params: params, headers: headers)
20
20
  end
21
21
 
22
- def post(path, body: nil)
23
- @api.post(endpoint, path, account: @account, body: body)
22
+ # options: body: (sent as JSON) and headers:
23
+ def post(path, **)
24
+ @api.post(endpoint, path, account: @account, **)
24
25
  end
25
26
 
26
- def patch(path, body: nil)
27
- @api.patch(endpoint, path, account: @account, body: body)
27
+ def patch(path, **)
28
+ @api.patch(endpoint, path, account: @account, **)
28
29
  end
29
30
 
30
31
  def post_to(target_endpoint, request)
@@ -34,6 +35,20 @@ module Teems
34
35
  def delete(path)
35
36
  @api.delete(endpoint, path, account: @account)
36
37
  end
38
+
39
+ # The items of a Graph collection from its first response, following @odata.nextLink
40
+ # (sent with the same headers) until the last page, or until limit items are in hand
41
+ # (no page is fetched past them)
42
+ def collect_pages(response, headers: {}, limit: nil)
43
+ items = Enumerator.new do |yielder|
44
+ loop do
45
+ Array(response['value']).each(&yielder)
46
+ link = response['@odata.nextLink'] or break
47
+ response = get(link, headers: headers)
48
+ end
49
+ end
50
+ limit ? items.first(limit) : items.to_a
51
+ end
37
52
  end
38
53
  end
39
54
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Api
5
+ # Paths and headers shared by the Graph mail wrappers. Message IDs are requested as immutable
6
+ # IDs so they survive moves between folders.
7
+ module MailPaths
8
+ private
9
+
10
+ def prefer_header = { 'Prefer' => 'IdType="ImmutableId"' }
11
+
12
+ def folder_path(folder_id) = "/v1.0/me/mailFolders/#{encode(folder_id)}"
13
+
14
+ def message_path(message_id) = "/v1.0/me/messages/#{encode(message_id)}"
15
+
16
+ def encode(segment) = URI.encode_www_form_component(segment)
17
+ end
18
+
19
+ # API wrapper for reading Microsoft Graph mail (see MailActions for changing it)
20
+ class Mail < Client
21
+ include MailPaths
22
+
23
+ LIST_FIELDS = %w[id receivedDateTime from subject isRead hasAttachments flag].freeze
24
+ # internetMessageHeaders only to tell protected mail apart (MailEncryption.from_headers); delta
25
+ # has no lighter way, since hasAttachments is unreliable for it and $expand is refused
26
+ MESSAGE_FIELDS = (LIST_FIELDS + %w[toRecipients ccRecipients categories body internetMessageId webLink
27
+ internetMessageHeaders]).freeze
28
+ PAGE_MAX = 250
29
+ # Delta pages carry full bodies, so keep them small
30
+ DELTA_PAGE_SIZE = 50
31
+ # $filter conditions messages can be limited to
32
+ FILTERS = { unread: 'isRead eq false', flagged: "flag/flagStatus eq 'flagged'" }.freeze
33
+
34
+ # Top-level folders, or the children of parent_id
35
+ def folders(parent_id: nil)
36
+ path = parent_id ? "#{folder_path(parent_id)}/childFolders" : '/v1.0/me/mailFolders'
37
+ collect(path, { '$top' => PAGE_MAX, '$select' => folder_fields }, limit: nil)
38
+ end
39
+
40
+ def folder(folder_id)
41
+ get(folder_path(folder_id), params: { '$select' => folder_fields })
42
+ end
43
+
44
+ # Newest first; only names FILTERS that every message must match, e.g. [:unread]
45
+ def messages(folder:, count:, only: [])
46
+ params = list_params(count).merge('$orderby' => 'receivedDateTime desc', '$filter' => filter(only)).compact
47
+ parse_messages(collect("#{folder_path(folder)}/messages", params, limit: count))
48
+ end
49
+
50
+ def message(message_id)
51
+ data = get(message_path(message_id), params: { '$select' => MESSAGE_FIELDS.join(',') },
52
+ headers: prefer_header)
53
+ Models::MailMessage.from_api(data)
54
+ end
55
+
56
+ # The message's Outlook on the web address
57
+ def web_link(message_id)
58
+ get(message_path(message_id), params: { '$select' => 'webLink' }, headers: prefer_header)['webLink']
59
+ end
60
+
61
+ # The conversation (thread) a message belongs to
62
+ def conversation_id(message_id)
63
+ get(message_path(message_id), params: { '$select' => 'conversationId' },
64
+ headers: prefer_header)['conversationId']
65
+ end
66
+
67
+ # Every message in a conversation, in any folder, oldest first: Graph refuses $orderby
68
+ # alongside this filter, so the sorting happens here. MESSAGE_FIELDS as fields adds bodies.
69
+ def conversation(conversation_id, fields: LIST_FIELDS)
70
+ params = { '$filter' => "conversationId eq '#{conversation_id.gsub("'", "''")}'", '$top' => PAGE_MAX,
71
+ '$select' => [*fields, 'parentFolderId'].join(',') }
72
+ parse_messages(collect('/v1.0/me/messages', params, limit: nil)).sort_by { |message| message.received_at.to_i }
73
+ end
74
+
75
+ # Every message in the conversations these messages belong to, each once
76
+ def conversations_of(message_ids)
77
+ message_ids.filter_map { |id| conversation_id(id) }.uniq.flat_map { |id| conversation(id) }.uniq(&:id)
78
+ end
79
+
80
+ # KQL search (Graph $search) across the mailbox, or within one folder
81
+ def search(query:, count:, folder: nil)
82
+ path = folder ? "#{folder_path(folder)}/messages" : '/v1.0/me/messages'
83
+ params = list_params(count).merge('$search' => "\"#{query.gsub(/["\\]/) { |char| "\\#{char}" }}\"")
84
+ parse_messages(collect(path, params, limit: count))
85
+ end
86
+
87
+ # First page of a delta query for one folder; since limits it to messages received after that time
88
+ def delta(folder:, since: nil)
89
+ params = { '$select' => MESSAGE_FIELDS.join(',') }
90
+ params['$filter'] = "receivedDateTime ge #{since.utc.strftime('%Y-%m-%dT%H:%M:%SZ')}" if since
91
+ get("#{folder_path(folder)}/messages/delta", params: params, headers: delta_header)
92
+ end
93
+
94
+ # Follows an @odata.nextLink or @odata.deltaLink from a delta query
95
+ def delta_page(link) = get(link, headers: delta_header)
96
+
97
+ # Attachment metadata (no content) for one message
98
+ def attachments(message_id)
99
+ path = "#{message_path(message_id)}/attachments"
100
+ collect(path, { '$select' => 'id,name,size,contentType,isInline' }, limit: nil)
101
+ end
102
+
103
+ # One attachment including its base64 contentBytes (file attachments only)
104
+ def attachment(message_id, attachment_id)
105
+ get("#{message_path(message_id)}/attachments/#{encode(attachment_id)}", headers: prefer_header)
106
+ end
107
+
108
+ private
109
+
110
+ # Graph wants each $orderby property (receivedDateTime) to lead the $filter, or it may
111
+ # refuse the query as InefficientFilter
112
+ def filter(names)
113
+ return if names.empty?
114
+
115
+ ['receivedDateTime ge 1900-01-01T00:00:00Z', *FILTERS.values_at(*names)].join(' and ')
116
+ end
117
+
118
+ def list_params(count) = { '$top' => count.clamp(1, PAGE_MAX), '$select' => LIST_FIELDS.join(',') }
119
+
120
+ def collect(path, params, limit:)
121
+ headers = prefer_header
122
+ collect_pages(get(path, params: params, headers: headers), headers: headers, limit: limit)
123
+ end
124
+
125
+ def folder_fields = 'id,displayName,parentFolderId,childFolderCount,unreadItemCount,totalItemCount'
126
+
127
+ def parse_messages(items) = items.map { |data| Models::MailMessage.from_api(data) }
128
+
129
+ def delta_header = { 'Prefer' => "IdType=\"ImmutableId\", odata.maxpagesize=#{DELTA_PAGE_SIZE}" }
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Api
5
+ # Changes to Microsoft Graph mail messages. Nothing here sends mail: a message's read state
6
+ # and flag can change, and it can move between folders (deleting is a move to Deleted Items,
7
+ # where Outlook still shows it).
8
+ class MailActions < Client
9
+ include MailPaths
10
+
11
+ # Sets message properties, e.g. { isRead: true } or { flag: { flagStatus: 'flagged' } }
12
+ def update(message_id, changes)
13
+ patch(message_path(message_id), body: changes, headers: prefer_header)
14
+ end
15
+
16
+ # Moves a message to a folder ID or well-known name ('archive', 'deleteditems')
17
+ def move(message_id, destination)
18
+ post("#{message_path(message_id)}/move", body: { destinationId: destination }, headers: prefer_header)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Api
5
+ # Inbox rules (Graph /me/mailFolders/inbox/messageRules), which Exchange applies to mail as
6
+ # it arrives. teems builds rules without forwarding or redirecting actions (see
7
+ # Commands::MailRules), so nothing here makes the mailbox send mail.
8
+ class MailRules < Client
9
+ RULES_PATH = '/v1.0/me/mailFolders/inbox/messageRules'
10
+
11
+ def list = collect_pages(get(RULES_PATH)).to_a
12
+
13
+ def create(rule) = post(RULES_PATH, body: rule)
14
+
15
+ def update(rule_id, changes) = patch(rule_path(rule_id), body: changes)
16
+
17
+ def destroy(rule_id) = delete(rule_path(rule_id))
18
+
19
+ private
20
+
21
+ def rule_path(rule_id) = "#{RULES_PATH}/#{URI.encode_www_form_component(rule_id)}"
22
+ end
23
+ end
24
+ end
data/lib/teems/cli.rb CHANGED
@@ -48,8 +48,10 @@ module Teems
48
48
  'channels' => Commands::Channels,
49
49
  'chats' => Commands::Chats,
50
50
  'meeting' => Commands::Meeting,
51
+ 'meet' => Commands::Meet,
51
52
  'transcripts' => Commands::Transcripts,
52
53
  'messages' => Commands::Messages,
54
+ 'mail' => Commands::Mail,
53
55
  'sync' => Commands::Sync,
54
56
  'who' => Commands::Who,
55
57
  'org' => Commands::Org,
@@ -14,6 +14,11 @@ module Teems
14
14
  def puts(message = '') = @options[:quiet] || output.puts(message)
15
15
  def print(msg) = @options[:quiet] || output.print(msg)
16
16
  def output_json(data) = output.puts(JSON.pretty_generate(data))
17
+
18
+ def api_error_result(prefix, err)
19
+ error("#{prefix}: #{err.message}")
20
+ 1
21
+ end
17
22
  end
18
23
 
19
24
  # Option validation helpers for CLI commands
@@ -127,25 +132,44 @@ module Teems
127
132
 
128
133
  # Execute a block with automatic token refresh on 401
129
134
  # Usage: with_token_refresh { runner.messages_api.chat_messages(chat_id: id) }
130
- def with_token_refresh
135
+ def with_token_refresh(&)
131
136
  yield
132
137
  rescue ApiError => e
133
- raise unless e.unauthorized? || e.message.include?('expired')
138
+ raise unless token_expired?(e)
134
139
 
135
- attempt_token_refresh
136
- yield
140
+ attempt_token_refresh == :refreshed ? retry_with_login(&) : yield
137
141
  end
138
142
 
143
+ def token_expired?(error) = error.unauthorized? || error.message.include?('expired')
144
+
145
+ # :refreshed, or :logged_in when only a new login worked; raises when neither did
139
146
  def attempt_token_refresh
140
147
  debug('Token expired, attempting refresh...')
141
- return debug('Token refreshed, retrying request...') if runner.refresh_tokens
148
+ return :refreshed.tap { debug('Token refreshed, retrying request...') } if runner.refresh_tokens
142
149
 
143
150
  debug('Token refresh failed, re-authenticating...')
144
151
  raise ApiError, 'Re-authentication failed' unless auto_login
152
+
153
+ :logged_in
145
154
  end
146
155
 
147
- def auto_login
148
- tokens = runner.token_extractor.extract
156
+ # A refresh can succeed without renewing the Graph token (the authsvc fallback renews
157
+ # only the skype token), so a request still rejected after one gets a login attempt
158
+ def retry_with_login
159
+ yield
160
+ rescue ApiError => e
161
+ raise unless token_expired?(e) && auto_login
162
+
163
+ debug('Logged in again, retrying request...')
164
+ yield
165
+ end
166
+
167
+ # Under --no-login (for commands that take it) only the silent headless login is tried,
168
+ # never Safari, so servers, launchd and cron jobs never open a window
169
+ def auto_login = save_extracted_tokens(&(@options[:no_login] ? :extract_silently : :extract))
170
+
171
+ def save_extracted_tokens
172
+ tokens = yield(runner.token_extractor)
149
173
  return unless tokens&.[](:auth_token) && tokens[:skype_token]
150
174
 
151
175
  runner.token_store.save(**tokens)