/* * 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.nifi.processors.email; import microsoft.exchange.webservices.data.autodiscover.IAutodiscoverRedirectionUrl; import microsoft.exchange.webservices.data.core.ExchangeService; import microsoft.exchange.webservices.data.core.PropertySet; import microsoft.exchange.webservices.data.core.enumeration.misc.ExchangeVersion; import microsoft.exchange.webservices.data.core.enumeration.property.BodyType; import microsoft.exchange.webservices.data.core.enumeration.property.WellKnownFolderName; import microsoft.exchange.webservices.data.core.enumeration.search.FolderTraversal; import microsoft.exchange.webservices.data.core.enumeration.search.LogicalOperator; import microsoft.exchange.webservices.data.core.enumeration.search.SortDirection; import microsoft.exchange.webservices.data.core.enumeration.service.ConflictResolutionMode; import microsoft.exchange.webservices.data.core.enumeration.service.DeleteMode; import microsoft.exchange.webservices.data.core.service.folder.Folder; import microsoft.exchange.webservices.data.core.service.item.EmailMessage; import microsoft.exchange.webservices.data.core.service.item.Item; import microsoft.exchange.webservices.data.core.service.schema.EmailMessageSchema; import microsoft.exchange.webservices.data.core.service.schema.FolderSchema; import microsoft.exchange.webservices.data.core.service.schema.ItemSchema; import microsoft.exchange.webservices.data.credential.ExchangeCredentials; import microsoft.exchange.webservices.data.credential.WebCredentials; import microsoft.exchange.webservices.data.property.complex.FileAttachment; import microsoft.exchange.webservices.data.search.FindFoldersResults; import microsoft.exchange.webservices.data.search.FindItemsResults; import microsoft.exchange.webservices.data.search.FolderView; import microsoft.exchange.webservices.data.search.ItemView; import microsoft.exchange.webservices.data.search.filter.SearchFilter; import org.apache.commons.mail.EmailAttachment; import org.apache.commons.mail.EmailException; import org.apache.commons.mail.HtmlEmail; import org.apache.commons.mail.MultiPartEmail; import org.apache.nifi.annotation.behavior.InputRequirement; import org.apache.nifi.annotation.documentation.CapabilityDescription; import org.apache.nifi.annotation.documentation.Tags; import org.apache.nifi.annotation.lifecycle.OnStopped; import org.apache.nifi.components.PropertyDescriptor; import org.apache.nifi.flowfile.FlowFile; import org.apache.nifi.processor.AbstractProcessor; import org.apache.nifi.processor.ProcessContext; import org.apache.nifi.processor.ProcessSession; import org.apache.nifi.processor.Relationship; import org.apache.nifi.processor.exception.ProcessException; import org.apache.nifi.processor.io.OutputStreamCallback; import org.apache.nifi.processor.util.StandardValidators; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.mail.Address; import javax.mail.Flags; import javax.mail.Message; import javax.mail.MessagingException; import javax.mail.internet.MimeMessage; import javax.mail.util.ByteArrayDataSource; import java.io.IOException; import java.io.OutputStream; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; @InputRequirement(InputRequirement.Requirement.INPUT_FORBIDDEN) @CapabilityDescription("Consumes messages from Microsoft Exchange using Exchange Web Services. " + "The raw-bytes of each received email message are written as contents of the FlowFile") @Tags({ "Email", "EWS", "Exchange", "Get", "Ingest", "Ingress", "Message", "Consume" }) public class ConsumeEWS extends AbstractProcessor { public static final PropertyDescriptor USER = new PropertyDescriptor.Builder() .name("user") .displayName("User Name") .description("User Name used for authentication and authorization with Email server.") .required(true) .expressionLanguageSupported(true) .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) .build(); public static final PropertyDescriptor PASSWORD = new PropertyDescriptor.Builder() .name("password") .displayName("Password") .description("Password used for authentication and authorization with Email server.") .required(true) .expressionLanguageSupported(true) .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) .sensitive(true) .build(); public static final PropertyDescriptor FOLDER = new PropertyDescriptor.Builder() .name("folder") .displayName("Folder") .description("Email folder to retrieve messages from (e.g., INBOX)") .required(true) .expressionLanguageSupported(true) .defaultValue("INBOX") .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) .build(); public static final PropertyDescriptor FETCH_SIZE = new PropertyDescriptor.Builder() .name("fetch.size") .displayName("Fetch Size") .description("Specify the maximum number of Messages to fetch per call to Email Server.") .required(true) .expressionLanguageSupported(true) .defaultValue("10") .addValidator(StandardValidators.POSITIVE_INTEGER_VALIDATOR) .build(); public static final PropertyDescriptor SHOULD_DELETE_MESSAGES = new PropertyDescriptor.Builder() .name("delete.messages") .displayName("Delete Messages") .description("Specify whether mail messages should be deleted after retrieval.") .required(true) .allowableValues("true", "false") .defaultValue("false") .addValidator(StandardValidators.BOOLEAN_VALIDATOR) .build(); static final PropertyDescriptor CONNECTION_TIMEOUT = new PropertyDescriptor.Builder() .name("connection.timeout") .displayName("Connection timeout") .description("The amount of time to wait to connect to Email server") .required(true) .addValidator(StandardValidators.TIME_PERIOD_VALIDATOR) .expressionLanguageSupported(true) .defaultValue("30 sec") .build(); public static final PropertyDescriptor EXCHANGE_VERSION = new PropertyDescriptor.Builder() .name("mail-ews-version") .displayName("Exchange Version") .description("What version of Exchange Server the server is running.") .required(true) .allowableValues(ExchangeVersion.values()) .defaultValue(ExchangeVersion.Exchange2010_SP2.name()) .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) .build(); public static final PropertyDescriptor EWS_URL = new PropertyDescriptor.Builder() .name("ews-url") .displayName("EWS URL") .description("URL of the EWS Endpoint. Required if Autodiscover is false.") .required(false) .addValidator(StandardValidators.URL_VALIDATOR) .build(); public static final PropertyDescriptor USE_AUTODISCOVER = new PropertyDescriptor.Builder() .name("ews-autodiscover") .displayName("Auto Discover URL") .description("Whether or not to use the Exchange email address to Autodiscover the EWS endpoint URL.") .required(true) .allowableValues("true","false") .defaultValue("true") .build(); public static final PropertyDescriptor SHOULD_MARK_READ = new PropertyDescriptor.Builder() .name("ews-mark-as-read") .displayName("Mark Messages as Read") .description("Specify if messages should be marked as read after retrieval.") .required(true) .allowableValues("true", "false") .defaultValue("true") .addValidator(StandardValidators.BOOLEAN_VALIDATOR) .build(); static final Relationship REL_SUCCESS = new Relationship.Builder() .name("success") .description("All messages that are the are successfully received from Email server and converted to FlowFiles are routed to this relationship") .build(); final protected List<PropertyDescriptor> DESCRIPTORS; final protected Set<Relationship> RELATIONSHIPS; protected final Logger logger = LoggerFactory.getLogger(this.getClass()); protected volatile BlockingQueue<Message> messageQueue; protected volatile String displayUrl; protected volatile ProcessSession processSession; protected volatile boolean shouldSetDeleteFlag; protected volatile String folderName; protected volatile int fetchSize; public ConsumeEWS(){ final Set<Relationship> relationshipSet = new HashSet<>(); relationshipSet.add(REL_SUCCESS); RELATIONSHIPS = relationshipSet; final List<PropertyDescriptor> descriptors = new ArrayList<>(); descriptors.add(USER); descriptors.add(PASSWORD); descriptors.add(FOLDER); descriptors.add(FETCH_SIZE); descriptors.add(SHOULD_DELETE_MESSAGES); descriptors.add(CONNECTION_TIMEOUT); descriptors.add(EXCHANGE_VERSION); descriptors.add(EWS_URL); descriptors.add(USE_AUTODISCOVER); descriptors.add(SHOULD_MARK_READ); DESCRIPTORS = descriptors; } @Override public Set<Relationship> getRelationships() { return RELATIONSHIPS; } @Override protected List<PropertyDescriptor> getSupportedPropertyDescriptors() { return DESCRIPTORS; } @Override public void onTrigger(ProcessContext context, ProcessSession processSession) throws ProcessException { if(this.messageQueue == null){ int fetchSize = context.getProperty(FETCH_SIZE).evaluateAttributeExpressions().asInteger(); this.messageQueue = new ArrayBlockingQueue<>(fetchSize); } this.folderName = context.getProperty(FOLDER).getValue(); Message emailMessage = this.receiveMessage(context); if (emailMessage != null) { this.transfer(emailMessage, context, processSession); } else { //No new messages found, yield the processor context.yield(); } } protected ExchangeService initializeIfNecessary(ProcessContext context) throws ProcessException { ExchangeVersion ver = ExchangeVersion.valueOf(context.getProperty(EXCHANGE_VERSION).getValue()); ExchangeService service = new ExchangeService(ver); final String timeoutInMillis = String.valueOf(context.getProperty(CONNECTION_TIMEOUT).evaluateAttributeExpressions().asTimePeriod(TimeUnit.MILLISECONDS)); service.setTimeout(Integer.parseInt(timeoutInMillis)); String userEmail = context.getProperty(USER).getValue(); String password = context.getProperty(PASSWORD).getValue(); ExchangeCredentials credentials = new WebCredentials(userEmail, password); service.setCredentials(credentials); Boolean useAutodiscover = context.getProperty(USE_AUTODISCOVER).asBoolean(); if(useAutodiscover){ try { service.autodiscoverUrl(userEmail, new RedirectionUrlCallback()); } catch (Exception e) { throw new ProcessException("Failure setting Autodiscover URL from email address.", e); } } else { String ewsURL = context.getProperty(EWS_URL).getValue(); try { service.setUrl(new URI(ewsURL)); } catch (URISyntaxException e) { throw new ProcessException("Failure setting EWS URL.", e); } } return service; } @Override protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) { return new PropertyDescriptor.Builder() .description("Specifies the value for '" + propertyDescriptorName + "' Java Mail property.") .name(propertyDescriptorName).addValidator(StandardValidators.NON_EMPTY_VALIDATOR).dynamic(true) .build(); } /** * Return the target receivere's mail protocol (e.g., imap, pop etc.) */ protected String getProtocol(ProcessContext processContext) { return "ews"; } /** * Fills the internal message queue if such queue is empty. This is due to * the fact that per single session there may be multiple messages retrieved * from the email server (see FETCH_SIZE). */ protected void fillMessageQueueIfNecessary(ProcessContext context) throws ProcessException { if (this.messageQueue.isEmpty()) { ExchangeService service = this.initializeIfNecessary(context); boolean deleteOnRead = context.getProperty(SHOULD_DELETE_MESSAGES).getValue().equals("true"); boolean markAsRead = context.getProperty(SHOULD_MARK_READ).getValue().equals("true"); try { //Get Folder Folder folder = getFolder(service); ItemView view = new ItemView(messageQueue.remainingCapacity()); view.getOrderBy().add(ItemSchema.DateTimeReceived, SortDirection.Ascending); SearchFilter sf = new SearchFilter.SearchFilterCollection(LogicalOperator.And, new SearchFilter.IsEqualTo(EmailMessageSchema.IsRead, false)); FindItemsResults<Item> findResults = service.findItems(folder.getId(), sf, view); if(findResults == null || findResults.getItems().size()== 0){ return; } service.loadPropertiesForItems(findResults, PropertySet.FirstClassProperties); for (Item item : findResults) { EmailMessage ewsMessage = (EmailMessage) item; messageQueue.add(parseMessage(ewsMessage)); if(deleteOnRead){ ewsMessage.delete(DeleteMode.HardDelete); } else if(markAsRead){ ewsMessage.setIsRead(true); ewsMessage.update(ConflictResolutionMode.AlwaysOverwrite); } } service.close(); } catch (Exception e) { throw new ProcessException("Failed retrieving new messages from EWS.", e); } } } protected Folder getFolder(ExchangeService service) { Folder folder; if(folderName.equals("INBOX")){ try { folder = Folder.bind(service, WellKnownFolderName.Inbox); } catch (Exception e) { throw new ProcessException("Failed to bind Inbox Folder on EWS Server", e); } } else { FolderView view = new FolderView(10); view.setTraversal(FolderTraversal.Deep); SearchFilter searchFilter = new SearchFilter.IsEqualTo(FolderSchema.DisplayName, folderName); try { FindFoldersResults foldersResults = service.findFolders(WellKnownFolderName.Root,searchFilter, view); ArrayList<Folder> folderIds = foldersResults.getFolders(); if(folderIds.size() > 1){ throw new ProcessException("More than 1 folder found with the name " + folderName); } folder = Folder.bind(service, folderIds.get(0).getId()); } catch (Exception e) { throw new ProcessException("Search for Inbox Subfolder failed.", e); } } return folder; } public MimeMessage parseMessage(EmailMessage item) throws Exception { EmailMessage ewsMessage = item; final String bodyText = ewsMessage.getBody().toString(); MultiPartEmail mm; if(ewsMessage.getBody().getBodyType() == BodyType.HTML){ mm = new HtmlEmail().setHtmlMsg(bodyText); } else { mm = new MultiPartEmail(); mm.setMsg(bodyText); } mm.setHostName("NiFi-EWS"); //from mm.setFrom(ewsMessage.getFrom().getAddress()); //to recipients ewsMessage.getToRecipients().forEach(x->{ try { mm.addTo(x.getAddress()); } catch (EmailException e) { throw new ProcessException("Failed to add TO recipient.", e); } }); //cc recipients ewsMessage.getCcRecipients().forEach(x->{ try { mm.addCc(x.getAddress()); } catch (EmailException e) { throw new ProcessException("Failed to add CC recipient.", e); } }); //subject mm.setSubject(ewsMessage.getSubject()); //sent date mm.setSentDate(ewsMessage.getDateTimeSent()); //add message headers ewsMessage.getInternetMessageHeaders().forEach(x-> mm.addHeader(x.getName(), x.getValue())); //Any attachments if(ewsMessage.getHasAttachments()){ ewsMessage.getAttachments().forEach(x->{ try { FileAttachment file = (FileAttachment)x; file.load(); ByteArrayDataSource bds = new ByteArrayDataSource(file.getContent(), file.getContentType()); mm.attach(bds,file.getName(), "", EmailAttachment.ATTACHMENT); } catch (MessagingException e) { e.printStackTrace(); } catch (Exception e) { e.printStackTrace(); } }); } mm.buildMimeMessage(); return mm.getMimeMessage(); } /** * Disposes the message by converting it to a {@link FlowFile} transferring * it to the REL_SUCCESS relationship. */ private void transfer(Message emailMessage, ProcessContext context, ProcessSession processSession) { long start = System.nanoTime(); FlowFile flowFile = processSession.create(); flowFile = processSession.append(flowFile, new OutputStreamCallback() { @Override public void process(final OutputStream out) throws IOException { try { emailMessage.writeTo(out); } catch (MessagingException e) { throw new IOException(e); } } }); long executionDuration = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); String fromAddressesString = ""; try { Address[] fromAddresses = emailMessage.getFrom(); if (fromAddresses != null) { fromAddressesString = Arrays.asList(fromAddresses).toString(); } } catch (MessagingException e) { this.logger.warn("Faild to retrieve 'From' attribute from Message."); } processSession.getProvenanceReporter().receive(flowFile, this.displayUrl, "Received message from " + fromAddressesString, executionDuration); this.getLogger().info("Successfully received {} from {} in {} millis", new Object[]{flowFile, fromAddressesString, executionDuration}); processSession.transfer(flowFile, REL_SUCCESS); try { emailMessage.setFlag(Flags.Flag.DELETED, this.shouldSetDeleteFlag); } catch (MessagingException e) { this.logger.warn("Failed to set DELETE Flag on the message, data duplication may occur."); } } /** * Receives message from the internal queue filling up the queue if * necessary. */ protected Message receiveMessage(ProcessContext context) { Message emailMessage = null; try { this.fillMessageQueueIfNecessary(context); emailMessage = this.messageQueue.poll(1, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { context.yield(); this.logger.error("Failed retrieving messages from EWS.", e); Thread.currentThread().interrupt(); this.logger.debug("Current thread is interrupted"); } return emailMessage; } @OnStopped public void stop(ProcessContext processContext) { this.flushRemainingMessages(processContext); } /** * Will flush the remaining messages when this processor is stopped. */ protected void flushRemainingMessages(ProcessContext processContext) { Message emailMessage; try { while ((emailMessage = this.messageQueue.poll(1, TimeUnit.MILLISECONDS)) != null) { this.transfer(emailMessage, processContext, this.processSession); this.processSession.commit(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); this.logger.debug("Current thread is interrupted"); } } static class RedirectionUrlCallback implements IAutodiscoverRedirectionUrl { public boolean autodiscoverRedirectionUrlValidationCallback( String redirectionUrl) { return redirectionUrl.toLowerCase().startsWith("https://"); } } }