/* * [Bulk.java] * * Summary: Send bulk emails. * * Copyright: (c) 2002-2017 Roedy Green, Canadian Mind Products, http://mindprod.com * * Licence: This software may be copied and used freely for any purpose but military. * http://mindprod.com/contact/nonmil.html * * Requires: JDK 1.8+ * * Created with: JetBrains IntelliJ IDEA IDE http://www.jetbrains.com/idea/ * * Version History: * 1.1 2002-05-10 display * some messages to both out and errorlog. - send errorlog back * to sender. - dedup logic - use of Log class to log both to file and * console. * 1.2 2002-05-13 no longer display "sending to * everyone" - ----- bar to separate messages - avoid reporting dups * when dup is sender or monitor. * 1.3 2002-05-17 avoid sending * info about previous batch. * 1.4 2003-05-05 MAX_EMAILS_PER_LOGIN * to limit batch size. * 1.5 2004-03-04 remove saveChanges (getting * POP is readonly) * 1.6 2007-05-27 flip to JDK 1.5, add pad, icon, * reneme ReSend To Bulk. * 1.7 2007-08-21 tidy code, fix MX-Comparator so will work even if no * priorities on MX records, add VALIDATE_EMAIL_SERVERS configuration parameter * so can run where IAP blocks mailserver access. */ package com.mindprod.bulk; import javax.mail.Address; import javax.mail.Flags; import javax.mail.Folder; import javax.mail.Message; import javax.mail.MessagingException; import javax.mail.Multipart; import javax.mail.Part; import javax.mail.Session; import javax.mail.Store; import javax.mail.Transport; import javax.mail.internet.AddressException; import javax.mail.internet.InternetAddress; import javax.mail.internet.MimeMessage; import javax.mail.internet.MimeMultipart; import java.io.File; import java.io.IOException; import java.io.StringReader; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.HashSet; import java.util.Properties; import static com.mindprod.bulk.CustConfig.*; import static java.lang.System.*; // TODO: allow to work on ordinary non-dedicated mailbox. // Leave messages alone that don't match. Dig till find one that does. // TODO: some protection against others using improperly. Ability to disable from here if abused. // TODO: catch no connect exception, note and sleep. // TODO: experiment with BCC and null FROM: to see if can make faster. // TODO: experiment with local mailserver. // TODO: macro to allow variable wording. /** * Send bulk emails. *

* Reads one message from mail server to later be bulk remailed. * It will have an attachment, a list of email addresses. * Program assumes ISP will allow email to be relayed through the * bulk mail account, 'lying' about the from address. * Otherwise we would have to relay through the original account, * knowing its loginID and password, tying up the original account. * on demo code from Sun * * @author Roedy Green, Canadian Mind Products * @version 1.7 2007-08-21 * @since 2002 */ public final class Bulk { private static final int FIRST_COPYRIGHT_YEAR = 2002; /** * undisplayed copyright notice. * * @noinspection UnusedDeclaration */ private static final String EMBEDDED_COPYRIGHT = "Copyright: (c) 2002-2017 Roedy Green, Canadian Mind Products, http://mindprod.com"; /** * date this version released. * * @noinspection UnusedDeclaration */ private static final String RELEASE_DATE = "2007-08-21"; /** * embedded version string. * * @noinspection UnusedDeclaration */ private static final String VERSION_STRING = "1.7"; /** * Presence of the file stop.dat means we should shut down. */ private static final File stop = new File( "stop.dat" ); /** * email addresses of anticipated duplicates, e.g. the sender and the monitors. We don't bother to report them when * we remove them. Holds email addresses as strings. */ private static final HashSet expectedRecipientDups = new HashSet<>( 7 ); /** * set of legitimate names for the list of email address attachments. */ private static final HashSet validAttachmentNamesSet = new HashSet<>( Arrays.asList( VALID_ATTACHMENT_NAMES ) ); /** * set of email addresses of people who are permitted to use the bulk mailer. */ private static final HashSet validSendersSet = new HashSet<>( Arrays.asList( VALID_SENDERS ) ); /** * Log file of errors, will be echoed back to sender. */ private static final Log log = new Log( CUST_ABBREVIATION + "errorlog.txt" ); /** * Which part contains the emails.txt attachment? We don't relayOneMessageToAllRecipients that on. -1 means it is * missing. */ private static int attachmentIndex = -1; /** * inbox folder on the server. */ private static Folder folder; /** * who sent the message to be relayed */ private static InternetAddress from; /** * Message ID of the message to relayOneMessageToAllRecipients. Unambigously identifies where it came from. */ private static String[] messageIDs; /** * Reason why we failed. */ private static String reason = "unknown failure"; /** * HowToProcess many parts were in the initial message. There will be at least two, the message and list of emails * attached. */ private static int receivedParts = -1; /** * Received message that we forward. */ private static MimeMessage rm; /** * Need to keep session open throughout the whole process */ private static Session session; /** * Send message that we compose from the received one and forward. */ private static MimeMessage sm; /** * store for received messages on mail server. */ private static Store store; /** * subject of the message relayed */ private static String subject; /** * True if this is a test. We do everything but send the messages. */ private static boolean test = false; /** * Shutdown receive session. */ private static void closeReceive() { try { if ( rm != null ) { // shaw won't let us mark deleted. /* mark the original message as deleted, whether we successfully processed it or not. */ rm.setFlag( Flags.Flag.SEEN, true ); rm.setFlag( Flags.Flag.DELETED, true ); } if ( folder != null ) { folder.close( true );// please delete messages marked deleted. } if ( store != null ) { store.close(); } /* no such thing as session.close(); */ } catch ( Exception e ) { err.println(); e.printStackTrace( err ); err.println(); log.println( e.getMessage() ); reason = "Trouble shutting down"; } } /** * remove duplicates from mail list only counting computer name. sets dups to null. * * @param recipients list of email addresses we are planning to send an email to. */ private static void deDupRecipients( InternetAddress[] recipients ) { // cannot collapse Comparator to Comparator<> Arrays.sort( recipients, new Comparator() { /** * compare just computer part of address, case-insensitive. * @param o1 first recipient to compare * @param o2 second recipient to compare * @return a-b, +1 if a greater than b. -=1 if b greater than a. 0 * if equal. */ public int compare( InternetAddress o1, InternetAddress o2 ) { /* sort just on computer address part */ return o1.getAddress().compareToIgnoreCase( o2.getAddress() ); } } ); if ( DEBUGGING ) { out.println( "Recipients prior to deduping" ); for ( InternetAddress r : recipients ) { out.println( r ); } } String prevEmail = ""; for ( int i = 0; i < recipients.length; i++ ) { String thisEmail = recipients[ i ].getAddress(); if ( thisEmail.equalsIgnoreCase( prevEmail ) ) { // don't report expected dups, but eliminate them. if ( !expectedRecipientDups.contains( thisEmail ) ) { log.println( "duplicate recipient ignored TO: " + recipients[ i ].toString() ); } recipients[ i ] = null; } else { prevEmail = thisEmail; } } if ( DEBUGGING ) { out.println( "Recipients after deduping:" ); for ( InternetAddress r : recipients ) { out.println( r ); } } } /** * Read one email message from the server */ private static void getOneMessageToRelay() { try { rm = null; // note that send needs a password. Properties props = System.getProperties(); if ( NEED_PASSWORD_TO_SEND ) { props.setProperty( "mail.smtp.auth", "true" ); } // Get a Session object session = Session.getDefaultInstance( props, null ); session.setDebug( DEBUGGING ); // Get a Store object, all mail on server store = session.getStore( RECEIVE_PROTOCOL ); // Connect store.connect( RECEIVE_HOST, RECEIVE_PORT, RECEIVE_LOGIN_ID, getReceivePassword() ); if ( !store.isConnected() ) { throw new MessagingException( "cannot connect to mailserver to pick up mail to relay." ); } // Open the INBOX Folder on the Server folder = store.getFolder( RECEIVE_MBOX ); if ( folder == null ) { reason = "no mail to relay."; return; } // try to open read/write and if that fails try read-only try { folder.open( Folder.READ_WRITE ); } catch ( MessagingException e ) { err.println(); e.printStackTrace( err ); err.println(); log.println( e.getMessage() ); log.println( "Can't get READ_WRITE access." ); folder.open( Folder.READ_ONLY ); } int totalMessages = folder.getMessageCount(); if ( totalMessages == 0 ) { reason = "no mail to relay."; return; } Message[] msgs = folder.getMessages(); if ( msgs.length != 0 ) { rm = ( MimeMessage ) msgs[ 0 ]; } else { reason = "no mail to relay."; } } // end try catch ( Exception ex ) { log.println( ex.getMessage() ); ex.printStackTrace(); reason = "Problems fetching email to relay."; } } /** * get the receive password, possibly indirectly from the set environment * * @return receive password */ private static String getReceivePassword() { final String receivePassword; if ( RECEIVE_PASSWORD.startsWith( "%" ) ) { receivePassword = System.getenv( RECEIVE_PASSWORD.substring( 1 ) ); } else { receivePassword = RECEIVE_PASSWORD; } return receivePassword; } /** * get the send password, possibly indirectly from the set environment * * @return send password */ private static String getSendPassword() { if ( SEND_PASSWORD.startsWith( "%" ) ) { return System.getenv( SEND_PASSWORD.substring( 1 ) ); } else { return SEND_PASSWORD; } } /** * Decide if this email is a valid, authorised relayOneMessageToAllRecipients request. * * @return True if this email is ok to relayOneMessageToAllRecipients to the attached mailing list. */ @SuppressWarnings( { "UnusedLabel" } ) private static boolean isOKToRelayThisMessage () { try { // subject: "don't send" bypasses send, except to originator subject = rm.getSubject(); if ( subject == null ) { reason = "Email to relay must have a Subject"; return false; } subject = subject.trim(); if ( subject.length() < 2 ) { reason = "Email to relay must have a Subject"; return false; } test = subject.trim().toLowerCase().startsWith( "don't send" ); messageIDs = rm.getHeader( "Message-ID" ); if ( messageIDs == null || messageIDs.length < 1 ) { log.println( "Warning: Email to relay does not have a Message-id." ); // use subject for messageID in a pinch. messageIDs = new String[] { subject }; } /* make sure we have not sent this message before */ for ( String messageID : messageIDs ) { if ( SentEmailTracker.isAlreadySent( messageID ) ) { reason = "Email with this Message-id was already sent previously."; return false; } } /* make sure email was from one of the legal people */ Address[] froms = rm.getFrom(); if ( froms == null || froms.length != 1 ) { reason = "Email to relay must have a From field."; return false; } from = ( InternetAddress ) froms[ 0 ]; String sender = from.getAddress(); if ( !validSendersSet.contains( sender ) ) { reason = "Email to relay not from an authorised sender: " + from; return false; } /* typical legit patterns for message body are: MULTIPART/MIXED plain: text/plain styled: text/html both: MULTIPART/ALTERNATIVE text/plain text/html embedded: MULTIPART/related text/html image/jpeg */ reason = "Email to relay is missing the attachment of recipients."; if ( !rm.isMimeType( "multipart/*" ) ) { return false; } Multipart mp = ( Multipart ) rm.getContent(); // all messages need at least 2 parts, body and attacment. receivedParts = mp.getCount(); if ( receivedParts < 2 ) { return false; } // find the attachment for ( int partIndex = 0; partIndex < receivedParts; partIndex++ ) { Part part = mp.getBodyPart( partIndex ); if ( !part.isMimeType( "text/plain" ) ) { continue; } // note reverse convention get fileName String attachmentName = part.getFileName(); if ( attachmentName != null ) { attachmentName = attachmentName.trim().toLowerCase(); if ( validAttachmentNamesSet.contains( attachmentName ) ) { // Bingo. We found the part containing the email addresses attachmentIndex = partIndex; break; } } } reason = "The attachment of recipients was missing or not named properly."; if ( attachmentIndex < 0 ) { return false; } // insist on finding at least something human-readable boolean hasContent = false; outer: for ( int partIndex = 0; partIndex < receivedParts; partIndex++ ) { Part part = mp.getBodyPart( partIndex ); // inner: for ( String aValidMimeType : VALID_MIME_TYPES ) { if ( part.isMimeType( aValidMimeType ) ) { hasContent = true; break outer; } } // end inner for } // end outer for reason = "There was no message body."; if ( !hasContent ) { return false; } // passed the gauntlet, it must be ok return true; } catch ( Exception e ) { e.printStackTrace(); err.println(); log.println( e.getMessage() ); reason = "Problems fetching the email to relay."; return false; } } // end isOKToRelayThisMessage /** * pause to give mail server a rest */ private static void pause() { // we take a series of 5 second naps so that we won't take too // long to respond to a stop request. // naps add up to interval between pestering mail server final int naps = Math.max( 1, ( int ) ( SLEEP_INTERVAL / ( 5 * 1000L ) ) ); for ( int i = 0; i < naps; i++ ) { try { /* was nothing to do, sleep for a while */ Thread.sleep( 5 * 1000L ); } catch ( InterruptedException e ) { // no problem. } // return early. Caller will retest stop. if ( stop.exists() ) { return; } } // end fon } /** * prepare message consisting of the error log to send to the originator. * * @throws MessagingException if problems setting message fields. * @throws IOException if trouble. */ private static void prepareErrorlogMessageForOriginator() throws MessagingException, IOException { /* we send a simple message without parts */ sm = new MimeMessage( session ); sm.setSentDate( new java.util.Date()/* now */ ); sm.setFrom( from ); sm.setRecipient( Message.RecipientType.TO, from ); // sm.setHeader( "X-Mailer", "Canadian Mind Products bulk mailer" ); sm.setSubject( "rejects for " + subject ); // sets MIME as text/plain sm.setText( log.asString(), ORIGINATORS_PREFERRED_ENCODING ); } /** * Prepare list of reciepients, including monitors and originator. * * @param recipientCSV CVS enclosure as big String * * @return recipients, null if none. */ private static InternetAddress[] prepareRecipients( String recipientCSV ) { // ArrayList of InternetAddress recipients ArrayList recipientList; if ( test ) { recipientList = new ArrayList<>( 1 );// just sender // throw away all recipients. Just verifiy them. Comb.comb( new StringReader( recipientCSV ), log ); } else { // production /* get list of Internet Addresses to bulk this to */ recipientList = Comb.comb( new StringReader( recipientCSV ), log ); if ( recipientList == null ) { reason = "problems combing email adresses."; return null; } if ( recipientList.size() > MAX_EMAILS_IN_BATCH ) { reason = recipientList.size() + " is too many email recipients. No emails sent. Limit is " + CustConfig .MAX_EMAILS_IN_BATCH + "."; return null; } if ( DEBUGGING ) { log.println( "got list of recipients" ); } // echo copy to monitors so they know what people are up to with the program, to make sure // is not used for spam, only see live runs. for ( String monitor : MONITORS ) { // we don't comb the monitors. We trust they are ok. // They do get a quick check by InternetAddress though. try { InternetAddress recipient = new InternetAddress( monitor ); recipientList.add( recipient ); expectedRecipientDups.add( recipient.getAddress() ); } catch ( AddressException e ) { err.println( "Invalid monitor email address: " + monitor ); } } } // ALWAYS echo a copy back to sender so she knows it is done, even for a test. recipientList.add( from ); expectedRecipientDups.add( from.getAddress() ); return recipientList.toArray( new InternetAddress[ recipientList.size() ] ); } /** * Prepare the new message to send using the old received one as a model. * * @throws MessagingException if problems creating message. * @throws IOException if can't get content. */ private static void prepareSendMessage() throws MessagingException, IOException { /* we can't make any changes to the original message. It belongs to the POP3 server so we must quasi-clone a new one. */ sm = new MimeMessage( session ); /* copy over the parts we need from the received message */ sm.setSentDate( new java.util.Date()/* now */ ); sm.setFrom( from ); /* leave out X-Sender, X-Envelope-To, X-Envelope-To inserted automatically. We do the TO: header just before sending each email. */ Address[] replyTos = rm.getReplyTo(); if ( replyTos != null && replyTos.length > 0 ) { sm.setReplyTo( replyTos ); } // don't change X-Mailer, just triggers spam detect //sm.setHeader( "X-Mailer", rm.getHeader( "X-Mailer",";" ) + ";Canadian Mind Products mailer" ); sm.setSubject( subject ); Multipart rmp = ( Multipart ) rm.getContent(); if ( receivedParts == 2 ) { /* we send a simple message without parts */ Part part = rmp.getBodyPart( 0 ); sm.setContent( part.getContent(), part.getContentType() ); } else { /* copy over each part to the new message, leaving behind the attachment */ /* create multipart to combine all the parts */ Multipart multipart = new MimeMultipart(); for ( int partIndex = 0; partIndex < receivedParts; partIndex++ ) { /* leave behind the attachment */ if ( partIndex == attachmentIndex ) { continue; } multipart.addBodyPart( rmp.getBodyPart( partIndex ) ); } sm.setContent( multipart ); } } /** * relayOneMessageToAllRecipients the message. */ private static void relayOneMessageToAllRecipients() { try { log.close();// make sure don't include previous batch log.open(); log.println( "--------------------------------" ); log.println( "subject: " + subject ); log.println( "from: " + from.toString() ); log.println( "Message-id: " + messageIDs[ 0 ] ); if ( test ) { log.println( "Test send, just to originator." ); } if ( DEBUGGING ) { log.println( "...Getting attachment..." ); } /* extract the list of recipients from the attachment */ /* all has been pre-checked */ Multipart rmp = ( Multipart ) rm.getContent(); Part attach = rmp.getBodyPart( attachmentIndex ); if ( DEBUGGING ) { log.println( "...Getting attachment CSV recipients..." ); } InternetAddress[] recipients = prepareRecipients( ( String ) attach.getContent() ); if ( recipients == null ) { return; } deDupRecipients( recipients ); if ( DEBUGGING ) { log.println( "...Preparing message to send to all recipients..." ); } prepareSendMessage(); if ( DEBUGGING ) { log.println( "...Message ready to send..." ); } // send email to all the recipients. sendBatches( recipients ); } catch ( Exception e ) { err.println(); e.printStackTrace( err ); err.println(); log.println( e.getMessage() ); reason = "Trouble sending"; } } /** * send the error report back to the originator. Monitors don't get a copy. */ private static void reportErrorsToOriginator() { try { out.println( "sending errorlog" ); prepareErrorlogMessageForOriginator(); Transport transport = session.getTransport( SEND_PROTOCOL ); transport.connect( SEND_HOST, SEND_PORT, SEND_LOGIN_ID, getSendPassword() ); transport.sendMessage( sm, sm.getAllRecipients() ); transport.close(); reason = "errorlog delivered"; } catch ( Exception e ) { err.println(); e.printStackTrace( err ); err.println(); log.println( e.getMessage() ); reason = "Trouble sending errorlog"; } } /** * send emails to all the recipients, breaking into several batches if necesary. * * @param recipients array of all recipients to send message to, including monitors and sender. * * @throws MessagingException if send fails. */ private static void sendBatches( InternetAddress[] recipients ) throws MessagingException { Transport transport = session.getTransport( SEND_PROTOCOL ); transport.connect( SEND_HOST, SEND_PORT, SEND_LOGIN_ID, getSendPassword() ); if ( !transport.isConnected() ) { throw new MessagingException( "Cannot connect to mail server for sending." ); } // mark this as sent so we won't repeat it. for ( String messageID : messageIDs ) { SentEmailTracker.markSent( messageID ); } SentEmailTracker.flush(); if ( DEBUGGING ) { log.println( "marked sent" ); } // send messages individually or else one bad email kills the entire batch. int goodCount = 0; int badCount = 0; int emailsSentThisBatch = 0; for ( InternetAddress recipient1 : recipients ) { try { if ( recipient1 != null ) { emailsSentThisBatch++; if ( emailsSentThisBatch > CustConfig .MAX_EMAILS_PER_LOGIN ) { // close and reopen the transport since we have hit the batch limit. emailsSentThisBatch = 1; transport.close(); out.println( " new batch" ); // transport = session.getTransport( SEND_PROTOCOL ); transport.connect( SEND_HOST, SEND_PORT, SEND_LOGIN_ID, getSendPassword() ); } out.println( " sending " + recipient1.toString() ); // apply an individualised TO: sm.setRecipient( Message.RecipientType.TO, recipient1 ); transport.sendMessage( sm, sm.getAllRecipients() ); goodCount++; } } catch ( Exception e ) { log.println( "Unable to deliver " + recipient1.toString() + " " + e.getMessage() ); badCount++; } } log.println( goodCount + " emails successfully sent, including copies to monitors, but not status logs.\n" + badCount + " emails failed." ); transport.close(); reason = "All went ok"; } /** * Main loop, handles everything from fireup to shutdown. * * @param args not used. */ @SuppressWarnings( { "EmptyCatchBlock", "UnnecessaryLabelOnBreakStatement" } ) public static void main( String args[] ) { try { while ( true ) { if ( stop.exists() ) { break; } try { log.open(); if ( DEBUGGING ) { log.println( "...relaying email addressed to " + BULK_EMAIL_ADDRESS + "..." ); } getOneMessageToRelay(); if ( rm != null && isOKToRelayThisMessage() ) { // send to everyone on list relayOneMessageToAllRecipients(); log.println( "<><><> " + reason + " <><><>" ); // send log to originator of broadcast email. reportErrorsToOriginator(); } // delete message whether we sent it or not. closeReceive(); log.println( reason ); } catch ( Exception e ) { err.println(); e.printStackTrace( err ); err.println(); log.println( e.getMessage() ); log.println( "<><><> Could not relay that email. " + reason + " <><><>" ); } if ( stop.exists() ) { break; } // if just processed a message, get on with processing next // without a pause. if ( rm == null ) { pause(); } } // end while stop.delete(); SentEmailTracker.close(); log.close(); } catch ( Exception e ) { err.println(); e.printStackTrace( err ); err.println(); } } // end main }