This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new e33b8cd69292 CAMEL-25710: camel-mail - remove the idempotent keys of
mails that a poll does not route (#27990)
e33b8cd69292 is described below
commit e33b8cd692927cc5ba986dc6392869e409e31137
Author: allthingssecurity <[email protected]>
AuthorDate: Sat Oct 17 19:17:35 2026 +0530
CAMEL-25710: camel-mail - remove the idempotent keys of mails that a poll
does not route (#27990)
With an idempotentRepository, the consumer adds the key of every mail when
it
retrieves the mails of a poll, and removes or confirms it when the exchange
completes.
Mails that the poll then did not route kept their key, and were skipped as
duplicates
by every later poll, also of other consumers sharing the repository:
- the rest of the batch when the consumer stops in the middle of it (a
graceful route
or CamelContext stop, default
ShutdownRunningTask.CompleteCurrentTaskOnly);
- the mails retrieved before a mail whose exchange cannot be created (for
example its
attachments cannot be read), which fails the poll, and that mail itself.
processBatch now removes the keys of the exchanges it does not route and
releases
them, as the aws2-s3 consumer does with its in-progress keys (CAMEL-25521),
and
retrieveMessages does the same when creating an exchange fails, before
rethrowing.
As a consequence, with an idempotentRepository a mail whose exchange cannot
be
created now fails every poll until it is removed (with the default
skipFailedMessage=false and handleFailedMessage=false), as it does without
an
idempotentRepository; before, it was skipped after the first failed poll.
Upgrade
guide note added.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../apache/camel/component/mail/MailConsumer.java | 73 +++++--
.../MailIdempotentRepositoryNotRoutedTest.java | 216 ++++++++++++++++++++
.../MailIdempotentRepositoryPollErrorTest.java | 218 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 9 +
4 files changed, 505 insertions(+), 11 deletions(-)
diff --git
a/components/camel-mail/src/main/java/org/apache/camel/component/mail/MailConsumer.java
b/components/camel-mail/src/main/java/org/apache/camel/component/mail/MailConsumer.java
index ff34dc74d783..82cd6f6e82be 100644
---
a/components/camel-mail/src/main/java/org/apache/camel/component/mail/MailConsumer.java
+++
b/components/camel-mail/src/main/java/org/apache/camel/component/mail/MailConsumer.java
@@ -223,6 +223,18 @@ public class MailConsumer extends
ScheduledBatchPollingConsumer {
@Override
public int processBatch(Queue<Object> exchanges) throws Exception {
int total = exchanges.size();
+ try {
+ routeBatch(exchanges, total);
+ } finally {
+ // the remaining exchanges are not routed, as the consumer is
stopping or routing an exchange failed: their
+ // mails must be consumed by a later poll (or by another consumer
sharing the idempotent repository)
+ releaseNotRouted(CastUtils.cast(exchanges));
+ }
+
+ return total;
+ }
+
+ private void routeBatch(Queue<Object> exchanges, int total) throws
Exception {
for (int index = 0; index < total && isBatchAllowed(); index++) {
// only loop if we are started (allowed to run)
Exchange exchange = ObjectHelper.cast(Exchange.class,
exchanges.poll());
@@ -266,8 +278,37 @@ public class MailConsumer extends
ScheduledBatchPollingConsumer {
// process the exchange
processExchange(exchange);
}
+ }
- return total;
+ /**
+ * Gives back exchanges that are not routed: removes the key of each mail
from the idempotent repository (where
+ * {@link #isValidMessage(String, Message)} added it when the mail was
retrieved), so the mail is not skipped as a
+ * duplicate later, and releases the exchange.
+ */
+ private void releaseNotRouted(Queue<Exchange> notRouted) {
+ Exchange exchange;
+ while ((exchange = notRouted.poll()) != null) {
+ removeIdempotentKey(exchange.getProperty(MAIL_MESSAGE_UID,
String.class));
+ try {
+ releaseExchange(exchange, true);
+ } catch (Exception e) {
+ // keep giving back the other mails
+ LOG.warn("Cannot release an exchange that is not routed: {}.",
e.toString());
+ }
+ }
+ }
+
+ private void removeIdempotentKey(String key) {
+ if (key != null && getEndpoint().getIdempotentRepository() != null) {
+ try {
+ getEndpoint().getIdempotentRepository().remove(key);
+ } catch (Exception e) {
+ // keep giving back the other mails, and do not hide the
failure of the poll
+ LOG.warn("Cannot remove the key {} of a mail that is not
routed from the idempotent repository: {}."
+ + " The mail is skipped as a duplicate until the key
is removed.",
+ key, e.toString());
+ }
+ }
}
private void peekMessage(Message mail) {
@@ -323,17 +364,27 @@ public class MailConsumer extends
ScheduledBatchPollingConsumer {
if (hasMessageLimit && answer.size() >= maxMessage) {
break;
}
- String key =
getEndpoint().getMailUidGenerator().generateUuid(getEndpoint(), message);
- if (isValidMessage(key, message)) {
- // need to call setPeek on java-mail to avoid the message
being flagged eagerly as SEEN on the server in case
- // we process the message and rollback due an exception
- if (getEndpoint().getConfiguration().isPeek()) {
- peekMessage(message);
- }
- Exchange exchange = createExchange(new KeyValueHolder<>(key,
message));
- if (exchange != null) {
- answer.add(exchange);
+ // the key of this mail once it has been added to the idempotent
repository
+ String addedKey = null;
+ try {
+ String key =
getEndpoint().getMailUidGenerator().generateUuid(getEndpoint(), message);
+ if (isValidMessage(key, message)) {
+ addedKey = key;
+ // need to call setPeek on java-mail to avoid the message
being flagged eagerly as SEEN on the server in case
+ // we process the message and rollback due an exception
+ if (getEndpoint().getConfiguration().isPeek()) {
+ peekMessage(message);
+ }
+ Exchange exchange = createExchange(new
KeyValueHolder<>(key, message));
+ if (exchange != null) {
+ answer.add(exchange);
+ }
}
+ } catch (Exception e) {
+ // the poll fails and none of the mails retrieved so far is
routed
+ removeIdempotentKey(addedKey);
+ releaseNotRouted(answer);
+ throw e;
}
}
diff --git
a/components/camel-mail/src/test/java/org/apache/camel/component/mail/MailIdempotentRepositoryNotRoutedTest.java
b/components/camel-mail/src/test/java/org/apache/camel/component/mail/MailIdempotentRepositoryNotRoutedTest.java
new file mode 100644
index 000000000000..a7a9d49c6268
--- /dev/null
+++
b/components/camel-mail/src/test/java/org/apache/camel/component/mail/MailIdempotentRepositoryNotRoutedTest.java
@@ -0,0 +1,216 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.camel.component.mail;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import jakarta.mail.Flags;
+import jakarta.mail.Folder;
+import jakarta.mail.Message;
+import jakarta.mail.Store;
+import jakarta.mail.internet.MimeMessage;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mail.Mailbox.MailboxUser;
+import org.apache.camel.component.mail.Mailbox.Protocol;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.ExceptionHandler;
+import
org.apache.camel.support.processor.idempotent.MemoryIdempotentRepository;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The mails of a poll that are not routed (the consumer stops in the middle
of the batch, or the poll fails while the
+ * mails are retrieved) must not stay in the idempotent repository, as they
would then be skipped as duplicates by every
+ * later poll, also of another consumer that shares the repository.
+ */
+class MailIdempotentRepositoryNotRoutedTest extends CamelTestSupport {
+ private static final MailboxUser jones =
Mailbox.getOrCreateUser("MailIdempotentRepositoryNotRoutedTest-jones",
"secret");
+
+ @BindToRegistry("myRepo")
+ private final MemoryIdempotentRepository myRepo = new
MemoryIdempotentRepository();
+
+ @BindToRegistry("myHandler")
+ private final PollErrorHandler pollErrorHandler = new PollErrorHandler();
+
+ private final AtomicBoolean stopOnFirst = new AtomicBoolean();
+
+ @Override
+ public void setupResources() throws Exception {
+ myRepo.start();
+ Mailbox.clearAll(jones);
+ }
+
+ @Test
+ void mailsOfABatchCutShortByAStopAreConsumedAfterTheRestart() throws
Exception {
+ appendMessages(createMessages(5));
+ stopOnFirst.set(true);
+
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedMessageCount(1);
+ context.getRouteController().startRoute("foo");
+ MockEndpoint.assertIsSatisfied(context);
+
+ // the route was stopped while it routed the first mail: the other
four were not routed
+ Awaitility.await().atMost(10, TimeUnit.SECONDS)
+ .until(() ->
context.getRouteController().getRouteStatus("foo").isStopped());
+ assertEquals(1, mock.getReceivedCounter());
+ assertEquals(0, myRepo.getCacheSize(), "The keys of the mails that
were not routed must be removed");
+
+ mock.reset();
+ mock.expectedMessageCount(4);
+ context.getRouteController().startRoute("foo");
+ MockEndpoint.assertIsSatisfied(context);
+ }
+
+ @Test
+ void mailsRetrievedBeforeAFailingMailAreConsumedOnceItIsRemoved() throws
Exception {
+ Message[] good = createMessages(3);
+ appendMessages(good[0], good[1], good[2], createMalformedMessage());
+
+ context.getRouteController().startRoute("foo");
+ // the poll fails on the malformed mail (default
skipFailedMessage=false), so no mail of it is routed
+ assertTrue(pollErrorHandler.failed.await(10, TimeUnit.SECONDS), "The
poll should fail on the malformed mail");
+ context.getRouteController().stopRoute("foo");
+ // a poll that was running when the route stopped may still be
retrieving mails
+ Awaitility.await().atMost(10, TimeUnit.SECONDS).untilAsserted(
+ () -> assertEquals(0, myRepo.getCacheSize(), "The keys of the
mails that were not routed must be removed"));
+
+ // the operator removes the malformed mail, the others must now be
consumed
+ removeMalformedMessage();
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedMessageCount(3);
+ context.getRouteController().startRoute("foo");
+ MockEndpoint.assertIsSatisfied(context);
+ }
+
+ private Message[] createMessages(int count) throws Exception {
+ JavaMailSender sender = new DefaultJavaMailSender();
+ Message[] messages = new Message[count];
+ for (int i = 0; i < count; i++) {
+ messages[i] = new MimeMessage(sender.getSession());
+ messages[i].setText("Message " + i);
+ messages[i].setHeader("Message-ID", "myuid-" + i);
+ }
+ return messages;
+ }
+
+ private static Message createMalformedMessage() throws Exception {
+ // a multipart mail whose body does not contain its boundary: reading
its parts fails
+ String raw = "Message-ID: malformed\r\n"
+ + "Subject: malformed\r\n"
+ + "MIME-Version: 1.0\r\n"
+ + "Content-Type: multipart/mixed;
boundary=\"the-boundary\"\r\n"
+ + "\r\n"
+ + "no parts here\r\n";
+ return new MimeMessage(
+ new DefaultJavaMailSender().getSession(), new
ByteArrayInputStream(raw.getBytes(StandardCharsets.US_ASCII)));
+ }
+
+ private static void appendMessages(Message... messages) throws Exception {
+ Store store = openStore();
+ try {
+ Folder folder = store.getFolder("INBOX");
+ folder.open(Folder.READ_WRITE);
+ folder.appendMessages(messages);
+ folder.close(true);
+ } finally {
+ store.close();
+ }
+ }
+
+ private static void removeMalformedMessage() throws Exception {
+ Store store = openStore();
+ try {
+ Folder folder = store.getFolder("INBOX");
+ folder.open(Folder.READ_WRITE);
+ for (Message message : folder.getMessages()) {
+ if ("malformed".equals(message.getSubject())) {
+ message.setFlag(Flags.Flag.DELETED, true);
+ }
+ }
+ folder.close(true);
+ } finally {
+ store.close();
+ }
+ }
+
+ private static Store openStore() throws Exception {
+ Store store = new
DefaultJavaMailSender().getSession().getStore("imap");
+ store.connect("localhost", Mailbox.getPort(Protocol.imap),
jones.getLogin(), jones.getPassword());
+ return store;
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ public void configure() {
+ from(jones.uriPrefix(Protocol.imap)
+ +
"&idempotentRepository=#myRepo&exceptionHandler=#myHandler&initialDelay=100&delay=100")
+ .routeId("foo").autoStartup(false)
+ .process(this::stopOnFirst)
+ .to("mock:result");
+ }
+
+ private void stopOnFirst(Exchange exchange) {
+ if (stopOnFirst.compareAndSet(true, false)) {
+ MailConsumer consumer = (MailConsumer)
exchange.getContext().getRoute("foo").getConsumer();
+ // a graceful stop of the route while it routes the first
mail of the batch: the stop waits for
+ // this exchange, and the consumer routes no further mail
of the batch
+ CompletableFuture.runAsync(() -> {
+ try {
+
exchange.getContext().getRouteController().stopRoute("foo");
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ });
+ Awaitility.await().atMost(10, TimeUnit.SECONDS).until(()
-> !consumer.isBatchAllowed());
+ }
+ }
+ };
+ }
+
+ static final class PollErrorHandler implements ExceptionHandler {
+ final CountDownLatch failed = new CountDownLatch(1);
+
+ @Override
+ public void handleException(Throwable exception) {
+ failed.countDown();
+ }
+
+ @Override
+ public void handleException(String message, Throwable exception) {
+ failed.countDown();
+ }
+
+ @Override
+ public void handleException(String message, Exchange exchange,
Throwable exception) {
+ failed.countDown();
+ }
+ }
+}
diff --git
a/components/camel-mail/src/test/java/org/apache/camel/component/mail/MailIdempotentRepositoryPollErrorTest.java
b/components/camel-mail/src/test/java/org/apache/camel/component/mail/MailIdempotentRepositoryPollErrorTest.java
new file mode 100644
index 000000000000..d779cc0036fa
--- /dev/null
+++
b/components/camel-mail/src/test/java/org/apache/camel/component/mail/MailIdempotentRepositoryPollErrorTest.java
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.camel.component.mail;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import jakarta.mail.Folder;
+import jakarta.mail.Message;
+import jakarta.mail.Store;
+import jakarta.mail.internet.MimeMessage;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.component.mail.Mailbox.MailboxUser;
+import org.apache.camel.component.mail.Mailbox.Protocol;
+import org.apache.camel.spi.ExceptionHandler;
+import
org.apache.camel.support.processor.idempotent.MemoryIdempotentRepository;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+/**
+ * When a poll fails in the middle (routing an exchange throws, or the
idempotent repository fails to add the key of a
+ * later mail), the keys of the mails that the poll added but did not route
must be removed from the idempotent
+ * repository, or every later poll skips these mails as duplicates.
+ */
+class MailIdempotentRepositoryPollErrorTest extends CamelTestSupport {
+ private static final MailboxUser james =
Mailbox.getOrCreateUser("MailIdempotentRepositoryPollErrorTest-james",
"secret");
+
+ private final FailingIdempotentRepository repo = new
FailingIdempotentRepository();
+ private final AtomicBoolean failNextRelease = new AtomicBoolean();
+ private final List<Throwable> pollErrors = new ArrayList<>();
+
+ @Override
+ public void setupResources() throws Exception {
+ Mailbox.clearAll(james);
+ repo.start();
+ }
+
+ @Test
+ void keysOfTheRestOfTheBatchAreRemovedWhenRoutingAnExchangeThrows() throws
Exception {
+ appendMessages(5);
+
+ List<String> routed = new ArrayList<>();
+ MailConsumer consumer = createConsumer(exchange -> {
+ routed.add(exchange.getProperty(MailConsumer.MAIL_MESSAGE_UID,
String.class));
+ if (routed.size() == 2) {
+ throw new RejectedExecutionException("Simulated: cannot route
the exchange");
+ }
+ });
+ try {
+ consumer.poll();
+ } finally {
+ consumer.stop();
+ }
+
+ assertEquals(1, pollErrors.size());
+ assertInstanceOf(RejectedExecutionException.class, pollErrors.get(0));
+ assertEquals(2, routed.size());
+ // the keys of the two mails that went into routing stay, the keys of
the three mails after them are removed
+ assertEquals(new TreeSet<>(routed), repo.keys());
+ }
+
+ @Test
+ void keysOfTheRestOfTheBatchAreRemovedWhenReleasingAnExchangeThrows()
throws Exception {
+ appendMessages(5);
+
+ List<String> routed = new ArrayList<>();
+ MailConsumer consumer = createConsumer(exchange -> {
+ routed.add(exchange.getProperty(MailConsumer.MAIL_MESSAGE_UID,
String.class));
+ if (routed.size() == 2) {
+ // the next release (the first exchange given back) fails
+ failNextRelease.set(true);
+ throw new RejectedExecutionException("Simulated: cannot route
the exchange");
+ }
+ });
+ try {
+ consumer.poll();
+ } finally {
+ consumer.stop();
+ }
+
+ assertEquals(2, routed.size());
+ // releasing the first given-back exchange fails, the keys of all
three mails after the routed ones are removed
+ assertEquals(new TreeSet<>(routed), repo.keys());
+ }
+
+ @Test
+ void
keysOfTheMailsRetrievedSoFarAreRemovedWhenAddingTheKeyOfALaterMailFails()
throws Exception {
+ appendMessages(5);
+ repo.failOnAdd.set(3);
+
+ List<String> routed = new ArrayList<>();
+ MailConsumer consumer
+ = createConsumer(exchange ->
routed.add(exchange.getProperty(MailConsumer.MAIL_MESSAGE_UID, String.class)));
+ try {
+ consumer.poll();
+ } finally {
+ consumer.stop();
+ }
+
+ assertEquals(1, pollErrors.size());
+ assertInstanceOf(IllegalStateException.class, pollErrors.get(0));
+ assertEquals(List.of(), routed, "The poll fails before any mail is
routed");
+ assertEquals(new TreeSet<>(), repo.keys(), "The keys of the mails
retrieved before the failure must be removed");
+ }
+
+ /**
+ * A consumer whose {@link MailConsumer#processExchange(Exchange)} calls
the given action instead of a route, polled
+ * directly by the test (the scheduler is not started).
+ */
+ private MailConsumer createConsumer(RouteAction action) throws Exception {
+ context.getRegistry().bind("pollErrorRepo", repo);
+ MailEndpoint endpoint = context.getEndpoint(
+ james.uriPrefix(Protocol.imap) +
"&idempotentRepository=#pollErrorRepo", MailEndpoint.class);
+ JavaMailSender sender =
endpoint.getConfiguration().createJavaMailSender(context);
+ endpoint.getConfiguration().configureJavaMailSender(context, sender);
+ MailConsumer consumer = new MailConsumer(endpoint, exchange -> {
+ }, sender) {
+ @Override
+ protected void processExchange(Exchange exchange) throws Exception
{
+ action.route(exchange);
+ }
+
+ @Override
+ public void releaseExchange(Exchange exchange, boolean
autoRelease) {
+ if (failNextRelease.compareAndSet(true, false)) {
+ throw new IllegalStateException("Simulated: cannot release
the exchange");
+ }
+ super.releaseExchange(exchange, autoRelease);
+ }
+ };
+ consumer.setStartScheduler(false);
+ consumer.setExceptionHandler(new ExceptionHandler() {
+ @Override
+ public void handleException(Throwable exception) {
+ pollErrors.add(exception);
+ }
+
+ @Override
+ public void handleException(String message, Throwable exception) {
+ pollErrors.add(exception);
+ }
+
+ @Override
+ public void handleException(String message, Exchange exchange,
Throwable exception) {
+ pollErrors.add(exception);
+ }
+ });
+ consumer.start();
+ return consumer;
+ }
+
+ private static void appendMessages(int count) throws Exception {
+ JavaMailSender sender = new DefaultJavaMailSender();
+ Message[] messages = new Message[count];
+ for (int i = 0; i < count; i++) {
+ messages[i] = new MimeMessage(sender.getSession());
+ messages[i].setText("Message " + i);
+ }
+ Store store = sender.getSession().getStore("imap");
+ store.connect("localhost", Mailbox.getPort(Protocol.imap),
james.getLogin(), james.getPassword());
+ try {
+ Folder folder = store.getFolder("INBOX");
+ folder.open(Folder.READ_WRITE);
+ folder.appendMessages(messages);
+ folder.close(true);
+ } finally {
+ store.close();
+ }
+ }
+
+ @FunctionalInterface
+ interface RouteAction {
+ void route(Exchange exchange) throws Exception;
+ }
+
+ /**
+ * A memory repository whose n-th {@code add} fails, as a JDBC/Redis
repository that is briefly unavailable.
+ */
+ static final class FailingIdempotentRepository extends
MemoryIdempotentRepository {
+ final AtomicInteger failOnAdd = new AtomicInteger();
+ private final AtomicInteger adds = new AtomicInteger();
+
+ @Override
+ public boolean add(String key) {
+ if (adds.incrementAndGet() == failOnAdd.get()) {
+ throw new IllegalStateException("Simulated: the repository is
not available");
+ }
+ return super.add(key);
+ }
+
+ Set<String> keys() {
+ return new TreeSet<>(getCache().keySet());
+ }
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 22dac2264492..cbb1be435b41 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -1266,6 +1266,15 @@ consumer stops after the message that is being routed.
The other messages of tha
was resumed. They are now kept and routed by the first poll after the resume,
together with the messages posted in
the meantime. They are kept in memory only: a route that is stopped and
started again does not route them.
+=== camel-mail - mails of a poll that were not routed, with an
idempotentRepository
+
+With an `idempotentRepository`, the mail consumer now removes the keys of the
mails that a poll retrieved but did not
+route (the consumer was stopped in the middle of the batch, or the poll failed
on a later mail), so that a later poll
+routes them. Before, they were skipped as duplicates by every later poll. As a
consequence, a mail that the consumer
+cannot read (with the default `skipFailedMessage=false` and
`handleFailedMessage=false`) now fails every poll until it
+is removed, as it does without an `idempotentRepository`. Before, it failed
only the first poll and was then skipped,
+together with the mails retrieved before it. Set `skipFailedMessage` or
`handleFailedMessage` to skip such a mail.
+
=== camel-smpp - the host name of the SMSC is verified over SSL
With `smpps` (or `usingSSL=true`), directly or through an HTTP proxy, the
connection now checks that the certificate of