From ed4f1c272cdf0c8c4559df41be80a62f823bef5b Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Wed, 5 Aug 2026 13:13:51 +0200 Subject: [PATCH 1/2] CAMEL-24344: camel-google-mail - return a body for raw and non-multipart messages The stream consumer always asked the Gmail API for the FULL message format, but the raw field is only populated for the RAW format, so raw=true always produced a null body. The consumer now asks for the format matching the option. The non-raw path only looked at the first element of getParts(), so a message that is not multipart - which carries its content directly on the payload - was delivered without a body, and a nested multipart yielded nothing either. The body is now taken from the first part carrying data, walking into nested parts. The payload and its header list are also no longer dereferenced without a check, and a failed exchange no longer marks the mail as unread when markAsRead was not enabled in the first place. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Andrea Cosentino --- .../mail/stream/GoogleMailStreamConsumer.java | 58 ++++++-- .../GoogleMailStreamConsumerBodyTest.java | 125 ++++++++++++++++++ .../pages/camel-4x-upgrade-guide-4_22.adoc | 12 ++ 3 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 components/camel-google/camel-google-mail/src/test/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumerBodyTest.java diff --git a/components/camel-google/camel-google-mail/src/main/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumer.java b/components/camel-google/camel-google-mail/src/main/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumer.java index 5269c8d34f1dc..0b3f4dce5a494 100644 --- a/components/camel-google/camel-google-mail/src/main/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumer.java +++ b/components/camel-google/camel-google-mail/src/main/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumer.java @@ -92,7 +92,8 @@ protected int poll() throws Exception { if (c.getMessages() != null) { for (Message message : c.getMessages()) { - Message mess = getClient().users().messages().get("me", message.getId()).setFormat("FULL").execute(); + Message mess + = getClient().users().messages().get("me", message.getId()).setFormat(messageFormat()).execute(); Exchange exchange = createExchange(getEndpoint().getExchangePattern(), mess); answer.add(exchange); } @@ -167,8 +168,13 @@ protected void processCommit(Exchange exchange, String unreadLabelId) { * Strategy when processing the exchange failed. */ protected void processRollback(Exchange exchange, String unreadLabelId) { + if (!getConfiguration().isMarkAsRead()) { + // the mail was never marked as read, so there is nothing to roll back + LOG.warn("Exchange failed: {}", exchange); + return; + } try { - LOG.warn("Exchange failed, so rolling back mail {} to un {}", exchange, unreadLabelId); + LOG.warn("Exchange failed, so marking mail {} as unread again", exchange); List add = new ArrayList<>(); add.add(unreadLabelId); @@ -176,7 +182,8 @@ protected void processRollback(Exchange exchange, String unreadLabelId) { getClient().users().messages() .modify("me", exchange.getIn().getHeader(GoogleMailStreamConstants.MAIL_ID, String.class), mods).execute(); } catch (Exception e) { - getExceptionHandler().handleException("Error occurred mark as read mail. This exception is ignored.", exchange, e); + getExceptionHandler().handleException("Error occurred marking the mail as unread. This exception is ignored.", + exchange, e); } } @@ -190,17 +197,52 @@ public Exchange createExchange(ExchangePattern pattern, Message mail) { if (getConfiguration().isRaw()) { message.setBody(mail.getRaw()); } else { - List parts = mail.getPayload().getParts(); - if (parts != null && parts.get(0).getBody().getData() != null) { - byte[] bodyBytes = Base64.decodeBase64(parts.get(0).getBody().getData().trim()); - String body = new String(bodyBytes, StandardCharsets.UTF_8); + String body = extractBody(mail.getPayload()); + if (body != null) { message.setBody(body); } } - configureHeaders(message, mail.getPayload().getHeaders()); + if (mail.getPayload() != null && mail.getPayload().getHeaders() != null) { + configureHeaders(message, mail.getPayload().getHeaders()); + } return exchange; } + /** + * The message format the consumer has to ask for. The raw field of a message is only populated when the RAW format + * is requested, the payload only when the FULL format is. + */ + String messageFormat() { + return getConfiguration().isRaw() ? "RAW" : "FULL"; + } + + /** + * Returns the decoded content of the first part carrying data, walking into nested multiparts. A message that is + * not multipart at all keeps its content directly on the payload. + */ + private String extractBody(MessagePart part) { + if (part == null) { + return null; + } + + if (part.getBody() != null && part.getBody().getData() != null) { + byte[] bodyBytes = Base64.decodeBase64(part.getBody().getData().trim()); + return new String(bodyBytes, StandardCharsets.UTF_8); + } + + List parts = part.getParts(); + if (parts != null) { + for (MessagePart child : parts) { + String body = extractBody(child); + if (body != null) { + return body; + } + } + } + + return null; + } + private void configureHeaders(org.apache.camel.Message message, List headers) { for (MessagePartHeader header : headers) { String headerName = header.getName(); diff --git a/components/camel-google/camel-google-mail/src/test/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumerBodyTest.java b/components/camel-google/camel-google-mail/src/test/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumerBodyTest.java new file mode 100644 index 0000000000000..bab3c31476f5a --- /dev/null +++ b/components/camel-google/camel-google-mail/src/test/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumerBodyTest.java @@ -0,0 +1,125 @@ +/* + * 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.google.mail.stream; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import com.google.api.client.util.Base64; +import com.google.api.services.gmail.model.Message; +import com.google.api.services.gmail.model.MessagePart; +import com.google.api.services.gmail.model.MessagePartBody; +import com.google.api.services.gmail.model.MessagePartHeader; +import org.apache.camel.Exchange; +import org.apache.camel.ExchangePattern; +import org.apache.camel.impl.DefaultCamelContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Verifies how the stream consumer turns a Gmail message into an exchange: which format it asks the API for, and where + * it picks the body up from. + */ +class GoogleMailStreamConsumerBodyTest { + + private DefaultCamelContext context; + + @AfterEach + void tearDown() { + if (context != null) { + context.stop(); + } + } + + private GoogleMailStreamConsumer consumer(boolean raw) throws Exception { + context = new DefaultCamelContext(); + context.start(); + GoogleMailStreamEndpoint endpoint = context.getEndpoint( + "google-mail-stream://index?clientId=id&clientSecret=secret&raw=" + raw, + GoogleMailStreamEndpoint.class); + return new GoogleMailStreamConsumer(endpoint, exchange -> { + }, "UNREAD", List.of()); + } + + private static MessagePartBody body(String content) { + return new MessagePartBody().setData(Base64.encodeBase64URLSafeString(content.getBytes(StandardCharsets.UTF_8))); + } + + @Test + void theRawOptionAsksForTheRawFormat() throws Exception { + // the raw field of a message is only returned for the RAW format, asking for FULL always left it null + assertThat(consumer(true).messageFormat()).isEqualTo("RAW"); + assertThat(consumer(false).messageFormat()).isEqualTo("FULL"); + } + + @Test + void aNonMultipartMessageKeepsItsBody() throws Exception { + Message mail = new Message().setId("1").setThreadId("t1") + .setPayload(new MessagePart().setMimeType("text/plain").setBody(body("plain content"))); + + Exchange exchange = consumer(false).createExchange(ExchangePattern.InOnly, mail); + + assertThat(exchange.getIn().getBody()).isEqualTo("plain content"); + } + + @Test + void aMultipartMessageUsesTheFirstPartCarryingData() throws Exception { + Message mail = new Message().setId("2").setPayload(new MessagePart().setMimeType("multipart/alternative") + .setParts(List.of( + new MessagePart().setMimeType("multipart/mixed") + .setParts(List.of(new MessagePart().setMimeType("text/plain").setBody(body("nested")))), + new MessagePart().setMimeType("text/html").setBody(body("

html

"))))); + + Exchange exchange = consumer(false).createExchange(ExchangePattern.InOnly, mail); + + assertThat(exchange.getIn().getBody()).isEqualTo("nested"); + } + + @Test + void aMessageWithoutPayloadIsNotAFailure() throws Exception { + Message mail = new Message().setId("3"); + + Exchange exchange = consumer(false).createExchange(ExchangePattern.InOnly, mail); + + assertThat(exchange.getIn().getBody()).isNull(); + assertThat(exchange.getIn().getHeader(GoogleMailStreamConstants.MAIL_ID)).isEqualTo("3"); + } + + @Test + void headersAreMappedWhenPresent() throws Exception { + Message mail = new Message().setId("4").setPayload(new MessagePart() + .setBody(body("content")) + .setHeaders(List.of( + new MessagePartHeader().setName("Subject").setValue("a subject"), + new MessagePartHeader().setName("From").setValue("someone@example.org")))); + + Exchange exchange = consumer(false).createExchange(ExchangePattern.InOnly, mail); + + assertThat(exchange.getIn().getHeader(GoogleMailStreamConstants.MAIL_SUBJECT)).isEqualTo("a subject"); + assertThat(exchange.getIn().getHeader(GoogleMailStreamConstants.MAIL_FROM)).isEqualTo("someone@example.org"); + } + + @Test + void aPayloadWithoutHeadersIsNotAFailure() throws Exception { + Message mail = new Message().setId("5").setPayload(new MessagePart().setBody(body("content"))); + + assertThatCode(() -> consumer(false).createExchange(ExchangePattern.InOnly, mail)).doesNotThrowAnyException(); + } +} diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index 323652455c692..caed74bf91894 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -1710,3 +1710,15 @@ transport `from` and the `atmosphere-websocket:` `to`. Allowing untrusted sender drive `WebsocketConstants.CONNECTION_KEY_LIST`, which selects the target peers and takes precedence over `CONNECTION_KEY`, without such a mapping step is not the intended use of the component. + +=== camel-google-mail - the raw option now returns the message + +The `google-mail-stream` consumer always asked the Gmail API for the `FULL` message format, but the +`raw` field is only populated for the `RAW` format, so `raw=true` produced a `null` body. The consumer +now requests the format that matches the option. + +Because the Gmail API does not return the parsed `payload` for the `RAW` format, a route running with +`raw=true` no longer receives the `CamelGoogleMailStreamSubject`, `...From`, `...To`, `...Cc`, `...Bcc` +and `...MessageId` headers — those values are part of the RFC 2822 content that is now in the body. +`CamelGoogleMailStreamId`, `...ThreadId` and `...LabelIds` are still set. Routes that need the parsed +headers should keep the default `raw=false`. From a7ba137bce8a451b8adc8a4e6a7607596b2a7e03 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Wed, 5 Aug 2026 15:30:08 +0200 Subject: [PATCH 2/2] CAMEL-24344: address review - drop the rollback log and stop the previous test context Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Andrea Cosentino --- .../component/google/mail/stream/GoogleMailStreamConsumer.java | 1 - .../google/mail/stream/GoogleMailStreamConsumerBodyTest.java | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/components/camel-google/camel-google-mail/src/main/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumer.java b/components/camel-google/camel-google-mail/src/main/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumer.java index 0b3f4dce5a494..182f2c6674b0c 100644 --- a/components/camel-google/camel-google-mail/src/main/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumer.java +++ b/components/camel-google/camel-google-mail/src/main/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumer.java @@ -170,7 +170,6 @@ protected void processCommit(Exchange exchange, String unreadLabelId) { protected void processRollback(Exchange exchange, String unreadLabelId) { if (!getConfiguration().isMarkAsRead()) { // the mail was never marked as read, so there is nothing to roll back - LOG.warn("Exchange failed: {}", exchange); return; } try { diff --git a/components/camel-google/camel-google-mail/src/test/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumerBodyTest.java b/components/camel-google/camel-google-mail/src/test/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumerBodyTest.java index bab3c31476f5a..7b960e0752f3e 100644 --- a/components/camel-google/camel-google-mail/src/test/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumerBodyTest.java +++ b/components/camel-google/camel-google-mail/src/test/java/org/apache/camel/component/google/mail/stream/GoogleMailStreamConsumerBodyTest.java @@ -49,6 +49,9 @@ void tearDown() { } private GoogleMailStreamConsumer consumer(boolean raw) throws Exception { + if (context != null) { + context.stop(); + } context = new DefaultCamelContext(); context.start(); GoogleMailStreamEndpoint endpoint = context.getEndpoint(