Skip to content

Commit 4da0b23

Browse files
committed
fix(gmail): handle reply-all to own message correctly
When replying-all to a message you sent, the original sender (you) was excluded from To, leaving it empty and producing an error. Gmail web handles this by using the original To recipients as reply targets. Detect self-reply by checking if the original From matches the user's primary email or send-as alias, then swap the candidate logic: - Self-reply: To = original To, CC = original CC - Normal reply: To = Reply-To or From, CC = original To + CC
1 parent a349499 commit 4da0b23

File tree

4 files changed

+115
-28
lines changed

4 files changed

+115
-28
lines changed

.changeset/gmail-default-sender.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44

55
feat(gmail): auto-populate From header with display name from send-as settings
66

7-
Fetch the user's send-as identities to set the From header with a display name in all mail helpers (+send, +reply, +reply-all, +forward), matching Gmail web client behavior. Also enriches bare `--from` emails with their configured display name. In reply-all, uses the send-as endpoint instead of `/users/me/profile` for self-email dedup (with profile as fallback).
7+
Fetch the user's send-as identities to set the From header with a display name in all mail helpers (+send, +reply, +reply-all, +forward), matching Gmail web client behavior. Also enriches bare `--from` emails with their configured display name.

.changeset/gmail-self-reply-fix.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@googleworkspace/cli": patch
3+
---
4+
5+
fix(gmail): handle reply-all to own message correctly
6+
7+
Reply-all to a message you sent no longer errors with "No To recipient remains." The original To recipients are now used as reply targets, matching Gmail web client behavior.

src/helpers/gmail/mod.rs

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -440,28 +440,20 @@ fn parse_send_as_response(body: &Value) -> Vec<SendAsIdentity> {
440440
/// Given pre-fetched send-as identities, resolve the `From` address.
441441
///
442442
/// - `from` is `None` → returns the default send-as identity
443-
/// - `from` has bare emails (no display name) → enriches with send-as display names
444-
/// - `from` already has display names → returns as-is
443+
/// - `from` has bare emails → enriches with send-as display names (mailboxes
444+
/// that already have a display name pass through unchanged)
445445
fn resolve_sender_from_identities(
446446
from: Option<&[Mailbox]>,
447447
identities: &[SendAsIdentity],
448448
) -> Option<Vec<Mailbox>> {
449-
// All provided mailboxes already have display names — nothing to resolve.
450-
// (Also checked in resolve_sender to skip the API call; duplicated here
451-
// so this pure function is independently correct for testing.)
452-
if let Some(addrs) = from {
453-
if addrs.iter().all(|m| m.name.is_some()) {
454-
return Some(addrs.to_vec());
455-
}
456-
}
457-
458449
match from {
459450
// No from provided → use default identity.
460451
None => identities
461452
.iter()
462453
.find(|id| id.is_default)
463454
.map(|id| vec![id.mailbox.clone()]),
464-
// Bare emails without display names → enrich from send-as list.
455+
// Enrich bare emails (no display name) from the send-as list.
456+
// Mailboxes that already have a display name pass through unchanged.
465457
Some(addrs) => {
466458
let enriched: Vec<Mailbox> = addrs
467459
.iter()
@@ -484,9 +476,9 @@ fn resolve_sender_from_identities(
484476
/// Resolve the `From` address using Gmail send-as identities.
485477
///
486478
/// Fetches send-as settings and enriches the From address with the display name.
487-
/// Degrades gracefully if the API call fails — returns `Ok(None)` when `from` is
488-
/// `None` and no default identity exists, or when the API call fails and `from`
489-
/// was not provided.
479+
/// Degrades gracefully if the API call fails — returns the original `from`
480+
/// addresses unchanged (without display name enrichment), or `Ok(None)` if
481+
/// `from` was not provided.
490482
///
491483
/// Note: this resolves the *sender identity* for the From header only. Callers
492484
/// that need the authenticated user's *primary* email (e.g. reply-all self-dedup)

src/helpers/gmail/reply.rs

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,9 @@ pub(super) struct ReplyConfig {
148148
pub attachments: Vec<Attachment>,
149149
}
150150

151-
/// Fetch the authenticated user's email from the Gmail profile API.
152-
/// Used as a fallback for reply-all self-dedup when the sendAs endpoint fails.
151+
/// Fetch the authenticated user's primary email from the Gmail profile API.
152+
/// Used in reply-all for self-dedup (excluding the user from recipients) and
153+
/// self-reply detection (switching to original-To-based addressing).
153154
async fn fetch_user_email(client: &reqwest::Client, token: &str) -> Result<String, GwsError> {
154155
let resp = crate::client::send_with_retry(|| {
155156
client
@@ -201,8 +202,33 @@ fn build_reply_all_recipients(
201202
self_email: Option<&str>,
202203
from_alias: Option<&str>,
203204
) -> Result<ReplyRecipients, GwsError> {
204-
let to_candidates = extract_reply_to_address(original);
205205
let excluded = collect_excluded_emails(remove, self_email, from_alias);
206+
207+
// When replying to your own message, the original sender (you) would be
208+
// excluded from To, leaving it empty. Gmail web handles this by using the
209+
// original To recipients as the reply targets instead, ignoring Reply-To.
210+
// (Gmail ignores Reply-To on self-sent messages — we approximate this by
211+
// checking the primary address and the current From alias.)
212+
// Detect self-reply by checking if the original sender matches the user's
213+
// primary or alias.
214+
let is_self_reply = [self_email, from_alias]
215+
.into_iter()
216+
.flatten()
217+
.any(|e| original.from.email.eq_ignore_ascii_case(e));
218+
219+
let (to_candidates, mut cc_candidates) = if is_self_reply {
220+
// Self-reply: To = original To, CC = original CC
221+
let cc = original.cc.clone().unwrap_or_default();
222+
(original.to.clone(), cc)
223+
} else {
224+
// Normal reply: To = Reply-To or From, CC = original To + CC
225+
let mut cc = original.to.clone();
226+
if let Some(orig_cc) = &original.cc {
227+
cc.extend(orig_cc.iter().cloned());
228+
}
229+
(extract_reply_to_address(original), cc)
230+
};
231+
206232
let mut to_emails = std::collections::HashSet::new();
207233
let to: Vec<Mailbox> = to_candidates
208234
.into_iter()
@@ -215,18 +241,12 @@ fn build_reply_all_recipients(
215241
})
216242
.collect();
217243

218-
// Combine original To and Cc as CC candidates
219-
let mut cc_candidates: Vec<Mailbox> = original.to.clone();
220-
if let Some(orig_cc) = &original.cc {
221-
cc_candidates.extend(orig_cc.iter().cloned());
222-
}
223-
224244
// Add extra CC if provided
225245
if let Some(extra) = extra_cc {
226246
cc_candidates.extend(extra.iter().cloned());
227247
}
228248

229-
// Filter CC: remove reply-to recipients, excluded addresses, and duplicates
249+
// Filter CC: remove To recipients, excluded addresses, and duplicates
230250
let mut seen = std::collections::HashSet::new();
231251
let cc: Vec<Mailbox> = cc_candidates
232252
.into_iter()
@@ -601,7 +621,9 @@ mod tests {
601621
}
602622

603623
#[test]
604-
fn test_build_reply_all_from_alias_removes_primary_returns_empty_to() {
624+
fn test_build_reply_all_from_alias_is_self_reply() {
625+
// When from_alias matches original.from, this is a self-reply.
626+
// To should be the original To recipients, not empty.
605627
let original = OriginalMessage {
606628
from: Mailbox::parse("sales@example.com"),
607629
to: vec![Mailbox::parse("bob@example.com")],
@@ -617,7 +639,8 @@ mod tests {
617639
Some("sales@example.com"),
618640
)
619641
.unwrap();
620-
assert!(recipients.to.is_empty());
642+
assert_eq!(recipients.to.len(), 1);
643+
assert_eq!(recipients.to[0].email, "bob@example.com");
621644
}
622645

623646
fn make_reply_matches(args: &[&str]) -> ArgMatches {
@@ -997,6 +1020,71 @@ mod tests {
9971020
assert!(cc.iter().any(|m| m.email == "carol@example.com"));
9981021
}
9991022

1023+
// --- self-reply tests ---
1024+
1025+
#[test]
1026+
fn test_reply_all_to_own_message_puts_original_to_in_to() {
1027+
let original = OriginalMessage {
1028+
from: Mailbox::parse("me@example.com"),
1029+
to: vec![
1030+
Mailbox::parse("alice@example.com"),
1031+
Mailbox::parse("bob@example.com"),
1032+
],
1033+
cc: Some(vec![Mailbox::parse("carol@example.com")]),
1034+
..Default::default()
1035+
};
1036+
let recipients =
1037+
build_reply_all_recipients(&original, None, None, Some("me@example.com"), None)
1038+
.unwrap();
1039+
// To should be the original To recipients, not the original sender
1040+
assert_eq!(recipients.to.len(), 2);
1041+
assert!(recipients.to.iter().any(|m| m.email == "alice@example.com"));
1042+
assert!(recipients.to.iter().any(|m| m.email == "bob@example.com"));
1043+
// CC should be the original CC
1044+
let cc = recipients.cc.unwrap();
1045+
assert_eq!(cc.len(), 1);
1046+
assert!(cc.iter().any(|m| m.email == "carol@example.com"));
1047+
}
1048+
1049+
#[test]
1050+
fn test_reply_all_to_own_message_detected_via_alias() {
1051+
let original = OriginalMessage {
1052+
from: Mailbox::parse("alias@work.com"),
1053+
to: vec![Mailbox::parse("alice@example.com")],
1054+
..Default::default()
1055+
};
1056+
// self_email is primary, from_alias matches the original sender
1057+
let recipients = build_reply_all_recipients(
1058+
&original,
1059+
None,
1060+
None,
1061+
Some("me@gmail.com"),
1062+
Some("alias@work.com"),
1063+
)
1064+
.unwrap();
1065+
assert_eq!(recipients.to.len(), 1);
1066+
assert_eq!(recipients.to[0].email, "alice@example.com");
1067+
}
1068+
1069+
#[test]
1070+
fn test_reply_all_to_own_message_excludes_self_from_original_to() {
1071+
// You sent to yourself + Alice (e.g. a note-to-self CC'd to someone)
1072+
let original = OriginalMessage {
1073+
from: Mailbox::parse("me@example.com"),
1074+
to: vec![
1075+
Mailbox::parse("me@example.com"),
1076+
Mailbox::parse("alice@example.com"),
1077+
],
1078+
..Default::default()
1079+
};
1080+
let recipients =
1081+
build_reply_all_recipients(&original, None, None, Some("me@example.com"), None)
1082+
.unwrap();
1083+
// Self should still be excluded from To
1084+
assert_eq!(recipients.to.len(), 1);
1085+
assert_eq!(recipients.to[0].email, "alice@example.com");
1086+
}
1087+
10001088
// --- dedup_recipients tests ---
10011089

10021090
#[test]

0 commit comments

Comments
 (0)