/*
* [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
}