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:
@@ -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 &
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}'
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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]:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface Target {
|
||||
service?: string
|
||||
epoch?: string
|
||||
apps?: string
|
||||
mail?: string
|
||||
mounts: Mount[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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" """
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user