diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 192846b..0bd9aa2 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -11,11 +11,45 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
- - uses: actions/setup-java@v3
- with:
- java-version: 17
- distribution: temurin
- cache: maven
- - name: Test
- run: ./mvnw -B package -Dquarkus.native.container-build=true
+ - uses: actions/checkout@v3
+ - uses: actions/setup-java@v3
+ with:
+ java-version: 17
+ distribution: temurin
+ cache: maven
+ - name: Test
+ run: ./mvnw -B package
+ env:
+ GMAIL_CLIENT_ID: dummy
+ GMAIL_CLIENT_SECRET: dummy
+ GMAIL_REFRESH_TOKEN: dummy
+ GMAIL_USER_EMAIL: bot@test.com
+ QUARKUS_GITHUB_APP_APP_ID: 12345
+ QUARKUS_GITHUB_APP_PRIVATE_KEY: |
+ -----BEGIN RSA PRIVATE KEY-----
+ MIIEowIBAAKCAQEAxlVR3TIkouAOvH79vaJTgFhpfvVKQIeVkFRZPVXK/zY0Gvrh
+ 4JAqGjJoW/PfrQv5sdD36qtHH3a+G5hLZ6Ni+t/mtfjucxZfuLGC3kmJ1T3XqEKZ
+ gXXI2IR7vVSoImREvDQGEDyJwtHzLANlkbGg0cghVhWZSCAndO8BenalC2v94/rt
+ DfkPekH6dgU3Sf40T0sBSeSY94mOzTaqOR2pfV1rWlLRdWmo33zeHBv52Rlbt0dM
+ uXAureXWiHztkm5GCBC1dgM+CaxNtizNEgC91KcD0xuRCCM2WxH+r1lpszyIJDct
+ YbrFmVEYl/kjQpafhy7Nsk1fqSTyRdriZSYmTQIDAQABAoIBAQC+kJgaCuX8wYAn
+ SXWQ0fmdZlXnMNRpcF0a0pD0SAzGb1RdYBXMaXiqtyhiwc53PPxsCDdNecjayIMd
+ jJVXPTwLhTruOgMS/bp3gcgWwV34UHV4LJXGOGAE+jbS0hbDBMiudOYmj6RmVshp
+ z9G1zZCSQNMXHaWsEYkX59XpzzoB384nRul2QgEtwzUNR9XlpzgtJBLk3SACkvsN
+ mQ/DW8IWHXLg8vLn1LzVJ2e3B16H4MoE2TCHxqfMgr03IDRRJogkenQuQsFhevYT
+ o/mJyHSWavVgzMHG9I5m+eepF4Wyhj1Y4WyKAuMI+9dHAX/h7Lt8XFCQCh5DbkVG
+ zGr34sWBAoGBAOs7n7YZqNaaguovfIdRRsxxZr1yJAyDsr6w3yGImDZYju4c4WY9
+ 5esO2kP3FA4p0c7FhQF5oOb1rBuHEPp36cpL4aGeK87caqTfq63WZAujoTZpr9Lp
+ BRbkL7w/xG7jpQ/clpA8sHzHGQs/nelxoOtC7E118FiRgvD/jdhlMyL9AoGBANfX
+ vyoN1pplfT2xR8QOjSZ+Q35S/+SAtMuBnHx3l0qH2bbBjcvM1MNDWjnRDyaYhiRu
+ i+KA7tqfib09+XpB3g5D6Ov7ls/Ldx0S/VcmVWtia2HK8y8iLGtokoBZKQ5AaFX2
+ iQU8+tC4h69GnJYQKqNwgCUzh8+gHX5Y46oDiTmRAoGAYpOx8lX+czB8/Da6MNrW
+ mIZNT8atZLEsDs2ANEVRxDSIcTCZJId7+m1W+nRoaycLTWNowZ1+2ErLvR10+AGY
+ b7Ys79Wg9idYaY9yGn9lnZsMzAiuLeyIvXcSqgjvAKlVWrhOQFOughvNWvFl85Yy
+ oWSCMlPiTLtt7CCsCKsgKuECgYBgdIp6GZsIfkgclKe0hqgvRoeU4TR3gcjJlM9A
+ lBTo+pKhaBectplx9RxR8AnsPobbqwcaHnIfAuKDzjk5mEvKZjClnFXF4HAHbyAF
+ nRzZEy9XkWFhc80T5rRpZO7C7qdxmu2aiKixM3V3L3/0U58qULEDbubHMw9bEhAT
+ PudI8QKBgHEEiMm/hr9T41hbQi/LYanWnlFw1ue+osKuF8bXQuxnnHNuFT/c+9/A
+ vWhgqG6bOEHu+p/IPrYm4tBMYlwsyh4nXCyGgDJLbLIfzKwKAWCtH9LwnyDVhOow
+ GH9shdR+sW3Ew97xef02KAH4VlNANEmBV4sQNqWWvsYrcFm2rOdL
+ -----END RSA PRIVATE KEY-----
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 9853b29..e2cdfc4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -59,6 +59,30 @@
commons-io
${commons-io.version}
+
+ io.quarkus
+ quarkus-mailer
+
+
+ com.google.apis
+ google-api-services-gmail
+ v1-rev20220404-1.32.1
+
+
+ com.google.auth
+ google-auth-library-oauth2-http
+ 1.19.0
+
+
+ com.google.http-client
+ google-http-client-gson
+ 1.43.3
+
+
+ org.eclipse.angus
+ angus-mail
+ 2.0.3
+
io.quarkus
quarkus-junit5
@@ -76,6 +100,11 @@
${mockito-core.version}
test
+
+ io.quarkus
+ quarkus-junit5-mockito
+ test
+
diff --git a/src/main/java/org/keycloak/gh/bot/email/CommandParser.java b/src/main/java/org/keycloak/gh/bot/email/CommandParser.java
new file mode 100644
index 0000000..9e977a2
--- /dev/null
+++ b/src/main/java/org/keycloak/gh/bot/email/CommandParser.java
@@ -0,0 +1,94 @@
+package org.keycloak.gh.bot.email;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.keycloak.gh.bot.GitHubInstallationProvider;
+
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Responsible for parsing raw text from GitHub comments into Command objects.
+ */
+@ApplicationScoped
+public class CommandParser {
+
+ @Inject
+ GitHubInstallationProvider gitHubProvider;
+
+ private Pattern mentionStartOfLine;
+ private Pattern replySecurity;
+ private Pattern newSecAlert;
+ private String cachedBotName;
+
+ public enum CommandType {
+ REPLY_KEYCLOAK_SECURITY,
+ NEW_SECALERT,
+ UNKNOWN
+ }
+
+ public record Command(CommandType type, Optional subject, String body) {}
+
+ public Optional parse(String text) {
+ if (text == null || text.isBlank()) return Optional.empty();
+
+ ensurePatternsInitialized();
+ String trimmedText = text.trim();
+
+ if (!mentionStartOfLine.matcher(trimmedText).find()) {
+ return Optional.empty();
+ }
+
+ Matcher mSecurity = replySecurity.matcher(trimmedText);
+ if (mSecurity.find()) {
+ String body = mSecurity.group(1).trim();
+ return body.isEmpty() ? Optional.empty() : Optional.of(new Command(CommandType.REPLY_KEYCLOAK_SECURITY, Optional.empty(), body));
+ }
+
+ Matcher mSecAlert = newSecAlert.matcher(trimmedText);
+ if (mSecAlert.find()) {
+ String subject = mSecAlert.group(1).trim();
+ String body = mSecAlert.group(2).trim();
+ if (subject.isEmpty() || body.isEmpty()) return Optional.empty();
+ return Optional.of(new Command(CommandType.NEW_SECALERT, Optional.of(subject), body));
+ }
+
+ return Optional.of(new Command(CommandType.UNKNOWN, Optional.empty(), trimmedText));
+ }
+
+ public String getBotName() {
+ String login = gitHubProvider.getBotLogin();
+ if (login == null) return "unknown-bot";
+ return login.endsWith("[bot]") ? login.replace("[bot]", "") : login;
+ }
+
+ public String getHelpMessage() {
+ String n = getBotName();
+ return String.format("""
+ I don't know this command or the format is incorrect.
+ **Rule:** Commands must be on their own line. The message body starts on the next line.
+
+ **Available Commands:**
+
+ `@%s /reply keycloak-security`
+ `REPLY BODY (Sent to: Sender + Keycloak Security List)`
+
+ `@%s /new secalert "Subject"`
+ `EMAIL BODY (Sent to: SecAlert + Keycloak Security List)`
+ """, n, n);
+ }
+
+ private synchronized void ensurePatternsInitialized() {
+ String currentBotName = getBotName();
+
+ if (cachedBotName == null || !cachedBotName.equals(currentBotName)) {
+ cachedBotName = currentBotName;
+ String quotedName = Pattern.quote(currentBotName);
+
+ mentionStartOfLine = Pattern.compile("(?mi)^@" + quotedName + "\\b");
+ replySecurity = Pattern.compile("(?msi)^@" + quotedName + "\\s+/reply\\s+keycloak-security\\s*[\r\n]+(.*)");
+ newSecAlert = Pattern.compile("(?msi)^@" + quotedName + "\\s+/new\\s+secalert\\s+\"([^\"]+)\"\\s*[\r\n]+(.*)");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/keycloak/gh/bot/email/CommandProcessor.java b/src/main/java/org/keycloak/gh/bot/email/CommandProcessor.java
new file mode 100644
index 0000000..555324e
--- /dev/null
+++ b/src/main/java/org/keycloak/gh/bot/email/CommandProcessor.java
@@ -0,0 +1,183 @@
+package org.keycloak.gh.bot.email;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.jboss.logging.Logger;
+import org.kohsuke.github.GHIssue;
+import org.kohsuke.github.GHIssueComment;
+import org.kohsuke.github.GHReaction;
+import org.kohsuke.github.ReactionContent;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Handles the processing of GitHub comments to identify and execute bot commands
+ */
+@ApplicationScoped
+public class CommandProcessor {
+
+ private static final Logger LOG = Logger.getLogger(CommandProcessor.class);
+ private static final Pattern VISIBLE_MARKER_PATTERN = Pattern.compile("\\*\\*Gmail-Thread-ID:\\*\\*\\s*([a-f0-9]+)");
+ private static final Pattern RAW_HEX_PATTERN = Pattern.compile("\\b([a-f0-9]{16})\\b");
+
+ private static final int MAX_PROCESSED_HISTORY = 10000;
+
+ @ConfigProperty(name = "google.group.target")
+ String targetGroup;
+
+ @ConfigProperty(name = "email.target.secalert")
+ String secAlertEmail;
+
+ @Inject GitHubAdapter github;
+ @Inject CommandParser parser;
+ @Inject MailSender mailSender;
+
+ private final Set processedComments = Collections.synchronizedSet(Collections.newSetFromMap(
+ new LinkedHashMap(MAX_PROCESSED_HISTORY + 1, .75F, true) {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry eldest) {
+ return size() > MAX_PROCESSED_HISTORY;
+ }
+ }));
+
+ private Instant lastPollTime = Instant.now().minus(10, ChronoUnit.MINUTES);
+
+ public void processCommands() {
+ try {
+ String myLogin = parser.getBotName();
+ if (myLogin == null || myLogin.isEmpty()) return;
+
+ Instant executionStart = Instant.now();
+
+ Date querySince = Date.from(lastPollTime.minus(1, ChronoUnit.MINUTES));
+ List updatedIssues = github.getIssuesUpdatedSince(querySince);
+
+ for (GHIssue issue : updatedIssues) {
+ try {
+ scanIssue(issue, myLogin);
+ } catch (Exception e) {
+ LOG.errorf(e, "Failed to scan issue #%d", issue.getNumber());
+ }
+ }
+
+ lastPollTime = executionStart;
+ } catch (Exception e) {
+ LOG.error("Fatal error fetching updated issues", e);
+ }
+ }
+
+ private void scanIssue(GHIssue issue, String myLogin) throws IOException {
+ Optional threadIdOpt = findThreadIdInComments(issue);
+
+ List recentComments = issue.queryComments()
+ .since(Date.from(lastPollTime.minus(1, ChronoUnit.MINUTES)))
+ .list()
+ .toList();
+
+ for (GHIssueComment comment : recentComments) {
+ if (hasAlreadyProcessed(comment, myLogin)) continue;
+
+ parser.parse(comment.getBody()).ifPresent(cmd -> executeCommand(issue, comment, cmd, threadIdOpt));
+ }
+ }
+
+ private void executeCommand(GHIssue issue, GHIssueComment comment, CommandParser.Command cmd, Optional threadId) {
+ boolean success = false;
+ ReactionContent reaction = ReactionContent.EYES;
+
+ switch (cmd.type()) {
+ case NEW_SECALERT:
+ success = mailSender.sendNewEmail(secAlertEmail, targetGroup, cmd.subject().orElse("No Subject"), cmd.body());
+ if (!success) {
+ replyWithError(issue, comment, "❌ Error: Failed to send email via Gmail API.");
+ reaction = ReactionContent.CONFUSED;
+ }
+ break;
+ case REPLY_KEYCLOAK_SECURITY:
+ if (threadId.isPresent()) {
+ success = mailSender.sendReply(threadId.get(), issue.getTitle(), cmd.body(), targetGroup);
+ if (!success) {
+ replyWithError(issue, comment, "❌ Error: Failed to send email via Gmail API.");
+ reaction = ReactionContent.CONFUSED;
+ }
+ } else {
+ replyWithError(issue, comment, "❌ Error: Gmail Thread ID not found.");
+ success = true;
+ reaction = ReactionContent.CONFUSED;
+ }
+ break;
+ case UNKNOWN:
+ sendHelpMessage(issue, comment);
+ success = true;
+ reaction = ReactionContent.CONFUSED;
+ break;
+ }
+
+ if (success) {
+ processedComments.add(comment.getId());
+ addReaction(comment, reaction);
+ LOG.debugf("✅ Command executed: %s", cmd.type());
+ }
+ }
+
+ private void addReaction(GHIssueComment comment, ReactionContent reaction) {
+ try {
+ comment.createReaction(reaction);
+ } catch (IOException e) {
+ LOG.errorf("Failed to react to comment %d", comment.getId());
+ }
+ }
+
+ private void replyWithError(GHIssue issue, GHIssueComment comment, String message) {
+ try {
+ github.commentOnIssue(issue, "@" + comment.getUser().getLogin() + " " + message);
+ } catch (IOException e) {
+ LOG.error("Failed to send error reply", e);
+ }
+ }
+
+ private void sendHelpMessage(GHIssue issue, GHIssueComment comment) {
+ try {
+ String body = "@" + comment.getUser().getLogin() + " " + parser.getHelpMessage();
+ github.commentOnIssue(issue, body);
+ } catch (IOException e) {
+ LOG.error("Failed to send help message", e);
+ }
+ }
+
+ private boolean hasAlreadyProcessed(GHIssueComment comment, String myLogin) throws IOException {
+ if (processedComments.contains(comment.getId())) return true;
+
+ for (GHReaction reaction : comment.listReactions()) {
+ String user = reaction.getUser().getLogin();
+ if ((reaction.getContent() == ReactionContent.EYES || reaction.getContent() == ReactionContent.CONFUSED) &&
+ (user.equalsIgnoreCase(myLogin) || user.equalsIgnoreCase(myLogin + "[bot]"))) {
+ processedComments.add(comment.getId());
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private Optional findThreadIdInComments(GHIssue issue) throws IOException {
+ for (GHIssueComment comment : issue.getComments()) {
+ Matcher m = VISIBLE_MARKER_PATTERN.matcher(comment.getBody());
+ if (m.find()) return Optional.of(m.group(1).trim());
+ Matcher raw = RAW_HEX_PATTERN.matcher(comment.getBody());
+ if (raw.find()) return Optional.of(raw.group(1).trim());
+ }
+ return Optional.empty();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/keycloak/gh/bot/email/EmailSyncScheduler.java b/src/main/java/org/keycloak/gh/bot/email/EmailSyncScheduler.java
new file mode 100644
index 0000000..650d8a2
--- /dev/null
+++ b/src/main/java/org/keycloak/gh/bot/email/EmailSyncScheduler.java
@@ -0,0 +1,26 @@
+package org.keycloak.gh.bot.email;
+
+import io.quarkus.scheduler.Scheduled;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import io.quarkus.scheduler.Scheduled.ConcurrentExecution;
+
+/**
+ * Manages the scheduled execution of email synchronization and command processing tasks.
+ */
+@ApplicationScoped
+public class EmailSyncScheduler {
+
+ @Inject IncomingMailProcessor incomingMail;
+ @Inject CommandProcessor commandProcessor;
+
+ @Scheduled(every = "${bot.email.sync.interval:60s}", concurrentExecution = ConcurrentExecution.SKIP)
+ public void syncGmailToGitHub() {
+ incomingMail.processUnreadEmails();
+ }
+
+ @Scheduled(every = "${bot.command.process.interval:10s}", concurrentExecution = ConcurrentExecution.SKIP)
+ public void processGitHubCommands() {
+ commandProcessor.processCommands();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/keycloak/gh/bot/email/GitHubAdapter.java b/src/main/java/org/keycloak/gh/bot/email/GitHubAdapter.java
new file mode 100644
index 0000000..9e7561d
--- /dev/null
+++ b/src/main/java/org/keycloak/gh/bot/email/GitHubAdapter.java
@@ -0,0 +1,83 @@
+package org.keycloak.gh.bot.email;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.jboss.logging.Logger;
+import org.keycloak.gh.bot.GitHubInstallationProvider;
+import org.kohsuke.github.GHIssue;
+import org.kohsuke.github.GHIssueState;
+import org.kohsuke.github.GHRepository;
+import org.kohsuke.github.PagedSearchIterable;
+
+import java.io.IOException;
+import java.util.Date;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Wraps the GitHub API client to provide domain-specific methods for the bot.
+ */
+@ApplicationScoped
+public class GitHubAdapter {
+
+ private static final Logger LOG = Logger.getLogger(GitHubAdapter.class);
+
+ @Inject
+ GitHubInstallationProvider gitHubProvider;
+
+ private GHRepository cachedRepository;
+
+ private GHRepository getRepository() throws IOException {
+ if (cachedRepository != null) {
+ return cachedRepository;
+ }
+ String fullRepoName = gitHubProvider.getRepositoryFullName();
+ if (fullRepoName == null) throw new IllegalStateException("Repository name is null.");
+
+ cachedRepository = gitHubProvider.getGitHub().getRepository(fullRepoName);
+ return cachedRepository;
+ }
+
+ public GHIssue createIssue(String subject, String body) {
+ try {
+ GHIssue issue = getRepository().createIssue(subject).body(body).create();
+ LOG.debugf("🆕 Created Issue #%d", issue.getNumber());
+ return issue;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to create GitHub issue", e);
+ }
+ }
+
+ public void commentOnIssue(GHIssue issue, String commentBody) {
+ try {
+ issue.comment(commentBody);
+ LOG.debugf("💬 Commented on Issue #%d", issue.getNumber());
+ } catch (Exception e) {
+ LOG.error("Failed to comment on issue", e);
+ }
+ }
+
+ public Optional findIssueByThreadId(String threadId) throws IOException {
+ String repoName = gitHubProvider.getRepositoryFullName();
+ String query = String.format("repo:%s \"%s\" in:comments type:issue", repoName, threadId);
+
+ PagedSearchIterable issues = gitHubProvider.getGitHub().searchIssues().q(query).list();
+ if (issues.getTotalCount() > 0) {
+ return Optional.of(issues.iterator().next());
+ }
+ return Optional.empty();
+ }
+
+ public List getIssuesUpdatedSince(Date since) {
+ try {
+ return getRepository().queryIssues()
+ .state(GHIssueState.OPEN)
+ .since(since)
+ .list()
+ .toList();
+ } catch (Exception e) {
+ LOG.error("Failed to fetch updated issues", e);
+ return List.of();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/keycloak/gh/bot/email/GmailAdapter.java b/src/main/java/org/keycloak/gh/bot/email/GmailAdapter.java
new file mode 100644
index 0000000..4acb5ca
--- /dev/null
+++ b/src/main/java/org/keycloak/gh/bot/email/GmailAdapter.java
@@ -0,0 +1,127 @@
+package org.keycloak.gh.bot.email;
+
+import com.google.api.services.gmail.Gmail;
+import com.google.api.services.gmail.model.ListMessagesResponse;
+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 com.google.api.services.gmail.model.ModifyMessageRequest;
+import com.google.api.services.gmail.model.Thread;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.mail.internet.MimeMessage;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.jboss.logging.Logger;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * A wrapper around the Google Gmail API client.
+ */
+@ApplicationScoped
+public class GmailAdapter {
+
+ private static final Logger LOG = Logger.getLogger(GmailAdapter.class);
+
+ @Inject
+ Gmail gmail;
+
+ @ConfigProperty(name = "gmail.batch.size", defaultValue = "20")
+ long batchSize;
+
+ public List fetchUnreadMessages(String query) {
+ try {
+ ListMessagesResponse listResponse = gmail.users().messages().list("me")
+ .setQ(query)
+ .setMaxResults(batchSize)
+ .execute();
+ return listResponse.getMessages() != null ? listResponse.getMessages() : Collections.emptyList();
+ } catch (IOException e) {
+ LOG.error("Failed to fetch messages", e);
+ return Collections.emptyList();
+ }
+ }
+
+ public Message getMessage(String id) {
+ try {
+ return gmail.users().messages().get("me", id).execute();
+ } catch (IOException e) {
+ LOG.errorf("Failed to get message %s", id, e);
+ return null;
+ }
+ }
+
+ public Thread getThread(String threadId) {
+ try {
+ return gmail.users().threads().get("me", threadId).setFormat("METADATA").execute();
+ } catch (IOException e) {
+ LOG.errorf("Failed to get thread %s", threadId, e);
+ return null;
+ }
+ }
+
+ public void markAsRead(String messageId) {
+ try {
+ ModifyMessageRequest mods = new ModifyMessageRequest().setRemoveLabelIds(Collections.singletonList("UNREAD"));
+ gmail.users().messages().modify("me", messageId, mods).execute();
+ } catch (IOException e) {
+ LOG.errorf("Failed to mark message %s as read", messageId, e);
+ }
+ }
+
+ public void sendMessage(String threadId, MimeMessage email) {
+ try {
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ email.writeTo(buffer);
+ String encodedEmail = Base64.getUrlEncoder().withoutPadding().encodeToString(buffer.toByteArray());
+
+ Message message = new Message();
+ message.setRaw(encodedEmail);
+ if (threadId != null) {
+ message.setThreadId(threadId);
+ }
+ gmail.users().messages().send("me", message).execute();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to send email via Gmail API", e);
+ }
+ }
+
+ public String getHeader(Message message, String name) {
+ if (message == null || message.getPayload() == null || message.getPayload().getHeaders() == null) return "";
+ return message.getPayload().getHeaders().stream()
+ .filter(h -> h.getName().equalsIgnoreCase(name))
+ .findFirst()
+ .map(MessagePartHeader::getValue).orElse("");
+ }
+
+ public String getBody(Message message) {
+ if (message == null || message.getPayload() == null) return "";
+
+ MessagePartBody body = message.getPayload().getBody();
+ if (body != null && body.getData() != null) {
+ return new String(Base64.getUrlDecoder().decode(body.getData()));
+ }
+
+ return getPartsBody(message.getPayload().getParts()).orElse("");
+ }
+
+ private Optional getPartsBody(List parts) {
+ if (parts == null) return Optional.empty();
+ for (MessagePart part : parts) {
+ if ("text/plain".equals(part.getMimeType()) && part.getBody() != null && part.getBody().getData() != null) {
+ return Optional.of(new String(Base64.getUrlDecoder().decode(part.getBody().getData())));
+ }
+ if (part.getParts() != null) {
+ Optional result = getPartsBody(part.getParts());
+ if (result.isPresent()) return result;
+ }
+ }
+ return Optional.empty();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/keycloak/gh/bot/email/GmailConfig.java b/src/main/java/org/keycloak/gh/bot/email/GmailConfig.java
new file mode 100644
index 0000000..480e1fe
--- /dev/null
+++ b/src/main/java/org/keycloak/gh/bot/email/GmailConfig.java
@@ -0,0 +1,47 @@
+package org.keycloak.gh.bot.email;
+
+import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
+import com.google.api.client.json.gson.GsonFactory;
+import com.google.api.services.gmail.Gmail;
+import com.google.auth.http.HttpCredentialsAdapter;
+import com.google.auth.oauth2.UserCredentials;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Produces;
+import jakarta.inject.Singleton;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+
+/**
+ * Configuration producer for the Gmail API client.
+ */
+@ApplicationScoped
+public class GmailConfig {
+
+ @ConfigProperty(name = "gmail.client.id") String clientId;
+ @ConfigProperty(name = "gmail.client.secret") String clientSecret;
+ @ConfigProperty(name = "gmail.refresh.token") String refreshToken;
+ @ConfigProperty(name = "quarkus.application.name") String appName;
+
+ @Produces
+ @Singleton
+ public Gmail createGmailClient() {
+ try {
+ UserCredentials credentials = UserCredentials.newBuilder()
+ .setClientId(clientId)
+ .setClientSecret(clientSecret)
+ .setRefreshToken(refreshToken)
+ .build();
+
+ return new Gmail.Builder(
+ GoogleNetHttpTransport.newTrustedTransport(),
+ GsonFactory.getDefaultInstance(),
+ new HttpCredentialsAdapter(credentials))
+ .setApplicationName(appName)
+ .build();
+ } catch (IOException | GeneralSecurityException e) {
+ throw new RuntimeException("Failed to initialize Gmail Client", e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/keycloak/gh/bot/email/IncomingMailProcessor.java b/src/main/java/org/keycloak/gh/bot/email/IncomingMailProcessor.java
new file mode 100644
index 0000000..d65f74a
--- /dev/null
+++ b/src/main/java/org/keycloak/gh/bot/email/IncomingMailProcessor.java
@@ -0,0 +1,118 @@
+package org.keycloak.gh.bot.email;
+
+import com.google.api.services.gmail.model.Message;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.jboss.logging.Logger;
+import org.keycloak.gh.bot.utils.Labels;
+import org.kohsuke.github.GHIssue;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Polls Gmail for unread messages, filters out bot auto-replies to prevent loops,
+ * and synchronizes valid emails to GitHub.
+ */
+@ApplicationScoped
+public class IncomingMailProcessor {
+
+ private static final Logger LOG = Logger.getLogger(IncomingMailProcessor.class);
+ private static final String VISIBLE_MARKER_PREFIX = "**Gmail-Thread-ID:** ";
+ private static final Pattern SIGNATURE_PATTERN = Pattern.compile("(?m)^--\\s*$|^You received this message because you are subscribed.*");
+ private static final String ISSUE_DESCRIPTION_TEMPLATE = "_Thread originally started in the keycloak-security mailing list. Replace the content here by a proper description._";
+
+ @ConfigProperty(name = "google.group.target") String targetGroup;
+ @ConfigProperty(name = "gmail.user.email") String botEmail;
+
+ @Inject GmailAdapter gmail;
+ @Inject GitHubAdapter github;
+ @Inject CommandParser commandParser;
+
+ public void processUnreadEmails() {
+ String query = "is:unread -from:" + botEmail;
+
+ List messages = gmail.fetchUnreadMessages(query);
+ for (Message msgSummary : messages) {
+ processMessage(msgSummary);
+ }
+ }
+
+ private void processMessage(Message msgSummary) {
+ Message msg = gmail.getMessage(msgSummary.getId());
+ if (msg == null) return;
+
+ try {
+ String from = gmail.getHeader(msg, "From");
+
+ if (isFromBot(from) || !isValidGroupMessage(msg)) {
+ gmail.markAsRead(msg.getId());
+ return;
+ }
+
+ String threadId = msg.getThreadId();
+ String subject = gmail.getHeader(msg, "Subject");
+ String cleanBody = sanitizeBody(gmail.getBody(msg)).orElse("(No content)");
+
+ github.findIssueByThreadId(threadId).ifPresentOrElse(
+ existing -> appendComment(existing, from, cleanBody, threadId),
+ () -> createNewIssue(threadId, subject, from, cleanBody)
+ );
+
+ gmail.markAsRead(msg.getId());
+
+ } catch (IOException e) {
+ LOG.warnf("Deferred processing message %s due to API error (Rate Limit?): %s", msg.getId(), e.getMessage());
+ } catch (Exception e) {
+ LOG.errorf(e, "Failed to process message %s", msg.getId());
+ }
+ }
+
+ private void appendComment(GHIssue issue, String from, String body, String threadId) {
+ String comment = "**Reply from " + from + ":**\n\n" + body;
+ github.commentOnIssue(issue, comment);
+ LOG.debugf("✅ Commented on #%d (Thread %s)", issue.getNumber(), threadId);
+ }
+
+ private void createNewIssue(String threadId, String subject, String from, String body) {
+ LOG.debugf("🤖 Creating Issue for Thread %s", threadId);
+ GHIssue newIssue = github.createIssue(subject, ISSUE_DESCRIPTION_TEMPLATE);
+ if (newIssue != null) {
+ try {
+ newIssue.addLabels(Labels.STATUS_TRIAGE);
+ } catch (Exception e) {
+ LOG.errorf(e, "Failed to label issue #%d", newIssue.getNumber());
+ }
+ String firstComment = VISIBLE_MARKER_PREFIX + threadId + "\n**From:** " + from + "\n\n" + body;
+ github.commentOnIssue(newIssue, firstComment);
+ }
+ }
+
+ private boolean isFromBot(String from) {
+ return from != null && from.toLowerCase().contains(botEmail.toLowerCase());
+ }
+
+ private boolean isValidGroupMessage(Message msg) {
+ String listId = gmail.getHeader(msg, "List-ID");
+ String groupIdentifier = targetGroup.split("@")[0];
+ if (listId != null && listId.contains(groupIdentifier)) return true;
+
+ String to = gmail.getHeader(msg, "To");
+ String cc = gmail.getHeader(msg, "Cc");
+ return (to != null && to.contains(targetGroup)) || (cc != null && cc.contains(targetGroup));
+ }
+
+ private Optional sanitizeBody(String body) {
+ if (body == null || body.isBlank()) return Optional.empty();
+ Matcher matcher = SIGNATURE_PATTERN.matcher(body);
+ if (matcher.find()) {
+ String trimmed = body.substring(0, matcher.start()).trim();
+ return trimmed.isEmpty() ? Optional.empty() : Optional.of(trimmed);
+ }
+ return Optional.of(body.trim());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/keycloak/gh/bot/email/MailSender.java b/src/main/java/org/keycloak/gh/bot/email/MailSender.java
new file mode 100644
index 0000000..7dd7450
--- /dev/null
+++ b/src/main/java/org/keycloak/gh/bot/email/MailSender.java
@@ -0,0 +1,104 @@
+package org.keycloak.gh.bot.email;
+
+import com.google.api.services.gmail.model.Message;
+import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.mail.Session;
+import jakarta.mail.internet.InternetAddress;
+import jakarta.mail.internet.MimeMessage;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.jboss.logging.Logger;
+
+import java.util.List;
+import java.util.Properties;
+
+/**
+ * Handles the construction and sending of MIME emails via the Gmail API.
+ */
+@ApplicationScoped
+public class MailSender {
+
+ private static final Logger LOG = Logger.getLogger(MailSender.class);
+
+ @ConfigProperty(name = "gmail.user.email") String botEmail;
+
+ @Inject GmailAdapter gmail;
+
+ private Session mailSession;
+
+ @PostConstruct
+ public void init() {
+ this.mailSession = Session.getDefaultInstance(new Properties(), null);
+ }
+
+ public boolean sendNewEmail(String to, String cc, String subject, String body) {
+ try {
+ MimeMessage email = createBaseMessage();
+
+ email.addRecipient(jakarta.mail.Message.RecipientType.TO, new InternetAddress(to));
+
+ if (cc != null && !cc.isBlank()) {
+ email.addRecipient(jakarta.mail.Message.RecipientType.CC, new InternetAddress(cc));
+ }
+
+ email.setSubject(subject);
+ email.setText(body);
+
+ gmail.sendMessage(null, email);
+ return true;
+ } catch (Exception e) {
+ LOG.error("Failed to send new email", e);
+ return false;
+ }
+ }
+
+ public boolean sendReply(String threadId, String subject, String body, String ccTarget) {
+ try {
+ com.google.api.services.gmail.model.Thread thread = gmail.getThread(threadId);
+ if (thread == null || thread.getMessages() == null) return false;
+
+ Message targetMsg = findLastHumanMessage(thread.getMessages());
+ if (targetMsg == null) return false;
+
+ MimeMessage email = createBaseMessage();
+ setupThreadingHeaders(email, targetMsg);
+
+ String sender = gmail.getHeader(targetMsg, "From");
+ if (sender != null) email.addRecipient(jakarta.mail.Message.RecipientType.TO, new InternetAddress(sender));
+ if (ccTarget != null) email.addRecipient(jakarta.mail.Message.RecipientType.CC, new InternetAddress(ccTarget));
+
+ email.setSubject(subject.startsWith("Re:") ? subject : "Re: " + subject);
+ email.setText(body);
+
+ gmail.sendMessage(threadId, email);
+ return true;
+ } catch (Exception e) {
+ LOG.error("Failed to send reply email", e);
+ return false;
+ }
+ }
+
+ private MimeMessage createBaseMessage() throws Exception {
+ MimeMessage email = new MimeMessage(mailSession);
+ email.setFrom(new InternetAddress(botEmail));
+ return email;
+ }
+
+ private void setupThreadingHeaders(MimeMessage email, Message targetMsg) throws Exception {
+ String parentId = gmail.getHeader(targetMsg, "Message-ID");
+ String refs = gmail.getHeader(targetMsg, "References");
+ if (parentId != null && !parentId.isEmpty()) {
+ email.setHeader("In-Reply-To", parentId);
+ email.setHeader("References", (refs == null || refs.isEmpty() ? "" : refs + " ") + parentId);
+ }
+ }
+
+ private Message findLastHumanMessage(List history) {
+ for (int i = history.size() - 1; i >= 0; i--) {
+ String from = gmail.getHeader(history.get(i), "From");
+ if (from != null && !from.toLowerCase().contains(botEmail.toLowerCase())) return history.get(i);
+ }
+ return history.get(history.size() - 1);
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index b6a1fa5..f65269d 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -22,3 +22,19 @@ autoBump.normal.reactions=10
autoExpire.cron=0 6 * * * ?
autoExpire.low.expiresDays=90
autoExpire.normal.expiresDays=180
+
+quarkus.scheduler.enabled=true
+
+# --- Gmail Credentials ---
+gmail.client.id=${GMAIL_CLIENT_ID}
+gmail.client.secret=${GMAIL_CLIENT_SECRET}
+gmail.refresh.token=${GMAIL_REFRESH_TOKEN}
+gmail.user.email=${GMAIL_USER_EMAIL}
+
+# --- Targets ---
+google.group.target=keycloak-security-lab@googlegroups.com
+email.target.secalert=bruno@abstractj.org
+
+# --- Test Configuration ---
+# Disable scheduler during tests
+%test.quarkus.scheduler.enabled=false
\ No newline at end of file
diff --git a/src/main/resources/application.properties.bkp2 b/src/main/resources/application.properties.bkp2
new file mode 100644
index 0000000..f65269d
--- /dev/null
+++ b/src/main/resources/application.properties.bkp2
@@ -0,0 +1,40 @@
+quarkus.application.name=keycloak-github-bot
+quarkus.application.version=${buildNumber:999-SNAPSHOT}
+
+quarkus.openshift.labels."app"=keycloak-github-bot
+quarkus.openshift.annotations."kubernetes.io/tls-acme"=true
+quarkus.openshift.env.vars.QUARKUS_GITHUB_APP_APP_ID=817634
+quarkus.openshift.env.vars.QUARKUS_GITHUB_APP_APP_NAME=keycloak-github-bot
+quarkus.openshift.env.vars.QUARKUS_OPTS=-Dquarkus.http.host=0.0.0.0 -Xmx150m
+quarkus.openshift.env.secrets=keycloak-github-bot
+
+quarkus.openshift.idempotent=true
+
+
+missingInfo.cron=0 4 * * * ?
+missingInfo.expiration.unit=DAYS
+missingInfo.expiration.value=14
+
+autoBump.cron=0 5 * * * ?
+autoBump.low.reactions=5
+autoBump.normal.reactions=10
+
+autoExpire.cron=0 6 * * * ?
+autoExpire.low.expiresDays=90
+autoExpire.normal.expiresDays=180
+
+quarkus.scheduler.enabled=true
+
+# --- Gmail Credentials ---
+gmail.client.id=${GMAIL_CLIENT_ID}
+gmail.client.secret=${GMAIL_CLIENT_SECRET}
+gmail.refresh.token=${GMAIL_REFRESH_TOKEN}
+gmail.user.email=${GMAIL_USER_EMAIL}
+
+# --- Targets ---
+google.group.target=keycloak-security-lab@googlegroups.com
+email.target.secalert=bruno@abstractj.org
+
+# --- Test Configuration ---
+# Disable scheduler during tests
+%test.quarkus.scheduler.enabled=false
\ No newline at end of file
diff --git a/src/test/java/org/keycloak/gh/bot/email/CommandParserTest.java b/src/test/java/org/keycloak/gh/bot/email/CommandParserTest.java
new file mode 100644
index 0000000..89cbd79
--- /dev/null
+++ b/src/test/java/org/keycloak/gh/bot/email/CommandParserTest.java
@@ -0,0 +1,72 @@
+package org.keycloak.gh.bot.email;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.keycloak.gh.bot.email.CommandParser.Command;
+import org.keycloak.gh.bot.email.CommandParser.CommandType;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class CommandParserTest {
+
+ private CommandParser parser;
+
+ @BeforeEach
+ public void setup() {
+ parser = new CommandParser();
+ parser.gitHubProvider = mock(org.keycloak.gh.bot.GitHubInstallationProvider.class);
+ when(parser.gitHubProvider.getBotLogin()).thenReturn("keycloak-bot");
+ }
+
+ @Test
+ public void testReplyParsing() {
+ String text = """
+ @keycloak-bot /reply keycloak-security
+ This is the body.
+ Multiple lines.
+ """;
+
+ Optional cmd = parser.parse(text);
+
+ assertTrue(cmd.isPresent());
+ assertEquals(CommandType.REPLY_KEYCLOAK_SECURITY, cmd.get().type());
+ assertEquals("This is the body.\nMultiple lines.", cmd.get().body());
+ }
+
+ @Test
+ public void testNewSecAlertParsing() {
+ String text = """
+ @keycloak-bot /new secalert "New CVE"
+ Details here.
+ """;
+
+ Optional cmd = parser.parse(text);
+
+ assertTrue(cmd.isPresent());
+ assertEquals(CommandType.NEW_SECALERT, cmd.get().type());
+ assertEquals("New CVE", cmd.get().subject().get());
+ assertEquals("Details here.", cmd.get().body());
+ }
+
+ @Test
+ public void testUnknownCommandParsing() {
+ String text = "@keycloak-bot /unknown command";
+
+ Optional cmd = parser.parse(text);
+
+ assertTrue(cmd.isPresent());
+ assertEquals(CommandType.UNKNOWN, cmd.get().type());
+ }
+
+ @Test
+ public void testIgnoreNonMention() {
+ String text = "Just a comment without bot mention.";
+ Optional cmd = parser.parse(text);
+ assertTrue(cmd.isEmpty());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/org/keycloak/gh/bot/email/CommandProcessorTest.java b/src/test/java/org/keycloak/gh/bot/email/CommandProcessorTest.java
new file mode 100644
index 0000000..70f87b2
--- /dev/null
+++ b/src/test/java/org/keycloak/gh/bot/email/CommandProcessorTest.java
@@ -0,0 +1,142 @@
+package org.keycloak.gh.bot.email;
+
+import io.quarkus.test.InjectMock;
+import io.quarkus.test.junit.QuarkusTest;
+import jakarta.inject.Inject;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.keycloak.gh.bot.email.CommandParser.Command;
+import org.keycloak.gh.bot.email.CommandParser.CommandType;
+import org.kohsuke.github.GHIssue;
+import org.kohsuke.github.GHIssueComment;
+import org.kohsuke.github.GHIssueCommentQueryBuilder;
+import org.kohsuke.github.GHReaction;
+import org.kohsuke.github.GHUser;
+import org.kohsuke.github.PagedIterable;
+import org.kohsuke.github.PagedIterator;
+import org.kohsuke.github.ReactionContent;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Random;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@QuarkusTest
+public class CommandProcessorTest {
+ // ... (Your test methods remain exactly as they were) ...
+ // Ensure NO ConfigProfile inner class
+
+ @Inject
+ CommandProcessor commandProcessor;
+
+ @InjectMock
+ MailSender mailSender;
+
+ @InjectMock
+ GitHubAdapter githubAdapter;
+
+ @InjectMock
+ CommandParser commandParser;
+
+ @ConfigProperty(name = "quarkus.application.name")
+ String botName;
+
+ @ConfigProperty(name = "google.group.target")
+ String targetGroup;
+
+ @ConfigProperty(name = "email.target.secalert")
+ String secAlertEmail;
+
+ private static final String THREAD_ID = "123456789abcdef";
+
+ @BeforeEach
+ public void setup() {
+ when(commandParser.getBotName()).thenReturn(botName);
+ when(githubAdapter.getIssuesUpdatedSince(any())).thenReturn(Collections.emptyList());
+ }
+
+ @Test
+ public void testNewSecAlertSuccess() throws IOException {
+ GHIssue issue = mock(GHIssue.class);
+ GHIssueComment comment = mockComment();
+
+ when(commandParser.parse(anyString())).thenReturn(
+ Optional.of(new Command(CommandType.NEW_SECALERT, Optional.of("CVE-123"), "Alert body."))
+ );
+
+ when(issue.getComments()).thenReturn(List.of(comment));
+
+ mockQueryComments(issue, List.of(comment));
+
+ when(githubAdapter.getIssuesUpdatedSince(any())).thenReturn(List.of(issue));
+ when(mailSender.sendNewEmail(any(), any(), any(), any())).thenReturn(true);
+
+ commandProcessor.processCommands();
+
+ verify(mailSender).sendNewEmail(eq(secAlertEmail), eq(targetGroup), eq("CVE-123"), eq("Alert body."));
+ verify(comment).createReaction(ReactionContent.EYES);
+ }
+
+ @Test
+ public void testReplyKeycloakSecuritySuccess() throws IOException {
+ GHIssue issue = mock(GHIssue.class);
+ when(issue.getTitle()).thenReturn("Security Thread");
+ GHIssueComment comment = mockComment();
+
+ when(commandParser.parse(anyString())).thenReturn(
+ Optional.of(new Command(CommandType.REPLY_KEYCLOAK_SECURITY, Optional.empty(), "Fixed."))
+ );
+
+ when(comment.getBody()).thenReturn("**Gmail-Thread-ID:** " + THREAD_ID);
+
+ when(issue.getComments()).thenReturn(List.of(comment));
+
+ mockQueryComments(issue, List.of(comment));
+
+ when(githubAdapter.getIssuesUpdatedSince(any())).thenReturn(List.of(issue));
+ when(mailSender.sendReply(any(), any(), any(), any())).thenReturn(true);
+
+ commandProcessor.processCommands();
+
+ verify(mailSender).sendReply(eq(THREAD_ID), eq("Security Thread"), eq("Fixed."), eq(targetGroup));
+ verify(comment).createReaction(ReactionContent.EYES);
+ }
+
+ private void mockQueryComments(GHIssue issue, List comments) throws IOException {
+ GHIssueCommentQueryBuilder queryBuilder = mock(GHIssueCommentQueryBuilder.class);
+ PagedIterable pagedIterable = mock(PagedIterable.class);
+
+ when(issue.queryComments()).thenReturn(queryBuilder);
+ when(queryBuilder.since(any())).thenReturn(queryBuilder);
+ when(queryBuilder.list()).thenReturn(pagedIterable);
+ when(pagedIterable.toList()).thenReturn(comments);
+ }
+
+ private GHIssueComment mockComment() throws IOException {
+ GHIssueComment comment = mock(GHIssueComment.class);
+ when(comment.getId()).thenReturn(new Random().nextLong());
+ when(comment.getBody()).thenReturn("");
+
+ GHUser user = mock(GHUser.class);
+ when(user.getLogin()).thenReturn("tester");
+ when(comment.getUser()).thenReturn(user);
+
+ PagedIterable reactions = mock(PagedIterable.class);
+ PagedIterator iterator = mock(PagedIterator.class);
+ when(iterator.hasNext()).thenReturn(false);
+ when(reactions.iterator()).thenReturn(iterator);
+
+ when(comment.listReactions()).thenReturn(reactions);
+
+ return comment;
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/org/keycloak/gh/bot/email/GitHubAdapterTest.java b/src/test/java/org/keycloak/gh/bot/email/GitHubAdapterTest.java
new file mode 100644
index 0000000..8237d49
--- /dev/null
+++ b/src/test/java/org/keycloak/gh/bot/email/GitHubAdapterTest.java
@@ -0,0 +1,57 @@
+package org.keycloak.gh.bot.email;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.keycloak.gh.bot.GitHubInstallationProvider;
+import org.kohsuke.github.GHIssueSearchBuilder;
+import org.kohsuke.github.GitHub;
+import org.kohsuke.github.PagedSearchIterable;
+import org.mockito.ArgumentCaptor;
+
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class GitHubAdapterTest {
+
+ GitHubAdapter gitHubAdapter;
+ GitHubInstallationProvider mockInstallationProvider;
+
+ @BeforeEach
+ public void setup() {
+ gitHubAdapter = new GitHubAdapter();
+ mockInstallationProvider = mock(GitHubInstallationProvider.class);
+
+ gitHubAdapter.gitHubProvider = mockInstallationProvider;
+ }
+
+ @Test
+ public void testFindIssueQueryStructure() throws IOException {
+ String repoName = "keycloak/keycloak-private";
+ String threadId = "abc12345";
+
+ GitHub mockGitHub = mock(GitHub.class);
+ GHIssueSearchBuilder mockSearch = mock(GHIssueSearchBuilder.class);
+
+ when(mockInstallationProvider.getRepositoryFullName()).thenReturn(repoName);
+ when(mockInstallationProvider.getGitHub()).thenReturn(mockGitHub);
+
+ when(mockGitHub.searchIssues()).thenReturn(mockSearch);
+ when(mockSearch.q(anyString())).thenReturn(mockSearch);
+ when(mockSearch.list()).thenReturn(mock(PagedSearchIterable.class));
+
+ gitHubAdapter.findIssueByThreadId(threadId);
+
+ ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class);
+ verify(mockSearch).q(queryCaptor.capture());
+
+ String query = queryCaptor.getValue();
+ assertTrue(query.contains("repo:" + repoName));
+ assertTrue(query.contains("\"" + threadId + "\""));
+ assertTrue(query.contains("in:comments"));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/org/keycloak/gh/bot/email/GmailAdapterTest.java b/src/test/java/org/keycloak/gh/bot/email/GmailAdapterTest.java
new file mode 100644
index 0000000..445bad8
--- /dev/null
+++ b/src/test/java/org/keycloak/gh/bot/email/GmailAdapterTest.java
@@ -0,0 +1,70 @@
+package org.keycloak.gh.bot.email;
+
+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 io.quarkus.test.junit.QuarkusTest;
+import jakarta.inject.Inject;
+import org.junit.jupiter.api.Test;
+
+import java.util.Base64;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+@QuarkusTest
+public class GmailAdapterTest {
+
+ @Inject
+ GmailAdapter gmailAdapter;
+
+ @Test
+ public void testGetHeaderCaseInsensitivity() {
+ Message msg = new Message();
+ msg.setPayload(new MessagePart().setHeaders(List.of(
+ new MessagePartHeader().setName("Subject").setValue("Critical Alert"),
+ new MessagePartHeader().setName("from").setValue("security@example.com")
+ )));
+
+ assertEquals("Critical Alert", gmailAdapter.getHeader(msg, "Subject"));
+ assertEquals("security@example.com", gmailAdapter.getHeader(msg, "From"));
+ assertEquals("", gmailAdapter.getHeader(msg, "Non-Existent-Header"));
+ }
+
+ @Test
+ public void testGetBodyDecodesBase64() {
+ String originalText = "Hello World";
+ String encoded = Base64.getUrlEncoder().encodeToString(originalText.getBytes());
+
+ Message msg = new Message();
+ MessagePart payload = new MessagePart();
+ payload.setBody(new MessagePartBody().setData(encoded));
+ msg.setPayload(payload);
+
+ assertEquals(originalText, gmailAdapter.getBody(msg));
+ }
+
+ @Test
+ public void testGetBodyMultipartNested() {
+ String expectedText = "Clean Text";
+ String encodedText = Base64.getUrlEncoder().encodeToString(expectedText.getBytes());
+ String encodedHtml = Base64.getUrlEncoder().encodeToString("Bad".getBytes());
+
+ MessagePart textPart = new MessagePart()
+ .setMimeType("text/plain")
+ .setBody(new MessagePartBody().setData(encodedText));
+
+ MessagePart htmlPart = new MessagePart()
+ .setMimeType("text/html")
+ .setBody(new MessagePartBody().setData(encodedHtml));
+
+ Message msg = new Message();
+ MessagePart root = new MessagePart();
+ root.setParts(List.of(textPart, htmlPart));
+ root.setBody(new MessagePartBody());
+ msg.setPayload(root);
+
+ assertEquals(expectedText, gmailAdapter.getBody(msg));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/org/keycloak/gh/bot/email/IncomingMailProcessorTest.java b/src/test/java/org/keycloak/gh/bot/email/IncomingMailProcessorTest.java
new file mode 100644
index 0000000..f361ec0
--- /dev/null
+++ b/src/test/java/org/keycloak/gh/bot/email/IncomingMailProcessorTest.java
@@ -0,0 +1,125 @@
+package org.keycloak.gh.bot.email;
+
+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 io.quarkus.test.InjectMock;
+import io.quarkus.test.junit.QuarkusTest;
+import jakarta.inject.Inject;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.keycloak.gh.bot.utils.Labels;
+import org.kohsuke.github.GHIssue;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@QuarkusTest
+public class IncomingMailProcessorTest {
+
+ @Inject
+ IncomingMailProcessor incomingMailProcessor;
+
+ @InjectMock
+ GmailAdapter gmailAdapter;
+
+ @InjectMock
+ GitHubAdapter githubAdapter;
+
+ @ConfigProperty(name = "google.group.target")
+ String targetGroup;
+
+ private static final String THREAD_ID = "123456789abcdef";
+
+ @BeforeEach
+ public void setup() {
+ when(gmailAdapter.fetchUnreadMessages(anyString())).thenReturn(Collections.emptyList());
+
+ when(gmailAdapter.getHeader(any(), anyString())).thenCallRealMethod();
+ when(gmailAdapter.getBody(any())).thenCallRealMethod();
+ }
+
+ @Test
+ public void testNewThreadCreatesIssue() throws IOException {
+ Message message = createMockMessage(THREAD_ID, "Vulnerability", "Body content", "user@test.com");
+ when(gmailAdapter.fetchUnreadMessages(anyString())).thenReturn(List.of(message));
+ when(gmailAdapter.getMessage(message.getId())).thenReturn(message);
+
+ when(githubAdapter.findIssueByThreadId(THREAD_ID)).thenReturn(Optional.empty());
+
+ GHIssue mockIssue = mock(GHIssue.class);
+ when(mockIssue.getNumber()).thenReturn(101);
+ when(githubAdapter.createIssue(anyString(), anyString())).thenReturn(mockIssue);
+
+ incomingMailProcessor.processUnreadEmails();
+
+ verify(githubAdapter).createIssue(eq("Vulnerability"), anyString());
+ verify(mockIssue).addLabels(Labels.STATUS_TRIAGE);
+ verify(gmailAdapter).markAsRead(message.getId());
+ }
+
+ @Test
+ public void testReplyAppendsComment() throws IOException {
+ Message message = createMockMessage(THREAD_ID, "Re: New Vuln", "More details here.", "user@test.com");
+ when(gmailAdapter.fetchUnreadMessages(anyString())).thenReturn(List.of(message));
+ when(gmailAdapter.getMessage(message.getId())).thenReturn(message);
+
+ GHIssue existingIssue = mock(GHIssue.class);
+
+ when(githubAdapter.findIssueByThreadId(THREAD_ID)).thenReturn(Optional.of(existingIssue));
+
+ incomingMailProcessor.processUnreadEmails();
+
+ verify(githubAdapter).commentOnIssue(eq(existingIssue), contains("More details here."));
+ verify(githubAdapter, never()).createIssue(anyString(), anyString());
+ verify(gmailAdapter).markAsRead(message.getId());
+ }
+
+ private Message createMockMessage(String threadId, String subject, String body, String from) {
+ Message msg = new Message();
+ msg.setId(UUID.randomUUID().toString());
+ msg.setThreadId(threadId);
+
+ MessagePart payload = new MessagePart();
+ List headers = new ArrayList<>();
+ headers.add(createHeader("Subject", subject));
+ headers.add(createHeader("From", from));
+
+ headers.add(createHeader("To", targetGroup));
+
+ String listId = targetGroup.replace("@", ".");
+ headers.add(createHeader("List-ID", "<" + listId + ">"));
+
+ payload.setHeaders(headers);
+ payload.setMimeType("text/plain");
+
+ MessagePartBody partBody = new MessagePartBody();
+ partBody.setData(Base64.getUrlEncoder().encodeToString(body.getBytes()));
+ payload.setBody(partBody);
+ msg.setPayload(payload);
+ return msg;
+ }
+
+ private MessagePartHeader createHeader(String name, String value) {
+ MessagePartHeader h = new MessagePartHeader();
+ h.setName(name);
+ h.setValue(value);
+ return h;
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/org/keycloak/gh/bot/email/MailSenderTest.java b/src/test/java/org/keycloak/gh/bot/email/MailSenderTest.java
new file mode 100644
index 0000000..d1b22b5
--- /dev/null
+++ b/src/test/java/org/keycloak/gh/bot/email/MailSenderTest.java
@@ -0,0 +1,63 @@
+package org.keycloak.gh.bot.email;
+
+import com.google.api.services.gmail.model.Message;
+import com.google.api.services.gmail.model.MessagePart;
+import com.google.api.services.gmail.model.MessagePartHeader;
+import io.quarkus.test.InjectMock;
+import io.quarkus.test.junit.QuarkusTest;
+import jakarta.inject.Inject;
+import jakarta.mail.internet.MimeMessage;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@QuarkusTest
+public class MailSenderTest {
+
+ @Inject
+ MailSender mailSender;
+
+ @InjectMock
+ GmailAdapter gmailAdapter;
+
+ @ConfigProperty(name = "gmail.user.email")
+ String botEmail;
+
+ @Test
+ public void testReplyFindsLastHumanMessage() throws Exception {
+ String threadId = "thread-123";
+
+ Message humanMsg = createMsg("msg-human", "human@example.com");
+ Message botMsg = createMsg("msg-bot", botEmail);
+
+ com.google.api.services.gmail.model.Thread thread = new com.google.api.services.gmail.model.Thread();
+ thread.setMessages(List.of(humanMsg, botMsg));
+
+ when(gmailAdapter.getThread(threadId)).thenReturn(thread);
+
+ when(gmailAdapter.getHeader(any(), eq("From"))).thenCallRealMethod();
+ when(gmailAdapter.getHeader(any(), eq("Message-ID"))).thenCallRealMethod();
+
+ mailSender.sendReply(threadId, "Re: Subject", "Body", null);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class);
+ verify(gmailAdapter).sendMessage(eq(threadId), captor.capture());
+
+ assertEquals("msg-human", captor.getValue().getHeader("In-Reply-To", null));
+ }
+
+ private Message createMsg(String id, String from) {
+ return new Message().setPayload(new MessagePart().setHeaders(List.of(
+ new MessagePartHeader().setName("Message-ID").setValue(id),
+ new MessagePartHeader().setName("From").setValue(from)
+ )));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/org/keycloak/gh/bot/email/MockGitHubInstallationProvider.java b/src/test/java/org/keycloak/gh/bot/email/MockGitHubInstallationProvider.java
new file mode 100644
index 0000000..dead06e
--- /dev/null
+++ b/src/test/java/org/keycloak/gh/bot/email/MockGitHubInstallationProvider.java
@@ -0,0 +1,34 @@
+package org.keycloak.gh.bot.email;
+
+import io.quarkus.test.Mock;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.Priority;
+import jakarta.enterprise.inject.Alternative;
+import jakarta.inject.Singleton;
+import org.keycloak.gh.bot.GitHubInstallationProvider;
+import org.kohsuke.github.GitHub;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+
+@Mock
+@Alternative
+@Priority(1)
+@Singleton
+public class MockGitHubInstallationProvider extends GitHubInstallationProvider {
+
+ @Override
+ @PostConstruct
+ public void init() throws IOException {
+ }
+
+ @Override
+ public GitHub getGitHub() {
+ return Mockito.mock(GitHub.class);
+ }
+
+ @Override
+ public String getRepositoryFullName() {
+ return "keycloak/keycloak";
+ }
+}
\ No newline at end of file