feat(gmail): add +reply, +reply-all, and +forward helpers (#105)
* feat(gmail): add +reply, +reply-all, and +forward helper commands Add first-class reply and forward support to the Gmail helpers, addressing the gap described in #88. These commands handle the complex RFC 2822 threading mechanics (In-Reply-To, References, threadId) that agents and CLI users struggle with today. New commands: - +reply: reply to a message with automatic threading - +reply-all: reply to all recipients with --remove/--cc support - +forward: forward a message with quoted original content * fix(gmail): encode message_id in URL path and fix auth signature - Use crate::validate::encode_path_segment() on message_id in fetch_message_metadata URL construction per AGENTS.md rules - Update auth::get_token calls to pass None for the new account parameter added on main * refactor(gmail): extract send_raw_email and deduplicate handlers - Add send_raw_email() to mod.rs: shared encode→json→auth→execute pattern for sending raw RFC 2822 messages via users.messages.send - Simplify handle_reply: delegate send logic to send_raw_email - Simplify handle_forward: delegate send logic to send_raw_email Addresses code duplication feedback from PR review. * fix(gmail): register --dry-run flag on reply/forward commands The handlers read matches.get_flag("dry-run") but the flag was missing from the clap command definitions, so it always returned false. Now dry-run works for +reply, +reply-all, and +forward. * chore: add changeset for gmail reply/forward feature * style: apply cargo fmt formatting * fix(gmail): register --dry-run flag on +send command Same class of bug fixed for +reply/+reply-all/+forward — the handler reads matches.get_flag("dry-run") but the arg was not registered. * fix(gmail): honor Reply-To header and use exact address matching - Prefer Reply-To over From when selecting reply recipients, fixing incorrect routing for mailing lists and support systems - Use exact email address comparison instead of substring matching for --remove filtering and sender deduplication, preventing unintended recipient removal (e.g. ann@ no longer drops joann@) * test(gmail): add comprehensive coverage for reply address handling - extract_email: malformed input (no closing bracket), empty string, whitespace-only - build_reply_all_recipients: display-name sender exclusion, --remove with display name, extra --cc, CC becomes None when all filtered, case-insensitive sender exclusion * Improves reply-all recipient deduplication Corrects how `build_reply_all_recipients` handles multi-address `Reply-To` headers. Previously, only the first address from `Reply-To` was used for deduplication, leading to potential redundancy by including those addresses in the `Cc` field. The updated logic now parses all addresses in `Reply-To`, ensuring they are fully moved to the `To` field and properly excluded from `Cc`. * style(gmail): add missing Apache 2.0 copyright headers reply.rs and forward.rs were missing the copyright header that all other source files in the repo include. * fix(gmail): use try_get_one for optional --remove arg in +reply parse_reply_args used get_one("remove") which panics when called from +reply (which does not register --remove). Switch to try_get_one to safely return None for unregistered args. * feat(gmail): support --dry-run without auth for reply/forward commands Skip auth and message fetch when --dry-run is set by using placeholder OriginalMessage data. This lets users preview the request structure without needing credentials. * fix(gmail): use RFC-aware mailbox list parsing for recipient splitting Replace naive comma-split with split_mailbox_list that respects quoted strings, so display names containing commas like "Doe, John" <john@example.com> are handled correctly in reply-all recipient parsing, deduplication, and --remove filtering. * fix(gmail): handle escaped quotes in mailbox list splitting split_mailbox_list toggled quote state on every `"` without accounting for backslash-escaped quotes (`\"`), causing display names like `"Doe \"JD, Sr\""` to split incorrectly at interior commas. Track `prev_backslash` so `\"` inside quoted strings is treated as a literal quote character rather than a delimiter toggle. Double backslashes (`\\`) are handled correctly as well. * fix(gmail): address PR review feedback for reply/forward helpers - Use reqwest .query() for metadata params per AGENTS.md convention - Add MIME-Version and Content-Type headers to raw messages - Add --from flag to +reply, +reply-all, +forward for send-as/alias - Narrow ReplyConfig/ForwardConfig visibility to pub(super) - Refactor create_reply_raw_message args into ReplyEnvelope struct * fix(gmail): address review feedback for reply/forward helpers - Exclude authenticated user's own email from reply-all CC by fetching user profile via Gmail API - Use format=full to extract full plain-text body instead of truncated snippet for quoting and forwarding - Deduplicate CC addresses using a HashSet - Reuse auth token from message fetch in send_raw_email to eliminate double auth round-trip - Propagate auth errors in send_raw_email instead of silently falling back to unauthenticated requests - Use consistent CRLF line endings in quoted and forwarded message bodies per RFC 2822 * fix(gmail): Gmail reply and forward helpers * fix(gmail): refactor shared reply-forward helpers * Preserve repeated Gmail address headers * chore: regenerate skills [skip ci] --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@googleworkspace/cli": minor
|
||||
---
|
||||
|
||||
feat(gmail): add +reply, +reply-all, and +forward helpers
|
||||
|
||||
Adds three new Gmail helper commands:
|
||||
- `+reply` -- reply to a message with automatic threading
|
||||
- `+reply-all` -- reply to all recipients with --remove/--cc support
|
||||
- `+forward` -- forward a message to new recipients
|
||||
@@ -38,6 +38,9 @@ Shortcut commands for common operations.
|
||||
| [gws-sheets-read](../skills/gws-sheets-read/SKILL.md) | Google Sheets: Read values from a spreadsheet. |
|
||||
| [gws-gmail-send](../skills/gws-gmail-send/SKILL.md) | Gmail: Send an email. |
|
||||
| [gws-gmail-triage](../skills/gws-gmail-triage/SKILL.md) | Gmail: Show unread inbox summary (sender, subject, date). |
|
||||
| [gws-gmail-reply](../skills/gws-gmail-reply/SKILL.md) | Gmail: Reply to a message (handles threading automatically). |
|
||||
| [gws-gmail-reply-all](../skills/gws-gmail-reply-all/SKILL.md) | Gmail: Reply-all to a message (handles threading automatically). |
|
||||
| [gws-gmail-forward](../skills/gws-gmail-forward/SKILL.md) | Gmail: Forward a message to new recipients. |
|
||||
| [gws-gmail-watch](../skills/gws-gmail-watch/SKILL.md) | Gmail: Watch for new emails and stream them as NDJSON. |
|
||||
| [gws-calendar-insert](../skills/gws-calendar-insert/SKILL.md) | Google Calendar: Create a new event. |
|
||||
| [gws-calendar-agenda](../skills/gws-calendar-agenda/SKILL.md) | Google Calendar: Show upcoming events across all calendars. |
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: gws-gmail-forward
|
||||
version: 1.0.0
|
||||
description: "Gmail: Forward a message to new recipients."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws gmail +forward --help"
|
||||
---
|
||||
|
||||
# gmail +forward
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Forward a message to new recipients
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws gmail +forward --message-id <ID> --to <EMAILS>
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--message-id` | ✓ | — | Gmail message ID to forward |
|
||||
| `--to` | ✓ | — | Recipient email address(es), comma-separated |
|
||||
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
|
||||
| `--cc` | — | — | CC recipients (comma-separated) |
|
||||
| `--body` | — | — | Optional note to include above the forwarded message |
|
||||
| `--dry-run` | — | — | Show the request that would be sent without executing it |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com
|
||||
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI see below'
|
||||
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Includes the original message with sender, date, subject, and recipients.
|
||||
- Sends the forward as a new message rather than forcing it into the original thread.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-gmail](../gws-gmail/SKILL.md) — All send, read, and manage email commands
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: gws-gmail-reply-all
|
||||
version: 1.0.0
|
||||
description: "Gmail: Reply-all to a message (handles threading automatically)."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws gmail +reply-all --help"
|
||||
---
|
||||
|
||||
# gmail +reply-all
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Reply-all to a message (handles threading automatically)
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws gmail +reply-all --message-id <ID> --body <TEXT>
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--message-id` | ✓ | — | Gmail message ID to reply to |
|
||||
| `--body` | ✓ | — | Reply body (plain text) |
|
||||
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
|
||||
| `--cc` | — | — | Additional CC recipients (comma-separated) |
|
||||
| `--remove` | — | — | Exclude recipients from the outgoing reply (comma-separated emails) |
|
||||
| `--dry-run` | — | — | Show the request that would be sent without executing it |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Sounds good to me!'
|
||||
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Updated' --remove bob@example.com
|
||||
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Replies to the sender and all original To/CC recipients.
|
||||
- Use --remove to exclude recipients from the outgoing reply, including the sender or Reply-To target.
|
||||
- The command fails if exclusions leave no reply target.
|
||||
- Use --cc to add new recipients.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-gmail](../gws-gmail/SKILL.md) — All send, read, and manage email commands
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: gws-gmail-reply
|
||||
version: 1.0.0
|
||||
description: "Gmail: Reply to a message (handles threading automatically)."
|
||||
metadata:
|
||||
openclaw:
|
||||
category: "productivity"
|
||||
requires:
|
||||
bins: ["gws"]
|
||||
cliHelp: "gws gmail +reply --help"
|
||||
---
|
||||
|
||||
# gmail +reply
|
||||
|
||||
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
|
||||
|
||||
Reply to a message (handles threading automatically)
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws gmail +reply --message-id <ID> --body <TEXT>
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `--message-id` | ✓ | — | Gmail message ID to reply to |
|
||||
| `--body` | ✓ | — | Reply body (plain text) |
|
||||
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
|
||||
| `--cc` | — | — | Additional CC recipients (comma-separated) |
|
||||
| `--dry-run` | — | — | Show the request that would be sent without executing it |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gws gmail +reply --message-id 18f1a2b3c4d --body 'Thanks, got it!'
|
||||
gws gmail +reply --message-id 18f1a2b3c4d --body 'Looping in Carol' --cc carol@example.com
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Automatically sets In-Reply-To, References, and threadId headers.
|
||||
- Quotes the original message in the reply body.
|
||||
- For reply-all, use +reply-all instead.
|
||||
|
||||
## See Also
|
||||
|
||||
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
|
||||
- [gws-gmail](../gws-gmail/SKILL.md) — All send, read, and manage email commands
|
||||
@@ -29,6 +29,7 @@ gws gmail +send --to <EMAIL> --subject <SUBJECT> --body <TEXT>
|
||||
| `--to` | ✓ | — | Recipient email address |
|
||||
| `--subject` | ✓ | — | Email subject |
|
||||
| `--body` | ✓ | — | Email body (plain text) |
|
||||
| `--dry-run` | — | — | Show the request that would be sent without executing it |
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ gws gmail <resource> <method> [flags]
|
||||
|---------|-------------|
|
||||
| [`+send`](../gws-gmail-send/SKILL.md) | Send an email |
|
||||
| [`+triage`](../gws-gmail-triage/SKILL.md) | Show unread inbox summary (sender, subject, date) |
|
||||
| [`+reply`](../gws-gmail-reply/SKILL.md) | Reply to a message (handles threading automatically) |
|
||||
| [`+reply-all`](../gws-gmail-reply-all/SKILL.md) | Reply-all to a message (handles threading automatically) |
|
||||
| [`+forward`](../gws-gmail-forward/SKILL.md) | Forward a message to new recipients |
|
||||
| [`+watch`](../gws-gmail-watch/SKILL.md) | Watch for new emails and stream them as NDJSON |
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// 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.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Handle the `+forward` subcommand.
|
||||
pub(super) async fn handle_forward(
|
||||
doc: &crate::discovery::RestDescription,
|
||||
matches: &ArgMatches,
|
||||
) -> Result<(), GwsError> {
|
||||
let config = parse_forward_args(matches);
|
||||
let dry_run = matches.get_flag("dry-run");
|
||||
|
||||
let (original, token) = if dry_run {
|
||||
(
|
||||
OriginalMessage::dry_run_placeholder(&config.message_id),
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
let t = auth::get_token(&[GMAIL_SCOPE])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
|
||||
let client = crate::client::build_client()?;
|
||||
let orig = fetch_message_metadata(&client, &t, &config.message_id).await?;
|
||||
(orig, Some(t))
|
||||
};
|
||||
|
||||
let subject = build_forward_subject(&original.subject);
|
||||
let raw = create_forward_raw_message(
|
||||
&config.to,
|
||||
config.cc.as_deref(),
|
||||
config.from.as_deref(),
|
||||
&subject,
|
||||
config.body_text.as_deref(),
|
||||
&original,
|
||||
);
|
||||
|
||||
super::send_raw_email(doc, matches, &raw, None, token.as_deref()).await
|
||||
}
|
||||
|
||||
pub(super) struct ForwardConfig {
|
||||
pub message_id: String,
|
||||
pub to: String,
|
||||
pub from: Option<String>,
|
||||
pub cc: Option<String>,
|
||||
pub body_text: Option<String>,
|
||||
}
|
||||
|
||||
fn build_forward_subject(original_subject: &str) -> String {
|
||||
if original_subject.to_lowercase().starts_with("fwd:") {
|
||||
original_subject.to_string()
|
||||
} else {
|
||||
format!("Fwd: {}", original_subject)
|
||||
}
|
||||
}
|
||||
|
||||
fn create_forward_raw_message(
|
||||
to: &str,
|
||||
cc: Option<&str>,
|
||||
from: Option<&str>,
|
||||
subject: &str,
|
||||
body: Option<&str>,
|
||||
original: &OriginalMessage,
|
||||
) -> String {
|
||||
let mut headers = format!(
|
||||
"To: {}\r\nSubject: {}\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8",
|
||||
to, subject
|
||||
);
|
||||
|
||||
if let Some(from) = from {
|
||||
headers.push_str(&format!("\r\nFrom: {}", from));
|
||||
}
|
||||
|
||||
if let Some(cc) = cc {
|
||||
headers.push_str(&format!("\r\nCc: {}", cc));
|
||||
}
|
||||
|
||||
let forwarded_block = format_forwarded_message(original);
|
||||
|
||||
match body {
|
||||
Some(body) => format!("{}\r\n\r\n{}\r\n\r\n{}", headers, body, forwarded_block),
|
||||
None => format!("{}\r\n\r\n{}", headers, forwarded_block),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_forwarded_message(original: &OriginalMessage) -> String {
|
||||
format!(
|
||||
"---------- Forwarded message ---------\r\n\
|
||||
From: {}\r\n\
|
||||
Date: {}\r\n\
|
||||
Subject: {}\r\n\
|
||||
To: {}\r\n\
|
||||
{}{}\r\n\
|
||||
----------",
|
||||
original.from,
|
||||
original.date,
|
||||
original.subject,
|
||||
original.to,
|
||||
if original.cc.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("Cc: {}\r\n", original.cc)
|
||||
},
|
||||
original.body_text
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_forward_args(matches: &ArgMatches) -> ForwardConfig {
|
||||
ForwardConfig {
|
||||
message_id: matches.get_one::<String>("message-id").unwrap().to_string(),
|
||||
to: matches.get_one::<String>("to").unwrap().to_string(),
|
||||
from: matches.get_one::<String>("from").map(|s| s.to_string()),
|
||||
cc: matches.get_one::<String>("cc").map(|s| s.to_string()),
|
||||
body_text: matches.get_one::<String>("body").map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_forward_subject_without_prefix() {
|
||||
assert_eq!(build_forward_subject("Hello"), "Fwd: Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_forward_subject_with_prefix() {
|
||||
assert_eq!(build_forward_subject("Fwd: Hello"), "Fwd: Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_forward_subject_case_insensitive() {
|
||||
assert_eq!(build_forward_subject("FWD: Hello"), "FWD: Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_forward_raw_message_without_body() {
|
||||
let original = super::super::OriginalMessage {
|
||||
thread_id: "t1".to_string(),
|
||||
message_id_header: "<abc@example.com>".to_string(),
|
||||
references: "".to_string(),
|
||||
from: "alice@example.com".to_string(),
|
||||
reply_to: "".to_string(),
|
||||
to: "bob@example.com".to_string(),
|
||||
cc: "".to_string(),
|
||||
subject: "Hello".to_string(),
|
||||
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
|
||||
body_text: "Original content".to_string(),
|
||||
};
|
||||
|
||||
let raw = create_forward_raw_message(
|
||||
"dave@example.com",
|
||||
None,
|
||||
None,
|
||||
"Fwd: Hello",
|
||||
None,
|
||||
&original,
|
||||
);
|
||||
|
||||
assert!(raw.contains("To: dave@example.com"));
|
||||
assert!(raw.contains("Subject: Fwd: Hello"));
|
||||
assert!(raw.contains("---------- Forwarded message ---------"));
|
||||
assert!(raw.contains("From: alice@example.com"));
|
||||
assert!(raw.contains("Original content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_forward_raw_message_with_body_and_cc() {
|
||||
let original = super::super::OriginalMessage {
|
||||
thread_id: "t1".to_string(),
|
||||
message_id_header: "<abc@example.com>".to_string(),
|
||||
references: "".to_string(),
|
||||
from: "alice@example.com".to_string(),
|
||||
reply_to: "".to_string(),
|
||||
to: "bob@example.com".to_string(),
|
||||
cc: "carol@example.com".to_string(),
|
||||
subject: "Hello".to_string(),
|
||||
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
|
||||
body_text: "Original content".to_string(),
|
||||
};
|
||||
|
||||
let raw = create_forward_raw_message(
|
||||
"dave@example.com",
|
||||
Some("eve@example.com"),
|
||||
None,
|
||||
"Fwd: Hello",
|
||||
Some("FYI see below"),
|
||||
&original,
|
||||
);
|
||||
|
||||
assert!(raw.contains("Cc: eve@example.com"));
|
||||
assert!(raw.contains("FYI see below"));
|
||||
assert!(raw.contains("Cc: carol@example.com"));
|
||||
}
|
||||
|
||||
fn make_forward_matches(args: &[&str]) -> ArgMatches {
|
||||
let cmd = Command::new("test")
|
||||
.arg(Arg::new("message-id").long("message-id"))
|
||||
.arg(Arg::new("to").long("to"))
|
||||
.arg(Arg::new("from").long("from"))
|
||||
.arg(Arg::new("cc").long("cc"))
|
||||
.arg(Arg::new("body").long("body"))
|
||||
.arg(
|
||||
Arg::new("dry-run")
|
||||
.long("dry-run")
|
||||
.action(ArgAction::SetTrue),
|
||||
);
|
||||
cmd.try_get_matches_from(args).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_forward_args() {
|
||||
let matches =
|
||||
make_forward_matches(&["test", "--message-id", "abc123", "--to", "dave@example.com"]);
|
||||
let config = parse_forward_args(&matches);
|
||||
assert_eq!(config.message_id, "abc123");
|
||||
assert_eq!(config.to, "dave@example.com");
|
||||
assert!(config.cc.is_none());
|
||||
assert!(config.body_text.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_forward_args_with_all_options() {
|
||||
let matches = make_forward_matches(&[
|
||||
"test",
|
||||
"--message-id",
|
||||
"abc123",
|
||||
"--to",
|
||||
"dave@example.com",
|
||||
"--cc",
|
||||
"eve@example.com",
|
||||
"--body",
|
||||
"FYI",
|
||||
]);
|
||||
let config = parse_forward_args(&matches);
|
||||
assert_eq!(config.cc.unwrap(), "eve@example.com");
|
||||
assert_eq!(config.body_text.unwrap(), "FYI");
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,14 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::Helper;
|
||||
pub mod forward;
|
||||
pub mod reply;
|
||||
pub mod send;
|
||||
pub mod triage;
|
||||
pub mod watch;
|
||||
|
||||
use forward::handle_forward;
|
||||
use reply::handle_reply;
|
||||
use send::handle_send;
|
||||
use triage::handle_triage;
|
||||
use watch::handle_watch;
|
||||
@@ -36,6 +40,280 @@ pub struct GmailHelper;
|
||||
pub(super) const GMAIL_SCOPE: &str = "https://www.googleapis.com/auth/gmail.modify";
|
||||
pub(super) const PUBSUB_SCOPE: &str = "https://www.googleapis.com/auth/pubsub";
|
||||
|
||||
pub(super) struct OriginalMessage {
|
||||
pub thread_id: String,
|
||||
pub message_id_header: String,
|
||||
pub references: String,
|
||||
pub from: String,
|
||||
pub reply_to: String,
|
||||
pub to: String,
|
||||
pub cc: String,
|
||||
pub subject: String,
|
||||
pub date: String,
|
||||
pub body_text: String,
|
||||
}
|
||||
|
||||
impl OriginalMessage {
|
||||
/// Placeholder used for `--dry-run` to avoid requiring auth/network.
|
||||
pub(super) fn dry_run_placeholder(message_id: &str) -> Self {
|
||||
Self {
|
||||
thread_id: format!("thread-{message_id}"),
|
||||
message_id_header: format!("<{message_id}@example.com>"),
|
||||
references: String::new(),
|
||||
from: "sender@example.com".to_string(),
|
||||
reply_to: String::new(),
|
||||
to: "you@example.com".to_string(),
|
||||
cc: String::new(),
|
||||
subject: "Original subject".to_string(),
|
||||
date: "Thu, 1 Jan 2026 00:00:00 +0000".to_string(),
|
||||
body_text: "Original message body".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ParsedMessageHeaders {
|
||||
from: String,
|
||||
reply_to: String,
|
||||
to: String,
|
||||
cc: String,
|
||||
subject: String,
|
||||
date: String,
|
||||
message_id_header: String,
|
||||
references: String,
|
||||
}
|
||||
|
||||
fn append_header_value(existing: &mut String, value: &str) {
|
||||
if !existing.is_empty() {
|
||||
existing.push(' ');
|
||||
}
|
||||
existing.push_str(value);
|
||||
}
|
||||
|
||||
fn append_address_list_header_value(existing: &mut String, value: &str) {
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !existing.is_empty() {
|
||||
existing.push_str(", ");
|
||||
}
|
||||
existing.push_str(value);
|
||||
}
|
||||
|
||||
fn parse_message_headers(headers: &[Value]) -> ParsedMessageHeaders {
|
||||
let mut parsed = ParsedMessageHeaders::default();
|
||||
|
||||
for header in headers {
|
||||
let name = header.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let value = header.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
match name {
|
||||
"From" => parsed.from = value.to_string(),
|
||||
"Reply-To" => append_address_list_header_value(&mut parsed.reply_to, value),
|
||||
"To" => append_address_list_header_value(&mut parsed.to, value),
|
||||
"Cc" => append_address_list_header_value(&mut parsed.cc, value),
|
||||
"Subject" => parsed.subject = value.to_string(),
|
||||
"Date" => parsed.date = value.to_string(),
|
||||
"Message-ID" | "Message-Id" => parsed.message_id_header = value.to_string(),
|
||||
"References" => append_header_value(&mut parsed.references, value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
parsed
|
||||
}
|
||||
|
||||
fn parse_original_message(msg: &Value) -> OriginalMessage {
|
||||
let thread_id = msg
|
||||
.get("threadId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let snippet = msg
|
||||
.get("snippet")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let parsed_headers = msg
|
||||
.get("payload")
|
||||
.and_then(|p| p.get("headers"))
|
||||
.and_then(|h| h.as_array())
|
||||
.map(|headers| parse_message_headers(headers))
|
||||
.unwrap_or_default();
|
||||
|
||||
let body_text = msg
|
||||
.get("payload")
|
||||
.and_then(extract_plain_text_body)
|
||||
.unwrap_or(snippet);
|
||||
|
||||
OriginalMessage {
|
||||
thread_id,
|
||||
message_id_header: parsed_headers.message_id_header,
|
||||
references: parsed_headers.references,
|
||||
from: parsed_headers.from,
|
||||
reply_to: parsed_headers.reply_to,
|
||||
to: parsed_headers.to,
|
||||
cc: parsed_headers.cc,
|
||||
subject: parsed_headers.subject,
|
||||
date: parsed_headers.date,
|
||||
body_text,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn fetch_message_metadata(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
message_id: &str,
|
||||
) -> Result<OriginalMessage, GwsError> {
|
||||
let url = format!(
|
||||
"https://gmail.googleapis.com/gmail/v1/users/me/messages/{}",
|
||||
crate::validate::encode_path_segment(message_id)
|
||||
);
|
||||
|
||||
let resp = crate::client::send_with_retry(|| {
|
||||
client
|
||||
.get(&url)
|
||||
.bearer_auth(token)
|
||||
.query(&[("format", "full")])
|
||||
})
|
||||
.await
|
||||
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch message: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let err = resp.text().await.unwrap_or_default();
|
||||
return Err(GwsError::Api {
|
||||
code: status,
|
||||
message: format!("Failed to fetch message {message_id}: {err}"),
|
||||
reason: "fetchFailed".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
let msg: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse message: {e}")))?;
|
||||
|
||||
Ok(parse_original_message(&msg))
|
||||
}
|
||||
|
||||
fn extract_plain_text_body(payload: &Value) -> Option<String> {
|
||||
let mime_type = payload
|
||||
.get("mimeType")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if mime_type == "text/plain" {
|
||||
if let Some(data) = payload
|
||||
.get("body")
|
||||
.and_then(|b| b.get("data"))
|
||||
.and_then(|d| d.as_str())
|
||||
{
|
||||
if let Ok(decoded) = URL_SAFE.decode(data) {
|
||||
return String::from_utf8(decoded).ok();
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(parts) = payload.get("parts").and_then(|p| p.as_array()) {
|
||||
for part in parts {
|
||||
if let Some(text) = extract_plain_text_body(part) {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn resolve_send_method(
|
||||
doc: &crate::discovery::RestDescription,
|
||||
) -> Result<&crate::discovery::RestMethod, GwsError> {
|
||||
let users_res = doc
|
||||
.resources
|
||||
.get("users")
|
||||
.ok_or_else(|| GwsError::Discovery("Resource 'users' not found".to_string()))?;
|
||||
let messages_res = users_res
|
||||
.resources
|
||||
.get("messages")
|
||||
.ok_or_else(|| GwsError::Discovery("Resource 'users.messages' not found".to_string()))?;
|
||||
messages_res
|
||||
.methods
|
||||
.get("send")
|
||||
.ok_or_else(|| GwsError::Discovery("Method 'users.messages.send' not found".to_string()))
|
||||
}
|
||||
|
||||
/// Shared helper: base64-encode a raw RFC 2822 message and send it via
|
||||
/// `users.messages.send`, optionally keeping it in the given thread.
|
||||
pub(super) fn build_raw_send_body(raw_message: &str, thread_id: Option<&str>) -> Value {
|
||||
let mut body =
|
||||
serde_json::Map::from_iter([("raw".to_string(), json!(URL_SAFE.encode(raw_message)))]);
|
||||
|
||||
if let Some(thread_id) = thread_id {
|
||||
body.insert("threadId".to_string(), json!(thread_id));
|
||||
}
|
||||
|
||||
Value::Object(body)
|
||||
}
|
||||
|
||||
pub(super) async fn send_raw_email(
|
||||
doc: &crate::discovery::RestDescription,
|
||||
matches: &ArgMatches,
|
||||
raw_message: &str,
|
||||
thread_id: Option<&str>,
|
||||
existing_token: Option<&str>,
|
||||
) -> Result<(), GwsError> {
|
||||
let body = build_raw_send_body(raw_message, thread_id);
|
||||
let body_str = body.to_string();
|
||||
|
||||
let send_method = resolve_send_method(doc)?;
|
||||
let params = json!({ "userId": "me" });
|
||||
let params_str = params.to_string();
|
||||
|
||||
let (token, auth_method) = match existing_token {
|
||||
Some(t) => (Some(t.to_string()), executor::AuthMethod::OAuth),
|
||||
None => {
|
||||
let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect();
|
||||
match auth::get_token(&scopes).await {
|
||||
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
|
||||
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
|
||||
Err(e) => return Err(GwsError::Auth(format!("Gmail auth failed: {e}"))),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let pagination = executor::PaginationConfig {
|
||||
page_all: false,
|
||||
page_limit: 10,
|
||||
page_delay_ms: 100,
|
||||
};
|
||||
|
||||
executor::execute_method(
|
||||
doc,
|
||||
send_method,
|
||||
Some(¶ms_str),
|
||||
Some(&body_str),
|
||||
token.as_deref(),
|
||||
auth_method,
|
||||
None,
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&pagination,
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Helper for GmailHelper {
|
||||
/// Injects helper subcommands (`+send`, `+watch`) into the main CLI command.
|
||||
fn inject_commands(
|
||||
@@ -67,6 +345,12 @@ impl Helper for GmailHelper {
|
||||
.required(true)
|
||||
.value_name("TEXT"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("dry-run")
|
||||
.long("dry-run")
|
||||
.help("Show the request that would be sent without executing it")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
@@ -115,6 +399,164 @@ TIPS:
|
||||
),
|
||||
);
|
||||
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+reply")
|
||||
.about("[Helper] Reply to a message (handles threading automatically)")
|
||||
.arg(
|
||||
Arg::new("message-id")
|
||||
.long("message-id")
|
||||
.help("Gmail message ID to reply to")
|
||||
.required(true)
|
||||
.value_name("ID"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("body")
|
||||
.long("body")
|
||||
.help("Reply body (plain text)")
|
||||
.required(true)
|
||||
.value_name("TEXT"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("from")
|
||||
.long("from")
|
||||
.help("Sender address (for send-as/alias; omit to use account default)")
|
||||
.value_name("EMAIL"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("cc")
|
||||
.long("cc")
|
||||
.help("Additional CC recipients (comma-separated)")
|
||||
.value_name("EMAILS"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("dry-run")
|
||||
.long("dry-run")
|
||||
.help("Show the request that would be sent without executing it")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws gmail +reply --message-id 18f1a2b3c4d --body 'Thanks, got it!'
|
||||
gws gmail +reply --message-id 18f1a2b3c4d --body 'Looping in Carol' --cc carol@example.com
|
||||
|
||||
TIPS:
|
||||
Automatically sets In-Reply-To, References, and threadId headers.
|
||||
Quotes the original message in the reply body.
|
||||
For reply-all, use +reply-all instead.",
|
||||
),
|
||||
);
|
||||
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+reply-all")
|
||||
.about("[Helper] Reply-all to a message (handles threading automatically)")
|
||||
.arg(
|
||||
Arg::new("message-id")
|
||||
.long("message-id")
|
||||
.help("Gmail message ID to reply to")
|
||||
.required(true)
|
||||
.value_name("ID"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("body")
|
||||
.long("body")
|
||||
.help("Reply body (plain text)")
|
||||
.required(true)
|
||||
.value_name("TEXT"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("from")
|
||||
.long("from")
|
||||
.help("Sender address (for send-as/alias; omit to use account default)")
|
||||
.value_name("EMAIL"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("cc")
|
||||
.long("cc")
|
||||
.help("Additional CC recipients (comma-separated)")
|
||||
.value_name("EMAILS"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("remove")
|
||||
.long("remove")
|
||||
.help("Exclude recipients from the outgoing reply (comma-separated emails)")
|
||||
.value_name("EMAILS"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("dry-run")
|
||||
.long("dry-run")
|
||||
.help("Show the request that would be sent without executing it")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Sounds good to me!'
|
||||
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Updated' --remove bob@example.com
|
||||
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com
|
||||
|
||||
TIPS:
|
||||
Replies to the sender and all original To/CC recipients.
|
||||
Use --remove to exclude recipients from the outgoing reply, including the sender or Reply-To target.
|
||||
The command fails if exclusions leave no reply target.
|
||||
Use --cc to add new recipients.",
|
||||
),
|
||||
);
|
||||
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+forward")
|
||||
.about("[Helper] Forward a message to new recipients")
|
||||
.arg(
|
||||
Arg::new("message-id")
|
||||
.long("message-id")
|
||||
.help("Gmail message ID to forward")
|
||||
.required(true)
|
||||
.value_name("ID"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("to")
|
||||
.long("to")
|
||||
.help("Recipient email address(es), comma-separated")
|
||||
.required(true)
|
||||
.value_name("EMAILS"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("from")
|
||||
.long("from")
|
||||
.help("Sender address (for send-as/alias; omit to use account default)")
|
||||
.value_name("EMAIL"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("cc")
|
||||
.long("cc")
|
||||
.help("CC recipients (comma-separated)")
|
||||
.value_name("EMAILS"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("body")
|
||||
.long("body")
|
||||
.help("Optional note to include above the forwarded message")
|
||||
.value_name("TEXT"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("dry-run")
|
||||
.long("dry-run")
|
||||
.help("Show the request that would be sent without executing it")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com
|
||||
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI see below'
|
||||
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com
|
||||
|
||||
TIPS:
|
||||
Includes the original message with sender, date, subject, and recipients.
|
||||
Sends the forward as a new message rather than forcing it into the original thread.",
|
||||
),
|
||||
);
|
||||
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+watch")
|
||||
.about("[Helper] Watch for new emails and stream them as NDJSON")
|
||||
@@ -212,6 +654,21 @@ TIPS:
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if let Some(matches) = matches.subcommand_matches("+reply") {
|
||||
handle_reply(doc, matches, false).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if let Some(matches) = matches.subcommand_matches("+reply-all") {
|
||||
handle_reply(doc, matches, true).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if let Some(matches) = matches.subcommand_matches("+forward") {
|
||||
handle_forward(doc, matches).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if let Some(matches) = matches.subcommand_matches("+triage") {
|
||||
handle_triage(matches).await?;
|
||||
return Ok(true);
|
||||
@@ -230,6 +687,7 @@ TIPS:
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_inject_commands() {
|
||||
@@ -242,5 +700,102 @@ mod tests {
|
||||
let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
|
||||
assert!(subcommands.contains(&"+watch"));
|
||||
assert!(subcommands.contains(&"+send"));
|
||||
assert!(subcommands.contains(&"+reply"));
|
||||
assert!(subcommands.contains(&"+reply-all"));
|
||||
assert!(subcommands.contains(&"+forward"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_raw_send_body_with_thread_id() {
|
||||
let body = build_raw_send_body("raw message", Some("thread-123"));
|
||||
|
||||
assert_eq!(body["raw"], URL_SAFE.encode("raw message"));
|
||||
assert_eq!(body["threadId"], "thread-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_raw_send_body_without_thread_id() {
|
||||
let body = build_raw_send_body("raw message", None);
|
||||
|
||||
assert_eq!(body["raw"], URL_SAFE.encode("raw message"));
|
||||
assert!(body.get("threadId").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_address_list_header_value() {
|
||||
let mut header_value = String::new();
|
||||
|
||||
append_address_list_header_value(&mut header_value, "alice@example.com");
|
||||
append_address_list_header_value(&mut header_value, "bob@example.com");
|
||||
append_address_list_header_value(&mut header_value, "");
|
||||
|
||||
assert_eq!(header_value, "alice@example.com, bob@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_original_message_concatenates_repeated_address_and_reference_headers() {
|
||||
let msg = json!({
|
||||
"threadId": "thread-123",
|
||||
"snippet": "Snippet fallback",
|
||||
"payload": {
|
||||
"mimeType": "text/html",
|
||||
"headers": [
|
||||
{ "name": "From", "value": "alice@example.com" },
|
||||
{ "name": "Reply-To", "value": "team@example.com" },
|
||||
{ "name": "Reply-To", "value": "owner@example.com" },
|
||||
{ "name": "To", "value": "bob@example.com" },
|
||||
{ "name": "To", "value": "carol@example.com" },
|
||||
{ "name": "Cc", "value": "dave@example.com" },
|
||||
{ "name": "Cc", "value": "erin@example.com" },
|
||||
{ "name": "Subject", "value": "Hello" },
|
||||
{ "name": "Date", "value": "Fri, 6 Mar 2026 12:00:00 +0000" },
|
||||
{ "name": "Message-ID", "value": "<msg@example.com>" },
|
||||
{ "name": "References", "value": "<ref-1@example.com>" },
|
||||
{ "name": "References", "value": "<ref-2@example.com>" }
|
||||
],
|
||||
"body": {
|
||||
"data": URL_SAFE.encode("<p>HTML only</p>")
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let original = parse_original_message(&msg);
|
||||
|
||||
assert_eq!(original.thread_id, "thread-123");
|
||||
assert_eq!(original.from, "alice@example.com");
|
||||
assert_eq!(original.reply_to, "team@example.com, owner@example.com");
|
||||
assert_eq!(original.to, "bob@example.com, carol@example.com");
|
||||
assert_eq!(original.cc, "dave@example.com, erin@example.com");
|
||||
assert_eq!(original.subject, "Hello");
|
||||
assert_eq!(original.date, "Fri, 6 Mar 2026 12:00:00 +0000");
|
||||
assert_eq!(original.message_id_header, "<msg@example.com>");
|
||||
assert_eq!(
|
||||
original.references,
|
||||
"<ref-1@example.com> <ref-2@example.com>"
|
||||
);
|
||||
assert_eq!(original.body_text, "Snippet fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_send_method_finds_gmail_send_method() {
|
||||
let mut doc = crate::discovery::RestDescription::default();
|
||||
let send_method = crate::discovery::RestMethod {
|
||||
http_method: "POST".to_string(),
|
||||
path: "gmail/v1/users/{userId}/messages/send".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut messages = crate::discovery::RestResource::default();
|
||||
messages.methods.insert("send".to_string(), send_method);
|
||||
|
||||
let mut users = crate::discovery::RestResource::default();
|
||||
users.resources.insert("messages".to_string(), messages);
|
||||
|
||||
doc.resources = HashMap::from([("users".to_string(), users)]);
|
||||
|
||||
let resolved = resolve_send_method(&doc).unwrap();
|
||||
|
||||
assert_eq!(resolved.http_method, "POST");
|
||||
assert_eq!(resolved.path, "gmail/v1/users/{userId}/messages/send");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,18 +10,7 @@ pub(super) async fn handle_send(
|
||||
let body = create_send_body(&message);
|
||||
let body_str = body.to_string();
|
||||
|
||||
let users_res = doc
|
||||
.resources
|
||||
.get("users")
|
||||
.ok_or_else(|| GwsError::Discovery("Resource 'users' not found".to_string()))?;
|
||||
let messages_res = users_res
|
||||
.resources
|
||||
.get("messages")
|
||||
.ok_or_else(|| GwsError::Discovery("Resource 'users.messages' not found".to_string()))?;
|
||||
let send_method = messages_res
|
||||
.methods
|
||||
.get("send")
|
||||
.ok_or_else(|| GwsError::Discovery("Method 'users.messages.send' not found".to_string()))?;
|
||||
let send_method = resolve_send_method(doc)?;
|
||||
|
||||
let pagination = executor::PaginationConfig {
|
||||
page_all: false,
|
||||
|
||||
Reference in New Issue
Block a user