/* * 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.axis2.transport.mail; import org.apache.axis2.AxisFault; import org.apache.axis2.Constants; import org.apache.axis2.addressing.EndpointReference; import org.apache.axis2.context.MessageContext; import org.apache.axis2.engine.AxisConfiguration; import org.apache.axis2.transport.TransportUtils; import org.apache.axis2.transport.RequestResponseTransport; import org.apache.axis2.transport.base.AbstractPollingTransportListener; import org.apache.axis2.transport.base.BaseConstants; import org.apache.axis2.transport.base.ManagementSupport; import org.apache.axis2.transport.base.event.TransportErrorListener; import org.apache.axis2.transport.base.event.TransportErrorSource; import org.apache.axis2.transport.base.event.TransportErrorSourceSupport; import javax.mail.*; import javax.mail.internet.ContentType; import javax.mail.internet.InternetAddress; import javax.mail.internet.MimeMessage; import javax.mail.internet.MimeBodyPart; import javax.mail.internet.ParseException; import javax.xml.stream.XMLStreamException; import java.io.IOException; import java.io.InputStream; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.lang.reflect.Method; /** * This mail transport lister implementation uses the base transport framework and is a polling * transport. i.e. a service can register itself with custom a custom mail configuration (i.e. * pop3 or imap) and specify its polling duration, and what action to be taken after processing * messages. The transport always deletes processed mails from the folder they were fetched from * and can be configured to be optionally moved to a different folder, if the server supports it * (e.g. with imap). When checking for new mail, the transport ignores messages already flaged as * SEEN and DELETED */ public class MailTransportListener extends AbstractPollingTransportListener<PollTableEntry> implements ManagementSupport, TransportErrorSource { public static final String DELETE = "DELETE"; public static final String MOVE = "MOVE"; private final TransportErrorSourceSupport tess = new TransportErrorSourceSupport(this); @Override protected void doInit() throws AxisFault { super.doInit(); // set the synchronise callback table if (cfgCtx.getProperty(BaseConstants.CALLBACK_TABLE) == null){ cfgCtx.setProperty(BaseConstants.CALLBACK_TABLE, new ConcurrentHashMap()); } } @Override protected void poll(PollTableEntry entry) { checkMail(entry, entry.getEmailAddress()); } /** * Check mail for a particular service that has registered with the mail transport * * @param entry the poll table entry that stores service specific informaiton * @param emailAddress the email address checked */ private void checkMail(final PollTableEntry entry, InternetAddress emailAddress) { if (log.isDebugEnabled()) { log.debug("Checking mail for account : " + emailAddress); } boolean connected = false; int retryCount = 0; int maxRetryCount = entry.getMaxRetryCount(); long reconnectionTimeout = entry.getReconnectTimeout(); Session session = entry.getSession(); Store store = null; Folder folder = null; boolean mailProcessingStarted = false; while (!connected) { try { retryCount++; if (log.isDebugEnabled()) { log.debug("Attempting to connect to POP3/IMAP server for : " + entry.getEmailAddress() + " using " + session.getProperties()); } store = session.getStore(entry.getProtocol()); if (entry.getUserName() != null && entry.getPassword() != null) { store.connect(entry.getUserName(), entry.getPassword()); } else { handleException("Unable to locate username and password for mail login", null); } // were we able to connect? connected = store.isConnected(); if (connected) { if (entry.getFolder() != null) { folder = store.getFolder(entry.getFolder()); } else { folder = store.getFolder(MailConstants.DEFAULT_FOLDER); } if (folder == null) { folder = store.getDefaultFolder(); } } } catch (Exception e) { log.error("Error connecting to mail server for address : " + emailAddress, e); if (maxRetryCount <= retryCount) { processFailure("Error connecting to mail server for address : " + emailAddress + " :: " + e.getMessage(), e, entry); return; } } if (!connected) { try { log.warn("Connection to mail server for account : " + entry.getEmailAddress() + " failed. Retrying in : " + reconnectionTimeout / 1000 + " seconds"); Thread.sleep(reconnectionTimeout); } catch (InterruptedException ignore) { } } } if (connected && folder != null) { CountDownLatch latch = null; Runnable onCompletion = new MailCheckCompletionTask(folder, store, emailAddress, entry); try { if (log.isDebugEnabled()) { log.debug("Connecting to folder : " + folder.getName() + " of email account : " + emailAddress); } folder.open(Folder.READ_WRITE); int total = folder.getMessageCount(); Message[] messages = folder.getMessages(); if (log.isDebugEnabled()) { log.debug(messages.length + " messgaes in folder : " + folder); } latch = new CountDownLatch(total); for (int i = 0; i < total; i++) { try { String[] status = messages[i].getHeader("Status"); if (status != null && status.length == 1 && status[0].equals("RO")) { // some times the mail server sends a special mail message which is // not relavent in processing. ignore this message. if (log.isDebugEnabled()) { log.debug("Skipping message # : " + messages[i].getMessageNumber() + " : " + messages[i].getSubject() + " - Status: RO"); } latch.countDown(); } else if (messages[i].isSet(Flags.Flag.SEEN)) { if (log.isDebugEnabled()) { log.debug("Skipping message # : " + messages[i].getMessageNumber() + " : " + messages[i].getSubject() + " - already marked SEEN"); } latch.countDown(); } else if (messages[i].isSet(Flags.Flag.DELETED)) { if (log.isDebugEnabled()) { log.debug("Skipping message # : " + messages[i].getMessageNumber() + " : " + messages[i].getSubject() + " - already marked DELETED"); } latch.countDown(); } else { processMail(entry, folder, store, messages[i], latch, onCompletion); mailProcessingStarted = true; } } catch (MessageRemovedException ignore) { // while reading the meta information, this mail was deleted, thats ok if (log.isDebugEnabled()) { log.debug("Skipping message # : " + messages[i].getMessageNumber() + " as it has been DELETED by another thread after processing"); } latch.countDown(); } } if (!mailProcessingStarted) { // if we didnt process any mail in this run, the onCompletion will not // run from the mail processor by default onCompletion.run(); } } catch (MessagingException me) { processFailure("Error checking mail for account : " + emailAddress + " :: " + me.getMessage(), me, entry); } } else { processFailure("Unable to access mail folder", null, entry); } } /** * Invoke the actual message processor in the current thread or another worker thread * @param entry PolltableEntry * @param folder mail folder * @param store mail store, to move or delete after processing * @param message message to process * @param pos the message position seen initially * @param mp the MailProcessor object * @param latch the completion latch to notify * @param onCompletion the tasks to run on the completion of mail processing */ private void processMail(PollTableEntry entry, Folder folder, Store store, Message message, CountDownLatch latch, Runnable onCompletion) { MailProcessor mp = new MailProcessor(entry, message, store, folder, latch, onCompletion); // should messages be processed in parallel? if (entry.isConcurrentPollingAllowed()) { // try to locate the UID of the message String uid = getMessageUID(folder, message); if (uid != null) { if (entry.isProcessingUID(uid)) { if (log.isDebugEnabled()) { log.debug("Skipping message # : " + message.getMessageNumber() + " : UIDL " + uid + " - already being processed by another thread"); } latch.countDown(); } else { entry.processingUID(uid); mp.setUID(uid); if (entry.isProcessingMailInParallel()) { if (log.isDebugEnabled()) { log.debug("Processing message # : " + message.getMessageNumber() + " with UID : " + uid + " with a worker thread"); } workerPool.execute(mp); } else { if (log.isDebugEnabled()) { log.debug("Processing message # : " + message.getMessageNumber() + " with UID : " + uid + " in same thread"); } mp.run(); } } } else { log.warn("Cannot process mail in parallel as the " + "folder does not support UIDs. Processing message # : " + message.getMessageNumber() + " in the same thread"); entry.setConcurrentPollingAllowed(false); mp.run(); } } else { if (entry.isProcessingMailInParallel()) { if (log.isDebugEnabled()) { log.debug("Processing message # : " + message.getMessageNumber() + " with a worker thread"); } workerPool.execute(mp); } else { if (log.isDebugEnabled()) { log.debug("Processing message # : " + message.getMessageNumber() + " in same thread"); } mp.run(); } } } /** * Handle processing of a message, possibly in a new thread */ private class MailProcessor implements Runnable { private PollTableEntry entry = null; private Message message = null; private Store store = null; private Folder folder = null; private String uid = null; private CountDownLatch doneSignal = null; private Runnable onCompletion = null; MailProcessor(PollTableEntry entry, Message message, Store store, Folder folder, CountDownLatch doneSignal, Runnable onCompletion) { this.entry = entry; this.message = message; this.store = store; this.folder = folder; this.doneSignal = doneSignal; this.onCompletion = onCompletion; } public void setUID(String uid) { this.uid = uid; } public void run() { entry.setLastPollState(PollTableEntry.NONE); try { processMail(message, entry); entry.setLastPollState(PollTableEntry.SUCCSESSFUL); metrics.incrementMessagesReceived(); } catch (Exception e) { log.error("Failed to process message", e); entry.setLastPollState(PollTableEntry.FAILED); metrics.incrementFaultsReceiving(); tess.error(entry.getService(), e); } finally { if (uid != null) { entry.removeUID(uid); } } try { moveOrDeleteAfterProcessing(entry, store, folder, message); } catch (Exception e) { log.error("Failed to move or delete email message", e); tess.error(entry.getService(), e); } doneSignal.countDown(); if (doneSignal.getCount() == 0) { onCompletion.run(); } } } /** * Handle optional logic of the mail transport, that needs to happen once all messages in * a check mail cycle has ended. */ private class MailCheckCompletionTask implements Runnable { private final Folder folder; private final Store store; private final InternetAddress emailAddress; private final PollTableEntry entry; private boolean taskStarted = false; public MailCheckCompletionTask(Folder folder, Store store, InternetAddress emailAddress, PollTableEntry entry) { this.folder = folder; this.store = store; this.emailAddress = emailAddress; this.entry = entry; } public void run() { synchronized(this) { if (taskStarted) { return; } else { taskStarted = true; } } if (log.isDebugEnabled()) { log.debug("Executing onCompletion task for the mail download of : " + emailAddress); } if (folder != null) { try { folder.close(true /** expunge messages flagged as DELETED*/); if (log.isDebugEnabled()) { log.debug("Mail folder closed, and deleted mail expunged"); } } catch (MessagingException e) { log.warn("Error closing mail folder : " + folder + " for account : " + emailAddress + " :: "+ e.getMessage()); } } if (store != null) { try { store.close(); if (log.isDebugEnabled()) { log.debug("Mail store closed for : " + emailAddress); } } catch (MessagingException e) { log.warn("Error closing mail store for account : " + emailAddress + " :: " + e.getMessage(), e); } } if (log.isDebugEnabled()) { log.debug("Scheduling next poll for : " + emailAddress); } onPollCompletion(entry); } } /** * Process a mail message through Axis2 * * @param message the email message * @param entry the poll table entry * @throws MessagingException on error * @throws IOException on error */ private void processMail(Message message, PollTableEntry entry) throws MessagingException, IOException { updateMetrics(message); // populate transport headers using the mail headers Map trpHeaders = getTransportHeaders(message, entry); // Allow the content type to be overridden by configuration. String contentType = entry.getContentType(); Part messagePart; if (contentType != null) { messagePart = message; } else { messagePart = getMessagePart(message, cfgCtx.getAxisConfiguration()); contentType = messagePart.getContentType(); } // FIXME: remove this ugly hack when Axis2 has learned that content types are case insensitive... int idx = contentType.indexOf(';'); if (idx == -1) { contentType = contentType.toLowerCase(); } else { contentType = contentType.substring(0, idx).toLowerCase() + contentType.substring(idx); } // if the content type was not found, we have an error if (contentType == null) { processFailure("Unable to determine Content-type for message : " + message.getMessageNumber() + " :: " + message.getSubject(), null, entry); return; } else if (log.isDebugEnabled()) { log.debug("Processing message as Content-Type : " + contentType); } MessageContext msgContext = entry.createMessageContext(); // Extract the charset encoding from the configured content type and // set the CHARACTER_SET_ENCODING property as e.g. SOAPBuilder relies on this. String charSetEnc; try { charSetEnc = new ContentType(contentType).getParameter("charset"); } catch (ParseException ex) { // ignore charSetEnc = null; } msgContext.setProperty(Constants.Configuration.CHARACTER_SET_ENCODING, charSetEnc); MailOutTransportInfo outInfo = buildOutTransportInfo(message, entry); // save out transport information msgContext.setProperty(Constants.OUT_TRANSPORT_INFO, outInfo); // this property only useful for supporting smtp with Sandesha2. msgContext.setProperty(RequestResponseTransport.TRANSPORT_CONTROL, new MailRequestResponseTransport()); // set message context From if (outInfo.getFromAddress() != null) { msgContext.setFrom( new EndpointReference(MailConstants.TRANSPORT_PREFIX + outInfo.getFromAddress().getAddress())); } // save original mail message id message context MessageID msgContext.setMessageID(outInfo.getRequestMessageID()); //Set the Sent date and received date. if(message.getSentDate() != null) { Calendar sentDate = Calendar.getInstance(); sentDate.setTime(message.getSentDate()); msgContext.setProperty(MailConstants.MAIL_SENT_DATE,sentDate); } msgContext.setProperty(MailConstants.MAIL_RECEIVED_DATE,Calendar.getInstance()); // set the message payload to the message context InputStream in = messagePart.getInputStream(); try { try { msgContext.setEnvelope(TransportUtils.createSOAPMessage(msgContext, in, contentType)); } catch (XMLStreamException ex) { handleException("Error parsing message", ex); } String soapAction = (String) trpHeaders.get(BaseConstants.SOAPACTION); if (soapAction == null && message.getSubject() != null && message.getSubject().startsWith(BaseConstants.SOAPACTION)) { soapAction = message.getSubject().substring(BaseConstants.SOAPACTION.length()); if (soapAction.startsWith(":")) { soapAction = soapAction.substring(1).trim(); } } handleIncomingMessage( msgContext, trpHeaders, soapAction, contentType ); } finally { in.close(); } if (log.isDebugEnabled()) { log.debug("Processed message : " + message.getMessageNumber() + " :: " + message.getSubject()); } } private void updateMetrics(Message message) throws IOException, MessagingException { if (message instanceof MimeMessage) { MimeMessage mimeMessage = (MimeMessage) message; if (mimeMessage.getContent() instanceof Multipart) { Multipart mp = (Multipart) mimeMessage.getContent(); for (int i=0; i<mp.getCount(); i++) { MimeBodyPart mbp = (MimeBodyPart) mp.getBodyPart(i); int size = mbp.getSize(); if (size != -1) { metrics.incrementBytesReceived(size); } } } else { int size = mimeMessage.getSize(); if (size != -1) { metrics.incrementBytesReceived(size); } } } } private Map getTransportHeaders(Message message, PollTableEntry entry) { //use a comaprator to ignore the case for headers. Comparator comparator = new Comparator(){ public int compare(Object o1, Object o2) { String string1 = (String) o1; String string2 = (String) o2; return string1.compareToIgnoreCase(string2); } }; Map trpHeaders = new TreeMap(comparator); try { Enumeration e = message.getAllHeaders(); while (e.hasMoreElements()) { Header h = (Header) e.nextElement(); if (entry.retainHeader(h.getName())) { trpHeaders.put(h.getName(), h.getValue()); } } } catch (MessagingException ignore) {} return trpHeaders; } /** * Extract the part from the mail that contains the message to be processed. * This method supports multipart/mixed messages that contain a text/plain * part alongside the message. * * @param message * @return * @throws MessagingException * @throws IOException */ private Part getMessagePart(Message message, AxisConfiguration axisCfg) throws MessagingException, IOException { ContentType contentType = new ContentType(message.getContentType()); if (contentType.getBaseType().equalsIgnoreCase("multipart/mixed")) { Multipart multipart = (Multipart)message.getContent(); Part textMainPart = null; for (int i=0; i<multipart.getCount(); i++) { MimeBodyPart bodyPart = (MimeBodyPart)multipart.getBodyPart(i); ContentType partContentType = new ContentType(bodyPart.getContentType()); if (axisCfg.getMessageBuilder(partContentType.getBaseType()) != null) { if (partContentType.getBaseType().equalsIgnoreCase("text/plain")) { // If it's a text/plain part, remember it. We will return // it later if we don't find something more interesting. textMainPart = bodyPart; } else { return bodyPart; } } } if (textMainPart != null) { return textMainPart; } else { // We have nothing else to return! return message; } } else { return message; } } private MailOutTransportInfo buildOutTransportInfo(Message message, PollTableEntry entry) throws MessagingException { MailOutTransportInfo outInfo = new MailOutTransportInfo(entry.getEmailAddress()); // determine reply address if (message.getReplyTo() != null) { outInfo.setTargetAddresses((InternetAddress[]) message.getReplyTo()); } else if (message.getFrom() != null) { outInfo.setTargetAddresses((InternetAddress[]) message.getFrom()); } else { // does the service specify a default reply address ? InternetAddress replyAddress = entry.getReplyAddress(); if (replyAddress != null) { outInfo.setTargetAddresses(new InternetAddress[] { replyAddress }); } } // save CC addresses if (message.getRecipients(Message.RecipientType.CC) != null) { outInfo.setCcAddresses( (InternetAddress[]) message.getRecipients(Message.RecipientType.CC)); } // determine and subject for the reply message if (message.getSubject() != null) { outInfo.setSubject("Re: " + message.getSubject()); } // save original message ID if one exists, so that replies can be correlated if (message.getHeader(MailConstants.MAIL_HEADER_X_MESSAGE_ID) != null) { outInfo.setRequestMessageID(message.getHeader(MailConstants.MAIL_HEADER_X_MESSAGE_ID)[0]); } else if (message instanceof MimeMessage && ((MimeMessage) message).getMessageID() != null) { outInfo.setRequestMessageID(((MimeMessage) message).getMessageID()); } return outInfo; } /** * Take specified action to either move or delete the processed email * * @param entry the PollTableEntry for the email that has been processed * @param store the mail store * @param folder mail folder * @param message the email message to be moved or deleted */ private void moveOrDeleteAfterProcessing(final PollTableEntry entry, Store store, Folder folder, Message message) { String moveToFolder = null; try { switch (entry.getLastPollState()) { case PollTableEntry.SUCCSESSFUL: if (entry.getActionAfterProcess() == PollTableEntry.MOVE) { moveToFolder = entry.getMoveAfterProcess(); } break; case PollTableEntry.FAILED: if (entry.getActionAfterFailure() == PollTableEntry.MOVE) { moveToFolder = entry.getMoveAfterFailure(); } break; case PollTableEntry.NONE: return; } if (moveToFolder != null) { if (log.isDebugEnabled()) { log.debug("Moving processed email to folder :" + moveToFolder); } Folder dFolder = store.getFolder(moveToFolder); if (!dFolder.exists()) { dFolder.create(Folder.HOLDS_MESSAGES); } folder.copyMessages(new Message[]{message}, dFolder); } if (log.isDebugEnabled()) { log.debug("Deleting email :" + message.getMessageNumber()); } message.setFlag(Flags.Flag.DELETED, true); } catch (MessagingException e) { log.error("Error deleting or resolving folder to move after processing : " + moveToFolder, e); } } @Override protected PollTableEntry createEndpoint() { return new PollTableEntry(log); } public void addErrorListener(TransportErrorListener listener) { tess.addErrorListener(listener); } public void removeErrorListener(TransportErrorListener listener) { tess.removeErrorListener(listener); } /** * Return the UID of a message from the given folder * @param folder the POP3 or IMAP folder * @param message the message * @return UID as a String (long is converted to a String) or null */ private String getMessageUID(Folder folder, Message message) { String uid = null; if (folder instanceof UIDFolder) { try { uid = Long.toString(((UIDFolder) folder).getUID(message)); } catch (MessagingException ignore) {} } else { try { Method m = folder.getClass().getMethod( "getUID", Message.class); Object o = m.invoke(folder, new Object[]{message}); if (o != null && o instanceof Long) { uid = Long.toString((Long) o); } else if (o != null && o instanceof String) { uid = (String) o; } } catch (Exception ignore) {} } return uid; } }