teems 0.3.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +77 -0
- data/bin/teems +8 -1
- data/lib/teems/api/calendar.rb +45 -14
- data/lib/teems/api/client.rb +19 -4
- data/lib/teems/api/mail.rb +132 -0
- data/lib/teems/api/mail_actions.rb +22 -0
- data/lib/teems/api/mail_rules.rb +24 -0
- data/lib/teems/cli.rb +2 -0
- data/lib/teems/commands/base.rb +31 -7
- data/lib/teems/commands/cal.rb +109 -28
- data/lib/teems/commands/cal_ics.rb +47 -0
- data/lib/teems/commands/confirmation.rb +19 -0
- data/lib/teems/commands/help.rb +4 -0
- data/lib/teems/commands/mail.rb +207 -0
- data/lib/teems/commands/mail_decrypting.rb +46 -0
- data/lib/teems/commands/mail_help.rb +85 -0
- data/lib/teems/commands/mail_rules.rb +201 -0
- data/lib/teems/commands/mail_sync.rb +92 -0
- data/lib/teems/commands/mail_thread.rb +87 -0
- data/lib/teems/commands/mail_triage.rb +241 -0
- data/lib/teems/commands/meet.rb +159 -0
- data/lib/teems/commands/sync.rb +15 -8
- data/lib/teems/commands/sync_folders.rb +118 -0
- data/lib/teems/formatters/format_utils.rb +23 -2
- data/lib/teems/formatters/html_entities.rb +49 -0
- data/lib/teems/formatters/html_text.rb +181 -0
- data/lib/teems/formatters/ics_format.rb +112 -0
- data/lib/teems/formatters/link_cleaner.rb +91 -0
- data/lib/teems/formatters/link_collector.rb +70 -0
- data/lib/teems/formatters/mail_formatter.rb +58 -0
- data/lib/teems/formatters/mail_markdown.rb +70 -0
- data/lib/teems/formatters/mail_rule_formatter.rb +45 -0
- data/lib/teems/formatters/mail_rule_text.rb +67 -0
- data/lib/teems/formatters/output.rb +45 -8
- data/lib/teems/models/chat.rb +19 -5
- data/lib/teems/models/event.rb +1 -23
- data/lib/teems/models/event_display.rb +40 -0
- data/lib/teems/models/ics_event.rb +60 -0
- data/lib/teems/models/mail_conversation.rb +62 -0
- data/lib/teems/models/mail_encryption.rb +41 -0
- data/lib/teems/models/mail_folder.rb +44 -0
- data/lib/teems/models/mail_message.rb +152 -0
- data/lib/teems/models/mail_rule.rb +38 -0
- data/lib/teems/runner.rb +6 -0
- data/lib/teems/services/api_client.rb +23 -11
- data/lib/teems/services/browser.rb +17 -0
- data/lib/teems/services/clipboard.rb +17 -0
- data/lib/teems/services/mail_folders.rb +76 -0
- data/lib/teems/services/mail_id_cache.rb +51 -0
- data/lib/teems/services/mail_legacy_files.rb +96 -0
- data/lib/teems/services/mail_sync_attachments.rb +103 -0
- data/lib/teems/services/mail_sync_engine.rb +98 -0
- data/lib/teems/services/mail_sync_folder.rb +142 -0
- data/lib/teems/services/mail_sync_store.rb +116 -0
- data/lib/teems/services/smime_content.rb +36 -0
- data/lib/teems/services/smime_decryptor.rb +36 -0
- data/lib/teems/services/sync_dir_naming.rb +16 -10
- data/lib/teems/services/sync_dir_ownership.rb +111 -0
- data/lib/teems/services/sync_dir_planning.rb +130 -0
- data/lib/teems/services/sync_store.rb +20 -43
- data/lib/teems/services/token_extractor.rb +4 -0
- data/lib/teems/support/control_chars.rb +19 -0
- data/lib/teems/support/mime_header.rb +61 -0
- data/lib/teems/support/mime_part.rb +97 -0
- data/lib/teems/support/signed_data.rb +23 -0
- data/lib/teems/support/time_parsing.rb +9 -1
- data/lib/teems/version.rb +1 -1
- data/lib/teems.rb +63 -2
- metadata +48 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85e10318e8ea5814ce7caeec5186ec86589ac7cbc6a1400e5174cdbd7a4112af
|
|
4
|
+
data.tar.gz: 0bab10db1f970e7d45ca782ae8ac9d52ef866b5691be6105c2be0d0f89c251be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79a9bbeb61fa5b1dd8f7fe7ce73d300694bdc53fdf263c64c067ae78a6e4d8fa530e4b518f86b1c7d2af9a401c6a4f85a6f16ab8f94eb9f53f2c73273fbbc56e
|
|
7
|
+
data.tar.gz: 6fb30e2bf57862725981303e3818448e666c6aa1a0ecc0e1e81063e818d401ef010adcf45dfd575fbd600a9add0356b02552bd063d01dce8d5507e16390a939a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
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
|
+
|
|
3
70
|
## [0.3.3] - 2026-10-02
|
|
4
71
|
|
|
5
72
|
### Added
|
data/README.md
CHANGED
|
@@ -47,9 +47,86 @@ 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
|
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(
|
|
14
|
+
exit Teems::CLI.new(args).run
|
data/lib/teems/api/calendar.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
76
|
-
|
|
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
|
-
|
|
82
|
-
end
|
|
107
|
+
"/v1.0/me/calendars/#{URI.encode_www_form_component(calendar['id'])}/calendarView"
|
|
83
108
|
end
|
|
84
109
|
|
|
85
|
-
def
|
|
86
|
-
|
|
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
|
|
90
|
-
|
|
120
|
+
def timezone_header(timezone)
|
|
121
|
+
{ 'Prefer' => "outlook.timezone=\"#{timezone}\"" }
|
|
91
122
|
end
|
|
92
123
|
end
|
|
93
124
|
end
|
data/lib/teems/api/client.rb
CHANGED
|
@@ -19,12 +19,13 @@ module Teems
|
|
|
19
19
|
@api.get(endpoint, path, account: @account, params: params, headers: headers)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
27
|
-
@api.patch(endpoint, path, account: @account,
|
|
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,
|
data/lib/teems/commands/base.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
148
|
-
|
|
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)
|