[ 
https://issues.apache.org/jira/browse/GROOVY-9848?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18092240#comment-18092240
 ] 

Paul King edited comment on GROOVY-9848 at 6/29/26 6:38 AM:
------------------------------------------------------------

h1. Path forward: decouple {{in}} membership from {{isCase}}

h2. Current behaviour (Groovy 6.0 / master)

* {{isCase(Map, Object)}} returns {{castToBoolean(map.get(switchValue))}} -- 
i.e. "the
key is present *and* its value is Groovy-truthy".
* The {{in}}/{{!in}} operators compile to 
{{ScriptBytecodeAdapter.isCase}}/{{isNotCase}}
({{BinaryExpressionHelper}}, {{KEYWORD_IN}}/{{COMPARE_NOT_IN}}). {{switch}} 
uses the same
{{isCase}} (via {{getIsCaseMethod}}); {{grep}} uses {{isCase}} internally in 
DGM.
* There is no {{isCase(CharSequence, ...)}}, so {{'ell' in 'hello'}} is 
{{false}} (falls to
{{isCase(Object,Object)}} == equality). This is the open GROOVY-2456.
* PR #1904 only added a test pinning the *current* behaviour; semantics are 
unchanged.

Consequences users keep hitting:
* {{'b' in [a:1, b:0]}} is {{false}} (value {{0}} is falsy) -- surprising; 
people expect
{{containsKey}} (JS/Python {{in}}).
* A membership test on a {{withDefault\{\}}} (auto-grow) map *mutates* it (the 
get inserts
the key).

h2. Proposal

Decouple the {{in}} operator from {{isCase}} *minimally*: change only {{Map}} 
and
{{CharSequence}} membership; leave {{switch}}/{{grep}} classification (and 
{{in}} on
collections, ranges, numbers, and user-defined {{isCase}}) exactly as today.

# {{in}}/{{!in}} dispatch to new {{ScriptBytecodeAdapter.isIn}}/{{isNotIn}}:
{{Map}} -> {{containsKey}}; {{CharSequence}} -> {{contains}} (fixes 
GROOVY-2456);
everything else -> existing {{isCase}} (so collections, custom {{isCase}}, etc. 
are
unchanged, with no dynamic-dispatch ambiguity).
# {{isCase(Map, Object)}} gains a mutation guard:
{{containsKey(k) && castToBoolean(map.get(k))}} -- so 
{{switch}}/{{grep}}/{{in}} never
auto-grow a {{withDefault}} map.

This preserves the genuinely-useful value-based {{grep}} filter
({{fruit.grep([apple:true, lemon:false])}}) and keeps the {{switch}} 
classification
semantics, while making {{in}} mean intuitive membership. The
{{a in b <=> switch(a)\{case b\}}} identity is intentionally broken (membership 
!=
classification) -- aligned with GEP-19, which reframes {{switch}} anyway.

h3. Deliberate divergence (new, by design)
{code:groovy}
'b' in [a:1, b:0]               // true  (key present)
switch('b') { case [a:1,b:0]: } // 'no'  (value 0 falsy -- isCase, unchanged)
{code}

h2. Extent of the breaking change

||Surface||Before||After||Breaking?||
|{{k in map}}|key present *and* truthy value|{{map.containsKey(k)}}|*Yes*|
|{{s in string}}|equality ({{'ell' in 'hello'}} == false)|substring 
({{contains}})|*Yes* (GROOVY-2456)|
|{{in}} on List/Set/Range/array|{{contains}}|{{contains}}|No|
|{{in}} on class with user {{isCase}}|{{obj.isCase(a)}}|{{obj.isCase(a)}}|No|
|{{switch(a)\{case map:\}}}|value-truthy|value-truthy|No (semantics same)|
|{{grep(map)}} value filter|value-truthy filter|value-truthy filter|No 
(semantics same)|
|membership/classification on {{withDefault\{\}}} map|*inserts* the key|does 
*not* insert|Yes (bug-fix direction)|

Concrete flips:
* {{'b' in [a:1, b:0]}} / {{[a:true, b:false]}}: {{false}} -> {{true}}
* {{'absent' in [:].withDefault\{true\}}}: {{true}}(+mutation) -> {{false}}(no 
mutation)
* {{'ell' in 'hello'}}: {{false}} -> {{true}}

Migration for affected code: use {{map[k]}}/{{map.get(k)}} for the old 
value-truthy meaning;
{{==}} for old string equality.

h2. Implementation (prototyped, branch {{groovy9848}})

Main (4 files): new {{ScriptBytecodeAdapter.isIn}}/{{isNotIn}} 
(Map/CharSequence inline,
else delegate to {{isCase}}); {{BinaryExpressionHelper}} emits 
{{isIn}}/{{isNotIn}};
{{isCase(Map)}} mutation guard; {{StaticTypeCheckingVisitor}} keeps the 
*direct* {{isCase}}
target for concrete non-Map/non-CharSequence receivers (no perf regression) and 
routes
{{Map}}/{{CharSequence}}/{{Object}} receivers through 
{{ScriptBytecodeAdapter.isIn}}.

Validation: full STC + static-compilation suites + 
switch/grep/operator/map/methods/nary +
a new {{bugs.Groovy9848}} (10 cases) -- *3835 passed, 0 failed*. Only two 
existing tests
needed updating, both pinning the old {{in}} semantics 
({{GroovyMethodsTest.testInForMaps}},
{{testInForStrings}}). {{switch}}/{{grep}} unaffected; user-defined {{isCase}} 
for {{in}}
still works.

h2. Porting vehicle: {{@OperatorRename}} membership support (extends 
GROOVY-10980)

{{@OperatorRename}} could not handle {{in}} (no membership member; and it 
rewrites
{{left.m(right)}} whereas {{in}} dispatches on the *right* operand). Prototyped 
extension:
new {{isIn}}/{{isNotIn}} members + a self-contained membership block that 
reverses operands
({{a in b}} -> {{b.name(a)}}). Naming follows the existing method-name 
convention ({{plus}},
{{compareTo}}, ...), keyed on the operator's default method {{isIn}}.

{code:groovy}
@OperatorRename(isIn='isCase', isNotIn='isNotCase')   // restore pre-9848 
value-based `in` in this scope
@OperatorRename(isIn='containsKey')                   // force key membership 
(maps-only scope)
@OperatorRename(isIn='isIn')                           // full new semantics 
(needs the isIn helper present)
{code}

Works in both dynamic and {{@CompileStatic}} (transform runs at 
{{SEMANTIC_ANALYSIS}}, before
type checking). 7 {{OperatorRenameTransformTest}} cases + 1212-test regression 
pass, including
the shared GEP-15 compound-assign path. Caveats: per-scope (renames *all* 
{{in}} in scope, so
{{containsKey}} is unsafe with mixed container types -- {{isIn}} is the safe 
target);
operator-only (doesn't touch {{grep}}/{{switch}}, which don't change semantics).

h2. Cross-version (binary) compatibility

Principle: the method a class *calls* for {{in}} is frozen at compile time; 
what it *does*
is the runtime's copy; and the called method must exist on the runtime. The 
only new symbols
are {{ScriptBytecodeAdapter.isIn}}/{{isNotIn}}; {{isCase}}/{{isNotCase}} exist 
in both lines.

* *Groovy 5 artifact on Groovy 6 runtime*: emits {{isCase}} (present in 6) -> 
keeps old
value/equality {{in}} semantics; the *only* difference is that {{withDefault}} 
maps no longer
auto-grow (the guard lives in 6's {{isCase(Map)}}). Backward-compatible.
* *Groovy 6 artifact on Groovy 5 runtime*: emits {{isIn}} -> *absent in stock 
5* ->
{{NoSuchMethodError}} (except static {{in}} on a concrete non-Map/String 
receiver, which links
to {{isCase}}). Resolved by the backport below.
* *{{switch}}/{{grep}}/explicit {{isCase}}*: link both directions; the 
mutation-guard detail
follows the runtime.

||Artifact \ Runtime||Groovy 5 (no isIn backport)||Groovy 5 (isIn 
backported)||Groovy 6||
|G5-compiled {{in}} (emits {{isCase}})|old semantics, mutates 
withDefault|same|old semantics, no mutation|
|G6-compiled {{in}} (emits {{isIn}})|{{NoSuchMethodError}}|links -> new 
semantics|new semantics|
|{{switch}}/{{grep}}/{{isCase}}|links; mutates withDefault|links; mutates 
withDefault|links; no mutation|

h2. Planned Groovy 5 backports (point release)

Two independently-shippable pieces; *neither changes Groovy 5's default {{in}} 
semantics*
(5.x keeps emitting {{isCase}}). They only add an opt-in knob and forward 
binary compat.

# *{{@OperatorRename}} membership members* ({{isIn}}/{{isNotIn}}) -- 
compile-time only, no
runtime dependency. Enables source-level opt-in 
{{@OperatorRename(isIn='containsKey')}}
(maps-only) in 5.x, portable verbatim to 6.x (where it becomes the backward 
opt-out).
# *{{ScriptBytecodeAdapter.isIn}}/{{isNotIn}} helpers* -- added as *available* 
methods only
(5.x {{in}} codegen unchanged). Serves double duty: (a) the full-fidelity 
opt-in target
{{@OperatorRename(isIn='isIn')}} (all types), and (b) makes Groovy-6-compiled 
{{in}}
artifacts *link and run* on a 5.x runtime (closing the {{NoSuchMethodError}} 
gap), with
behaviour consistent with 6.0.

Explicitly *not* backported: rewiring 5.x's {{in}} codegen to {{isIn}} -- that 
*is* the
breaking change and belongs only in 6.0.

The same {{@OperatorRename(isIn=...)}} annotation is then portable across lines:

||Annotation||Groovy 5 (default value-based)||Groovy 6 (default key-based)||
|{{@OperatorRename(isIn='isIn')}}|opt *in* to new behaviour|no-op (already 
default)|
|{{@OperatorRename(isIn='isCase')}}|no-op (already default)|opt *out* to legacy|

h2. Timing

Recommend landing the {{in}} change in *Groovy 6.0* (a major version = the 
right window for
a breaking change; the bug is actively biting; independent of GEP-19, which 
only reframes
list/map *literals* in {{switch}} and does not touch {{in}} or map-variable 
receivers). Ship
the two opt-in/compat backports in a Groovy 5.x point release so teams can 
pre-migrate and so
6.0 {{in}}-artifacts can run on 5.x.

h2. Open questions / decisions

* Confirm the direction ({{in}} == {{containsKey}}); there is prior support on 
this issue for
JS/Python alignment.
* Ship the {{@OperatorRename}} membership extension and the two 5.x backports? 
(This is one way
to address the earlier GROOVY-10980 "isCase on OperatorRename" suggestion.)
* Final member names ({{isIn}}/{{isNotIn}}).
* Relationship to GROOVY-11970 / GEP-15 -- shared operator machinery, but the 
{{in}} change
should be decided on its own merits.

h2. Related issues
* GROOVY-2456 -- string {{in}} as substring (fixed by the same 
{{isIn(CharSequence)}}).
* GROOVY-10980 -- {{@OperatorRename}} (extended here for the porting vehicle).
* GROOVY-7802 -- {{withDefault(autoGrow, autoShrink)}} (the partial mutation 
mitigation).
* GROOVY-11970 / GEP-15 -- compound-assignment overloading (shared transform).
* GEP-19 -- structural pattern matching in {{switch}} (Groovy 7).


was (Author: paulk):
h1. Path forward: decouple {{in}} membership from {{isCase}}

h2. Current behaviour (Groovy 6.0 / master)

* {{isCase(Map, Object)}} returns {{castToBoolean(map.get(switchValue))}} -- 
i.e. "the
key is present *and* its value is Groovy-truthy".
* The {{in}}/{{!in}} operators compile to 
{{ScriptBytecodeAdapter.isCase}}/{{isNotCase}}
({{BinaryExpressionHelper}}, {{KEYWORD_IN}}/{{COMPARE_NOT_IN}}). {{switch}} 
uses the same
{{isCase}} (via {{getIsCaseMethod}}); {{grep}} uses {{isCase}} internally in 
DGM.
* There is no {{isCase(CharSequence, ...)}}, so {{'ell' in 'hello'}} is 
{{false}} (falls to
{{isCase(Object,Object)}} == equality). This is the open GROOVY-2456.
* PR #1904 only added a test pinning the *current* behaviour; semantics are 
unchanged.

Consequences users keep hitting:
* {{'b' in [a:1, b:0]}} is {{false}} (value {{0}} is falsy) -- surprising; 
people expect
{{containsKey}} (JS/Python {{in}}).
* A membership test on a {{withDefault\{\}}} (auto-grow) map *mutates* it (the 
get inserts
the key).

h2. Proposal

Decouple the {{in}} operator from {{isCase}} *minimally*: change only {{Map}} 
and
{{CharSequence}} membership; leave {{switch}}/{{grep}} classification (and 
{{in}} on
collections, ranges, numbers, and user-defined {{isCase}}) exactly as today.

# {{in}}/{{!in}} dispatch to new {{ScriptBytecodeAdapter.isIn}}/{{isNotIn}}:
{{Map}} -> {{containsKey}}; {{CharSequence}} -> {{contains}} (fixes 
GROOVY-2456);
everything else -> existing {{isCase}} (so collections, custom {{isCase}}, etc. 
are
unchanged, with no dynamic-dispatch ambiguity).
# {{isCase(Map, Object)}} gains a mutation guard:
{{containsKey(k) && castToBoolean(map.get(k))}} -- so 
{{switch}}/{{grep}}/{{in}} never
auto-grow a {{withDefault}} map.

This preserves the genuinely-useful value-based {{grep}} filter
({{fruit.grep([apple:true, lemon:false])}}) and keeps the {{switch}} 
classification
semantics, while making {{in}} mean intuitive membership. The
{{x in y <=> switch(x)\{case y\}}} identity is intentionally broken (membership 
!=
classification) -- aligned with GEP-19, which reframes {{switch}} anyway.

h3. Deliberate divergence (new, by design)
{code:groovy}
'b' in [a:1, b:0]               // true  (key present)
switch('b') { case [a:1,b:0]: } // 'no'  (value 0 falsy -- isCase, unchanged)
{code}

h2. Extent of the breaking change

||Surface||Before||After||Breaking?||
|{{k in map}}|key present *and* truthy value|{{map.containsKey(k)}}|*Yes*|
|{{s in string}}|equality ({{'ell' in 'hello'}} == false)|substring 
({{contains}})|*Yes* (GROOVY-2456)|
|{{in}} on List/Set/Range/array|{{contains}}|{{contains}}|No|
|{{in}} on class with user {{isCase}}|{{obj.isCase(x)}}|{{obj.isCase(x)}}|No|
|{{switch(x)\{case map:\}}}|value-truthy|value-truthy|No (semantics same)|
|{{grep(map)}} value filter|value-truthy filter|value-truthy filter|No 
(semantics same)|
|membership/classification on {{withDefault\{\}}} map|*inserts* the key|does 
*not* insert|Yes (bug-fix direction)|

Concrete flips:
* {{'b' in [a:1, b:0]}} / {{[a:true, b:false]}}: {{false}} -> {{true}}
* {{'absent' in [:].withDefault\{true\}}}: {{true}}(+mutation) -> {{false}}(no 
mutation)
* {{'ell' in 'hello'}}: {{false}} -> {{true}}

Migration for affected code: use {{map[k]}}/{{map.get(k)}} for the old 
value-truthy meaning;
{{==}} for old string equality.

h2. Implementation (prototyped, branch {{groovy9848}})

Main (4 files): new {{ScriptBytecodeAdapter.isIn}}/{{isNotIn}} 
(Map/CharSequence inline,
else delegate to {{isCase}}); {{BinaryExpressionHelper}} emits 
{{isIn}}/{{isNotIn}};
{{isCase(Map)}} mutation guard; {{StaticTypeCheckingVisitor}} keeps the 
*direct* {{isCase}}
target for concrete non-Map/non-CharSequence receivers (no perf regression) and 
routes
{{Map}}/{{CharSequence}}/{{Object}} receivers through 
{{ScriptBytecodeAdapter.isIn}}.

Validation: full STC + static-compilation suites + 
switch/grep/operator/map/methods/nary +
a new {{bugs.Groovy9848}} (10 cases) -- *3835 passed, 0 failed*. Only two 
existing tests
needed updating, both pinning the old {{in}} semantics 
({{GroovyMethodsTest.testInForMaps}},
{{testInForStrings}}). {{switch}}/{{grep}} unaffected; user-defined {{isCase}} 
for {{in}}
still works.

h2. Porting vehicle: {{@OperatorRename}} membership support (extends 
GROOVY-10980)

{{@OperatorRename}} could not handle {{in}} (no membership member; and it 
rewrites
{{left.m(right)}} whereas {{in}} dispatches on the *right* operand). Prototyped 
extension:
new {{isIn}}/{{isNotIn}} members + a self-contained membership block that 
reverses operands
({{a in b}} -> {{b.name(a)}}). Naming follows the existing method-name 
convention ({{plus}},
{{compareTo}}, ...), keyed on the operator's default method {{isIn}}.

{code:groovy}
@OperatorRename(isIn='isCase', isNotIn='isNotCase')   // restore pre-9848 
value-based `in` in this scope
@OperatorRename(isIn='containsKey')                   // force key membership 
(maps-only scope)
@OperatorRename(isIn='isIn')                           // full new semantics 
(needs the isIn helper present)
{code}

Works in both dynamic and {{@CompileStatic}} (transform runs at 
{{SEMANTIC_ANALYSIS}}, before
type checking). 7 {{OperatorRenameTransformTest}} cases + 1212-test regression 
pass, including
the shared GEP-15 compound-assign path. Caveats: per-scope (renames *all* 
{{in}} in scope, so
{{containsKey}} is unsafe with mixed container types -- {{isIn}} is the safe 
target);
operator-only (doesn't touch {{grep}}/{{switch}}, which don't change semantics).

h2. Cross-version (binary) compatibility

Principle: the method a class *calls* for {{in}} is frozen at compile time; 
what it *does*
is the runtime's copy; and the called method must exist on the runtime. The 
only new symbols
are {{ScriptBytecodeAdapter.isIn}}/{{isNotIn}}; {{isCase}}/{{isNotCase}} exist 
in both lines.

* *Groovy 5 artifact on Groovy 6 runtime*: emits {{isCase}} (present in 6) -> 
keeps old
value/equality {{in}} semantics; the *only* difference is that {{withDefault}} 
maps no longer
auto-grow (the guard lives in 6's {{isCase(Map)}}). Backward-compatible.
* *Groovy 6 artifact on Groovy 5 runtime*: emits {{isIn}} -> *absent in stock 
5* ->
{{NoSuchMethodError}} (except static {{in}} on a concrete non-Map/String 
receiver, which links
to {{isCase}}). Resolved by the backport below.
* *{{switch}}/{{grep}}/explicit {{isCase}}*: link both directions; the 
mutation-guard detail
follows the runtime.

||Artifact \ Runtime||Groovy 5 (no isIn backport)||Groovy 5 (isIn 
backported)||Groovy 6||
|G5-compiled {{in}} (emits {{isCase}})|old semantics, mutates 
withDefault|same|old semantics, no mutation|
|G6-compiled {{in}} (emits {{isIn}})|{{NoSuchMethodError}}|links -> new 
semantics|new semantics|
|{{switch}}/{{grep}}/{{isCase}}|links; mutates withDefault|links; mutates 
withDefault|links; no mutation|

h2. Planned Groovy 5 backports (point release)

Two independently-shippable pieces; *neither changes Groovy 5's default {{in}} 
semantics*
(5.x keeps emitting {{isCase}}). They only add an opt-in knob and forward 
binary compat.

# *{{@OperatorRename}} membership members* ({{isIn}}/{{isNotIn}}) -- 
compile-time only, no
runtime dependency. Enables source-level opt-in 
{{@OperatorRename(isIn='containsKey')}}
(maps-only) in 5.x, portable verbatim to 6.x (where it becomes the backward 
opt-out).
# *{{ScriptBytecodeAdapter.isIn}}/{{isNotIn}} helpers* -- added as *available* 
methods only
(5.x {{in}} codegen unchanged). Serves double duty: (a) the full-fidelity 
opt-in target
{{@OperatorRename(isIn='isIn')}} (all types), and (b) makes Groovy-6-compiled 
{{in}}
artifacts *link and run* on a 5.x runtime (closing the {{NoSuchMethodError}} 
gap), with
behaviour consistent with 6.0.

Explicitly *not* backported: rewiring 5.x's {{in}} codegen to {{isIn}} -- that 
*is* the
breaking change and belongs only in 6.0.

The same {{@OperatorRename(isIn=...)}} annotation is then portable across lines:

||Annotation||Groovy 5 (default value-based)||Groovy 6 (default key-based)||
|{{@OperatorRename(isIn='isIn')}}|opt *in* to new behaviour|no-op (already 
default)|
|{{@OperatorRename(isIn='isCase')}}|no-op (already default)|opt *out* to legacy|

h2. Timing

Recommend landing the {{in}} change in *Groovy 6.0* (a major version = the 
right window for
a breaking change; the bug is actively biting; independent of GEP-19, which 
only reframes
list/map *literals* in {{switch}} and does not touch {{in}} or map-variable 
receivers). Ship
the two opt-in/compat backports in a Groovy 5.x point release so teams can 
pre-migrate and so
6.0 {{in}}-artifacts can run on 5.x.

h2. Open questions / decisions

* Confirm the direction ({{in}} == {{containsKey}}); there is prior support on 
this issue for
JS/Python alignment.
* Ship the {{@OperatorRename}} membership extension and the two 5.x backports? 
(This is one way
to address the earlier GROOVY-10980 "isCase on OperatorRename" suggestion.)
* Final member names ({{isIn}}/{{isNotIn}}).
* Relationship to GROOVY-11970 / GEP-15 -- shared operator machinery, but the 
{{in}} change
should be decided on its own merits.

h2. Related issues
* GROOVY-2456 -- string {{in}} as substring (fixed by the same 
{{isIn(CharSequence)}}).
* GROOVY-10980 -- {{@OperatorRename}} (extended here for the porting vehicle).
* GROOVY-7802 -- {{withDefault(autoGrow, autoShrink)}} (the partial mutation 
mitigation).
* GROOVY-11970 / GEP-15 -- compound-assignment overloading (shared transform).
* GEP-19 -- structural pattern matching in {{switch}} (Groovy 7).

> Allow membership operator to work on maps
> -----------------------------------------
>
>                 Key: GROOVY-9848
>                 URL: https://issues.apache.org/jira/browse/GROOVY-9848
>             Project: Groovy
>          Issue Type: Improvement
>            Reporter: Keegan Witt
>            Assignee: Eric Milles
>            Priority: Major
>              Labels: breaking, breaking_change
>
> {code:groovy}def map = [a:1,b:2,z:0].withDefault{3}{code}
> Options:
> # do nothing
>   {code:groovy}
>   assert 'a'  in map
>   assert 'b'  in map
>   assert 'x'  in map // mutates!
>   assert 'z' !in map
>   assert  4  === map.size()
>   {code}
> # change mutation of {{isCase}} -- guard with {{containsKey}}:
>   {code:groovy}
>   assert 'a'  in map
>   assert 'b'  in map
>   assert 'x' !in map
>   assert 'z' !in map
>   assert  3  === map.size()
>   {code}
> # change {{isCase}} to {{containsKey}}:
>   {code:groovy}
>   assert 'a'  in map
>   assert 'b'  in map
>   assert 'x' !in map
>   assert 'z'  in map
>   assert  3  === map.size()
>   // grep and switch behavior change (see below)
>   {code}
> # ask user to test key set
>   {code:groovy}
>   assert 'a'  in map.keySet()
>   assert 'b'  in map.keySet()
>   assert 'x' !in map.keySet()
>   assert 'z' !in map.keySet()
>   assert  3  === map.size()
>   {code}
> # provide {{@OperatorRename}} support:
>   {code:groovy}
>   @OperatorRename(isCase='containsKey')
>   void test() {
>   assert 'a'  in map
>   assert 'b'  in map
>   assert 'x' !in map
>   assert 'z' !in map
>   assert  3  === map.size()
>   }
>   {code}
> # remap operator {{in}} to {{isIn}} (or some such):
>   {code:groovy}
>   // DGMs:
>   boolean isIn(Map map, Object key) {
>     return map != null && map.containsKey(key);
>   }
>   boolean isIn(Object obj, Object value) {
>     return InvokerHelper.invokeMethod(obj, "isCase", new Object[]{value});
>   }
>   assert 'a'  in map
>   assert 'b'  in map
>   assert 'x' !in map
>   assert 'z'  in map
>   assert  3  === map.size()
>   // behavior change for any type that provides "isIn"
>   {code}



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to