test(integ): gmail + email targets with attachment coverage, mail backend fixes (#564)

* test(integ): gmail + email targets, attachment coverage, mail backend fixes

* feat(mail): gws gmail official syntax + email commands with himalaya aliases

Gmail follows the official Google Workspace CLI: gws gmail +send/+reply/
+reply-all/+forward/+triage/+read helpers plus a raw gws gmail users ...
Discovery passthrough. Drops the bespoke delete command (use trash).

Email exposes canonical email list/read/send/reply/forward commands, with
himalaya-style aliases dispatching to the same handlers via a shared
add_aliases/withAliases helper. reply-all folds into email reply --all.

Prompts, docs, integ cases, and unit tests updated on both hosts.

* refactor(mail): himalaya email commands (drop email aliases)

Email commands use the himalaya CLI grammar as their canonical names:
himalaya envelope list, himalaya message read/send/reply/forward. The
earlier email* names and the add_aliases/withAliases alias helper are
removed. Docs note that send/reply/forward require a write-mode mount.
This commit is contained in:
Zecheng Zhang
2026-07-19 05:11:12 -07:00
committed by GitHub
parent db35b4888f
commit ccc1cb2b84
81 changed files with 2486 additions and 589 deletions
+20
View File
@@ -326,6 +326,18 @@ jobs:
--health-interval 10s
--health-timeout 5s
--health-retries 5
greenmail:
image: greenmail/standalone:2.1.3
ports:
- 3025:3025
- 3143:3143
- 8080:8080
env:
GREENMAIL_OPTS: >-
-Dgreenmail.setup.test.all
-Dgreenmail.users=integ:secret@example.com
-Dgreenmail.users.login=email
-Dgreenmail.hostname=0.0.0.0
mongodb:
image: mongo:8
ports:
@@ -337,6 +349,7 @@ jobs:
--health-retries 5
env:
REDIS_URL: redis://localhost:6379/0
EMAIL_HOST: localhost
MONGODB_URI: mongodb://localhost:27017
steps:
- uses: actions/checkout@v7
@@ -425,6 +438,13 @@ jobs:
cat /tmp/hfsrv.log
echo "HF_ENDPOINT=http://127.0.0.1:5099" >> "$GITHUB_ENV"
- name: Wait for GreenMail
run: |
for i in $(seq 1 30); do
curl -sf -X POST http://localhost:8080/api/service/reset && break
sleep 2
done
- name: Start fake Box API for the typescript host
run: |
nohup ./python/.venv/bin/python integ/server/box_server.py --port 5096 > /tmp/boxsrv.log 2>&1 &
+35 -40
View File
@@ -174,12 +174,12 @@ async def main():
print(await r.stdout_str())
# Triage unread messages
r = await ws.execute("email-triage --folder INBOX --unseen --max 5")
r = await ws.execute("himalaya envelope list --folder INBOX --unseen --max 5")
print(await r.stdout_str())
# Send an email
r = await ws.execute(
'email-send --to "user@example.com"'
'himalaya message send --to "user@example.com"'
' --subject "Hello from MIRAGE"'
' --body "This email was sent via the MIRAGE email resource."')
print(await r.stdout_str())
@@ -200,8 +200,8 @@ ls /email/INBOX/2026-04-14/
# -> Meeting_Notes__12345.email.json <- uid = 12345
# Read a message then reply
email-read --uid 12345 --folder INBOX
email-reply --uid 12345 --folder INBOX --body "Thanks for the notes"
himalaya message read --uid 12345 --folder INBOX
himalaya message reply --uid 12345 --folder INBOX --body "Thanks for the notes"
```
## Shell Commands
@@ -224,14 +224,22 @@ Standard commands available on the mounted email tree:
| `realpath` | Resolve path to absolute form |
| `nl` | Number lines of output |
Resource-specific commands:
Resource-specific commands follow the
[himalaya](https://github.com/pimalaya/himalaya) CLI command structure
(`himalaya envelope ...` and `himalaya message ...`). Mirage keeps the
message body as files in the tree, so these commands take `--uid` and
`--folder` flags and return JSON rather than opening an interactive editor.
### `email-send`
The write commands (`send`, `reply`, `forward`) require the email mount to
be mounted with write access; on a read-only mount they are rejected. The
read commands (`list`, `read`) always work.
Send a new email.
### `himalaya message send`
Send a new email. Requires write access.
```bash
email-send --to "user@example.com" --subject "Hello" --body "Hi there"
himalaya message send --to "user@example.com" --subject "Hello" --body "Hi there"
```
| Option | Required | Description |
@@ -242,44 +250,31 @@ email-send --to "user@example.com" --subject "Hello" --body "Hi there"
Returns the sent message status JSON.
### `email-reply`
### `himalaya message reply`
Reply to a message.
Reply to a message. Pass `--all` to reply to every recipient (To and CC).
Requires write access.
```bash
email-reply --uid 12345 --folder INBOX --body "Thanks for the update"
himalaya message reply --uid 12345 --folder INBOX --body "Thanks for the update"
himalaya message reply --uid 12345 --folder INBOX --body "Acknowledged" --all
```
| Option | Required | Description |
| ---------- | -------- | ---------------------------- |
| `--uid` | yes | Message UID |
| `--folder` | no | IMAP folder (default: INBOX) |
| `--body` | yes | Reply body text |
| Option | Required | Description |
| ---------- | -------- | --------------------------------- |
| `--uid` | yes | Message UID |
| `--folder` | no | IMAP folder (default: INBOX) |
| `--body` | yes | Reply body text |
| `--all` | no | Reply to all recipients (To + CC) |
Returns the sent reply JSON.
### `email-reply-all`
### `himalaya message forward`
Reply-all to a message.
Forward a message to another recipient. Requires write access.
```bash
email-reply-all --uid 12345 --folder INBOX --body "Acknowledged by the team"
```
| Option | Required | Description |
| ---------- | -------- | ---------------------------- |
| `--uid` | yes | Message UID |
| `--folder` | no | IMAP folder (default: INBOX) |
| `--body` | yes | Reply body text |
Returns the sent reply JSON.
### `email-forward`
Forward a message to another recipient.
```bash
email-forward --uid 12345 --folder INBOX --to "colleague@example.com"
himalaya message forward --uid 12345 --folder INBOX --to "colleague@example.com"
```
| Option | Required | Description |
@@ -290,12 +285,12 @@ email-forward --uid 12345 --folder INBOX --to "colleague@example.com"
Returns the forwarded message JSON.
### `email-triage`
### `himalaya envelope list`
Search and triage emails.
List and triage messages in a folder.
```bash
email-triage --folder INBOX --unseen --max 10
himalaya envelope list --folder INBOX --unseen --max 10
```
| Option | Required | Description |
@@ -306,12 +301,12 @@ email-triage --folder INBOX --unseen --max 10
Returns matching messages as JSON.
### `email-read`
### `himalaya message read`
Read a message by its UID.
```bash
email-read --uid 12345 --folder INBOX
himalaya message read --uid 12345 --folder INBOX
```
| Option | Required | Description |
+2 -1
View File
@@ -278,7 +278,8 @@ wc-parquet /gdrive/data/sales.parquet
Google Drive registers the `gws` command family so Google-native
files can be created and updated from the Drive mount. The syntax
mirrors the Google Workspace CLI:
mirrors the official
[Google Workspace CLI](https://github.com/googleworkspace/cli):
```bash
gws <service> <resource> <method> [--params JSON] [--json JSON] # API passthrough
+40 -17
View File
@@ -159,12 +159,12 @@ async def main():
print(await r.stdout_str())
# Triage unread messages
r = await ws.execute('gws-gmail-triage --query "is:unread" --max 5')
r = await ws.execute('gws gmail +triage --query "is:unread" --max 5')
print(await r.stdout_str())
# Send an email
r = await ws.execute(
'gws-gmail-send --to "user@example.com"'
'gws gmail +send --to "user@example.com"'
' --subject "Hello from MIRAGE"'
' --body "This email was sent via the MIRAGE Gmail resource."')
print(await r.stdout_str())
@@ -192,8 +192,8 @@ basename /gmail/INBOX/2026-04-12/Meeting_Notes__msg123.gmail.json .gmail.json
# The part after "__" is the message ID: msg123
# Read a message then reply
gws-gmail-read --id msg123
gws-gmail-reply --message-id msg123 --body "Thanks for the notes"
gws gmail +read --id msg123
gws gmail +reply --message-id msg123 --body "Thanks for the notes"
```
## Working with Large Labels
@@ -244,14 +244,17 @@ Standard commands available on the mounted Gmail tree:
| `realpath` | Resolve path to absolute form |
| `nl` | Number lines of output |
Resource-specific commands:
Resource-specific commands follow the official
[Google Workspace CLI](https://github.com/googleworkspace/cli) syntax:
ergonomic `gws gmail +<helper>` commands for common tasks, plus a raw
`gws gmail <resource> <method>` passthrough for any Gmail API method.
### `gws-gmail-send`
### `gws gmail +send`
Send a new email.
```bash
gws-gmail-send --to "user@example.com" --subject "Hello" --body "Hi there"
gws gmail +send --to "user@example.com" --subject "Hello" --body "Hi there"
```
| Option | Required | Description |
@@ -262,12 +265,12 @@ gws-gmail-send --to "user@example.com" --subject "Hello" --body "Hi there"
Returns the sent message JSON.
### `gws-gmail-reply`
### `gws gmail +reply`
Reply to a message.
```bash
gws-gmail-reply --message-id msg123 --body "Thanks for the update"
gws gmail +reply --message-id msg123 --body "Thanks for the update"
```
| Option | Required | Description |
@@ -277,12 +280,12 @@ gws-gmail-reply --message-id msg123 --body "Thanks for the update"
Returns the sent reply JSON.
### `gws-gmail-reply-all`
### `gws gmail +reply-all`
Reply-all to a message.
```bash
gws-gmail-reply-all --message-id msg123 --body "Acknowledged by the team"
gws gmail +reply-all --message-id msg123 --body "Acknowledged by the team"
```
| Option | Required | Description |
@@ -292,12 +295,12 @@ gws-gmail-reply-all --message-id msg123 --body "Acknowledged by the team"
Returns the sent reply JSON.
### `gws-gmail-forward`
### `gws gmail +forward`
Forward a message to another recipient.
```bash
gws-gmail-forward --message-id msg123 --to "colleague@example.com"
gws gmail +forward --message-id msg123 --to "colleague@example.com"
```
| Option | Required | Description |
@@ -307,12 +310,12 @@ gws-gmail-forward --message-id msg123 --to "colleague@example.com"
Returns the forwarded message JSON.
### `gws-gmail-triage`
### `gws gmail +triage`
Search and triage emails using Gmail query syntax.
```bash
gws-gmail-triage --query "is:unread" --max 10
gws gmail +triage --query "is:unread" --max 10
```
| Option | Required | Description |
@@ -322,12 +325,12 @@ gws-gmail-triage --query "is:unread" --max 10
Returns matching messages as JSON.
### `gws-gmail-read`
### `gws gmail +read`
Read a message by its ID.
```bash
gws-gmail-read --id msg123
gws gmail +read --id msg123
```
| Option | Required | Description |
@@ -335,3 +338,23 @@ gws-gmail-read --id msg123
| `--id` | yes | Gmail message ID |
Returns the full message JSON.
### Raw API passthrough
Every Gmail Discovery method is also reachable directly. `--params`
carries the path and query parameters (JSON), `--json` the request body,
and the output is the raw API response. The user id is always `me`.
```bash
# List labels
gws gmail users labels list --params '{"userId": "me"}'
# List message ids matching a query
gws gmail users messages list --params '{"userId": "me", "q": "is:unread"}'
# Fetch one message
gws gmail users messages get --params '{"userId": "me", "id": "msg123"}'
# Move a message to Trash
gws gmail users messages trash --params '{"userId": "me", "id": "msg123"}'
```
+53
View File
@@ -0,0 +1,53 @@
[
{
"folder": "INBOX",
"from": "lila@example.com",
"to": "integ@example.com",
"subject": "Q2 Budget Review",
"date": "Mon, 05 Jan 2026 09:30:00 +0000",
"body": "please find the budget attached\nfinal numbers due friday",
"attachments": [
{
"filename": "budget.csv",
"content": "amount,category\n120,travel\n80,meals\n"
},
{
"filename": "notes.txt",
"content": "travel spend trending up since march\n"
}
]
},
{
"folder": "INBOX",
"from": "marcus@example.com",
"to": "integ@example.com",
"subject": "Standup notes",
"date": "Mon, 05 Jan 2026 14:00:00 +0000",
"body": "yesterday shipped the parser\ntoday reviewing the budget forecast",
"seen": true
},
{
"folder": "INBOX",
"from": "ops@example.com",
"to": "integ@example.com",
"cc": ["marcus@example.com"],
"subject": "Server alert: worker-3",
"date": "Wed, 07 Jan 2026 03:15:00 +0000",
"body": "disk usage at 91 percent on worker-3"
},
{
"folder": "Archive",
"from": "billing@vendor.example",
"to": "integ@example.com",
"subject": "Invoice 4471",
"date": "Tue, 06 Jan 2026 08:45:00 +0000",
"body": "invoice 4471 attached, total 240 usd",
"seen": true,
"attachments": [
{
"filename": "invoice-4471.txt",
"content": "invoice 4471\ntotal: 240 usd\n"
}
]
}
]
+59
View File
@@ -0,0 +1,59 @@
[
{
"from": "lila@example.com",
"to": "integ@example.com",
"subject": "Q2 Budget Review",
"date": "Mon, 05 Jan 2026 09:30:00 +0000",
"body": "please find the budget attached\nfinal numbers due friday",
"labels": ["INBOX", "UNREAD"],
"attachments": [
{
"filename": "budget.csv",
"content": "amount,category\n120,travel\n80,meals\n"
},
{
"filename": "notes.txt",
"content": "travel spend trending up since march\n"
}
]
},
{
"from": "marcus@example.com",
"to": "integ@example.com",
"subject": "Standup notes",
"date": "Mon, 05 Jan 2026 14:00:00 +0000",
"body": "yesterday shipped the parser\ntoday reviewing the budget forecast",
"labels": ["INBOX"]
},
{
"from": "ops@example.com",
"to": "integ@example.com",
"cc": ["marcus@example.com"],
"subject": "Server alert: worker-3",
"date": "Wed, 07 Jan 2026 03:15:00 +0000",
"body": "disk usage at 91 percent on worker-3",
"labels": ["INBOX", "UNREAD"]
},
{
"from": "integ@example.com",
"to": "lila@example.com",
"subject": "Re: onboarding",
"date": "Tue, 06 Jan 2026 11:00:00 +0000",
"body": "welcome aboard, the handbook is on its way",
"labels": ["SENT"]
},
{
"from": "billing@vendor.example",
"to": "integ@example.com",
"subject": "Invoice 4471",
"date": "Wed, 07 Jan 2026 08:45:00 +0000",
"body": "invoice 4471 attached, total 240 usd",
"labels": ["INBOX", "expenses"],
"attachments": [
{
"filename": "invoice-4471.txt",
"content": "invoice 4471\ntotal: 240 usd\n"
}
]
}
]
+1
View File
@@ -30,6 +30,7 @@
"@struktoai/mirage-browser": "workspace:*",
"@struktoai/mirage-core": "workspace:*",
"@struktoai/mirage-node": "workspace:*",
"imapflow": "^1.3.2",
"mongodb": "^6.0.0",
"pg": "^8.0.0",
"ssh2": "^1.16.0",
+581
View File
@@ -0,0 +1,581 @@
{
"cases": [
{
"id": "em_root_ls",
"seq": 540000,
"targets": [
"email"
],
"clear_cache": true,
"command": "ls /mail",
"expect": {
"exit": 0,
"stdout": "Archive\nINBOX\n",
"stderr": ""
}
},
{
"id": "em_folder_date_dirs",
"seq": 540001,
"targets": [
"email"
],
"command": "ls /mail/INBOX",
"expect": {
"exit": 0,
"stdout": "2026-01-05\n2026-01-07\n",
"stderr": ""
}
},
{
"id": "em_date_ls",
"seq": 540002,
"targets": [
"email"
],
"command": "ls /mail/INBOX/2026-01-05",
"expect": {
"exit": 0,
"stdout": "Q2_Budget_Review__1\nQ2_Budget_Review__1.email.json\nStandup_notes__2.email.json\n",
"stderr": ""
}
},
{
"id": "em_archive_ls",
"seq": 540003,
"targets": [
"email"
],
"command": "ls /mail/Archive/2026-01-06",
"expect": {
"exit": 0,
"stdout": "Invoice_4471__1\nInvoice_4471__1.email.json\n",
"stderr": ""
}
},
{
"id": "em_cat_headers",
"seq": 540004,
"targets": [
"email"
],
"command": "cat /mail/INBOX/2026-01-05/Q2_Budget_Review__1.email.json | jq -r '.from.email + \" \" + .subject'",
"expect": {
"exit": 0,
"stdout": "lila@example.com Q2 Budget Review\n",
"stderr": ""
}
},
{
"id": "em_cat_body",
"seq": 540005,
"targets": [
"email"
],
"command": "cat /mail/INBOX/2026-01-05/Q2_Budget_Review__1.email.json | jq -r .body_text",
"expect": {
"exit": 0,
"stdout": "please find the budget attached\nfinal numbers due friday\n",
"stderr": ""
}
},
{
"id": "em_flags_unseen_preserved",
"seq": 540006,
"targets": [
"email"
],
"command": "cat /mail/INBOX/2026-01-05/Q2_Budget_Review__1.email.json | jq -r '.flags | length'",
"expect": {
"exit": 0,
"stdout": "0\n",
"stderr": ""
}
},
{
"id": "em_flags_seen",
"seq": 540007,
"targets": [
"email"
],
"command": "cat /mail/INBOX/2026-01-05/Standup_notes__2.email.json | jq -r '.flags[0]'",
"expect": {
"exit": 0,
"stdout": "\\Seen\n",
"stderr": ""
}
},
{
"id": "em_attachment_meta",
"seq": 540008,
"targets": [
"email"
],
"command": "cat /mail/INBOX/2026-01-05/Q2_Budget_Review__1.email.json | jq -r '.attachments[].filename'",
"expect": {
"exit": 0,
"stdout": "budget.csv\nnotes.txt\n",
"stderr": ""
}
},
{
"id": "em_attachment_dir_ls",
"seq": 540009,
"targets": [
"email"
],
"command": "ls /mail/INBOX/2026-01-05/Q2_Budget_Review__1",
"expect": {
"exit": 0,
"stdout": "budget.csv\nnotes.txt\n",
"stderr": ""
}
},
{
"id": "em_attachment_cat",
"seq": 540010,
"targets": [
"email"
],
"command": "cat /mail/INBOX/2026-01-05/Q2_Budget_Review__1/budget.csv",
"expect": {
"exit": 0,
"stdout": "amount,category\n120,travel\n80,meals\n",
"stderr": ""
}
},
{
"id": "em_attachment_wc",
"seq": 540011,
"targets": [
"email"
],
"command": "wc -c < /mail/INBOX/2026-01-05/Q2_Budget_Review__1/notes.txt",
"expect": {
"exit": 0,
"stdout": "37\n",
"stderr": ""
}
},
{
"id": "em_grep_message",
"seq": 540012,
"targets": [
"email"
],
"command": "grep -c budget /mail/INBOX/2026-01-05/Q2_Budget_Review__1.email.json",
"expect": {
"exit": 0,
"stdout": "1\n",
"stderr": ""
}
},
{
"id": "em_grep_files_only",
"seq": 540013,
"targets": [
"email"
],
"command": "grep -l friday /mail/INBOX/2026-01-05/*.email.json",
"expect": {
"exit": 0,
"stdout": "/mail/INBOX/2026-01-05/Q2_Budget_Review__1.email.json\n",
"stderr": ""
}
},
{
"id": "em_grep_folder_recursive",
"seq": 540014,
"targets": [
"email"
],
"command": "grep -r 'worker-3' /mail/INBOX | sort",
"expect": {
"exit": 0,
"stdout": "/mail/INBOX/2026-01-07/Server_alert_worker-3__3.email.json:{\"from\":{\"name\":\"\",\"email\":\"ops@example.com\"},\"to\":[{\"name\":\"\",\"email\":\"integ@example.com\"}],\"cc\":[{\"name\":\"\",\"email\":\"marcus@example.com\"}],\"subject\":\"Server alert: worker-3\",\"date\":\"Wed, 07 Jan 2026 03:15:00 +0000\",\"body_text\":\"disk usage at 91 percent on worker-3\",\"body_html\":\"\",\"snippet\":\"disk usage at 91 percent on worker-3\",\"message_id\":\"\",\"in_reply_to\":null,\"references\":[],\"has_attachments\":false,\"attachments\":[],\"uid\":\"3\",\"flags\":[]}\n",
"stderr": ""
}
},
{
"id": "em_grep_root_recursive",
"seq": 540015,
"targets": [
"email"
],
"command": "grep -ri 'invoice 4471' /mail | sort",
"expect": {
"exit": 0,
"stdout": "/mail/Archive/2026-01-06/Invoice_4471__1.email.json:{\"from\":{\"name\":\"\",\"email\":\"billing@vendor.example\"},\"to\":[{\"name\":\"\",\"email\":\"integ@example.com\"}],\"cc\":[],\"subject\":\"Invoice 4471\",\"date\":\"Tue, 06 Jan 2026 08:45:00 +0000\",\"body_text\":\"invoice 4471 attached, total 240 usd\",\"body_html\":\"\",\"snippet\":\"invoice 4471 attached, total 240 usd\",\"message_id\":\"\",\"in_reply_to\":null,\"references\":[],\"has_attachments\":true,\"attachments\":[{\"filename\":\"invoice-4471.txt\",\"content_type\":\"text/plain\",\"size\":28}],\"uid\":\"1\",\"flags\":[\"\\\\Seen\"]}\n/mail/Archive/2026-01-06/Invoice_4471__1/invoice-4471.txt:invoice 4471\n",
"stderr": ""
}
},
{
"id": "em_find_messages",
"seq": 540016,
"targets": [
"email"
],
"command": "find /mail -name '*.email.json' | wc -l",
"expect": {
"exit": 0,
"stdout": "4\n",
"stderr": ""
}
},
{
"id": "em_find_attachment",
"seq": 540017,
"targets": [
"email"
],
"command": "find /mail/INBOX -name '*.csv'",
"expect": {
"exit": 0,
"stdout": "/mail/INBOX/2026-01-05/Q2_Budget_Review__1/budget.csv\n",
"stderr": ""
}
},
{
"id": "em_rg_folder",
"seq": 540018,
"targets": [
"email"
],
"command": "rg 'forecast' /mail/INBOX",
"expect": {
"exit": 0,
"stdout": "/mail/INBOX/2026-01-05/Standup_notes__2.email.json:{\"from\":{\"name\":\"\",\"email\":\"marcus@example.com\"},\"to\":[{\"name\":\"\",\"email\":\"integ@example.com\"}],\"cc\":[],\"subject\":\"Standup notes\",\"date\":\"Mon, 05 Jan 2026 14:00:00 +0000\",\"body_text\":\"yesterday shipped the parser\\ntoday reviewing the budget forecast\",\"body_html\":\"\",\"snippet\":\"yesterday shipped the parser\\ntoday reviewing the budget forecast\",\"message_id\":\"\",\"in_reply_to\":null,\"references\":[],\"has_attachments\":false,\"attachments\":[],\"uid\":\"2\",\"flags\":[\"\\\\Seen\"]}\n",
"stderr": ""
}
},
{
"id": "em_triage_unseen",
"seq": 540019,
"targets": [
"email"
],
"command": "himalaya envelope list --unseen | jq -r '.[].subject'",
"expect": {
"exit": 0,
"stdout": "Q2 Budget Review\nServer alert: worker-3\n",
"stderr": ""
}
},
{
"id": "em_triage_from",
"seq": 540020,
"targets": [
"email"
],
"command": "himalaya envelope list --from billing@vendor.example --folder Archive | jq -r '.[].uid'",
"expect": {
"exit": 0,
"stdout": "1\n",
"stderr": ""
}
},
{
"id": "em_read_chain",
"seq": 540021,
"targets": [
"email"
],
"command": "U=$(cat /mail/Archive/2026-01-06/Invoice_4471__1.email.json | jq -r .uid) && himalaya message read --folder Archive --uid $U | jq -r .snippet",
"expect": {
"exit": 0,
"stdout": "invoice 4471 attached, total 240 usd\n",
"stderr": ""
}
},
{
"id": "em_send",
"seq": 540022,
"targets": [
"email"
],
"command": "himalaya message send --to integ@example.com --subject 'Ship update' --body 'departing tuesday'",
"expect": {
"exit": 0,
"stdout": "{\"status\":\"sent\",\"to\":\"integ@example.com\",\"subject\":\"Ship update\"}",
"stderr": ""
}
},
{
"id": "em_send_visible",
"seq": 540023,
"targets": [
"email"
],
"clear_cache": true,
"command": "find /mail/INBOX -name 'Ship_update*' | wc -l",
"expect": {
"exit": 0,
"stdout": "1\n",
"stderr": ""
}
},
{
"id": "em_reply",
"seq": 540024,
"targets": [
"email"
],
"clear_cache": true,
"command": "U=$(cat /mail/INBOX/2026-01-05/Q2_Budget_Review__1.email.json | jq -r .uid) && himalaya message reply --folder INBOX --uid $U --body 'numbers look good' | jq -r .subject",
"expect": {
"exit": 0,
"stdout": "Re: Q2 Budget Review\n",
"stderr": ""
}
},
{
"id": "em_forward",
"seq": 540025,
"targets": [
"email"
],
"clear_cache": true,
"command": "U=$(cat /mail/Archive/2026-01-06/Invoice_4471__1.email.json | jq -r .uid) && himalaya message forward --folder Archive --uid $U --to integ@example.com | jq -r .status",
"expect": {
"exit": 0,
"stdout": "sent\n",
"stderr": ""
}
},
{
"id": "em_forward_visible",
"seq": 540026,
"targets": [
"email"
],
"clear_cache": true,
"command": "find /mail/INBOX -name 'Fwd_*' | wc -l",
"expect": {
"exit": 0,
"stdout": "1\n",
"stderr": ""
}
},
{
"id": "em_att_head",
"seq": 540100,
"targets": [
"email"
],
"command": "head -1 /mail/INBOX/2026-01-05/Q2_Budget_Review__1/budget.csv",
"expect": {
"exit": 0,
"stdout": "amount,category\n",
"stderr": ""
}
},
{
"id": "em_att_tail",
"seq": 540101,
"targets": [
"email"
],
"command": "tail -1 /mail/INBOX/2026-01-05/Q2_Budget_Review__1/budget.csv",
"expect": {
"exit": 0,
"stdout": "80,meals\n",
"stderr": ""
}
},
{
"id": "em_att_wc_full",
"seq": 540102,
"targets": [
"email"
],
"command": "wc /mail/INBOX/2026-01-05/Q2_Budget_Review__1/budget.csv",
"expect": {
"exit": 0,
"stdout": " 3 3 36 /mail/INBOX/2026-01-05/Q2_Budget_Review__1/budget.csv\n",
"stderr": ""
}
},
{
"id": "em_att_grep_n",
"seq": 540103,
"targets": [
"email"
],
"command": "grep -n travel /mail/INBOX/2026-01-05/Q2_Budget_Review__1/budget.csv",
"expect": {
"exit": 0,
"stdout": "2:120,travel\n",
"stderr": ""
}
},
{
"id": "em_att_cut",
"seq": 540104,
"targets": [
"email"
],
"command": "cut -d, -f2 /mail/INBOX/2026-01-05/Q2_Budget_Review__1/budget.csv",
"expect": {
"exit": 0,
"stdout": "category\ntravel\nmeals\n",
"stderr": ""
}
},
{
"id": "em_att_sort",
"seq": 540105,
"targets": [
"email"
],
"command": "sort /mail/INBOX/2026-01-05/Q2_Budget_Review__1/budget.csv",
"expect": {
"exit": 0,
"stdout": "120,travel\n80,meals\namount,category\n",
"stderr": ""
}
},
{
"id": "em_att_file_txt",
"seq": 540106,
"targets": [
"email"
],
"command": "file /mail/INBOX/2026-01-05/Q2_Budget_Review__1/notes.txt",
"expect": {
"exit": 0,
"stdout": "/mail/INBOX/2026-01-05/Q2_Budget_Review__1/notes.txt: text\n",
"stderr": ""
}
},
{
"id": "em_att_file_csv",
"seq": 540107,
"targets": [
"email"
],
"command": "file /mail/INBOX/2026-01-05/Q2_Budget_Review__1/budget.csv",
"expect": {
"exit": 0,
"stdout": "/mail/INBOX/2026-01-05/Q2_Budget_Review__1/budget.csv: csv\n",
"stderr": ""
}
},
{
"id": "em_att_du",
"seq": 540108,
"targets": [
"email"
],
"command": "du -h /mail/INBOX/2026-01-05/Q2_Budget_Review__1/budget.csv",
"expect": {
"exit": 0,
"stdout": "36B\t/mail/INBOX/2026-01-05/Q2_Budget_Review__1/budget.csv\n",
"stderr": ""
}
},
{
"id": "em_att_ls_long",
"seq": 540109,
"targets": [
"email"
],
"command": "ls -l /mail/INBOX/2026-01-05/Q2_Budget_Review__1",
"expect": {
"exit": 0,
"stdout": "-rw-r--r-- 1 user user 36 Jan 1 00:00 budget.csv\n-rw-r--r-- 1 user user 37 Jan 1 00:00 notes.txt\n",
"stderr": ""
}
},
{
"id": "em_att_md5",
"seq": 540110,
"targets": [
"email"
],
"command": "md5 /mail/INBOX/2026-01-05/Q2_Budget_Review__1/notes.txt",
"expect": {
"exit": 0,
"stdout": "46481447a30e753660bd2101f8d3ee8a /mail/INBOX/2026-01-05/Q2_Budget_Review__1/notes.txt\n",
"stderr": ""
}
},
{
"id": "em_att_multi_cat",
"seq": 540111,
"targets": [
"email"
],
"command": "cat /mail/INBOX/2026-01-05/Q2_Budget_Review__1/budget.csv /mail/INBOX/2026-01-05/Q2_Budget_Review__1/notes.txt | wc -l",
"expect": {
"exit": 0,
"stdout": "4\n",
"stderr": ""
}
},
{
"id": "em_att_readonly_cp",
"seq": 540112,
"targets": [
"email"
],
"command": "cp /mail/INBOX/2026-01-05/Q2_Budget_Review__1/notes.txt /mail/x.txt",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "cp: /mail/INBOX/2026-01-05/Q2_Budget_Review__1/notes.txt: Operation not supported\n"
}
},
{
"id": "em_x_save_attachment",
"seq": 540200,
"targets": [
"email"
],
"command": "cp /mail/INBOX/2026-01-05/Q2_Budget_Review__1/notes.txt /scratch/notes.txt && cat /scratch/notes.txt",
"expect": {
"exit": 0,
"stdout": "travel spend trending up since march\n",
"stderr": ""
}
},
{
"id": "em_x_save_att_dir",
"seq": 540201,
"targets": [
"email"
],
"command": "cp -r /mail/INBOX/2026-01-05/Q2_Budget_Review__1 /scratch/saved && ls /scratch/saved",
"expect": {
"exit": 0,
"stdout": "budget.csv\nnotes.txt\n",
"stderr": ""
}
},
{
"id": "em_x_saved_grep",
"seq": 540202,
"targets": [
"email"
],
"command": "grep -n travel /scratch/saved/budget.csv",
"expect": {
"exit": 0,
"stdout": "2:120,travel\n",
"stderr": ""
}
},
{
"id": "em_x_save_message",
"seq": 540203,
"targets": [
"email"
],
"command": "cp /mail/INBOX/2026-01-05/Q2_Budget_Review__1.email.json /scratch/msg.json && cat /scratch/msg.json | jq -r .subject",
"expect": {
"exit": 0,
"stdout": "Q2 Budget Review\n",
"stderr": ""
}
}
]
}
+609
View File
@@ -0,0 +1,609 @@
{
"cases": [
{
"id": "gm_root_ls",
"seq": 530000,
"targets": [
"gmail"
],
"clear_cache": true,
"command": "ls /mail",
"expect": {
"exit": 0,
"stdout": "INBOX\nSENT\nTRASH\nUNREAD\nexpenses\n",
"stderr": ""
}
},
{
"id": "gm_label_date_dirs",
"seq": 530001,
"targets": [
"gmail"
],
"command": "ls /mail/INBOX",
"expect": {
"exit": 0,
"stdout": "2026-01-05\n2026-01-07\n",
"stderr": ""
}
},
{
"id": "gm_date_ls",
"seq": 530002,
"targets": [
"gmail"
],
"command": "ls /mail/INBOX/2026-01-05",
"expect": {
"exit": 0,
"stdout": "Q2_Budget_Review__msg0001\nQ2_Budget_Review__msg0001.gmail.json\nStandup_notes__msg0002.gmail.json\n",
"stderr": ""
}
},
{
"id": "gm_sent_ls",
"seq": 530003,
"targets": [
"gmail"
],
"command": "ls /mail/SENT",
"expect": {
"exit": 0,
"stdout": "2026-01-06\n",
"stderr": ""
}
},
{
"id": "gm_custom_label_ls",
"seq": 530004,
"targets": [
"gmail"
],
"command": "ls /mail/expenses/2026-01-07",
"expect": {
"exit": 0,
"stdout": "Invoice_4471__msg0005\nInvoice_4471__msg0005.gmail.json\n",
"stderr": ""
}
},
{
"id": "gm_cat_headers",
"seq": 530005,
"targets": [
"gmail"
],
"command": "cat /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001.gmail.json | jq -r '.from.email + \" \" + .subject'",
"expect": {
"exit": 0,
"stdout": "lila@example.com Q2 Budget Review\n",
"stderr": ""
}
},
{
"id": "gm_cat_body",
"seq": 530006,
"targets": [
"gmail"
],
"command": "cat /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001.gmail.json | jq -r .body_text",
"expect": {
"exit": 0,
"stdout": "please find the budget attached\nfinal numbers due friday\n",
"stderr": ""
}
},
{
"id": "gm_cat_attachment_meta",
"seq": 530007,
"targets": [
"gmail"
],
"command": "cat /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001.gmail.json | jq -r '.attachments[].filename'",
"expect": {
"exit": 0,
"stdout": "budget.csv\nnotes.txt\n",
"stderr": ""
}
},
{
"id": "gm_grep_message",
"seq": 530008,
"targets": [
"gmail"
],
"command": "grep -c budget /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001.gmail.json",
"expect": {
"exit": 0,
"stdout": "1\n",
"stderr": ""
}
},
{
"id": "gm_grep_date_dir",
"seq": 530009,
"targets": [
"gmail"
],
"command": "grep -l friday /mail/INBOX/2026-01-05/*.gmail.json",
"expect": {
"exit": 0,
"stdout": "/mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001.gmail.json\n",
"stderr": ""
}
},
{
"id": "gm_grep_label_recursive",
"seq": 530010,
"targets": [
"gmail"
],
"command": "grep -r 'worker-3' /mail/INBOX | sort",
"expect": {
"exit": 0,
"stdout": "/mail/INBOX/2026-01-07/Server_alert_worker-3__msg0003.gmail.json:[ops@example.com] Server alert: worker-3 disk usage at 91 percent on worker-3\n",
"stderr": ""
}
},
{
"id": "gm_grep_root_recursive",
"seq": 530011,
"targets": [
"gmail"
],
"command": "grep -ri 'invoice 4471' /mail | sort",
"expect": {
"exit": 0,
"stdout": "/mail/INBOX/2026-01-07/Invoice_4471__msg0005.gmail.json:[billing@vendor.example] Invoice 4471 invoice 4471 attached, total 240 usd\n",
"stderr": ""
}
},
{
"id": "gm_attachment_dir_ls",
"seq": 530012,
"targets": [
"gmail"
],
"command": "ls /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001",
"expect": {
"exit": 0,
"stdout": "budget.csv\nnotes.txt\n",
"stderr": ""
}
},
{
"id": "gm_attachment_cat",
"seq": 530013,
"targets": [
"gmail"
],
"command": "cat /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/budget.csv",
"expect": {
"exit": 0,
"stdout": "amount,category\n120,travel\n80,meals\n",
"stderr": ""
}
},
{
"id": "gm_attachment_wc",
"seq": 530014,
"targets": [
"gmail"
],
"command": "wc -c < /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/notes.txt",
"expect": {
"exit": 0,
"stdout": "37\n",
"stderr": ""
}
},
{
"id": "gm_find_messages",
"seq": 530015,
"targets": [
"gmail"
],
"command": "find /mail -name '*.gmail.json' | wc -l",
"expect": {
"exit": 0,
"stdout": "8\n",
"stderr": ""
}
},
{
"id": "gm_find_attachment",
"seq": 530016,
"targets": [
"gmail"
],
"command": "find /mail/INBOX -name '*.csv'",
"expect": {
"exit": 0,
"stdout": "/mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/budget.csv\n",
"stderr": ""
}
},
{
"id": "gm_rg_label",
"seq": 530017,
"targets": [
"gmail"
],
"command": "rg 'forecast' /mail/INBOX",
"expect": {
"exit": 0,
"stdout": "/mail/INBOX/2026-01-05/Standup_notes__msg0002.gmail.json:[marcus@example.com] Standup notes yesterday shipped the parser today reviewing the budget forecast\n",
"stderr": ""
}
},
{
"id": "gm_triage_unread",
"seq": 530018,
"targets": [
"gmail"
],
"command": "gws gmail +triage --query is:unread | jq -r '.[].subject'",
"expect": {
"exit": 0,
"stdout": "Server alert: worker-3\nQ2 Budget Review\n",
"stderr": ""
}
},
{
"id": "gm_triage_from",
"seq": 530019,
"targets": [
"gmail"
],
"command": "gws gmail +triage --query from:billing | jq -r '.[].id'",
"expect": {
"exit": 0,
"stdout": "msg0005\n",
"stderr": ""
}
},
{
"id": "gm_read_chain",
"seq": 530020,
"targets": [
"gmail"
],
"command": "M=$(cat /mail/INBOX/2026-01-07/Invoice_4471__msg0005.gmail.json | jq -r .id) && gws gmail +read --id $M | jq -r .snippet",
"expect": {
"exit": 0,
"stdout": "invoice 4471 attached, total 240 usd\n",
"stderr": ""
}
},
{
"id": "gm_send",
"seq": 530021,
"targets": [
"gmail"
],
"command": "gws gmail +send --to lila@example.com --subject 'Ship update' --body 'departing tuesday'",
"expect": {
"exit": 0,
"stdout": "{\"id\":\"msg0006\",\"threadId\":\"msg0006\",\"labelIds\":[\"SENT\"]}",
"stderr": ""
}
},
{
"id": "gm_send_visible",
"seq": 530022,
"targets": [
"gmail"
],
"clear_cache": true,
"command": "ls /mail/SENT",
"expect": {
"exit": 0,
"stdout": "2026-01-06\n2026-02-01\n",
"stderr": ""
}
},
{
"id": "gm_reply_thread",
"seq": 530023,
"targets": [
"gmail"
],
"clear_cache": true,
"command": "M=$(cat /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001.gmail.json | jq -r .id) && gws gmail +reply --message-id $M --body 'numbers look good' | jq -r .threadId",
"expect": {
"exit": 0,
"stdout": "msg0001\n",
"stderr": ""
}
},
{
"id": "gm_forward",
"seq": 530024,
"targets": [
"gmail"
],
"clear_cache": true,
"command": "M=$(cat /mail/INBOX/2026-01-07/Invoice_4471__msg0005.gmail.json | jq -r .id) && gws gmail +forward --message-id $M --to marcus@example.com | jq -r '.labelIds[0]'",
"expect": {
"exit": 0,
"stdout": "SENT\n",
"stderr": ""
}
},
{
"id": "gm_delete",
"seq": 530025,
"targets": [
"gmail"
],
"clear_cache": true,
"command": "M=$(cat /mail/INBOX/2026-01-07/Invoice_4471__msg0005.gmail.json | jq -r .id) && gws gmail users messages trash --params \"{\\\"userId\\\": \\\"me\\\", \\\"id\\\": \\\"$M\\\"}\" | jq -r '.labelIds[-1]'",
"expect": {
"exit": 0,
"stdout": "TRASH\n",
"stderr": ""
}
},
{
"id": "gm_delete_visible",
"seq": 530026,
"targets": [
"gmail"
],
"clear_cache": true,
"command": "ls /mail/INBOX/2026-01-07",
"expect": {
"exit": 0,
"stdout": "Server_alert_worker-3__msg0003.gmail.json\n",
"stderr": ""
}
},
{
"id": "gm_trash_visible",
"seq": 530027,
"targets": [
"gmail"
],
"clear_cache": true,
"command": "ls /mail/TRASH",
"expect": {
"exit": 0,
"stdout": "2026-01-07\n",
"stderr": ""
}
},
{
"id": "gm_raw_labels_list",
"seq": 530028,
"targets": [
"gmail"
],
"command": "gws gmail users labels list --params '{\"userId\": \"me\"}' | jq -c '[.labels[].id] | contains([\"INBOX\", \"SENT\", \"TRASH\"])'",
"expect": {
"exit": 0,
"stdout": "true\n",
"stderr": ""
}
},
{
"id": "gm_att_head",
"seq": 530100,
"targets": [
"gmail"
],
"command": "head -1 /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/budget.csv",
"expect": {
"exit": 0,
"stdout": "amount,category\n",
"stderr": ""
}
},
{
"id": "gm_att_tail",
"seq": 530101,
"targets": [
"gmail"
],
"command": "tail -1 /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/budget.csv",
"expect": {
"exit": 0,
"stdout": "80,meals\n",
"stderr": ""
}
},
{
"id": "gm_att_wc_full",
"seq": 530102,
"targets": [
"gmail"
],
"command": "wc /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/budget.csv",
"expect": {
"exit": 0,
"stdout": " 3 3 36 /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/budget.csv\n",
"stderr": ""
}
},
{
"id": "gm_att_grep_n",
"seq": 530103,
"targets": [
"gmail"
],
"command": "grep -n travel /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/budget.csv",
"expect": {
"exit": 0,
"stdout": "2:120,travel\n",
"stderr": ""
}
},
{
"id": "gm_att_cut",
"seq": 530104,
"targets": [
"gmail"
],
"command": "cut -d, -f2 /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/budget.csv",
"expect": {
"exit": 0,
"stdout": "category\ntravel\nmeals\n",
"stderr": ""
}
},
{
"id": "gm_att_sort",
"seq": 530105,
"targets": [
"gmail"
],
"command": "sort /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/budget.csv",
"expect": {
"exit": 0,
"stdout": "120,travel\n80,meals\namount,category\n",
"stderr": ""
}
},
{
"id": "gm_att_file_txt",
"seq": 530106,
"targets": [
"gmail"
],
"command": "file /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/notes.txt",
"expect": {
"exit": 0,
"stdout": "/mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/notes.txt: text\n",
"stderr": ""
}
},
{
"id": "gm_att_file_csv",
"seq": 530107,
"targets": [
"gmail"
],
"command": "file /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/budget.csv",
"expect": {
"exit": 0,
"stdout": "/mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/budget.csv: csv\n",
"stderr": ""
}
},
{
"id": "gm_att_du",
"seq": 530108,
"targets": [
"gmail"
],
"command": "du -h /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/budget.csv",
"expect": {
"exit": 0,
"stdout": "36B\t/mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/budget.csv\n",
"stderr": ""
}
},
{
"id": "gm_att_ls_long",
"seq": 530109,
"targets": [
"gmail"
],
"command": "ls -l /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001",
"expect": {
"exit": 0,
"stdout": "-rw-r--r-- 1 user user 36 Jan 1 00:00 budget.csv\n-rw-r--r-- 1 user user 37 Jan 1 00:00 notes.txt\n",
"stderr": ""
}
},
{
"id": "gm_att_md5",
"seq": 530110,
"targets": [
"gmail"
],
"command": "md5 /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/notes.txt",
"expect": {
"exit": 0,
"stdout": "46481447a30e753660bd2101f8d3ee8a /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/notes.txt\n",
"stderr": ""
}
},
{
"id": "gm_att_multi_cat",
"seq": 530111,
"targets": [
"gmail"
],
"command": "cat /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/budget.csv /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/notes.txt | wc -l",
"expect": {
"exit": 0,
"stdout": "4\n",
"stderr": ""
}
},
{
"id": "gm_att_readonly_cp",
"seq": 530112,
"targets": [
"gmail"
],
"command": "cp /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/notes.txt /mail/x.txt",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "cp: /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/notes.txt: Operation not supported\n"
}
},
{
"id": "gm_x_save_attachment",
"seq": 530200,
"targets": [
"gmail"
],
"command": "cp /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001/notes.txt /scratch/notes.txt && cat /scratch/notes.txt",
"expect": {
"exit": 0,
"stdout": "travel spend trending up since march\n",
"stderr": ""
}
},
{
"id": "gm_x_save_att_dir",
"seq": 530201,
"targets": [
"gmail"
],
"command": "cp -r /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001 /scratch/saved && ls /scratch/saved",
"expect": {
"exit": 0,
"stdout": "budget.csv\nnotes.txt\n",
"stderr": ""
}
},
{
"id": "gm_x_saved_grep",
"seq": 530202,
"targets": [
"gmail"
],
"command": "grep -n travel /scratch/saved/budget.csv",
"expect": {
"exit": 0,
"stdout": "2:120,travel\n",
"stderr": ""
}
},
{
"id": "gm_x_save_message",
"seq": 530203,
"targets": [
"gmail"
],
"command": "cp /mail/INBOX/2026-01-05/Q2_Budget_Review__msg0001.gmail.json /scratch/msg.json && cat /scratch/msg.json | jq -r .subject",
"expect": {
"exit": 0,
"stdout": "Q2 Budget Review\n",
"stderr": ""
}
}
]
}
+1 -1
View File
@@ -23,7 +23,7 @@ INTEG = Path(__file__).resolve().parents[1]
SHARED_TARGETS = ["ram", "disk", "redis"]
S3_TARGETS = ["s3", "s3-prefix"]
SSH_TARGETS = ["ssh"]
GDRIVE_TARGETS = ["gdrive", "gdrive-folder", "gdrive-shared", "gapps"]
GDRIVE_TARGETS = ["gdrive", "gdrive-folder", "gdrive-shared", "gapps", "gmail"]
def load(path: str) -> dict[tuple[str, str], dict]:
+152
View File
@@ -12,6 +12,8 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import base64
import imaplib
import importlib.util
import json
import logging
@@ -20,6 +22,9 @@ import shutil
import tempfile
import uuid
from collections.abc import Awaitable, Callable
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import parsedate_to_datetime
from pathlib import Path
from types import ModuleType
@@ -35,10 +40,14 @@ from mirage.core.sharepoint import _resolver as sharepoint_resolver
from mirage.resource.box import BoxConfig, BoxResource
from mirage.resource.disk import DiskResource
from mirage.resource.dropbox import DropboxConfig, DropboxResource
from mirage.resource.email.config import EmailConfig
from mirage.resource.email.email import EmailResource
from mirage.resource.gdocs.config import GDocsConfig
from mirage.resource.gdocs.gdocs import GDocsResource
from mirage.resource.gdrive.config import GoogleDriveConfig
from mirage.resource.gdrive.gdrive import GoogleDriveResource
from mirage.resource.gmail.config import GmailConfig
from mirage.resource.gmail.gmail import GmailResource
from mirage.resource.gridfs import GridFSConfig, GridFSResource
from mirage.resource.gsheets.config import GSheetsConfig
from mirage.resource.gsheets.gsheets import GSheetsResource
@@ -54,6 +63,11 @@ from mirage.resource.sharepoint.sharepoint import SharePointResource
from mirage.resource.ssh import SSHConfig, SSHResource
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
EMAIL_IMAP_PORT = int(os.environ.get("EMAIL_IMAP_PORT", "3143"))
EMAIL_SMTP_PORT = int(os.environ.get("EMAIL_SMTP_PORT", "3025"))
EMAIL_API_PORT = int(os.environ.get("EMAIL_API_PORT", "8080"))
EMAIL_USERNAME = "integ@example.com"
EMAIL_PASSWORD = "secret"
MONGODB_URI = os.environ.get("MONGODB_URI", "mongodb://localhost:27017")
S3_ENDPOINT = os.environ.get("S3_ENDPOINT")
S3_REGION = os.environ.get("S3_REGION", "us-east-1")
@@ -65,6 +79,37 @@ async def _noop() -> None:
return None
def manifest_mime(entry: dict) -> MIMEText | MIMEMultipart:
"""Build the constrained RFC822 shape shared mail manifests describe.
Args:
entry (dict): manifest row with from/to/cc/subject/date/body and
optional attachments.
Returns:
MIMEText | MIMEMultipart: single text part, or multipart/mixed with
text attachments.
"""
if entry.get("attachments"):
mime: MIMEText | MIMEMultipart = MIMEMultipart("mixed")
mime.attach(MIMEText(entry["body"], "plain", "utf-8"))
for att in entry["attachments"]:
part = MIMEText(att["content"], "plain", "utf-8")
part.add_header("Content-Disposition",
"attachment",
filename=att["filename"])
mime.attach(part)
else:
mime = MIMEText(entry["body"], "plain", "utf-8")
mime["From"] = entry["from"]
mime["To"] = entry["to"]
if entry.get("cc"):
mime["Cc"] = ", ".join(entry["cc"])
mime["Subject"] = entry["subject"]
mime["Date"] = entry["date"]
return mime
class S3Service:
def __init__(self, run_id: str) -> None:
@@ -321,6 +366,12 @@ class GwsService:
).parents[2] / "fixtures" / f"{apps}.json"
await cls._seed_apps(session, url,
json.loads(manifest.read_text()))
mail = target.get("mail")
if mail:
manifest = Path(__file__).resolve(
).parents[2] / "fixtures" / f"{mail}.json"
await cls._seed_mail(session, url,
json.loads(manifest.read_text()))
return cls(url, folder_ids)
@staticmethod
@@ -367,6 +418,25 @@ class GwsService:
else:
raise ValueError(f"unknown google-apps kind: {kind}")
@staticmethod
async def _seed_mail(session: aiohttp.ClientSession, url: str,
entries: list[dict]) -> None:
# Messages are API objects: each manifest entry becomes an RFC822
# payload inserted through messages.insert with
# internalDateSource=dateHeader, so date dirs come from the
# manifest, not the server clock.
for entry in entries:
raw = base64.urlsafe_b64encode(
manifest_mime(entry).as_bytes()).decode()
async with session.post(
f"{url}/gmail/v1/users/me/messages",
params={"internalDateSource": "dateHeader"},
json={
"raw": raw,
"labelIds": entry.get("labels", []),
}) as resp:
resp.raise_for_status()
@staticmethod
async def _folder(session: aiohttp.ClientSession, url: str, name: str,
parent: str) -> str:
@@ -412,6 +482,70 @@ class GwsService:
refresh_token="integ",
api_base=self.url))
def gmail_resource(self) -> GmailResource:
return GmailResource(
GmailConfig(client_id="integ",
refresh_token="integ",
api_base=self.url))
async def teardown(self) -> None:
return None
class EmailService:
"""Points the email mount at a GreenMail IMAP+SMTP server.
The server is external (a greenmail/standalone container) and shared
across runs; its REST API /api/service/reset purges every mailbox.
Seeding appends RFC822 payloads over IMAP so folder UIDs are the append
order (1, 2, ...) and date dirs come from the manifest Date headers.
"""
def __init__(self, host: str) -> None:
self.host = host
@classmethod
async def create(cls, run_id: str, target: dict) -> "EmailService":
host = os.environ["EMAIL_HOST"]
api = f"http://{host}:{EMAIL_API_PORT}/api/service/reset"
async with aiohttp.ClientSession() as session:
async with session.post(api) as resp:
resp.raise_for_status()
mail = target.get("mail")
if mail:
manifest = Path(
__file__).resolve().parents[2] / "fixtures" / f"{mail}.json"
cls._seed_imap(host, json.loads(manifest.read_text()))
return cls(host)
@staticmethod
def _seed_imap(host: str, entries: list[dict]) -> None:
# Sync imaplib is fine here: this is test scaffolding running
# before the workspace opens, not backend code.
imap = imaplib.IMAP4(host, EMAIL_IMAP_PORT)
imap.login(EMAIL_USERNAME, EMAIL_PASSWORD)
known = {"INBOX"}
for entry in entries:
folder = entry["folder"]
if folder not in known:
imap.create(folder)
known.add(folder)
flags = "(\\Seen)" if entry.get("seen") else None
date = imaplib.Time2Internaldate(
parsedate_to_datetime(entry["date"]))
imap.append(folder, flags, date, manifest_mime(entry).as_bytes())
imap.logout()
def resource(self, mount: dict) -> EmailResource:
return EmailResource(
EmailConfig(imap_host=self.host,
imap_port=EMAIL_IMAP_PORT,
smtp_host=self.host,
smtp_port=EMAIL_SMTP_PORT,
username=EMAIL_USERNAME,
password=EMAIL_PASSWORD,
use_ssl=False))
async def teardown(self) -> None:
return None
@@ -692,6 +826,20 @@ def build_gslides(
return service.gslides_resource(), _noop
def build_email(
mount: dict, run_id: str, service: Service | None
) -> tuple[object, Callable[[], Awaitable[None]]]:
assert isinstance(service, EmailService)
return service.resource(mount), _noop
def build_gmail(
mount: dict, run_id: str, service: Service | None
) -> tuple[object, Callable[[], Awaitable[None]]]:
assert isinstance(service, GwsService)
return service.gmail_resource(), _noop
def build_nextcloud(
mount: dict, run_id: str, service: Service | None
) -> tuple[object, Callable[[], Awaitable[None]]]:
@@ -713,6 +861,8 @@ BUILDERS = {
"gdocs": build_gdocs,
"gsheets": build_gsheets,
"gslides": build_gslides,
"gmail": build_gmail,
"email": build_email,
"hf": build_hf,
"box": build_box,
"dropbox": build_dropbox,
@@ -737,6 +887,8 @@ async def open_target(
service = await NextcloudService.create(run_id, target)
elif target.get("service") == "gws":
service = await GwsService.create(run_id, target)
elif target.get("service") == "email":
service = await EmailService.create(run_id, target)
elif target.get("service") == "hf":
service = await HfService.create(run_id)
elif target.get("service") == "box":
+4
View File
@@ -84,6 +84,10 @@ async def main() -> None:
if (target.get("service") == "gws" and not os.environ.get("GWS_URL")):
print(f"skip [{target_id}]: GWS_URL not set", file=sys.stderr)
continue
if (target.get("service") == "email"
and not os.environ.get("EMAIL_HOST")):
print(f"skip [{target_id}]: EMAIL_HOST not set", file=sys.stderr)
continue
await run_target(target, cases, root, report, emit)
if args.emit:
+154 -2
View File
@@ -27,8 +27,10 @@ import {
BoxResource,
DiskResource,
DropboxResource,
EmailResource,
GDocsResource,
GDriveResource,
GmailResource,
GridFSResource,
GSheetsResource,
GSlidesResource,
@@ -40,6 +42,7 @@ import {
SSHResource,
Workspace,
} from '@struktoai/mirage-node'
import { ImapFlow } from 'imapflow'
import { installFakeNavigator, makeMockRoot } from '../../../typescript/packages/browser/src/test-utils.ts'
import { startFakeDropbox, type FakeDropbox } from '../../server/dropbox.ts'
import { integRoot, walkFiles } from './harness.ts'
@@ -188,6 +191,71 @@ async function openS3(target: Target): Promise<Open> {
return { ws: ws as unknown as ExecWorkspace, cleanup }
}
const EMAIL_IMAP_PORT = Number(process.env.EMAIL_IMAP_PORT ?? '3143')
const EMAIL_SMTP_PORT = Number(process.env.EMAIL_SMTP_PORT ?? '3025')
const EMAIL_API_PORT = Number(process.env.EMAIL_API_PORT ?? '8080')
const EMAIL_USERNAME = 'integ@example.com'
const EMAIL_PASSWORD = 'secret'
// The GreenMail server is external and shared; its REST API purges every
// mailbox between runs. Seeding appends RFC822 payloads over IMAP so folder
// UIDs are the append order (1, 2, ...) and date dirs come from the
// manifest Date headers.
async function openEmail(target: Target): Promise<Open> {
const host = process.env.EMAIL_HOST
if (host === undefined || host === '') throw new Error('email target requires EMAIL_HOST')
const reset = await fetch(`http://${host}:${String(EMAIL_API_PORT)}/api/service/reset`, {
method: 'POST',
})
if (!reset.ok) throw new Error(`greenmail reset failed: ${String(reset.status)}`)
if (target.mail !== undefined) {
const manifest = join(integRoot(), 'fixtures', `${target.mail}.json`)
const entries = JSON.parse(readFileSync(manifest, 'utf8')) as MailEntry[]
const imap = new ImapFlow({
host,
port: EMAIL_IMAP_PORT,
secure: false,
auth: { user: EMAIL_USERNAME, pass: EMAIL_PASSWORD },
logger: false,
})
await imap.connect()
const known = new Set(['INBOX'])
for (const entry of entries) {
const folder = entry.folder ?? 'INBOX'
if (!known.has(folder)) {
await imap.mailboxCreate(folder)
known.add(folder)
}
await imap.append(
folder,
buildRfc822(entry),
entry.seen === true ? ['\\Seen'] : [],
new Date(entry.date),
)
}
await imap.logout()
}
const mounts: Record<string, EmailResource | RAMResource> = {}
for (const m of target.mounts) {
if (m.resource === 'ram') {
mounts[m.path] = new RAMResource()
continue
}
mounts[m.path] = new EmailResource({
imapHost: host,
imapPort: EMAIL_IMAP_PORT,
smtpHost: host,
smtpPort: EMAIL_SMTP_PORT,
username: EMAIL_USERNAME,
password: EMAIL_PASSWORD,
useSsl: false,
maxMessages: 200,
})
}
const ws = new Workspace(mounts, { mode: MountMode.WRITE })
return { ws: ws as unknown as ExecWorkspace, cleanup: () => ws.close() }
}
async function openHf(target: Target): Promise<Open> {
const endpoint = process.env.HF_ENDPOINT
if (!endpoint) throw new Error('hf target requires HF_ENDPOINT')
@@ -423,10 +491,81 @@ async function seedGwsApps(base: string, entries: GwsAppEntry[]): Promise<void>
}
}
function gwsNativeResource(base: string, resource: string): GDocsResource | GSheetsResource | GSlidesResource {
interface MailEntry {
from: string
to: string
cc?: string[]
subject: string
date: string
body: string
labels?: string[]
folder?: string
seen?: boolean
attachments?: { filename: string; content: string }[]
}
function mimeTextPart(content: string, filename?: string): string {
const lines = [
'Content-Type: text/plain; charset="utf-8"',
'MIME-Version: 1.0',
'Content-Transfer-Encoding: base64',
]
if (filename !== undefined) {
lines.push(`Content-Disposition: attachment; filename="${filename}"`)
}
return `${lines.join('\r\n')}\r\n\r\n${Buffer.from(content, 'utf-8').toString('base64')}`
}
// Builds the same constrained RFC822 shape python's email.mime emits: one
// base64 text/plain body plus base64 text attachments under multipart/mixed.
function buildRfc822(entry: MailEntry): string {
const headers = [`From: ${entry.from}`, `To: ${entry.to}`]
if (entry.cc !== undefined && entry.cc.length > 0) headers.push(`Cc: ${entry.cc.join(', ')}`)
headers.push(`Subject: ${entry.subject}`, `Date: ${entry.date}`)
const attachments = entry.attachments ?? []
if (attachments.length === 0) {
return `${headers.join('\r\n')}\r\n${mimeTextPart(entry.body)}`
}
const boundary = 'integ-mime-boundary'
const parts = [
mimeTextPart(entry.body),
...attachments.map((att) => mimeTextPart(att.content, att.filename)),
]
return [
...headers,
`Content-Type: multipart/mixed; boundary="${boundary}"`,
'MIME-Version: 1.0',
'',
...parts.map((part) => `--${boundary}\r\n${part}`),
`--${boundary}--`,
].join('\r\n')
}
// Messages are API objects: each manifest entry becomes an RFC822 payload
// inserted through messages.insert with internalDateSource=dateHeader, so
// date dirs come from the manifest, not the server clock.
async function seedGwsMail(base: string, entries: MailEntry[]): Promise<void> {
for (const entry of entries) {
const raw = Buffer.from(buildRfc822(entry), 'utf-8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
await gwsJson(`${base}/gmail/v1/users/me/messages?internalDateSource=dateHeader`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ raw, labelIds: entry.labels ?? [] }),
})
}
}
function gwsNativeResource(
base: string,
resource: string,
): GDocsResource | GSheetsResource | GSlidesResource | GmailResource {
const config = { clientId: 'integ', clientSecret: 'integ', refreshToken: 'integ', apiBase: base }
if (resource === 'gdocs') return new GDocsResource(config)
if (resource === 'gsheets') return new GSheetsResource(config)
if (resource === 'gmail') return new GmailResource(config)
return new GSlidesResource(config)
}
@@ -441,9 +580,16 @@ async function openGws(target: Target): Promise<Open> {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(target.epoch !== undefined ? { epoch: target.epoch } : {}),
})
const mounts: Record<string, GDriveResource | GDocsResource | GSheetsResource | GSlidesResource> = {}
const mounts: Record<
string,
GDriveResource | GDocsResource | GSheetsResource | GSlidesResource | GmailResource | RAMResource
> = {}
const driveIds: Record<string, string> = {}
for (const m of target.mounts) {
if (m.resource === 'ram') {
mounts[m.path] = new RAMResource()
continue
}
if (m.resource !== 'gdrive') {
mounts[m.path] = gwsNativeResource(base, m.resource)
continue
@@ -475,6 +621,10 @@ async function openGws(target: Target): Promise<Open> {
const manifest = join(integRoot(), 'fixtures', `${target.apps}.json`)
await seedGwsApps(base, JSON.parse(readFileSync(manifest, 'utf8')) as GwsAppEntry[])
}
if (target.mail !== undefined) {
const manifest = join(integRoot(), 'fixtures', `${target.mail}.json`)
await seedGwsMail(base, JSON.parse(readFileSync(manifest, 'utf8')) as MailEntry[])
}
const ws = new Workspace(mounts, { mode: MountMode.WRITE })
const cleanup = async (): Promise<void> => {
await ws.close()
@@ -494,6 +644,8 @@ export const ADAPTERS: Record<string, (target: Target) => Promise<Open>> = {
gdocs: openGws,
gsheets: openGws,
gslides: openGws,
gmail: openGws,
email: openEmail,
hf: openHf,
box: openBox,
dropbox: openDropbox,
+1
View File
@@ -41,6 +41,7 @@ export interface Target {
service?: string
epoch?: string
apps?: string
mail?: string
mounts: Mount[]
}
+4
View File
@@ -102,6 +102,10 @@ async function main(): Promise<void> {
process.stderr.write(`skip [${id}]: GWS_URL not set\n`)
continue
}
if (target.service === 'email' && !process.env.EMAIL_HOST) {
process.stderr.write(`skip [${id}]: EMAIL_HOST not set\n`)
continue
}
if (target.service === 'hf' && !process.env.HF_ENDPOINT) {
process.stderr.write(`skip [${id}]: HF_ENDPOINT not set\n`)
continue
+391 -10
View File
@@ -13,20 +13,23 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Fake Google Workspace server for integ: Drive v3 + Docs v1 + Sheets v4 +
// Slides v1 on one in-memory host, plus a fake OAuth /token and a /reset
// for per-run isolation. Mirrors the real REST surface closely enough that
// mirage's google backends and the gws passthrough commands run unmodified
// against it (GoogleConfig.api_base points here). Deliberate simplifications,
// all deterministic so both language runners see byte-identical responses:
// Slides v1 + Gmail v1 on one in-memory host, plus a fake OAuth /token and
// a /reset for per-run isolation. Mirrors the real REST surface closely
// enough that mirage's google backends and the gws passthrough commands run
// unmodified against it (GoogleConfig.api_base points here). Deliberate
// simplifications, all deterministic so both language runners see
// byte-identical responses:
// - ids and timestamps are counters over a fixed clock, not random
// - `fields` masks are ignored (full resources are returned)
// - sheets store literal values; formulas are not evaluated
// - list pagination is single-page (pageToken is never emitted)
// - Gmail search matches case-insensitive substrings, not word stems
// Faithful behaviors that matter to the backends: Drive allows duplicate
// sibling names, folder deletes are recursive, creating a file with a
// google-apps MIME type auto-creates the linked Docs/Sheets/Slides resource
// (and vice versa), and every content write records a revision that
// /revisions can list and serve.
// (and vice versa), every content write records a revision that /revisions
// can list and serve, Gmail messages.insert honors
// internalDateSource=dateHeader, and messages.trash swaps INBOX for TRASH.
import { createHash } from 'node:crypto'
import http from 'node:http'
@@ -87,23 +90,51 @@ interface Presentation {
slides: SlidePage[]
}
interface GmailAttachment {
attachmentId: string
filename: string
mimeType: string
data: Buffer
}
interface GmailMessage {
id: string
threadId: string
labelIds: string[]
internalDate: number
headers: { name: string; value: string }[]
bodyText: string
attachments: GmailAttachment[]
}
interface GmailLabel {
id: string
name: string
type: string
}
const SYSTEM_LABELS = ['INBOX', 'SENT', 'UNREAD', 'TRASH']
class GwsState {
files = new Map<string, DriveItem>()
drives = new Map<string, { id: string; name: string }>()
docs = new Map<string, { title: string; text: string }>()
sheets = new Map<string, Spreadsheet>()
presentations = new Map<string, Presentation>()
messages = new Map<string, GmailMessage>()
labels = new Map<string, GmailLabel>()
private counters = new Map<string, number>()
private ticks = 0
// Frozen at construction (i.e. per /reset) so find -mtime windows
// relative to "now" behave like a live backend, while the +1s tick per
// touch keeps ordering deterministic. /reset may pin an explicit epoch
// instead: mounts that render timestamps into filenames (gdocs/gsheets/
// gslides date prefixes) need fully baked-in listings.
// gslides date prefixes, gmail date dirs) need fully baked-in listings.
private readonly baseMs: number
constructor(epoch?: string) {
this.baseMs = epoch === undefined ? Date.now() : Date.parse(epoch)
for (const id of SYSTEM_LABELS) this.labels.set(id, { id, name: id, type: 'system' })
}
nextId(kind: string): string {
@@ -112,9 +143,13 @@ class GwsState {
return `${kind}${String(n).padStart(4, '0')}`
}
now(): string {
nowMs(): number {
this.ticks += 1
return new Date(this.baseMs + this.ticks * 1000).toISOString()
return this.baseMs + this.ticks * 1000
}
now(): string {
return new Date(this.nowMs()).toISOString()
}
}
@@ -720,6 +755,347 @@ function slidesBatchUpdate(id: string, requests: Record<string, unknown>[]): [nu
return [200, { presentationId: id, replies }]
}
// ---------------------------------------------------------------- gmail ---
function b64url(data: Buffer): string {
return data.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function b64urlDecode(data: string): Buffer {
return Buffer.from(data.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
}
interface MimePart {
headers: Map<string, string>
body: Buffer
}
function splitMime(raw: Buffer): MimePart {
let sep = raw.indexOf('\r\n\r\n')
let sepLen = 4
if (sep === -1) {
sep = raw.indexOf('\n\n')
sepLen = 2
}
const headers = new Map<string, string>()
const head = sep === -1 ? raw.toString('utf-8') : raw.subarray(0, sep).toString('utf-8')
let lastKey = ''
for (const line of head.split(/\r?\n/)) {
if ((line.startsWith(' ') || line.startsWith('\t')) && lastKey !== '') {
headers.set(lastKey, `${headers.get(lastKey) ?? ''} ${line.trim()}`)
continue
}
const colon = line.indexOf(':')
if (colon === -1) continue
lastKey = line.slice(0, colon).trim().toLowerCase()
headers.set(lastKey, line.slice(colon + 1).trim())
}
return { headers, body: sep === -1 ? Buffer.alloc(0) : raw.subarray(sep + sepLen) }
}
function decodePartBody(part: MimePart): Buffer {
const cte = (part.headers.get('content-transfer-encoding') ?? '').toLowerCase()
if (cte === 'base64') {
return Buffer.from(part.body.toString('ascii').replace(/\s+/g, ''), 'base64')
}
// 7bit/8bit: trim the trailing CRLF the MIME serialization appends.
let body = part.body
while (body.length > 0 && (body[body.length - 1] === 10 || body[body.length - 1] === 13)) {
body = body.subarray(0, body.length - 1)
}
return body
}
function filenameOf(part: MimePart): string {
const disposition = part.headers.get('content-disposition') ?? ''
const m = /filename="?([^";]+)"?/.exec(disposition)
if (m !== null) return m[1] as string
const n = /name="?([^";]+)"?/.exec(part.headers.get('content-type') ?? '')
return n === null ? '' : (n[1] as string)
}
// Parses the constrained MIME the adapters and mirage's send path emit:
// either a single text/plain message or multipart/mixed with one text part
// and base64 attachment parts.
function parseRfc822(raw: Buffer): {
headers: { name: string; value: string }[]
bodyText: string
attachments: { filename: string; mimeType: string; data: Buffer }[]
} {
const top = splitMime(raw)
const wanted = ['From', 'To', 'Cc', 'Subject', 'Date', 'Message-ID', 'In-Reply-To', 'References']
const headers: { name: string; value: string }[] = []
for (const name of wanted) {
const value = top.headers.get(name.toLowerCase())
if (value !== undefined) headers.push({ name, value })
}
const contentType = top.headers.get('content-type') ?? 'text/plain'
if (!contentType.toLowerCase().startsWith('multipart/')) {
return { headers, bodyText: decodePartBody(top).toString('utf-8'), attachments: [] }
}
const m = /boundary=(?:"([^"]+)"|([^;]+))/.exec(contentType)
if (m === null) throw new Error('missing MIME boundary')
const boundary = `--${((m[1] ?? m[2]) as string).trim()}`
let bodyText = ''
const attachments: { filename: string; mimeType: string; data: Buffer }[] = []
const text = top.body
let from = text.indexOf(boundary)
while (from !== -1) {
const start = from + boundary.length
if (text.subarray(start, start + 2).toString() === '--') break
const next = text.indexOf(boundary, start)
if (next === -1) break
let chunk = text.subarray(start, next)
while (chunk.length > 0 && (chunk[0] === 10 || chunk[0] === 13)) chunk = chunk.subarray(1)
const part = splitMime(chunk)
const partType = (part.headers.get('content-type') ?? 'text/plain').split(';')[0]?.trim() ?? ''
const filename = filenameOf(part)
if (filename !== '') {
attachments.push({ filename, mimeType: partType, data: decodePartBody(part) })
} else if (partType === 'text/plain' || partType === '') {
bodyText = decodePartBody(part).toString('utf-8')
}
from = next
}
return { headers, bodyText, attachments }
}
function gmailHeader(msg: GmailMessage, name: string): string {
const found = msg.headers.find((h) => h.name.toLowerCase() === name.toLowerCase())
return found === undefined ? '' : found.value
}
function gmailSnippet(text: string): string {
const flat = text.split(/\s+/).filter((w) => w !== '').join(' ')
return flat.length > 100 ? flat.slice(0, 100) : flat
}
function gmailSizeEstimate(msg: GmailMessage): number {
return (
Buffer.byteLength(msg.bodyText, 'utf-8') +
msg.attachments.reduce((total, a) => total + a.data.length, 0)
)
}
function fmtGmailMessage(msg: GmailMessage): Record<string, unknown> {
const headers = msg.headers.map((h) => ({ name: h.name, value: h.value }))
const bodyData = Buffer.from(msg.bodyText, 'utf-8')
let payload: Record<string, unknown>
if (msg.attachments.length === 0) {
payload = {
partId: '',
mimeType: 'text/plain',
filename: '',
headers,
body: { size: bodyData.length, data: b64url(bodyData) },
}
} else {
const parts: Record<string, unknown>[] = [
{
partId: '0',
mimeType: 'text/plain',
filename: '',
headers: [],
body: { size: bodyData.length, data: b64url(bodyData) },
},
]
msg.attachments.forEach((att, i) => {
parts.push({
partId: String(i + 1),
mimeType: att.mimeType,
filename: att.filename,
headers: [],
body: { attachmentId: att.attachmentId, size: att.data.length },
})
})
payload = { partId: '', mimeType: 'multipart/mixed', filename: '', headers, body: { size: 0 }, parts }
}
return {
id: msg.id,
threadId: msg.threadId,
labelIds: [...msg.labelIds],
snippet: gmailSnippet(msg.bodyText),
internalDate: String(msg.internalDate),
sizeEstimate: gmailSizeEstimate(msg),
payload,
}
}
function labelByName(name: string): GmailLabel | undefined {
const lower = name.toLowerCase()
return [...state.labels.values()].find(
(label) => label.name.toLowerCase() === lower || label.id.toLowerCase() === lower,
)
}
function gmailDateMs(token: string): number {
const m = /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/.exec(token)
if (m === null) return NaN
return Date.UTC(parseInt(m[1] as string, 10), parseInt(m[2] as string, 10) - 1, parseInt(m[3] as string, 10))
}
// AND-only Gmail query subset: label:, from:, to:, subject:, is:unread,
// is:read, after:YYYY/MM/DD, before:YYYY/MM/DD, and bare terms matching
// subject or body as case-insensitive substrings.
function matchGmailQuery(msg: GmailMessage, q: string): boolean {
for (const token of q.split(/\s+/)) {
if (token === '') continue
const lower = token.toLowerCase()
if (lower.startsWith('label:')) {
const label = labelByName(token.slice(6))
if (label === undefined || !msg.labelIds.includes(label.id)) return false
} else if (lower.startsWith('from:')) {
if (!gmailHeader(msg, 'From').toLowerCase().includes(lower.slice(5))) return false
} else if (lower.startsWith('to:')) {
if (!gmailHeader(msg, 'To').toLowerCase().includes(lower.slice(3))) return false
} else if (lower.startsWith('subject:')) {
if (!gmailHeader(msg, 'Subject').toLowerCase().includes(lower.slice(8))) return false
} else if (lower === 'is:unread') {
if (!msg.labelIds.includes('UNREAD')) return false
} else if (lower === 'is:read') {
if (msg.labelIds.includes('UNREAD')) return false
} else if (lower.startsWith('after:')) {
const ms = gmailDateMs(token.slice(6))
if (Number.isNaN(ms) || msg.internalDate < ms) return false
} else if (lower.startsWith('before:')) {
const ms = gmailDateMs(token.slice(7))
if (Number.isNaN(ms) || msg.internalDate >= ms) return false
} else {
const haystack = `${gmailHeader(msg, 'Subject')}\n${msg.bodyText}`.toLowerCase()
if (!haystack.includes(lower)) return false
}
}
return true
}
function ensureLabel(name: string): GmailLabel {
const existing = labelByName(name)
if (existing !== undefined) return existing
const label: GmailLabel = { id: state.nextId('label'), name, type: 'user' }
state.labels.set(label.id, label)
return label
}
function insertGmailMessage(
raw: Buffer,
labelIds: string[],
threadId: string | undefined,
useDateHeader: boolean,
): GmailMessage {
const parsed = parseRfc822(raw)
const id = state.nextId('msg')
const dateHeader = parsed.headers.find((h) => h.name === 'Date')?.value
const headerMs = dateHeader === undefined ? NaN : Date.parse(dateHeader)
const msg: GmailMessage = {
id,
threadId: threadId !== undefined && threadId !== '' ? threadId : id,
labelIds: labelIds.map((name) => ensureLabel(name).id),
internalDate: useDateHeader && !Number.isNaN(headerMs) ? headerMs : state.nowMs(),
headers: parsed.headers,
bodyText: parsed.bodyText,
attachments: parsed.attachments.map((att) => ({
attachmentId: state.nextId('att'),
filename: att.filename,
mimeType: att.mimeType,
data: att.data,
})),
}
state.messages.set(id, msg)
return msg
}
function listGmailMessages(query: URLSearchParams): [number, object] {
const q = query.get('q')
const labelParam = query.get('labelIds')
const maxResults = parseInt(query.get('maxResults') ?? '100', 10)
let items = [...state.messages.values()]
if (labelParam !== null) {
items = items.filter((msg) => msg.labelIds.includes(labelParam))
} else if (q === null || !q.includes('label:TRASH')) {
// Real messages.list hides TRASH unless it is asked for explicitly.
items = items.filter((msg) => !msg.labelIds.includes('TRASH'))
}
if (q !== null && q.trim() !== '') {
items = items.filter((msg) => matchGmailQuery(msg, q))
}
items.sort((a, b) =>
a.internalDate === b.internalDate
? b.id.localeCompare(a.id)
: b.internalDate - a.internalDate,
)
items = items.slice(0, maxResults)
const out: Record<string, unknown> = { resultSizeEstimate: items.length }
if (items.length > 0) {
out.messages = items.map((msg) => ({ id: msg.id, threadId: msg.threadId }))
}
return [200, out]
}
function routeGmail(ctx: Ctx): [number, object | Buffer | null, string?] | null {
const { method, path, query } = ctx
if (path === '/gmail/v1/users/me/labels' && method === 'GET') {
return [
200,
{
labels: [...state.labels.values()].map((label) => ({
id: label.id,
name: label.name,
type: label.type,
})),
},
]
}
if (path === '/gmail/v1/users/me/messages' && method === 'GET') {
return listGmailMessages(query)
}
if (path === '/gmail/v1/users/me/messages' && method === 'POST') {
const body = json(ctx) as { raw?: string; labelIds?: string[]; threadId?: string }
if (typeof body.raw !== 'string') {
return googleError(400, "'raw' RFC822 payload is required.", 'INVALID_ARGUMENT')
}
const msg = insertGmailMessage(
b64urlDecode(body.raw),
body.labelIds ?? [],
body.threadId,
query.get('internalDateSource') === 'dateHeader',
)
return [200, { id: msg.id, threadId: msg.threadId, labelIds: [...msg.labelIds] }]
}
if (path === '/gmail/v1/users/me/messages/send' && method === 'POST') {
const body = json(ctx) as { raw?: string; threadId?: string }
if (typeof body.raw !== 'string') {
return googleError(400, "'raw' RFC822 payload is required.", 'INVALID_ARGUMENT')
}
const msg = insertGmailMessage(b64urlDecode(body.raw), ['SENT'], body.threadId, false)
return [200, { id: msg.id, threadId: msg.threadId, labelIds: [...msg.labelIds] }]
}
let m = /^\/gmail\/v1\/users\/me\/messages\/([^/]+)\/trash$/.exec(path)
if (m !== null && method === 'POST') {
const msg = state.messages.get(m[1] as string)
if (msg === undefined) return googleError(404, 'Requested entity was not found.', 'NOT_FOUND')
msg.labelIds = msg.labelIds.filter((id) => id !== 'INBOX' && id !== 'UNREAD')
msg.labelIds.push('TRASH')
return [200, { id: msg.id, threadId: msg.threadId, labelIds: [...msg.labelIds] }]
}
m = /^\/gmail\/v1\/users\/me\/messages\/([^/]+)\/attachments\/([^/]+)$/.exec(path)
if (m !== null && method === 'GET') {
const msg = state.messages.get(m[1] as string)
const att = msg?.attachments.find((a) => a.attachmentId === m?.[2])
if (msg === undefined || att === undefined) {
return googleError(404, 'Requested entity was not found.', 'NOT_FOUND')
}
return [200, { size: att.data.length, data: b64url(att.data) }]
}
m = /^\/gmail\/v1\/users\/me\/messages\/([^/]+)$/.exec(path)
if (m !== null && method === 'GET') {
const msg = state.messages.get(m[1] as string)
if (msg === undefined) return googleError(404, 'Requested entity was not found.', 'NOT_FOUND')
return [200, fmtGmailMessage(msg)]
}
return null
}
// ------------------------------------------------------------- routing ---
function parseMultipartRelated(body: Buffer, contentType: string): { metadata: Record<string, unknown>; media: Buffer } {
@@ -779,6 +1155,11 @@ function route(ctx: Ctx): [number, object | Buffer | null, string?] {
return [200, { ok: true }]
}
if (path.startsWith('/gmail/v1/')) {
const handled = routeGmail(ctx)
if (handled !== null) return handled
}
let m = /^\/upload\/drive\/v3\/files$/.exec(path)
if (m !== null && method === 'POST') {
if (query.get('uploadType') === 'multipart') {
+43
View File
@@ -613,6 +613,49 @@
"backend": "gslides"
}
]
},
{
"id": "gmail",
"hosts": [
"python",
"typescript-node"
],
"service": "gws",
"epoch": "2026-02-01T00:00:00Z",
"mail": "gmail/v1",
"mounts": [
{
"path": "/mail",
"resource": "gmail",
"backend": "gmail"
},
{
"path": "/scratch",
"resource": "ram",
"backend": "ram"
}
]
},
{
"id": "email",
"hosts": [
"python",
"typescript-node"
],
"service": "email",
"mail": "email/v1",
"mounts": [
{
"path": "/mail",
"resource": "email",
"backend": "email"
},
{
"path": "/scratch",
"resource": "ram",
"backend": "ram"
}
]
}
]
}
@@ -15,7 +15,6 @@
from mirage.commands.builtin.email.email_forward import email_forward
from mirage.commands.builtin.email.email_read import email_read
from mirage.commands.builtin.email.email_reply import email_reply
from mirage.commands.builtin.email.email_reply_all import email_reply_all
from mirage.commands.builtin.email.email_send import email_send
from mirage.commands.builtin.email.email_triage import email_triage
from mirage.commands.builtin.email.find import find
@@ -41,7 +40,6 @@ COMMANDS = [
rg,
email_send,
email_reply,
email_reply_all,
email_forward,
email_triage,
email_read,
@@ -30,7 +30,7 @@ SPEC = CommandSpec(options=(
), )
@command("email-forward", resource="email", spec=SPEC, write=True)
@command("himalaya message forward", resource="email", spec=SPEC, write=True)
async def email_forward(
accessor: EmailAccessor,
paths: list[PathSpec],
@@ -28,7 +28,7 @@ SPEC = CommandSpec(options=(
), )
@command("email-read", resource="email", spec=SPEC)
@command("himalaya message read", resource="email", spec=SPEC)
async def email_read(
accessor: EmailAccessor,
paths: list[PathSpec],
@@ -18,7 +18,7 @@ from mirage.accessor.email import EmailAccessor
from mirage.commands.registry import command
from mirage.commands.spec.types import CommandSpec, OperandKind, Option
from mirage.core.email._client import fetch_message
from mirage.core.email.send import reply_message
from mirage.core.email.send import reply_all_message, reply_message
from mirage.io.stream import yield_bytes
from mirage.io.types import ByteSource, IOResult
from mirage.types import PathSpec
@@ -27,10 +27,11 @@ SPEC = CommandSpec(options=(
Option(long="--uid", value_kind=OperandKind.TEXT),
Option(long="--folder", value_kind=OperandKind.TEXT),
Option(long="--body", value_kind=OperandKind.TEXT),
Option(long="--all"),
), )
@command("email-reply", resource="email", spec=SPEC, write=True)
@command("himalaya message reply", resource="email", spec=SPEC, write=True)
async def email_reply(
accessor: EmailAccessor,
paths: list[PathSpec],
@@ -47,7 +48,10 @@ async def email_reply(
if not body or not isinstance(body, str):
raise ValueError("--body is required")
original = await fetch_message(accessor, folder, uid)
result = await reply_message(accessor.config, original, body)
if _extra.get("all"):
result = await reply_all_message(accessor.config, original, body)
else:
result = await reply_message(accessor.config, original, body)
out = json.dumps(result, ensure_ascii=False,
separators=(",", ":")).encode()
return yield_bytes(out), IOResult()
@@ -1,53 +0,0 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import json
from mirage.accessor.email import EmailAccessor
from mirage.commands.registry import command
from mirage.commands.spec.types import CommandSpec, OperandKind, Option
from mirage.core.email._client import fetch_message
from mirage.core.email.send import reply_all_message
from mirage.io.stream import yield_bytes
from mirage.io.types import ByteSource, IOResult
from mirage.types import PathSpec
SPEC = CommandSpec(options=(
Option(long="--uid", value_kind=OperandKind.TEXT),
Option(long="--folder", value_kind=OperandKind.TEXT),
Option(long="--body", value_kind=OperandKind.TEXT),
), )
@command("email-reply-all", resource="email", spec=SPEC, write=True)
async def email_reply_all(
accessor: EmailAccessor,
paths: list[PathSpec],
*texts: str,
**_extra: object,
) -> tuple[ByteSource | None, IOResult]:
uid = _extra.get("uid", "")
folder = _extra.get("folder", "")
body = _extra.get("body", "")
if not uid or not isinstance(uid, str):
raise ValueError("--uid is required")
if not folder or not isinstance(folder, str):
raise ValueError("--folder is required")
if not body or not isinstance(body, str):
raise ValueError("--body is required")
original = await fetch_message(accessor, folder, uid)
result = await reply_all_message(accessor.config, original, body)
out = json.dumps(result, ensure_ascii=False,
separators=(",", ":")).encode()
return yield_bytes(out), IOResult()
@@ -29,7 +29,7 @@ SPEC = CommandSpec(options=(
), )
@command("email-send", resource="email", spec=SPEC, write=True)
@command("himalaya message send", resource="email", spec=SPEC, write=True)
async def email_send(
accessor: EmailAccessor,
paths: list[PathSpec],
@@ -37,7 +37,7 @@ SPEC = CommandSpec(options=(
))
@command("email-triage", resource="email", spec=SPEC)
@command("himalaya envelope list", resource="email", spec=SPEC)
async def email_triage(
accessor: EmailAccessor,
paths: list[PathSpec],
@@ -15,7 +15,6 @@
from mirage.commands.builtin.filetype_factory import make_filetype_commands
from mirage.commands.builtin.generic_bind import make_generic_commands
from mirage.commands.builtin.gmail.grep import grep
from mirage.commands.builtin.gmail.gws_gmail_delete import gws_gmail_delete
from mirage.commands.builtin.gmail.gws_gmail_forward import gws_gmail_forward
from mirage.commands.builtin.gmail.gws_gmail_read import gws_gmail_read
from mirage.commands.builtin.gmail.gws_gmail_reply import gws_gmail_reply
@@ -25,6 +24,7 @@ from mirage.commands.builtin.gmail.gws_gmail_send import gws_gmail_send
from mirage.commands.builtin.gmail.gws_gmail_triage import gws_gmail_triage
from mirage.commands.builtin.gmail.io import IO as _IO
from mirage.commands.builtin.gmail.rg import rg
from mirage.commands.builtin.gws import GWS_GMAIL_API_COMMANDS
from mirage.core.gmail.read import read as _read
COMMANDS = [
@@ -43,5 +43,5 @@ COMMANDS = [
gws_gmail_forward,
gws_gmail_triage,
gws_gmail_read,
gws_gmail_delete,
*GWS_GMAIL_API_COMMANDS,
]
+6 -1
View File
@@ -64,8 +64,13 @@ async def grep(
fl = FlagView(flags, spec=SPECS["grep"])
pattern = pattern_arg(texts, fl)
max_count = fl.as_int("m")
# Output-shaping flags need real per-line matching, which the search-API
# push-down cannot emulate; fall through to the generic grep over
# rendered files instead.
shaping = (fl.as_bool("args_l") or fl.as_bool("c") or fl.as_bool("n")
or fl.as_bool("o") or fl.as_bool("v") or fl.as_bool("q"))
if paths and pattern is not None and "\n" not in pattern:
if paths and pattern is not None and "\n" not in pattern and not shaping:
scope = detect_scope(paths[0])
if scope.use_native:
file_prefix = mount_prefix_of(paths[0].virtual,
@@ -1,41 +0,0 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.accessor.gmail import GmailAccessor
from mirage.commands.registry import command
from mirage.commands.spec.types import CommandSpec, OperandKind, Option
from mirage.core.gmail.messages import trash_message
from mirage.io.types import ByteSource, IOResult
from mirage.types import PathSpec
SPEC = CommandSpec(
description="Move one Gmail message to Trash (reversible).",
options=(Option(long="--id",
value_kind=OperandKind.TEXT,
description="Gmail message ID (required)"), ),
)
@command("gws-gmail-delete", resource="gmail", spec=SPEC, write=True)
async def gws_gmail_delete(
accessor: GmailAccessor,
paths: list[PathSpec],
*texts: str,
**_extra: object,
) -> tuple[ByteSource | None, IOResult]:
message_id = _extra.get("id", "")
if not message_id or not isinstance(message_id, str):
raise ValueError("--id is required")
await trash_message(accessor.token_manager, message_id)
return None, IOResult()
@@ -35,7 +35,7 @@ SPEC = CommandSpec(
)
@command("gws-gmail-forward", resource="gmail", spec=SPEC, write=True)
@command("gws gmail +forward", resource="gmail", spec=SPEC, write=True)
async def gws_gmail_forward(
accessor: GmailAccessor,
paths: list[PathSpec],
@@ -31,7 +31,7 @@ SPEC = CommandSpec(
)
@command("gws-gmail-read", resource="gmail", spec=SPEC)
@command("gws gmail +read", resource="gmail", spec=SPEC)
async def gws_gmail_read(
accessor: GmailAccessor,
paths: list[PathSpec],
@@ -35,7 +35,7 @@ SPEC = CommandSpec(
)
@command("gws-gmail-reply", resource="gmail", spec=SPEC, write=True)
@command("gws gmail +reply", resource="gmail", spec=SPEC, write=True)
async def gws_gmail_reply(
accessor: GmailAccessor,
paths: list[PathSpec],
@@ -35,7 +35,7 @@ SPEC = CommandSpec(
)
@command("gws-gmail-reply-all", resource="gmail", spec=SPEC, write=True)
@command("gws gmail +reply-all", resource="gmail", spec=SPEC, write=True)
async def gws_gmail_reply_all(
accessor: GmailAccessor,
paths: list[PathSpec],
@@ -38,7 +38,7 @@ SPEC = CommandSpec(
)
@command("gws-gmail-send", resource="gmail", spec=SPEC, write=True)
@command("gws gmail +send", resource="gmail", spec=SPEC, write=True)
async def gws_gmail_send(
accessor: GmailAccessor,
paths: list[PathSpec],
@@ -37,7 +37,7 @@ SPEC = CommandSpec(
)
@command("gws-gmail-triage", resource="gmail", spec=SPEC)
@command("gws gmail +triage", resource="gmail", spec=SPEC)
async def gws_gmail_triage(
accessor: GmailAccessor,
paths: list[PathSpec],
+6 -1
View File
@@ -48,8 +48,13 @@ async def rg(
if pattern_str is None:
raise UsageError("rg: usage: rg [flags] pattern [path]")
max_count = fl.as_int("m")
# Output-shaping flags need real per-line matching, which the search-API
# push-down cannot emulate; fall through to the generic rg over
# rendered files instead.
shaping = (fl.as_bool("args_l") or fl.as_bool("c") or fl.as_bool("n")
or fl.as_bool("o") or fl.as_bool("v"))
if paths and "\n" not in pattern_str:
if paths and "\n" not in pattern_str and not shaping:
scope = detect_scope(paths[0])
if scope.use_native:
file_prefix = mount_prefix_of(paths[0].virtual,
@@ -19,6 +19,7 @@ GWS_DRIVE_API_COMMANDS = make_gws_api_commands("drive")
GWS_DOCS_API_COMMANDS = make_gws_api_commands("docs")
GWS_SHEETS_API_COMMANDS = make_gws_api_commands("sheets")
GWS_SLIDES_API_COMMANDS = make_gws_api_commands("slides")
GWS_GMAIL_API_COMMANDS = make_gws_api_commands("gmail")
__all__ = [
"GWS_METHODS",
@@ -27,5 +28,6 @@ __all__ = [
"GWS_DOCS_API_COMMANDS",
"GWS_SHEETS_API_COMMANDS",
"GWS_SLIDES_API_COMMANDS",
"GWS_GMAIL_API_COMMANDS",
"make_gws_api_commands",
]
+19 -1
View File
@@ -17,7 +17,7 @@ from dataclasses import dataclass
from mirage.commands.spec.types import CommandSpec, OperandKind, Option
from mirage.core.google._client import (TokenManager, docs_base, drive_base,
sheets_base, slides_base)
gmail_base, sheets_base, slides_base)
# The official gws CLI generates one command per Discovery method and
# speaks raw API resources: `--params` carries path/query parameters,
@@ -110,6 +110,22 @@ GWS_METHODS: tuple[GwsMethod, ...] = (
"/files/{fileId}/permissions"),
GwsMethod("drive", "permissions", "delete", "DELETE",
"/files/{fileId}/permissions/{permissionId}"),
GwsMethod("gmail", "users labels", "list", "GET",
"/users/{userId}/labels"),
GwsMethod("gmail", "users messages", "list", "GET",
"/users/{userId}/messages"),
GwsMethod("gmail", "users messages", "get", "GET",
"/users/{userId}/messages/{id}"),
GwsMethod("gmail",
"users messages",
"send",
"POST",
"/users/{userId}/messages/send",
needs_body=True),
GwsMethod("gmail", "users messages", "trash", "POST",
"/users/{userId}/messages/{id}/trash"),
GwsMethod("gmail", "users messages attachments", "get", "GET",
"/users/{userId}/messages/{messageId}/attachments/{id}"),
)
GWS_API_SPEC = CommandSpec(options=(
@@ -122,6 +138,7 @@ SERVICE_BASES: dict[str, Callable[[TokenManager], str]] = {
"docs": docs_base,
"sheets": sheets_base,
"slides": slides_base,
"gmail": gmail_base,
}
SERVICE_RESOURCES: dict[str, list[str]] = {
@@ -129,4 +146,5 @@ SERVICE_RESOURCES: dict[str, list[str]] = {
"docs": ["gdocs", "gdrive"],
"sheets": ["gsheets", "gdrive"],
"slides": ["gslides", "gdrive"],
"gmail": ["gmail"],
}
+12 -4
View File
@@ -83,7 +83,9 @@ async def fetch_message(
) -> dict[str, Any]:
imap = await accessor.get_imap()
await imap.select(folder)
response = await imap.uid("fetch", uid, "(RFC822 FLAGS)")
# BODY.PEEK[] instead of RFC822: reading a rendered file must not flip
# \Seen on the mailbox, matching the imapflow client in the TS backend.
response = await imap.uid("fetch", uid, "(BODY.PEEK[] FLAGS)")
raw_bytes = _extract_body(response)
flags = _extract_flags(response)
msg_dict = parse_rfc822(raw_bytes)
@@ -106,7 +108,11 @@ async def fetch_headers(
for i in range(0, len(uids), batch_size):
batch = uids[i:i + batch_size]
uid_set = ",".join(batch)
response = await imap.uid("fetch", uid_set, "(BODY[HEADER] FLAGS UID)")
# Full BODY.PEEK[] rather than BODY[HEADER]: attachment names live
# in the MIME structure, and listings must surface attachment dirs
# without flipping \Seen (the gmail backend fetches full messages
# on readdir the same way).
response = await imap.uid("fetch", uid_set, "(BODY.PEEK[] FLAGS UID)")
results.extend(_parse_multi_fetch(response, batch))
return results
@@ -119,7 +125,7 @@ async def fetch_attachment(
) -> bytes | None:
imap = await accessor.get_imap()
await imap.select(folder)
response = await imap.uid("fetch", uid, "(RFC822)")
response = await imap.uid("fetch", uid, "(BODY.PEEK[])")
raw_bytes = _extract_body(response)
attachments = _parse_with_payloads(raw_bytes)
for att in attachments:
@@ -195,7 +201,9 @@ def _parse_multi_fetch(response, uids: list[str]) -> list[dict[str, Any]]:
if isinstance(item, (bytearray, )) and len(item) > 20:
raw = bytes(item)
msg_dict = parse_rfc822(raw, headers_only=True)
# Full parse (not headers_only): listings need the MIME
# structure to surface attachment dirs.
msg_dict = parse_rfc822(raw)
msg_dict["uid"] = current_uid or (uids[len(results)] if
len(results) < len(uids) else "")
msg_dict["flags"] = current_flags
+4 -1
View File
@@ -22,13 +22,16 @@ from mirage.resource.secrets import reveal_secret
async def _smtp_send(config: EmailConfig, msg: EmailMessage) -> None:
# start_tls=None upgrades opportunistically when the server advertises
# STARTTLS and stays plaintext otherwise, mirroring nodemailer's
# behavior in the TS backend (which only forces TLS on port 465).
await aiosmtplib.send(
msg,
hostname=config.smtp_host,
port=config.smtp_port,
username=config.username,
password=reveal_secret(config.password),
start_tls=True,
start_tls=None,
)
+2 -2
View File
@@ -14,7 +14,7 @@
from typing import Any
from mirage.core.google._client import GMAIL_API_BASE, TokenManager, google_get
from mirage.core.google._client import TokenManager, gmail_base, google_get
async def list_labels(token_manager: TokenManager) -> list[dict[str, Any]]:
@@ -26,6 +26,6 @@ async def list_labels(token_manager: TokenManager) -> list[dict[str, Any]]:
Returns:
list[dict]: list of label objects.
"""
url = f"{GMAIL_API_BASE}/users/me/labels"
url = f"{gmail_base(token_manager)}/users/me/labels"
data = await google_get(token_manager, url)
return data.get("labels", [])
+5 -19
View File
@@ -15,8 +15,7 @@
import base64
from typing import Any
from mirage.core.google._client import (GMAIL_API_BASE, TokenManager,
google_get, google_post)
from mirage.core.google._client import TokenManager, gmail_base, google_get
async def list_messages(
@@ -41,25 +40,11 @@ async def list_messages(
params["labelIds"] = label_id
if query:
params["q"] = query
url = f"{GMAIL_API_BASE}/users/me/messages"
url = f"{gmail_base(token_manager)}/users/me/messages"
data = await google_get(token_manager, url, params=params)
return data.get("messages", [])
async def trash_message(
token_manager: TokenManager,
message_id: str,
) -> None:
"""Move a Gmail message to Trash.
Args:
token_manager (TokenManager): manages OAuth2 tokens.
message_id (str): Gmail message ID.
"""
url = f"{GMAIL_API_BASE}/users/me/messages/{message_id}/trash"
await google_post(token_manager, url, json={})
async def get_message_raw(
token_manager: TokenManager,
message_id: str,
@@ -73,7 +58,8 @@ async def get_message_raw(
Returns:
dict: full message resource.
"""
url = f"{GMAIL_API_BASE}/users/me/messages/{message_id}?format=full"
url = (f"{gmail_base(token_manager)}/users/me/messages"
f"/{message_id}?format=full")
return await google_get(token_manager, url)
@@ -126,7 +112,7 @@ async def get_attachment(
Returns:
bytes: decoded attachment content.
"""
url = (f"{GMAIL_API_BASE}/users/me/messages"
url = (f"{gmail_base(token_manager)}/users/me/messages"
f"/{message_id}/attachments/{attachment_id}")
data = await google_get(token_manager, url)
raw = data.get("data", "")
+4 -5
View File
@@ -18,8 +18,7 @@ from typing import Any
from mirage.core.gmail.messages import (_extract_header, get_message_processed,
get_message_raw)
from mirage.core.google._client import (GMAIL_API_BASE, TokenManager,
google_post)
from mirage.core.google._client import TokenManager, gmail_base, google_post
async def send_message(
@@ -43,7 +42,7 @@ async def send_message(
msg["To"] = to
msg["Subject"] = subject
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()
url = f"{GMAIL_API_BASE}/users/me/messages/send"
url = f"{gmail_base(token_manager)}/users/me/messages/send"
return await google_post(token_manager, url, {"raw": raw})
@@ -82,7 +81,7 @@ async def reply_message(
payload: dict[str, Any] = {"raw": raw}
if thread_id:
payload["threadId"] = thread_id
url = f"{GMAIL_API_BASE}/users/me/messages/send"
url = f"{gmail_base(token_manager)}/users/me/messages/send"
return await google_post(token_manager, url, payload)
@@ -127,7 +126,7 @@ async def reply_all_message(
payload: dict[str, Any] = {"raw": raw}
if thread_id:
payload["threadId"] = thread_id
url = f"{GMAIL_API_BASE}/users/me/messages/send"
url = f"{gmail_base(token_manager)}/users/me/messages/send"
return await google_post(token_manager, url, payload)
+5
View File
@@ -60,6 +60,11 @@ def sheets_base(token_manager: "TokenManager") -> str:
return f"{base}/v4" if base else SHEETS_API_BASE
def gmail_base(token_manager: "TokenManager") -> str:
base = token_manager.config.api_base
return f"{base}/gmail/v1" if base else GMAIL_API_BASE
async def refresh_access_token(config: GoogleConfig, ) -> tuple[str, int]:
"""Exchange refresh token for a new access token.
+8 -4
View File
@@ -21,10 +21,14 @@ PROMPT = """\
<attachment-filename>
Folders include: INBOX, Sent, Drafts, etc. cat shows email as JSON.
<subject> is sanitized don't construct it; ls the date dir."""
<subject> is sanitized (don't construct it; ls the date dir).
Read commands:
himalaya envelope list --folder INBOX --unseen # id/from/subject/date
himalaya message read --folder INBOX --uid <uid> # one message as JSON"""
WRITE_PROMPT = """\
Write commands:
email-send "to@email.com" "subject" "body"
email-reply <email-path> "reply body"
email-forward <email-path> "to@email.com" """
himalaya message send --to "to@email.com" --subject "Hi" --body "..."
himalaya message reply --folder INBOX --uid <uid> --body "..." [--all]
himalaya message forward --folder INBOX --uid <uid> --to "to@email.com" """
+16 -10
View File
@@ -24,7 +24,7 @@ PROMPT = """\
only. Read with `cat`/`head`/`jq` on `<path>.gmail.json` (keep the suffix).
Commands: cat, ls, head, tail, nl, wc, stat, find, tree, grep, rg, jq,
basename, dirname, realpath, gws-gmail-read, gws-gmail-triage.
basename, dirname, realpath, gws gmail +read, gws gmail +triage.
No others (no readFile, etc.).
Path: <label>/<yyyy-mm-dd>/<subject>__<message-id>.gmail.json
@@ -73,18 +73,24 @@ PROMPT = """\
.labels[]
.attachments[] | .filename
Read commands:
gws-gmail-read --id <message-id> # same shape as cat
gws-gmail-triage --query "is:unread" --max 20 # summary list (id, from,
# subject, date, snippet)"""
Read commands (official Google Workspace CLI helper syntax):
gws gmail +read --id <message-id> # same shape as cat
gws gmail +triage --query "is:unread" --max 20 # summary list (id, from,
# subject, date, snippet)
Raw Gmail API passthrough (one command per Discovery method, --params
carries path/query args, --json the request body):
gws gmail users messages list --params '{{"userId":"me","q":"is:unread"}}'
gws gmail users messages get --params '{{"userId":"me","id":"<id>"}}'
gws gmail users labels list --params '{{"userId":"me"}}'"""
WRITE_PROMPT = """\
Write commands:
gws-gmail-send --to "to@email.com" --subject "Hi" --body "..."
Write commands (official Google Workspace CLI helper syntax):
gws gmail +send --to "to@email.com" --subject "Hi" --body "..."
gws-gmail-reply --message-id <id> --body "..."
gws-gmail-reply-all --message-id <id> --body "..."
gws-gmail-forward --message-id <id> --to "to@email.com"
gws gmail +reply --message-id <id> --body "..."
gws gmail +reply-all --message-id <id> --body "..."
gws gmail +forward --message-id <id> --to "to@email.com"
Body gotcha: bash double-quoted "...\\n..." is NOT a newline.
Use $'line1\\nline2' (ANSI-C quoting) or "$(printf '...\\n...')"
@@ -18,7 +18,6 @@ from unittest.mock import AsyncMock, patch
import pytest
from mirage.accessor.gmail import GmailAccessor
from mirage.commands.builtin.gmail.gws_gmail_delete import gws_gmail_delete
from mirage.commands.builtin.gmail.gws_gmail_read import gws_gmail_read
from mirage.commands.builtin.gmail.gws_gmail_send import gws_gmail_send
from mirage.core.google._client import TokenManager
@@ -96,20 +95,3 @@ async def test_gws_gmail_read(accessor):
async def test_gws_gmail_read_missing_id(accessor):
with pytest.raises(ValueError, match="--id is required"):
await gws_gmail_read(accessor, [])
@pytest.mark.asyncio
async def test_gws_gmail_delete(accessor):
with patch(
"mirage.commands.builtin.gmail.gws_gmail_delete.trash_message",
new_callable=AsyncMock,
) as trash:
stream, io = await gws_gmail_delete(accessor, [], id="msg1")
assert stream is None
trash.assert_awaited_once_with(accessor.token_manager, "msg1")
@pytest.mark.asyncio
async def test_gws_gmail_delete_missing_id(accessor):
with pytest.raises(ValueError, match="--id is required"):
await gws_gmail_delete(accessor, [])
+3 -1
View File
@@ -13,6 +13,7 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import base64
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
@@ -78,10 +79,11 @@ def test_extract_attachments_nested():
@pytest.mark.asyncio
async def test_get_attachment():
encoded = base64.urlsafe_b64encode(b"hello world").decode().rstrip("=")
token_manager = SimpleNamespace(config=SimpleNamespace(api_base=None))
with patch(
"mirage.core.gmail.messages.google_get",
new_callable=AsyncMock,
return_value={"data": encoded},
):
result = await get_attachment(None, "msg1", "att1")
result = await get_attachment(token_manager, "msg1", "att1")
assert result == b"hello world"
+10 -5
View File
@@ -12,12 +12,15 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# yapf: disable
from mirage.core.google._client import (DOCS_API_BASE, DRIVE_API_BASE,
DRIVE_UPLOAD_BASE, SHEETS_API_BASE,
SLIDES_API_BASE, TOKEN_URL,
TokenManager, docs_base, drive_base,
drive_upload_base, sheets_base,
slides_base, token_url)
DRIVE_UPLOAD_BASE, GMAIL_API_BASE,
SHEETS_API_BASE, SLIDES_API_BASE,
TOKEN_URL, TokenManager, docs_base,
drive_base, drive_upload_base,
gmail_base, sheets_base, slides_base,
token_url)
# yapf: enable
from mirage.core.google.config import GoogleConfig
@@ -33,6 +36,7 @@ def test_bases_default_to_real_google_hosts():
assert docs_base(tm) == DOCS_API_BASE
assert slides_base(tm) == SLIDES_API_BASE
assert sheets_base(tm) == SHEETS_API_BASE
assert gmail_base(tm) == GMAIL_API_BASE
assert token_url(tm.config) == TOKEN_URL
@@ -43,4 +47,5 @@ def test_api_base_override_rewrites_every_service():
assert docs_base(tm) == "http://127.0.0.1:19999/v1"
assert slides_base(tm) == "http://127.0.0.1:19999/v1"
assert sheets_base(tm) == "http://127.0.0.1:19999/v4"
assert gmail_base(tm) == "http://127.0.0.1:19999/gmail/v1"
assert token_url(tm.config) == "http://127.0.0.1:19999/token"
+6 -6
View File
@@ -22,8 +22,8 @@ def test_prompt_includes_path_anatomy_and_processed_shape():
assert "after:/before:" in rendered
assert "mirage-processed" in rendered
assert ".body_text" in rendered
assert "gws-gmail-read" in rendered
assert "gws-gmail-triage" in rendered
assert "gws gmail +read" in rendered
assert "gws gmail +triage" in rendered
def test_prompt_documents_file_per_message_layout():
@@ -41,13 +41,13 @@ def test_prompt_mentions_grep_skips_binary_attachments():
def test_write_prompt_examples_match_actual_signatures():
assert "gws-gmail-send" in WRITE_PROMPT
assert "gws gmail +send" in WRITE_PROMPT
assert "--to" in WRITE_PROMPT
assert "--subject" in WRITE_PROMPT
assert "--body" in WRITE_PROMPT
assert "gws-gmail-reply" in WRITE_PROMPT
assert "gws-gmail-reply-all" in WRITE_PROMPT
assert "gws-gmail-forward" in WRITE_PROMPT
assert "gws gmail +reply" in WRITE_PROMPT
assert "gws gmail +reply-all" in WRITE_PROMPT
assert "gws gmail +forward" in WRITE_PROMPT
assert "--message-id" in WRITE_PROMPT
@@ -50,9 +50,13 @@ async function grepCommand(
): Promise<CommandFnResult> {
const pattern = patternArg(texts, opts.flags)
const maxCount = typeof opts.flags.m === 'string' ? Number.parseInt(opts.flags.m, 10) : null
// Output-shaping flags need real per-line matching, which the search-API
// push-down cannot emulate; fall through to the generic grep over
// rendered files instead.
const shaping = ['args_l', 'l', 'c', 'n', 'o', 'v', 'q'].some((flag) => opts.flags[flag] === true)
const first = paths[0]
if (first !== undefined && pattern !== null && !pattern.includes('\n')) {
if (first !== undefined && pattern !== null && !pattern.includes('\n') && !shaping) {
const scope = detectScope(first)
if (scope.useNative) {
const filePrefix =
@@ -1,55 +0,0 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { GmailAccessor } from '../../../accessor/gmail.ts'
import { trashMessage } from '../../../core/gmail/messages.ts'
import { IOResult } from '../../../io/types.ts'
import { ResourceName, type PathSpec } from '../../../types.ts'
import { command, type CommandFnResult, type CommandOpts } from '../../config.ts'
import { CommandSpec, OperandKind, Option } from '../../spec/types.ts'
const ENC = new TextEncoder()
const SPEC = new CommandSpec({
description: 'Move one Gmail message to Trash (reversible).',
options: [
new Option({
long: '--id',
valueKind: OperandKind.TEXT,
description: 'Gmail message ID (required)',
}),
],
})
async function gwsGmailDeleteCommand(
accessor: GmailAccessor,
_paths: PathSpec[],
_texts: string[],
opts: CommandOpts,
): Promise<CommandFnResult> {
const id = typeof opts.flags.id === 'string' ? opts.flags.id : ''
if (id === '') {
return [null, new IOResult({ exitCode: 2, stderr: ENC.encode('--id is required\n') })]
}
await trashMessage(accessor.tokenManager, id)
return [null, new IOResult()]
}
export const GMAIL_GWS_DELETE = command({
name: 'gws-gmail-delete',
resource: ResourceName.GMAIL,
spec: SPEC,
fn: gwsGmailDeleteCommand,
write: true,
})
@@ -62,7 +62,7 @@ async function gwsGmailForwardCommand(
}
export const GMAIL_GWS_FORWARD = command({
name: 'gws-gmail-forward',
name: 'gws gmail +forward',
resource: ResourceName.GMAIL,
spec: SPEC,
fn: gwsGmailForwardCommand,
@@ -49,7 +49,7 @@ async function gwsGmailReadCommand(
}
export const GMAIL_GWS_READ = command({
name: 'gws-gmail-read',
name: 'gws gmail +read',
resource: ResourceName.GMAIL,
spec: SPEC,
fn: gwsGmailReadCommand,
@@ -62,7 +62,7 @@ async function gwsGmailReplyCommand(
}
export const GMAIL_GWS_REPLY = command({
name: 'gws-gmail-reply',
name: 'gws gmail +reply',
resource: ResourceName.GMAIL,
spec: SPEC,
fn: gwsGmailReplyCommand,
@@ -62,7 +62,7 @@ async function gwsGmailReplyAllCommand(
}
export const GMAIL_GWS_REPLY_ALL = command({
name: 'gws-gmail-reply-all',
name: 'gws gmail +reply-all',
resource: ResourceName.GMAIL,
spec: SPEC,
fn: gwsGmailReplyAllCommand,
@@ -66,7 +66,7 @@ async function gwsGmailSendCommand(
}
export const GMAIL_GWS_SEND = command({
name: 'gws-gmail-send',
name: 'gws gmail +send',
resource: ResourceName.GMAIL,
spec: SPEC,
fn: gwsGmailSendCommand,
@@ -72,7 +72,7 @@ async function gwsGmailTriageCommand(
}
export const GMAIL_GWS_TRIAGE = command({
name: 'gws-gmail-triage',
name: 'gws gmail +triage',
resource: ResourceName.GMAIL,
spec: SPEC,
fn: gwsGmailTriageCommand,
@@ -16,8 +16,8 @@ import type { GmailAccessor } from '../../../accessor/gmail.ts'
import { ResourceName } from '../../../types.ts'
import type { ProvisionFn, RegisteredCommand } from '../../config.ts'
import { makeGenericCommands } from '../generic_bind/index.ts'
import { GWS_GMAIL_API_COMMANDS } from '../gws/index.ts'
import { GMAIL_GREP } from './grep.ts'
import { GMAIL_GWS_DELETE } from './gws_gmail_delete.ts'
import { GMAIL_GWS_FORWARD } from './gws_gmail_forward.ts'
import { GMAIL_GWS_READ } from './gws_gmail_read.ts'
import { GMAIL_GWS_REPLY } from './gws_gmail_reply.ts'
@@ -45,5 +45,5 @@ export const GMAIL_COMMANDS: readonly RegisteredCommand[] = [
...GMAIL_GWS_FORWARD,
...GMAIL_GWS_TRIAGE,
...GMAIL_GWS_READ,
...GMAIL_GWS_DELETE,
...GWS_GMAIL_API_COMMANDS,
]
@@ -55,8 +55,12 @@ async function rgCommand(
]
}
const maxCount = typeof opts.flags.m === 'string' ? Number.parseInt(opts.flags.m, 10) : null
// Output-shaping flags need real per-line matching, which the search-API
// push-down cannot emulate; fall through to the generic rg over rendered
// files instead.
const shaping = ['args_l', 'l', 'c', 'n', 'o', 'v'].some((flag) => opts.flags[flag] === true)
if (paths.length > 0 && !pattern.includes('\n')) {
if (paths.length > 0 && !pattern.includes('\n') && !shaping) {
const first = paths[0]
if (first !== undefined) {
const scope = detectScope(first)
@@ -19,3 +19,4 @@ export const GWS_DRIVE_API_COMMANDS: readonly RegisteredCommand[] = makeGwsApiCo
export const GWS_DOCS_API_COMMANDS: readonly RegisteredCommand[] = makeGwsApiCommands('docs')
export const GWS_SHEETS_API_COMMANDS: readonly RegisteredCommand[] = makeGwsApiCommands('sheets')
export const GWS_SLIDES_API_COMMANDS: readonly RegisteredCommand[] = makeGwsApiCommands('slides')
export const GWS_GMAIL_API_COMMANDS: readonly RegisteredCommand[] = makeGwsApiCommands('gmail')
@@ -13,7 +13,13 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { TokenManager } from '../../../core/google/_client.ts'
import { docsBase, driveBase, sheetsBase, slidesBase } from '../../../core/google/_client.ts'
import {
docsBase,
driveBase,
gmailBase,
sheetsBase,
slidesBase,
} from '../../../core/google/_client.ts'
import { ResourceName } from '../../../types.ts'
import { CommandSpec, OperandKind, Option } from '../../spec/types.ts'
@@ -23,7 +29,7 @@ import { CommandSpec, OperandKind, Option } from '../../spec/types.ts'
// Each entry here is one such passthrough method; the bespoke gws_*
// commands (create/batchUpdate/+read/+append/+write) stay hand-written.
export type GwsService = 'drive' | 'docs' | 'sheets' | 'slides'
export type GwsService = 'drive' | 'docs' | 'sheets' | 'slides' | 'gmail'
export interface GwsMethod {
service: GwsService
@@ -171,6 +177,49 @@ export const GWS_METHODS: readonly GwsMethod[] = [
http: 'DELETE',
path: '/files/{fileId}/permissions/{permissionId}',
},
{
service: 'gmail',
resource: 'users labels',
method: 'list',
http: 'GET',
path: '/users/{userId}/labels',
},
{
service: 'gmail',
resource: 'users messages',
method: 'list',
http: 'GET',
path: '/users/{userId}/messages',
},
{
service: 'gmail',
resource: 'users messages',
method: 'get',
http: 'GET',
path: '/users/{userId}/messages/{id}',
},
{
service: 'gmail',
resource: 'users messages',
method: 'send',
http: 'POST',
path: '/users/{userId}/messages/send',
needsBody: true,
},
{
service: 'gmail',
resource: 'users messages',
method: 'trash',
http: 'POST',
path: '/users/{userId}/messages/{id}/trash',
},
{
service: 'gmail',
resource: 'users messages attachments',
method: 'get',
http: 'GET',
path: '/users/{userId}/messages/{messageId}/attachments/{id}',
},
] as const
export const GWS_API_SPEC = new CommandSpec({
@@ -185,6 +234,7 @@ export const SERVICE_BASES: Record<GwsService, (tm: TokenManager) => string> = {
docs: docsBase,
sheets: sheetsBase,
slides: slidesBase,
gmail: gmailBase,
}
export const SERVICE_RESOURCES: Record<GwsService, string[]> = {
@@ -192,4 +242,5 @@ export const SERVICE_RESOURCES: Record<GwsService, string[]> = {
docs: [ResourceName.GDOCS, ResourceName.GDRIVE],
sheets: [ResourceName.GSHEETS, ResourceName.GDRIVE],
slides: [ResourceName.GSLIDES, ResourceName.GDRIVE],
gmail: [ResourceName.GMAIL],
}
@@ -12,7 +12,7 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { GMAIL_API_BASE, type TokenManager, googleGet } from '../google/_client.ts'
import { type TokenManager, gmailBase, googleGet } from '../google/_client.ts'
export interface GmailLabel {
id: string
@@ -25,7 +25,7 @@ interface LabelsResponse {
}
export async function listLabels(tokenManager: TokenManager): Promise<GmailLabel[]> {
const url = `${GMAIL_API_BASE}/users/me/labels`
const url = `${gmailBase(tokenManager)}/users/me/labels`
const data = (await googleGet(tokenManager, url)) as LabelsResponse
return data.labels ?? []
}
@@ -12,7 +12,7 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { GMAIL_API_BASE, type TokenManager, googleGet, googlePost } from '../google/_client.ts'
import { type TokenManager, gmailBase, googleGet } from '../google/_client.ts'
export interface GmailHeader {
name?: string
@@ -102,7 +102,7 @@ export async function listMessages(
if (opts.query !== undefined && opts.query !== null && opts.query !== '') {
params.q = opts.query
}
const url = `${GMAIL_API_BASE}/users/me/messages`
const url = `${gmailBase(tokenManager)}/users/me/messages`
const data = (await googleGet(tokenManager, url, params)) as ListMessagesResponse
return data.messages ?? []
}
@@ -111,15 +111,10 @@ export async function getMessageRaw(
tokenManager: TokenManager,
messageId: string,
): Promise<GmailMessageRaw> {
const url = `${GMAIL_API_BASE}/users/me/messages/${messageId}?format=full`
const url = `${gmailBase(tokenManager)}/users/me/messages/${messageId}?format=full`
return (await googleGet(tokenManager, url)) as GmailMessageRaw
}
export async function trashMessage(tokenManager: TokenManager, messageId: string): Promise<void> {
const url = `${GMAIL_API_BASE}/users/me/messages/${messageId}/trash`
await googlePost(tokenManager, url, {})
}
function base64UrlDecodeToBytes(input: string): Uint8Array {
const padded = input + '=='.slice((input.length + 2) % 4)
const standard = padded.replace(/-/g, '+').replace(/_/g, '/')
@@ -177,7 +172,7 @@ export async function getAttachment(
messageId: string,
attachmentId: string,
): Promise<Uint8Array> {
const url = `${GMAIL_API_BASE}/users/me/messages/${messageId}/attachments/${attachmentId}`
const url = `${gmailBase(tokenManager)}/users/me/messages/${messageId}/attachments/${attachmentId}`
const data = (await googleGet(tokenManager, url)) as { data?: string }
const raw = data.data ?? ''
return base64UrlDecodeToBytes(raw)
@@ -12,7 +12,7 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { GMAIL_API_BASE, type TokenManager, googlePost } from '../google/_client.ts'
import { type TokenManager, gmailBase, googlePost } from '../google/_client.ts'
import { extractHeader, getMessageProcessed, getMessageRaw } from './messages.ts'
const ENC = new TextEncoder()
@@ -46,7 +46,7 @@ export async function sendMessage(
): Promise<unknown> {
const mime = buildMime({ To: to, Subject: subject }, body)
const raw = base64UrlEncode(ENC.encode(mime))
const url = `${GMAIL_API_BASE}/users/me/messages/send`
const url = `${gmailBase(tokenManager)}/users/me/messages/send`
return googlePost(tokenManager, url, { raw })
}
@@ -72,7 +72,7 @@ export async function replyMessage(
const raw = base64UrlEncode(ENC.encode(mime))
const payload: Record<string, string> = { raw }
if (threadId !== '') payload.threadId = threadId
const url = `${GMAIL_API_BASE}/users/me/messages/send`
const url = `${gmailBase(tokenManager)}/users/me/messages/send`
return googlePost(tokenManager, url, payload)
}
@@ -102,7 +102,7 @@ export async function replyAllMessage(
const raw = base64UrlEncode(ENC.encode(mime))
const payload: Record<string, string> = { raw }
if (threadId !== '') payload.threadId = threadId
const url = `${GMAIL_API_BASE}/users/me/messages/send`
const url = `${gmailBase(tokenManager)}/users/me/messages/send`
return googlePost(tokenManager, url, payload)
}
@@ -52,6 +52,11 @@ export function sheetsBase(tokenManager: TokenManager): string {
return base !== undefined ? `${base}/v4` : SHEETS_API_BASE
}
export function gmailBase(tokenManager: TokenManager): string {
const base = tokenManager.config.apiBase
return base !== undefined ? `${base}/gmail/v1` : GMAIL_API_BASE
}
export class GoogleApiError extends Error {
readonly status: number
constructor(message: string, status: number) {
+1
View File
@@ -784,6 +784,7 @@ export {
docsBase,
driveBase,
driveUploadBase,
gmailBase,
sheetsBase,
slidesBase,
tokenUrl,
@@ -23,8 +23,8 @@ describe('GMAIL_PROMPT', () => {
expect(rendered).toContain('after:/before:')
expect(rendered).toContain('mirage-processed')
expect(rendered).toContain('.body_text')
expect(rendered).toContain('gws-gmail-read')
expect(rendered).toContain('gws-gmail-triage')
expect(rendered).toContain('gws gmail +read')
expect(rendered).toContain('gws gmail +triage')
})
it('documents file-per-message layout with sibling attachments dir', () => {
@@ -44,13 +44,13 @@ describe('GMAIL_PROMPT', () => {
describe('GMAIL_WRITE_PROMPT', () => {
it('matches actual command flag signatures', () => {
expect(GMAIL_WRITE_PROMPT).toContain('gws-gmail-send')
expect(GMAIL_WRITE_PROMPT).toContain('gws gmail +send')
expect(GMAIL_WRITE_PROMPT).toContain('--to')
expect(GMAIL_WRITE_PROMPT).toContain('--subject')
expect(GMAIL_WRITE_PROMPT).toContain('--body')
expect(GMAIL_WRITE_PROMPT).toContain('gws-gmail-reply')
expect(GMAIL_WRITE_PROMPT).toContain('gws-gmail-reply-all')
expect(GMAIL_WRITE_PROMPT).toContain('gws-gmail-forward')
expect(GMAIL_WRITE_PROMPT).toContain('gws gmail +reply')
expect(GMAIL_WRITE_PROMPT).toContain('gws gmail +reply-all')
expect(GMAIL_WRITE_PROMPT).toContain('gws gmail +forward')
expect(GMAIL_WRITE_PROMPT).toContain('--message-id')
})
@@ -23,7 +23,7 @@ export const GMAIL_PROMPT = `{prefix}
only. Read with \`cat\`/\`head\`/\`jq\` on \`<path>.gmail.json\` (keep the suffix).
Commands: cat, ls, head, tail, nl, wc, stat, find, tree, grep, rg, jq,
basename, dirname, realpath, gws-gmail-read, gws-gmail-triage.
basename, dirname, realpath, gws gmail +read, gws gmail +triage.
No others (no readFile, etc.).
Path: <label>/<yyyy-mm-dd>/<subject>__<message-id>.gmail.json
@@ -72,17 +72,23 @@ export const GMAIL_PROMPT = `{prefix}
.labels[]
.attachments[] | .filename
Read commands:
gws-gmail-read --id <message-id> # same shape as cat
gws-gmail-triage --query "is:unread" --max 20 # summary list (id, from,
# subject, date, snippet)`
Read commands (official Google Workspace CLI helper syntax):
gws gmail +read --id <message-id> # same shape as cat
gws gmail +triage --query "is:unread" --max 20 # summary list (id, from,
# subject, date, snippet)
export const GMAIL_WRITE_PROMPT = ` Write commands:
gws-gmail-send --to "to@email.com" --subject "Hi" --body "..."
Raw Gmail API passthrough (one command per Discovery method, --params
carries path/query args, --json the request body):
gws gmail users messages list --params '{"userId":"me","q":"is:unread"}'
gws gmail users messages get --params '{"userId":"me","id":"<id>"}'
gws gmail users labels list --params '{"userId":"me"}'`
gws-gmail-reply --message-id <id> --body "..."
gws-gmail-reply-all --message-id <id> --body "..."
gws-gmail-forward --message-id <id> --to "to@email.com"
export const GMAIL_WRITE_PROMPT = ` Write commands (official Google Workspace CLI helper syntax):
gws gmail +send --to "to@email.com" --subject "Hi" --body "..."
gws gmail +reply --message-id <id> --body "..."
gws gmail +reply-all --message-id <id> --body "..."
gws gmail +forward --message-id <id> --to "to@email.com"
Body gotcha: bash double-quoted "...\\n..." is NOT a newline.
Use $'line1\\nline2' (ANSI-C quoting) or "$(printf '...\\n...')"
@@ -63,7 +63,7 @@ async function emailForwardCommand(
}
export const EMAIL_FORWARD = command({
name: 'email-forward',
name: 'himalaya message forward',
resource: ResourceName.EMAIL,
spec: SPEC,
fn: emailForwardCommand,
@@ -56,7 +56,7 @@ async function emailReadCommand(
}
export const EMAIL_READ = command({
name: 'email-read',
name: 'himalaya message read',
resource: ResourceName.EMAIL,
spec: SPEC,
fn: emailReadCommand,
@@ -26,7 +26,7 @@ import {
} from '@struktoai/mirage-core'
import type { EmailAccessor } from '../../../accessor/email.ts'
import { fetchMessage } from '../../../core/email/_client.ts'
import { replyMessage } from '../../../core/email/send.ts'
import { replyAllMessage, replyMessage } from '../../../core/email/send.ts'
const ENC = new TextEncoder()
@@ -35,6 +35,7 @@ const SPEC = new CommandSpec({
new Option({ long: '--uid', valueKind: OperandKind.TEXT }),
new Option({ long: '--folder', valueKind: OperandKind.TEXT }),
new Option({ long: '--body', valueKind: OperandKind.TEXT }),
new Option({ long: '--all' }),
],
})
@@ -57,13 +58,16 @@ async function emailReplyCommand(
return [null, new IOResult({ exitCode: 2, stderr: ENC.encode('--body is required\n') })]
}
const original = await fetchMessage(accessor, folder, uid)
const result = await replyMessage(accessor.config, original, body)
const result =
opts.flags.all === true
? await replyAllMessage(accessor.config, original, body)
: await replyMessage(accessor.config, original, body)
const out: ByteSource = ENC.encode(JSON.stringify(result))
return [out, new IOResult()]
}
export const EMAIL_REPLY = command({
name: 'email-reply',
name: 'himalaya message reply',
resource: ResourceName.EMAIL,
spec: SPEC,
fn: emailReplyCommand,
@@ -1,71 +0,0 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import {
CommandSpec,
IOResult,
OperandKind,
Option,
ResourceName,
command,
type ByteSource,
type CommandFnResult,
type CommandOpts,
type PathSpec,
} from '@struktoai/mirage-core'
import type { EmailAccessor } from '../../../accessor/email.ts'
import { fetchMessage } from '../../../core/email/_client.ts'
import { replyAllMessage } from '../../../core/email/send.ts'
const ENC = new TextEncoder()
const SPEC = new CommandSpec({
options: [
new Option({ long: '--uid', valueKind: OperandKind.TEXT }),
new Option({ long: '--folder', valueKind: OperandKind.TEXT }),
new Option({ long: '--body', valueKind: OperandKind.TEXT }),
],
})
async function emailReplyAllCommand(
accessor: EmailAccessor,
_paths: PathSpec[],
_texts: string[],
opts: CommandOpts,
): Promise<CommandFnResult> {
const uid = typeof opts.flags.uid === 'string' ? opts.flags.uid : ''
const folder = typeof opts.flags.folder === 'string' ? opts.flags.folder : ''
const body = typeof opts.flags.body === 'string' ? opts.flags.body : ''
if (uid === '') {
return [null, new IOResult({ exitCode: 2, stderr: ENC.encode('--uid is required\n') })]
}
if (folder === '') {
return [null, new IOResult({ exitCode: 2, stderr: ENC.encode('--folder is required\n') })]
}
if (body === '') {
return [null, new IOResult({ exitCode: 2, stderr: ENC.encode('--body is required\n') })]
}
const original = await fetchMessage(accessor, folder, uid)
const result = await replyAllMessage(accessor.config, original, body)
const out: ByteSource = ENC.encode(JSON.stringify(result))
return [out, new IOResult()]
}
export const EMAIL_REPLY_ALL = command({
name: 'email-reply-all',
resource: ResourceName.EMAIL,
spec: SPEC,
fn: emailReplyAllCommand,
write: true,
})
@@ -61,7 +61,7 @@ async function emailSendCommand(
}
export const EMAIL_SEND = command({
name: 'email-send',
name: 'himalaya message send',
resource: ResourceName.EMAIL,
spec: SPEC,
fn: emailSendCommand,
@@ -76,7 +76,7 @@ async function emailTriageCommand(
}
export const EMAIL_TRIAGE = command({
name: 'email-triage',
name: 'himalaya envelope list',
resource: ResourceName.EMAIL,
spec: SPEC,
fn: emailTriageCommand,
@@ -13,6 +13,7 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import {
FileType,
IOResult,
PathSpec,
ResourceName,
@@ -27,9 +28,11 @@ import {
type ByteSource,
type CommandFnResult,
type CommandOpts,
type FileStat,
} from '@struktoai/mirage-core'
import type { EmailAccessor } from '../../../accessor/email.ts'
import { readdir as emailReaddir } from '../../../core/email/readdir.ts'
import { stat as emailStat } from '../../../core/email/stat.ts'
import { EMAIL_IO } from './io.ts'
import { metadataProvision } from './provision.ts'
import { fnmatch } from '@struktoai/mirage-core'
@@ -53,16 +56,23 @@ async function walk(
}
const results: string[] = []
for (const child of children) {
const isFolder = child.endsWith('/')
const trimmed = isFolder ? rstripSlash(child) : child
const trimmed = rstripSlash(child)
results.push(trimmed)
if (isFolder) {
const childSpec = new PathSpec({
virtual: trimmed,
directory: trimmed,
resolved: false,
resourcePath: mountKey(trimmed, mountPrefixOf(path.virtual, path.resourcePath)),
})
const childSpec = new PathSpec({
virtual: trimmed,
directory: trimmed,
resolved: false,
resourcePath: mountKey(trimmed, mountPrefixOf(path.virtual, path.resourcePath)),
})
// readdir emits plain names, so directory-ness comes from stat (served
// from the index entries the readdir above just wrote).
let st: FileStat
try {
st = await emailStat(accessor, childSpec, index ?? undefined)
} catch {
continue
}
if (st.type === FileType.DIRECTORY) {
const sub = await walk(accessor, childSpec, index, maxDepth, depth + 1)
results.push(...sub)
}
@@ -14,29 +14,21 @@
import {
IOResult,
PathSpec,
ResourceName,
command,
compilePattern,
exitOnEmpty,
formatRecords,
grepFilesOnly,
grepGeneric,
grepLines,
grepStream,
mountKey,
mountPrefixOf,
prefixAggregate,
quietMatch,
resolveGlobOf,
resolveSource,
specOf,
type AsyncReadBytesFn,
type AsyncReaddirFn,
type AsyncStatFn,
type ByteSource,
type CommandFnResult,
type CommandOpts,
yieldBytes,
type FileStat,
type IndexCacheStore,
type PathSpec,
} from '@struktoai/mirage-core'
import type { EmailAccessor } from '../../../accessor/email.ts'
import { read as emailRead } from '../../../core/email/read.ts'
@@ -50,7 +42,14 @@ import { fileReadProvision } from './provision.ts'
const resolveGlob = resolveGlobOf(EMAIL_IO)
const ENC = new TextEncoder()
const DEC = new TextDecoder('utf-8', { fatal: false })
async function* emailStream(
accessor: EmailAccessor,
p: PathSpec,
index?: IndexCacheStore,
): AsyncIterable<Uint8Array> {
yield await emailRead(accessor, p, index)
}
interface FlagSet {
ignoreCase: boolean
@@ -98,11 +97,6 @@ function getPattern(
throw new Error('grep: usage: grep [flags] pattern [path]')
}
function splitLinesNoTrailing(text: string): string[] {
const stripped = text.endsWith('\n') ? text.slice(0, -1) : text
return stripped === '' ? [] : stripped.split('\n')
}
async function grepCommand(
accessor: EmailAccessor,
paths: PathSpec[],
@@ -117,7 +111,6 @@ async function grepCommand(
return [null, new IOResult({ exitCode: 2, stderr: ENC.encode(`${msg}\n`) })]
}
const f = parseFlags(opts.flags)
const recursive = opts.flags.r === true || opts.flags.R === true
if (paths.length > 0) {
const first = paths[0]
@@ -144,118 +137,16 @@ async function grepCommand(
return [out, new IOResult()]
}
}
const resolved = await resolveGlob(accessor, paths, opts.index ?? undefined)
if (resolved.length === 0) return [new Uint8Array(0), new IOResult({ exitCode: 1 })]
const filePrefix =
(resolved[0] === undefined
? undefined
: mountPrefixOf(resolved[0].virtual, resolved[0].resourcePath)) ?? ''
const readdirFn: AsyncReaddirFn = async (path) => {
const spec = new PathSpec({
virtual: path,
directory: path,
resolved: false,
resourcePath: mountKey(path, filePrefix),
})
return emailReaddir(accessor, spec, opts.index ?? undefined)
}
const statFn: AsyncStatFn = async (path) => {
const spec = new PathSpec({
virtual: path,
directory: path,
resolved: false,
resourcePath: mountKey(path, filePrefix),
})
return emailStat(accessor, spec, opts.index ?? undefined)
}
const readBytesFn: AsyncReadBytesFn = async (path) => {
const spec = new PathSpec({
virtual: path,
directory: path,
resolved: true,
resourcePath: mountKey(path, filePrefix),
})
return emailRead(accessor, spec, opts.index ?? undefined)
}
if (f.filesOnly) {
const warnings: string[] = []
const firstResolved = resolved[0]
if (firstResolved === undefined) return [new Uint8Array(0), new IOResult({ exitCode: 1 })]
const results = await grepFilesOnly(
readdirFn,
statFn,
readBytesFn,
firstResolved.virtual,
pattern,
{
recursive,
ignoreCase: f.ignoreCase,
invert: f.invert,
lineNumbers: f.lineNumbers,
countOnly: f.countOnly,
fixedString: f.fixedString,
onlyMatching: f.onlyMatching,
maxCount: f.maxCount,
wholeWord: f.wholeWord,
},
warnings,
)
const stderr = warnings.length > 0 ? formatRecords(warnings) : null
if (results.length === 0) return [new Uint8Array(0), new IOResult({ exitCode: 1, stderr })]
const out: ByteSource = formatRecords(results)
return [out, new IOResult({ stderr })]
}
const pat = compilePattern(pattern, f.ignoreCase, f.fixedString, f.wholeWord)
if (resolved.length > 1) {
const allResults: string[] = []
for (const p of resolved) {
const data = splitLinesNoTrailing(
DEC.decode(await emailRead(accessor, p, opts.index ?? undefined)),
)
const hits = grepLines(p.virtual, data, pat, f)
if (f.countOnly) {
if (hits.length > 0) allResults.push(`${p.virtual}:${hits[0] ?? ''}`)
} else {
for (const h of hits) allResults.push(`${p.virtual}:${h}`)
}
}
if (allResults.length === 0) return [new Uint8Array(0), new IOResult({ exitCode: 1 })]
const out: ByteSource = formatRecords(allResults)
return [out, new IOResult()]
}
const firstResolved = resolved[0]
if (firstResolved === undefined) return [null, new IOResult()]
const data = await emailRead(accessor, firstResolved, opts.index ?? undefined)
const source = yieldBytes(data)
const stream = grepStream(source, pat, f)
if (f.quiet) {
const io = new IOResult({ exitCode: 1 })
return [quietMatch(stream, io), io]
}
const io = new IOResult()
return [exitOnEmpty(stream, io), io]
}
let source: AsyncIterable<Uint8Array>
try {
source = resolveSource(opts.stdin, 'grep: usage: grep [flags] pattern [path]')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return [null, new IOResult({ exitCode: 2, stderr: ENC.encode(`${msg}\n`) })]
}
const pat = compilePattern(pattern, f.ignoreCase, f.fixedString, f.wholeWord)
const stream = grepStream(source, pat, f)
if (f.quiet) {
const io = new IOResult({ exitCode: 1 })
return [quietMatch(stream, io), io]
}
const io = new IOResult()
return [exitOnEmpty(stream, io), io]
const resolved =
paths.length > 0 ? await resolveGlob(accessor, paths, opts.index ?? undefined) : []
const stat = (p: PathSpec): Promise<FileStat> => emailStat(accessor, p, opts.index ?? undefined)
const readdir = (p: PathSpec): Promise<string[]> =>
emailReaddir(accessor, p, opts.index ?? undefined)
return grepGeneric('grep', resolved, texts, opts, stat, readdir, (p) =>
emailStream(accessor, p, opts.index ?? undefined),
)
}
export const EMAIL_GREP = command({
@@ -20,7 +20,6 @@ import { EMAIL_GREP } from './grep.ts'
import { EMAIL_IO } from './io.ts'
import { EMAIL_READ } from './email_read.ts'
import { EMAIL_REPLY } from './email_reply.ts'
import { EMAIL_REPLY_ALL } from './email_reply_all.ts'
import { EMAIL_RG } from './rg.ts'
import { EMAIL_SEND } from './email_send.ts'
import { EMAIL_TRIAGE } from './email_triage.ts'
@@ -36,7 +35,6 @@ export const EMAIL_COMMANDS: readonly RegisteredCommand[] = [
...EMAIL_RG,
...EMAIL_SEND,
...EMAIL_REPLY,
...EMAIL_REPLY_ALL,
...EMAIL_FORWARD,
...EMAIL_TRIAGE,
...EMAIL_READ,
@@ -185,11 +185,12 @@ export async function fetchHeaders(
try {
const results: FetchedMessage[] = []
for (const uid of uids) {
const msg = await imap.fetchOne(uid, { headers: true, flags: true, uid: true }, { uid: true })
// Full source (not headers-only): listings need the MIME structure
// to surface attachment dirs, mirroring the python backend.
const msg = await imap.fetchOne(uid, { source: true, flags: true, uid: true }, { uid: true })
if (msg === false) continue
const headers =
msg.headers instanceof Buffer ? new Uint8Array(msg.headers) : new Uint8Array(0)
const parsed = await parseRfc822(headers, true)
const source = msg.source instanceof Buffer ? new Uint8Array(msg.source) : new Uint8Array(0)
const parsed = await parseRfc822(source)
results.push({
...parsed,
uid,
@@ -78,6 +78,15 @@ function toAddrList(list: AddressObject | AddressObject[] | undefined): EmailAdd
return objs.flatMap((obj) => obj.value.map((a) => toAddr(a)))
}
// The rendered date must be the raw Date header text (python serves the
// header untouched); mailparser's parsed.date would re-format it.
function rawDateHeader(parsed: ParsedMail): string {
const found = parsed.headerLines.find((h) => h.key === 'date')
if (found === undefined) return ''
const colon = found.line.indexOf(':')
return colon === -1 ? '' : found.line.slice(colon + 1).trim()
}
function fromParsed(parsed: ParsedMail, headersOnly: boolean): ParsedRfc822 {
const text = headersOnly ? '' : (parsed.text ?? '')
const html = headersOnly ? '' : typeof parsed.html === 'string' ? parsed.html : ''
@@ -96,7 +105,7 @@ function fromParsed(parsed: ParsedMail, headersOnly: boolean): ParsedRfc822 {
to: toAddrList(parsed.to),
cc: toAddrList(parsed.cc),
subject: parsed.subject ?? '',
date: parsed.date instanceof Date ? parsed.date.toUTCString() : '',
date: rawDateHeader(parsed),
body_text: text,
body_html: html,
snippet: text.slice(0, 100),
@@ -12,10 +12,11 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import type { IndexCacheStore, PathSpec } from '@struktoai/mirage-core'
import { FileStat, FileType, mountPrefixOf } from '@struktoai/mirage-core'
import type { IndexCacheStore } from '@struktoai/mirage-core'
import { FileStat, FileType, PathSpec, mountKey, mountPrefixOf } from '@struktoai/mirage-core'
import type { EmailAccessor } from '../../accessor/email.ts'
import { listFolders } from './folders.ts'
import { readdir } from './readdir.ts'
function guessType(name: string): FileType {
const lower = name.toLowerCase()
@@ -50,14 +51,32 @@ export async function stat(
if (index === undefined) throw enoent(path.virtual)
const virtualKey = prefix !== '' ? `${prefix}/${key}` : `/${key}`
const result = await index.get(virtualKey)
let result = await index.get(virtualKey)
if (result.entry === undefined || result.entry === null) {
if (!key.includes('/')) {
const folders = await listFolders(accessor)
if (folders.includes(key)) return new FileStat({ name: key, type: FileType.DIRECTORY })
throw enoent(path.virtual)
}
throw enoent(path.virtual)
// Cold index: populate by listing the parent, mirroring the python
// backend's stat fallback.
const parentVirtual = virtualKey.slice(0, virtualKey.lastIndexOf('/')) || '/'
try {
await readdir(
accessor,
new PathSpec({
virtual: parentVirtual,
directory: parentVirtual,
resolved: false,
resourcePath: mountKey(parentVirtual, prefix),
}),
index,
)
} catch {
throw enoent(path.virtual)
}
result = await index.get(virtualKey)
if (result.entry === undefined || result.entry === null) throw enoent(path.virtual)
}
const rt = result.entry.resourceType
const vfsName = result.entry.vfsName !== '' ? result.entry.vfsName : result.entry.name
@@ -18,9 +18,13 @@ export const EMAIL_PROMPT = `{prefix}
<subject>__<uid>.email.json
<subject>__<uid>/ # if attachments exist
<attachment-filename>
Folders include: INBOX, Sent, Drafts, etc. cat shows email as JSON.`
Folders include: INBOX, Sent, Drafts, etc. cat shows email as JSON.
Read commands:
himalaya envelope list --folder INBOX --unseen # id/from/subject/date
himalaya message read --folder INBOX --uid <uid> # one message as JSON`
export const EMAIL_WRITE_PROMPT = ` Write commands:
email-send "to@email.com" "subject" "body"
email-reply <email-path> "reply body"
email-forward <email-path> "to@email.com"`
himalaya message send --to "to@email.com" --subject "Hi" --body "..."
himalaya message reply --folder INBOX --uid <uid> --body "..." [--all]
himalaya message forward --folder INBOX --uid <uid> --to "to@email.com"`
+3
View File
@@ -239,6 +239,9 @@ importers:
chromadb:
specifier: ^3.4.3
version: 3.4.3
imapflow:
specifier: ^1.3.2
version: 1.3.2
mongodb:
specifier: ^6.0.0
version: 6.21.0(socks@2.8.7)