package com.safeqr.app.gmail.service; import com.google.api.client.auth.oauth2.BearerToken; import com.google.api.client.auth.oauth2.ClientParametersAuthentication; import com.google.api.client.auth.oauth2.Credential; import com.google.api.client.auth.oauth2.TokenResponse; import com.google.api.client.googleapis.auth.oauth2.GoogleRefreshTokenRequest; import com.google.api.client.googleapis.json.GoogleJsonResponseException; import com.google.api.client.http.GenericUrl; import com.google.api.client.http.HttpTransport; import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.client.json.JsonFactory; import com.google.api.client.json.gson.GsonFactory; import com.google.api.services.gmail.Gmail; import com.google.api.services.gmail.model.*; import com.google.zxing.*; import com.google.zxing.client.j2se.BufferedImageLuminanceSource; import com.google.zxing.common.HybridBinarizer; import com.google.zxing.multi.qrcode.QRCodeMultiReader; import com.safeqr.app.exceptions.ResourceNotFoundExceptions; import com.safeqr.app.gmail.dto.BaseResponse; import com.safeqr.app.gmail.dto.ScannedGmailResponseDto; import com.safeqr.app.gmail.entity.GmailCidEntity; import com.safeqr.app.gmail.entity.GmailEmailEntity; import com.safeqr.app.gmail.entity.GmailUrlEntity; import com.safeqr.app.gmail.model.EmailMessage; import com.safeqr.app.gmail.model.QRCodeByContentId; import com.safeqr.app.gmail.model.QRCodeByURL; import com.safeqr.app.gmail.repository.GmailCidRespository; import com.safeqr.app.gmail.repository.GmailEmailRespository; import com.safeqr.app.gmail.repository.GmailUrlsRespository; import com.safeqr.app.qrcode.model.QRCodeModel; import com.safeqr.app.qrcode.service.QRCodeTypeService; import com.safeqr.app.user.entity.UserEntity; import com.safeqr.app.user.service.UserService; import com.safeqr.app.utils.DateParsingUtils; import org.apache.commons.codec.binary.Base64; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.select.Elements; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.IOException; import java.lang.Thread; import java.math.BigInteger; import java.net.ConnectException; import java.net.URI; import java.net.URISyntaxException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.http.HttpTimeoutException; import java.time.OffsetDateTime; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import java.util.stream.Stream; import static com.google.api.client.googleapis.auth.oauth2.GoogleOAuthConstants.TOKEN_SERVER_URL; import static com.safeqr.app.constants.APIConstants.APPLICATION_NAME; import static com.safeqr.app.constants.CommonConstants.GMAIL_ACTIVE; @Service public class GmailService { @Value("${gmail.client.clientId}") private String clientId; @Value("${gmail.client.clientSecret}") private String clientSecret; private final GmailEmailRespository gmailEmailRespository; private final GmailCidRespository gmailCidRespository; private final GmailUrlsRespository gmailUrlsRespository; private final QRCodeTypeService qrCodeTypeService; private final UserService userService; private static final Logger logger = LoggerFactory.getLogger(GmailService.class); private static final HttpTransport httpTransport = new NetHttpTransport(); private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); private static final long MAX_RESULTS = 100L; public GmailService(GmailEmailRespository gmailEmailRespository, GmailCidRespository gmailCidRespository, GmailUrlsRespository gmailUrlsRespository, QRCodeTypeService qrCodeTypeService, UserService userService) { this.gmailEmailRespository = gmailEmailRespository; this.gmailCidRespository = gmailCidRespository; this.gmailUrlsRespository = gmailUrlsRespository; this.qrCodeTypeService = qrCodeTypeService; this.userService = userService; } private Gmail getGmailService(String accessToken, String refreshToken) throws IOException { Credential userCredentials = new Credential.Builder(BearerToken.authorizationHeaderAccessMethod()) .setTransport(httpTransport) .setJsonFactory(JSON_FACTORY) .setTokenServerUrl(new GenericUrl(TOKEN_SERVER_URL)) .setClientAuthentication(new ClientParametersAuthentication(clientId, clientSecret)) .build() .setAccessToken(accessToken) .setRefreshToken(refreshToken); return new Gmail.Builder(httpTransport, JSON_FACTORY, userCredentials) .setApplicationName(APPLICATION_NAME) .build(); } // Renew the access token if it has expired using the refresh token. private String refreshAccessToken(String refreshToken) throws IOException { TokenResponse response = new GoogleRefreshTokenRequest( httpTransport, JSON_FACTORY, refreshToken, clientId, clientSecret) .execute(); return response.getAccessToken(); } private Gmail refreshAndGetGmailService(String accessToken, String refreshToken) throws IOException { try { return getGmailService(accessToken, refreshToken); } catch (GoogleJsonResponseException e) { if (e.getStatusCode() == 401) { logger.info("Access token expired. Refreshing token..."); String newAccessToken = refreshAccessToken(refreshToken); return getGmailService(newAccessToken, refreshToken); } throw e; } } // Async method to scan all emails in the user's inbox to prevent timeout. @Async public void getEmailAsync(String userId, String accessToken, String refreshToken) { try { ScannedGmailResponseDto result = getEmail(userId, accessToken, refreshToken); CompletableFuture.completedFuture(result); } catch (IOException e) { logger.error("Error processing Gmail", e); } } // Scan all emails in the user's inbox. public ScannedGmailResponseDto getEmail(String userId, String accessToken, String refreshToken) throws IOException { Gmail service = refreshAndGetGmailService(accessToken, refreshToken); logger.info("Gmail service initialized: {}", service); List emailMessagesList = new ArrayList<>(); String meUserId = "me"; String nextPageToken = null; UserEntity userEntity = userService.getUserByIdForGmail(userId); BigInteger historyId = userEntity.getGmailHistoryId(); logger.info("history id: {}", historyId); // Fetch history if historyId is not 0 (Default db value) if (historyId.compareTo(BigInteger.ZERO) != 0) { logger.info("HistoryId from db: {}", historyId); ListHistoryResponse historyResponse = service.users().history().list(meUserId) .setStartHistoryId(historyId) .execute(); List historyList = historyResponse.getHistory(); if (historyList != null) { for (History history : historyList) { logger.info("History Response - History Id: {}, Message Id: {}", history.getId(), history.getMessages().get(0).getId()); List messages = history.getMessages(); for (Message message : messages) { EmailMessage emailMessage = processMessage(service, meUserId, message); if (emailMessage != null) { emailMessagesList.add(emailMessage); saveEmailMessageAndScanQRCode(userId, emailMessage); } } } } } else { // Fetching email messages with page token and setting max results, Default value is 100. do { ListMessagesResponse listResponse = fetchMessages(service, meUserId, nextPageToken); List messages = listResponse.getMessages(); nextPageToken = listResponse.getNextPageToken(); // Iterate all the messages and save to gmail db only if it has a valid QR code. for (Message message : messages) { EmailMessage emailMessage = processMessage(service, meUserId, message); if (emailMessage != null) { emailMessagesList.add(emailMessage); // Save email message to database. saveEmailMessageAndScanQRCode(userId, emailMessage); } } } while (nextPageToken != null); } // Update user's history id. BigInteger latestHistoryId = getLatestHistoryId(service, meUserId); if (latestHistoryId != null) { userEntity.setGmailHistoryId(latestHistoryId); userService.updateUserEntity(userEntity); } return new ScannedGmailResponseDto(emailMessagesList); } private BigInteger getLatestHistoryId(Gmail service, String userId) throws IOException { Profile profile = service.users().getProfile(userId).execute(); return profile.getHistoryId(); } // Save email message to database and scan QR code. private void saveEmailMessageAndScanQRCode(String userId, EmailMessage emailMessage) { GmailEmailEntity gmailEmailEntity = saveEmailMessage(userId, emailMessage); if (gmailEmailEntity != null) { // Save QR codes by content ID saveQRCodeByContentId(gmailEmailEntity, emailMessage.getQrCodeByContentId()); // Save QR codes by URL saveQRCodeByURL(gmailEmailEntity, emailMessage.getQrCodeByURL()); } else { logger.warn("Skipping QR code processing due to failure in saving email message."); } } // Save to gmail_email table private GmailEmailEntity saveEmailMessage(String userId, EmailMessage emailMessage) { logger.info("userId: {}", userId); OffsetDateTime dateReceived = DateParsingUtils.parseDate(emailMessage.getDate()); try { GmailEmailEntity gmailEmailEntity = GmailEmailEntity.builder() .userId(userId) .messageId(emailMessage.getMessageId()) .threadId(emailMessage.getThreadId()) .historyId(new BigInteger(emailMessage.getHistoryId())) .subject(emailMessage.getSubject()) .dateReceived(dateReceived) .active(emailMessage.getActive()) .build(); return gmailEmailRespository.save(gmailEmailEntity); } catch (DataIntegrityViolationException e) { if (e.getCause() instanceof org.hibernate.exception.ConstraintViolationException) { logger.warn("Duplicate entry for userId: {}, messageId: {}", userId, emailMessage.getMessageId()); } else { logger.error("Error saving to gmail_email table: {}", e.getMessage(), e); } } catch (Exception e) { logger.error("Error saving gmail_email table: {}", e.getMessage(), e); } return null; } // Iterate through decoded contents found in attachment as content id and save to gmail_cid table private void saveQRCodeByContentId(GmailEmailEntity gmailEmailEntity, List qrCodeByContentIdList) { qrCodeByContentIdList.forEach(cid -> { cid.getDecodedContent().forEach(decodedContent -> { try { QRCodeModel qrCodeModel = qrCodeTypeService.scanGmailDecodedContents(gmailEmailEntity.getUserId(), decodedContent); GmailCidEntity gmailCidEntity = GmailCidEntity.builder() .gmailId(gmailEmailEntity.getId()) .qrCodeId(qrCodeModel.getData().getId()) .cid(cid.getCid()) .attachmentId(cid.getAttachmentId()) .decodedContent(decodedContent) .build(); gmailCidRespository.save(gmailCidEntity); } catch (Exception e) { logger.error("Error saving QR code by content ID to gmail_cid table: {}", e.getMessage(), e); } }); }); } // Iterate through decoded content found in url and save to gmail_url table private void saveQRCodeByURL(GmailEmailEntity gmailEmailEntity, List qrCodeByURLList) { qrCodeByURLList.forEach(imageUrl -> { imageUrl.getDecodedContent().forEach(decodedContent -> { try { QRCodeModel qrCodeModel = qrCodeTypeService.scanGmailDecodedContents(gmailEmailEntity.getUserId(), decodedContent); GmailUrlEntity gmailUrlEntity = GmailUrlEntity.builder() .gmailId(gmailEmailEntity.getId()) .qrCodeId(qrCodeModel.getData().getId()) .imageUrl(imageUrl.getUrl()) .decodedContent(decodedContent) .build(); gmailUrlsRespository.save(gmailUrlEntity); } catch (Exception e) { logger.error("Error saving QR code by URL to gmail_urls table: {}", e.getMessage(), e); } }); }); } // Fetching Scanned Gmail from database public ScannedGmailResponseDto fetchScannedGmail(String userId){ // Fetching all emails from gmail_email table List userEmailsList = gmailEmailRespository.findByUserIdAndActive(userId, GMAIL_ACTIVE); List emailMessageList = new ArrayList<>(); if (userEmailsList != null && !userEmailsList.isEmpty()) { userEmailsList.forEach(email -> { EmailMessage emailMessage = new EmailMessage( email.getMessageId(), email.getThreadId(), email.getSubject(), email.getHistoryId().toString(), email.getDateReceived().toString() ); // Fetching all CIDs from gmail_cid table List cidList = gmailCidRespository.findByGmailId(email.getId()); Map qrCodeByContentIdMap = new HashMap<>(); for (GmailCidEntity cid : cidList) { String key = cid.getCid() + "-" + cid.getAttachmentId(); QRCodeByContentId qrCodeByContentId = qrCodeByContentIdMap.get(key); if (qrCodeByContentId == null) { qrCodeByContentId = QRCodeByContentId.builder() .cid(cid.getCid()) .attachmentId(cid.getAttachmentId()) .decodedContent(new ArrayList<>()) .totalQRCodeFound(0) .build(); qrCodeByContentIdMap.put(key, qrCodeByContentId); } // Append decoded content to the existing list qrCodeByContentId.getDecodedContent().add(cid.getDecodedContent()); // Fetch scanned QR code from database and add to message object emailMessage.addQRCodeModel(qrCodeTypeService.getScannedQRCodeDetailsInModel(cid.getQrCodeId())); qrCodeByContentId.setTotalQRCodeFound(qrCodeByContentId.getTotalQRCodeFound() + 1); } emailMessage.setQrCodeByContentId(new ArrayList<>(qrCodeByContentIdMap.values())); // Fetching all URLs from gmail_urls table List urlList = gmailUrlsRespository.findByGmailId(email.getId()); Map qrCodeByURLMap = new HashMap<>(); for (GmailUrlEntity url : urlList) { String key = url.getImageUrl(); QRCodeByURL qrCodeByURL = qrCodeByURLMap.get(key); if (qrCodeByURL == null) { qrCodeByURL = QRCodeByURL.builder() .url(url.getImageUrl()) .decodedContent(new ArrayList<>()) .totalQRCodeFound(0) .build(); qrCodeByURLMap.put(key, qrCodeByURL); } // Append decoded content to the existing list qrCodeByURL.getDecodedContent().add(url.getDecodedContent()); // Fetch scanned QR code from database and add to message object emailMessage.addQRCodeModel(qrCodeTypeService.getScannedQRCodeDetailsInModel(url.getQrCodeId())); qrCodeByURL.setTotalQRCodeFound(qrCodeByURL.getTotalQRCodeFound() + 1); } emailMessage.setQrCodeByURL(new ArrayList<>(qrCodeByURLMap.values())); emailMessageList.add(emailMessage); }); } return ScannedGmailResponseDto.builder().messages(emailMessageList).build(); } // Fetching email messages with page token and setting max results private ListMessagesResponse fetchMessages(Gmail service, String userId, String pageToken) throws IOException { return service.users().messages().list(userId) .setPageToken(pageToken) .setMaxResults(MAX_RESULTS) .execute(); } // Processing email message and returning EmailMessage object if it has a valid QR code. private EmailMessage processMessage(Gmail service, String userId, Message message) { try { message = service.users().messages().get(userId, message.getId()).setFormat("full").execute(); List parts = message.getPayload().getParts(); Set attachmentIds = new HashSet<>(); Set imageUrls = new HashSet<>(); processPartsRecursively(parts, attachmentIds, imageUrls); if (attachmentIds.isEmpty() && imageUrls.isEmpty()) { return null; } String subject = getHeader(message, "Subject"); String emailDate = getHeader(message, "Date"); logger.info("Email Subject: {}", subject); logger.info("Message ID: {}", message.getId()); logger.info("History ID: {}", message.getHistoryId()); EmailMessage emailMessage = new EmailMessage(message.getId(), message.getThreadId(), subject, String.valueOf(message.getHistoryId()), emailDate); processAttachments(service, message.getId(), parts, attachmentIds, emailMessage); processImageUrls(imageUrls, emailMessage); return emailMessage.hasQRCodes() ? emailMessage : null; } catch (GoogleJsonResponseException e) { if (e.getStatusCode() == 404) { logger.warn("Message with ID {} not found. It may have been deleted.", message.getId()); return null; } else { logger.error("Error processing message with ID {}: {}", message.getId(), e.getMessage()); throw new RuntimeException("Error processing Gmail message", e); } } catch (IOException e) { logger.error("IO error processing message with ID {}: {}", message.getId(), e.getMessage()); throw new RuntimeException("IO error processing Gmail message", e); } } // Process all the attachments. private void processAttachments(Gmail service, String messageId, List parts, Set attachmentIds, EmailMessage emailMessage) throws IOException { for (String attachmentId : attachmentIds) { Optional attachment = findAttachmentIdByCid(parts, attachmentId); if (attachment.isPresent()) { List qrCodeValue = processAttachment(service, messageId, attachment.get()); if (!qrCodeValue.isEmpty()) { emailMessage.addQRCodeByContentId(new QRCodeByContentId(attachmentId, attachment.get(), qrCodeValue, qrCodeValue.size())); } } } } // Process all the image URLs. private void processImageUrls(Set imageUrls, EmailMessage emailMessage) { for (String imageUrl : imageUrls) { List qrCodeValue = scanQRCodeFromUrl(imageUrl); if (!qrCodeValue.isEmpty()) { emailMessage.addQRCodeByURL(new QRCodeByURL(imageUrl, qrCodeValue, qrCodeValue.size())); } } } // Find the header with the given name. private String getHeader(Message message, String name) { return message.getPayload().getHeaders().stream() .filter(header -> name.equalsIgnoreCase(header.getName())) .findFirst() .map(MessagePartHeader::getValue) .orElse("No " + name); } // Find the attachment ID in the given message part. private Optional findAttachmentIdByCid(List parts, String cid) { return parts.stream() .flatMap(part -> Stream.concat(findAttachmentIdInCurrentPart(part, cid).stream(), Optional.ofNullable(part.getParts()) .flatMap(subParts -> findAttachmentIdByCid(subParts, cid)).stream())) .findFirst(); } // Find the attachment ID in the message subpart. private Optional findAttachmentIdInCurrentPart(MessagePart part, String cid) { return Optional.ofNullable(part.getHeaders()) .flatMap(headers -> headers.stream() .filter(header -> isContentIdHeader(header, cid)) .findFirst() .map(header -> part.getBody().getAttachmentId())); } // Check if the header is a Content-ID header with the given CID. private boolean isContentIdHeader(MessagePartHeader header, String cid) { return "Content-ID".equalsIgnoreCase(header.getName()) && header.getValue().contains(cid); } // Recursive method to handle nested parts to search for CID URIs private void processPartsRecursively(List parts, Set attachmentIds, Set imageURLs) { if (parts != null) { for (MessagePart part : parts) { if (part.getMimeType().equalsIgnoreCase("text/html")) { String html = new String(Base64.decodeBase64(part.getBody().getData())); attachmentIds.addAll(extractCIDsFromHtml(html)); imageURLs.addAll(extractImageUrlsFromHtml(html)); } else if (part.getParts() != null) { // Recursive call to handle nested parts processPartsRecursively(part.getParts(), attachmentIds, imageURLs); } } } } private List scanQRCodeFromUrl(String imageUrl) { try { BufferedImage image = downloadImageFromUrl(imageUrl); if (image != null) { return decodeQRCodes(image); } } catch (IllegalArgumentException e) { logger.error("Invalid URI scheme for URL: {} -> {}", imageUrl, e.getMessage()); } catch(URISyntaxException e) { logger.error("Error while scanning QR code from URL", e); } return Collections.emptyList(); } // Download the image from the given URL private BufferedImage downloadImageFromUrl(String imageUrl) throws URISyntaxException { try { imageUrl = imageUrl.replace(" ", "%20"); HttpClient client = HttpClient.newBuilder() .followRedirects(HttpClient.Redirect.ALWAYS) .build(); logger.info("Downloading image from URL: {}", imageUrl); HttpRequest request = HttpRequest.newBuilder() .uri(new URI(imageUrl)) .header("User-Agent", "Mozilla/5.0") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() == 200) { byte[] imageBytes = response.body(); return ImageIO.read(new ByteArrayInputStream(imageBytes)); } else { logger.warn("Failed to download image. HTTP response code: {}", response.statusCode()); } } catch (URISyntaxException e) { logger.error("Invalid URL: {} -> {}", imageUrl, e.getMessage()); } catch (HttpTimeoutException e) { logger.error("Request timed out for URL: {} -> {}", imageUrl, e.getMessage()); } catch (ConnectException e) { logger.warn("Failed to connect to URL: {} -> {}", imageUrl, e.getMessage()); } catch (IOException e) { logger.warn("Error downloading image from URL: {} -> {}", imageUrl, e.getMessage()); if (Thread.currentThread().isInterrupted()) { logger.warn("Thread was interrupted during IO operation for URL: {}", imageUrl); } } catch (InterruptedException e) { logger.warn("Thread was interrupted during HTTP request for URL: {} -> {}", imageUrl, e.getMessage()); Thread.currentThread().interrupt(); } return null; } private List processAttachment(Gmail service, String messageId, String attachmentId) throws IOException { MessagePartBody attachPart = service.users().messages().attachments().get("me", messageId, attachmentId).execute(); byte[] imageBytes = Base64.decodeBase64(attachPart.getData()); BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageBytes)); // ImageIO.write(image, "png", new File("debug_image.png")); return decodeQRCodes(image); } private List decodeQRCodes(BufferedImage image) { List qrCodeValues = new ArrayList<>(); LuminanceSource source = new BufferedImageLuminanceSource(image); BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); // Set up decoding hints Map hints = new EnumMap<>(DecodeHintType.class); hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE); hints.put(DecodeHintType.POSSIBLE_FORMATS, List.of(BarcodeFormat.QR_CODE)); try { QRCodeMultiReader multiReader = new QRCodeMultiReader(); Result[] results = multiReader.decodeMultiple(bitmap, hints); if (results != null) { for (Result result : results) { qrCodeValues.add(result.getText()); logger.info("Detected QR code: {}", result.getText()); } } } catch (NotFoundException e) { // No QR codes found } catch (Exception e) { logger.error("Error decoding QR codes", e); } if (!qrCodeValues.isEmpty()) logger.info("Total QR codes found: {}", qrCodeValues.size()); return qrCodeValues; } //Extract CIDs from HTML private Set extractCIDsFromHtml(String html) { Document doc = Jsoup.parse(html); Elements imgs = doc.select("img[src^=cid:]"); return imgs.stream() .map(img -> img.attr("src")) .filter(src -> src.startsWith("cid:")) .map(src -> src.substring(4)) // Remove "cid:" prefix .collect(Collectors.toSet()); } //Extract image URLs from HTML private Set extractImageUrlsFromHtml(String html) { Document doc = Jsoup.parse(html); Elements imgs = doc.select("img[src]"); return imgs.stream() .map(img -> img.attr("src")) .filter(this::isImageUrl) .collect(Collectors.toSet()); } // Check if the URL is an image URL private boolean isImageUrl(String url) { String lowerUrl = url.toLowerCase(); return lowerUrl.endsWith(".jpg") || lowerUrl.endsWith(".jpeg") || lowerUrl.endsWith(".png") || lowerUrl.endsWith(".gif") || lowerUrl.endsWith(".bmp"); } @Transactional public BaseResponse deleteMessage(String userId, String messageId) { int updatedCount = gmailEmailRespository.deactivateEmailByUserIdAndMessageId(userId, messageId); // throw exception if email message not found if (updatedCount < 1) throw new ResourceNotFoundExceptions("Email message not found"); return BaseResponse.builder().message("Email deleted successfully").build(); } @Transactional public BaseResponse deleteAllMessages(String userId) { int updatedCount = gmailEmailRespository.deactivateEmailsByUserId(userId); return (updatedCount < 1) ? BaseResponse.builder().message("No Email found").build() : BaseResponse.builder().message("All Emails deleted successfully").build(); } }