001package votorola.s.mail; // Copyright 2007-2012, Michael Allan.  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Votorola Software"), to deal in the Votorola Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicence, and/or sell copies of the Votorola Software, and to permit persons to whom the Votorola Software is furnished to do so, subject to the following conditions: The preceding copyright notice and this permission notice shall be included in all copies or substantial portions of the Votorola Software. THE VOTOROLA SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE VOTOROLA SOFTWARE OR THE USE OR OTHER DEALINGS IN THE VOTOROLA SOFTWARE.
002
003import java.io.*;
004import java.util.*;
005import java.util.concurrent.*;
006import java.util.concurrent.atomic.*;
007import java.util.logging.*; import votorola.g.logging.*;
008import java.util.regex.*;
009import java.sql.SQLException;
010import javax.activation.*;
011import javax.mail.*;
012import javax.mail.internet.*;
013import javax.script.*;
014import javax.xml.ws.Holder;
015import votorola.a.*;
016import votorola.a.response.*;
017import votorola.a.response.line.*;
018import votorola.a.voter.*;
019import votorola.g.*;
020import votorola.g.hold.*;
021import votorola.g.lang.*;
022import votorola.g.locale.*;
023import votorola.g.mail.*;
024import votorola.g.option.*;
025import votorola.g.script.*;
026import votorola.g.util.*;
027import votorola.g.util.concurrent.*;
028
029import static votorola.a.response.line.CommandLine.COMMAND_ARGUMENT_PATTERN;
030
031
032/** The mail-based voter interface (mail interface).
033  *
034  *     @see #main(String[])
035  *     @see <a href='../../../../../s/manual.xht#mail'>../../s/manual.xht#mail</a>
036  */
037public final @ThreadSafe class VOFaceMail implements Runnable, VoterInterface
038{
039
040
041    /** Creates the mail interface from the command line, and starts it.
042      *
043      *     @param argv the command line argument array.
044      *
045      *     @see #i()
046      *     @see <a href='../../../../../s/manual.xht#line-voface-mail'>voface-mail</a>
047      */
048    public static void main( String[] argv )
049    {
050        Thread.setDefaultUncaughtExceptionHandler( fatalExceptionHandler ); // early
051        LoggerX.i(VOFaceMail.class).info( "voface-mail is running with arguments " + Arrays.toString( argv ));
052        final Map<String,Option> optionMap = CommandLine.compileBaseOptions();
053        {
054            final int aFirstNonOption = GetoptX.parse( "voface-mail", argv, optionMap );
055            if( aFirstNonOption < argv.length )
056            {
057                System.err.println( GetoptX.createUnexpectedArgWarning(
058                  "voface-mail", argv[aFirstNonOption] ));
059                System.err.println( GetoptX.createHelpPrompt( "voface-mail" ));
060                System.exit( 1 );
061            }
062        }
063        if( optionMap.get("help").hasOccured() )
064        {
065            System.out.print(
066              "Usage: voface-mail\n" +
067              "Await and respond to email messages from voters.\n" );
068            System.exit( 0 );
069        }
070
071        try
072        {
073            LoggerX.i(VOFaceMail.class).info( "starting mail-based voter interface" );
074            new VOFaceMail(); // its thread will keep the VM running (if nothing else does) until Runtime.exit() is requested
075        }
076        catch( RuntimeException x ) { throw x; } // though it goes to fatalExceptionHandler regardless
077        catch( Exception x )
078        {
079            fatalExceptionHandler.uncaughtException( Thread.currentThread(), x ); // and exit
080        }
081    }
082
083
084
085    /** The single instance of VOFaceMail, if it was created.
086      */
087    static VOFaceMail i() { return instanceA.get(); }
088
089
090        private static final AtomicReference<VOFaceMail> instanceA =
091          new AtomicReference<VOFaceMail>();
092
093
094
095    private VOFaceMail() throws AddressException, IOException, ScriptException, SQLException,
096      java.net.URISyntaxException
097    {
098        if( !instanceA.compareAndSet( /*expect*/null, VOFaceMail.this )) throw new IllegalStateException();
099
100     // Class.forName( "org.postgresql.Driver" ); // register the database driver
101     // Connection databaseConnection = DriverManager.getConnection( url, username, password );
102        metaService = vsRun.init_ensureVoterService( new File(
103          vsRun.voteServer().votorolaDirectory(), "mail" + File.separator + "mail-meta-service.js" ),
104          MailMetaService.class );
105
106        thread.start();
107        spool.add( new Hold()
108        {
109            public @ThreadSafe void release() { thread.tryJoin( 4000/*ms*/ ); } // wait for it to stop gracefully (about as long as a user would wait)
110        });
111
112        cc = null; // done with it, free the memory
113    }
114
115
116
117   // ````````````````````````````````````````````````````````````````````````````````````
118   // init for early use
119
120
121    private final VoteServer.Run vsRun =
122      new VoteServer( System.getProperty( "user.name" )).new Run( /*isSingleThreaded*/true );
123
124
125    ConstructionContext cc = ConstructionContext.configure( // nulled after init
126      vsRun.voteServer(), compileConfigurationScript( vsRun.voteServer() ));
127
128
129
130   // ------------------------------------------------------------------------------------
131
132
133    /** Executes the configuration script of the mail interface (without making any
134      * configuration calls), thus compiling it for subsequent use.
135      */
136    public static JavaScriptIncluder compileConfigurationScript( final VoteServer voteServer )
137      throws IOException, ScriptException
138    {
139        return new JavaScriptIncluder(
140          new File( voteServer.votorolaDirectory(), "mail" + File.separator + "voface-mail.js" ));
141    }
142
143
144
145    /** The delay prior to each poll of the inbox for new messages.  If the inbox gets
146      * busy, then this value is ignored and messages are read without delay.
147      *
148      *     @see ConstructionContext#setInboxPollSleepSeconds(int)
149      */
150    int inboxPollSleepSeconds() { return inboxPollSleepSeconds; }
151
152
153        private final int inboxPollSleepSeconds = cc.getInboxPollSleepSeconds();
154
155
156
157    /** The protocol and location of the inbox.  Supported protocols are IMAP, Maildir and
158      * POP3.  For example:
159      *
160      * <pre class='indent'>"imap:?" (actually, we're unsure of IMAP syntax, and have not tested it), per:
161      *   <a href='http://java.sun.com/products/javamail/javadocs/com/sun/mail/imap/package-summary.html'>http://java.sun.com/products/javamail/javadocs/com/sun/mail/imap/package-summary.html</a>
162      *
163      * "maildir:/home/vote-serverName/.mail"
164      *   <a href='http://javamaildir.sourceforge.net/'>http://javamaildir.sourceforge.net/</a>
165      *
166      * "pop3://vote-serverName:password@host:port" (not yet tested), per:
167      *   <a href='http://java.sun.com/products/javamail/javadocs/com/sun/mail/pop3/package-summary.html'>http://java.sun.com/products/javamail/javadocs/com/sun/mail/pop3/package-summary.html</a></pre>
168      *
169      *     @see ConstructionContext#setInboxStoreURLName(String)
170      */
171    URLName inboxStoreURLName() { return inboxStoreURLName; }
172
173
174        private final URLName inboxStoreURLName = new URLName( cc.getInboxStoreURLName() );
175
176
177
178    /** Returns true if this interface is to run without making any persistent state changes.
179      * It will run as usual, in that case, but without actually writing to any database;
180      * nor replying to any incoming message;  nor altering any other significant state
181      * that would persist and affect the next run.
182      * <p>
183      *     Consequently, each dry run will read the messages of the inbox
184      *     over and over again, in an endless loop.
185      *     </p>
186      *
187      *     @see ConstructionContext#setDryRun(boolean)
188      */
189    boolean isDryRun() { return dryRun; }
190
191
192        private final boolean dryRun = cc.isDryRun();
193
194
195
196    /** The startup configuration file for this mail interface.  The language is
197      * JavaScript.  There are restrictions on the {@linkplain
198      * votorola.g.script.JavaScriptIncluder character encoding}.
199      *
200      *     @see <a href='../../../../../s/manual.xht#voface-mail.js'>../manual.xht#voface-mail.js</a>
201      */
202    File startupConfigurationFile() { return startupConfigurationFile; }
203
204
205        private final File startupConfigurationFile = cc.startupConfigurationFile;
206
207
208
209   // - V o t e r - I n t e r f a c e ----------------------------------------------------
210
211
212    /** @return descriptive email address of <code>s</code>, including a personal part
213      * @see MailMetaService#serviceEmail(VoterService)
214      */
215    public String serviceAccessDescriptor( final VoterService s )
216    {
217        final String serviceEmail = metaService.serviceEmail( s );
218        try
219        {
220            return new InternetAddress( serviceEmail, s.title() ).toUnicodeString();
221        }
222        catch( UnsupportedEncodingException x )
223        {
224            assert false;
225            return serviceEmail;
226        }
227    }
228
229
230
231   // ====================================================================================
232
233
234    /** A context for configuring the mail interface.  The interface is configured by the
235      * responder's {@linkplain #startupConfigurationFile startup configuration file},
236      * which contains a script (s) for that purpose.  During construction of the
237      * interface, an instance of this context (mailCC) is passed to s, via
238      * s::constructingVOFaceMail(mailCC).
239      */
240    public static @ThreadSafe final class ConstructionContext
241    {
242
243
244        /** Constructs the complete configuration of the mail interface.
245          *
246          *     @param s the compiled startup configuration script.
247          */
248        public static ConstructionContext configure( final VoteServer voteServer,
249          final JavaScriptIncluder s ) throws ScriptException
250        {
251            final ConstructionContext cc = new ConstructionContext( voteServer, s );
252            s.invokeKnownFunction( "constructingVOFaceMail", cc );
253            return cc;
254        }
255
256
257
258        private ConstructionContext( VoteServer voteServer, JavaScriptIncluder s )
259        {
260            this.s = s;
261            startupConfigurationFile = s.scriptFile();
262            inboxStoreURLName = "maildir:/home/" + voteServer.name() + "/Maildir";
263            transferService = new SMTPTransportX.ConstructionContext( startupConfigurationFile );
264        }
265
266
267
268        private final File startupConfigurationFile;
269
270
271
272        private final JavaScriptIncluder s;
273
274
275
276       // --------------------------------------------------------------------------------
277
278
279        /** @see VOFaceMail#inboxPollSleepSeconds()
280          * @see #setInboxPollSleepSeconds(int)
281          */
282        public int getInboxPollSleepSeconds() { return inboxPollSleepSeconds; }
283
284
285            private int inboxPollSleepSeconds = 20;
286
287
288            /** Sets the delay prior to each poll of the inbox.  The default value is 20
289              * seconds.
290              *
291              *     @see VOFaceMail#inboxPollSleepSeconds()
292              */
293                @ThreadRestricted("constructor")
294            public void setInboxPollSleepSeconds( int inboxPollSleepSeconds )
295            {
296                this.inboxPollSleepSeconds = inboxPollSleepSeconds;
297            }
298
299
300
301        /** @see VOFaceMail#inboxStoreURLName()
302          * @see #setInboxStoreURLName(String)
303          */
304        public String getInboxStoreURLName() { return inboxStoreURLName; }
305
306
307            private String inboxStoreURLName;
308
309
310            /** Sets the protocol and location of the inbox.  The default value is
311              * "maildir:/home/{@linkplain VoteServer#name() vote-server-name}/Maildir".
312              *
313              * <p class='indent'><code>"maildir:/home/" + voteServer.{@linkplain
314              * VoteServer#name() name}() + "/Maildir"</code></p>
315              *
316              *     @see VOFaceMail#inboxStoreURLName()
317              */
318                @ThreadRestricted("constructor")
319            public void setInboxStoreURLName( String inboxStoreURLName )
320            {
321                this.inboxStoreURLName = inboxStoreURLName;
322            }
323
324
325
326        /** @see VOFaceMail#isDryRun()
327          * @see #setDryRun(boolean)
328          */
329        public final boolean isDryRun() { return dryRun; };
330
331
332            private boolean dryRun;
333
334
335            /** Sets whether the interface is to run without making any
336              * persistent state changes.
337              *
338              *     @see VOFaceMail#isDryRun()
339              */
340            public final void setDryRun( boolean newDryRun ) { dryRun = newDryRun; }
341
342
343
344        /** The context for configuring access to the mail transfer server,
345          * through which outgoing messages (such as replies to voters) are sent.
346          */
347        public SMTPTransportX.ConstructionContext transferService() { return transferService; }
348
349
350            private final SMTPTransportX.ConstructionContext transferService;
351
352
353    }
354
355
356
357//// P r i v a t e ///////////////////////////////////////////////////////////////////////
358
359
360    private static final Thread.UncaughtExceptionHandler fatalExceptionHandler =
361      new ThreadX.UncaughtExceptionLogger( LoggerX.SEVERE )
362    {
363        public void uncaughtException( Thread thread, final Throwable t )
364        {
365            super.uncaughtException( thread, t ); // log the full trace
366            System.err.println( "Fatal error" + Votorola.unmessagedDetails(t) + ": " + t ); // give the user a brief message
367            System.exit( 1 ); // rather than risk running with a dead thread
368        }
369    };
370
371
372
373    /** Matches an RFC 3282 language tag. Groups primary, secondary,
374      * and remaining subtags. Leading delimiters ('-') of secondary
375      * and remaining subtags are not stripped away.
376      */
377    private static final Pattern LANGUAGE_TAG_PATTERN = Pattern.compile
378        ( "^([A-Z]+)(-[a-z0-9]+)?((?:-[a-z0-9]+)+)?$", Pattern.CASE_INSENSITIVE );
379
380
381
382    private static final Logger logger = LoggerX.i( VOFaceMail.class );
383
384
385
386    private @ThreadRestricted("thread") final MailSender mailSender
387      = new MailSender( cc.transferService() );
388    {
389        mailSender.setDryRun( cc.isDryRun() );
390    }
391
392
393
394    private @ThreadRestricted("thread") final Session mailSession;
395    {
396        final Properties p = new Properties( System.getProperties() );
397        {
398            SMTPTransportX.SimpleAuthentication transferAuthentication =
399              cc.transferService().getAuthenticationMethod();
400            if( transferAuthentication != null ) p.put( "mail.smtp.auth", "true" );
401        }
402        mailSession = Session.getInstance( p );
403    }
404
405
406
407    private static InternetAddress matchingAddress(
408      final String[] envelopeBareAddressArray, final Address[] messageAddressArray )
409    {
410        if( envelopeBareAddressArray == null || messageAddressArray == null ) return null;
411
412        for( Address messageAddress : messageAddressArray )
413        {
414            if( !( messageAddress instanceof InternetAddress )) continue;
415
416            InternetAddress messageIAddress = (InternetAddress)messageAddress;
417            for( String envelopeBareAddress : envelopeBareAddressArray )
418            {
419                if( envelopeBareAddress.equals( messageIAddress.getAddress() ))
420                {
421                 // if( matchingAddress == null ) matchingAddress = messageIAddress;
422                 // else if( !matchingAddress.equals( messageIAddress ))
423                 // {
424                 //     throw new BadDeliveryException(
425                 //      "multiple envelope 'Delivered-To' addresses " + Arrays.toString( envelopeBareAddressArray )
426                 //       + " match message 'To' addresses " + Arrays.toString( messageAddressArray ));
427                 // }
428                 ////// not using only 'Delivered-To' header now, so relax the sanity check:
429                    return messageIAddress;
430                }
431            }
432        }
433        return null;
434    }
435
436
437
438    private final MailMetaService metaService;
439
440
441
442    /** Advances the command indeces to the next command in the message text.  Multiple
443      * commands are separated by blank lines (and so unaffected by any line wrapping by
444      * the sender's mail client).
445      *
446      *     @param ii the command indeces from the previous call, or null to advance to
447      *       the first command.
448      *     @param messageText the text in which to seek the command.
449      *
450      *     @return Indeces of next command in a two-element array
451      *       (reusing ii if it was non-null). Element 0 is set to the index
452      *       of the first character of the first line of the next command;
453      *       or to beyond the message length, if there are no more commands.
454      *       Element 1 is set to the index of the end bound
455      *       (last plus 1) character.
456      */
457    private static int[] nextCommand( int[] ii, final CharSequence messageText ) // will later be used by the authentication layer bypass, to parse the message for any command that requires authentication
458    {
459        if( ii == null ) ii = new int[2];
460
461        int i = ii[1];
462        final int iN = messageText.length();
463        for( int iLineStart = i; i < iN; ++i ) // find the start of the next command
464        {
465            final char ch = messageText.charAt( i );
466            if( ch == '\n' ) iLineStart = i + 1;
467            else if( !Character.isWhitespace( ch ))
468            {
469                i = iLineStart;
470                break;
471            }
472        }
473        ii[0] = i;
474
475        if( i < iN )
476        {
477            int iLastNewline = -1;
478            for( ;; ) // find the end bound of the command
479            {
480                ++i;
481                if( i >= iN ) break;
482
483                final char ch = messageText.charAt( i );
484                if( ch == '\n' )
485                {
486                    if( iLastNewline != -1 ) break; // two newlines in a row (or with only whitespace between them)
487
488                    iLastNewline = i;
489                }
490                else if( !Character.isWhitespace( ch )) iLastNewline = -1;
491            }
492            if( iLastNewline != -1 ) i = iLastNewline;
493        }
494        ii[1] = i;
495        return ii;
496    }
497
498
499
500    /** Matches a signature delimiter.
501      */
502    private static final Pattern SIGNATURE_DELIMITER_PATTERN = Pattern.compile( "(?m)^-- $" );
503
504
505
506    /** Spool unwound just before this run ends, at VM shut-down.
507      */
508    private final Spool spool = new SpoolT();
509    {
510        Thread shutdownThread = new Thread()
511        {
512            public void run()
513            {
514             // LoggerX.i(VOFaceMail.class).info( "stopping mail-based voter interface" );
515             ///// logging service unreliable, at this stage
516                stopLatch.countDown();
517                final CatcherP<Hold> catcher = CatcherP.i();
518                spool.unwind( catcher );
519             // throw new Error( "test error in shutdown thread" );
520            }
521        };
522        shutdownThread.setUncaughtExceptionHandler( new ThreadX.UncaughtExceptionPrinter() ); // instead of this run's setDefaultUncaughtExceptionHandler, which depends on the logger
523        Runtime.getRuntime().addShutdownHook( shutdownThread );
524     // spool.add( new Hold()
525     // {
526     //     public @ThreadSafe void release() { throw new RuntimeException( "test exception during shutdown" ); }
527     // });
528    }
529
530
531
532    /** A latch that zeroes when this interface is stopping.
533      */
534    CountDownLatchX stopLatch() { return stopLatch; }
535
536
537        private CountDownLatchX stopLatch = new CountDownLatchX( 1 );
538
539
540
541    private final ThreadX thread = new ThreadX( VOFaceMail.this, "mail interface" );
542
543
544
545   // - R u n n a b l e ------------------------------------------------------------------
546
547
548    public void run()
549    {
550        assert Thread.currentThread() == thread; // private run()
551        boolean toSleep = false; // initially, except for this small one:
552        if( stopLatch().tryAwait( 5, TimeUnit.SECONDS )) return;
553
554        vsRun.singleServiceLock().lock(); // no need to unlock, single access
555        final LevelSwitchR lsrInboxPoll = new LevelSwitchR();
556        pollInbox: for( ;; )
557        {
558            if( toSleep )
559            {
560                logger.finest( "inboxPollSleepSeconds=" + inboxPollSleepSeconds );
561                if( stopLatch().tryAwait(
562                  inboxPollSleepSeconds, TimeUnit.SECONDS )) break pollInbox; // because shutdown is holding for this thread, per tryJoin farther above
563            }
564            else toSleep = true; // default for next time, to avoid busy looping
565
566            Exception xInboxPoll = null;
567            try
568            {
569                final Store store = mailSession.getStore( inboxStoreURLName() );
570                store.connect();
571                try
572                {
573                 // final Folder folder = StoreX.ensureDefaultFolder( store );
574                 /// for Maildir, that now gives the 'cur' directory.  This gives 'new':
575                    final Folder folder = store.getDefaultFolder();
576                    folder.open( Folder.READ_WRITE );
577                    try
578                    {
579                        final int mN = folder.getMessageCount();
580                        if( mN == 0 ) continue pollInbox;
581
582                        readInbox: for( int m = 1; m <= mN; ++m )
583                        {
584                            if( stopLatch().getCount() == 0 ) break pollInbox; // If more frequent checks are ever needed, to minimize stop lag, then divide message processing into smaller stages. And record stage state prior to each stage (message.setFlag with custom flags). See notebook 2007-10-10 (MCA).
585
586                            final Message message;
587                            try
588                            {
589                                message = folder.getMessage( m );
590                            }
591                            catch( IndexOutOfBoundsException x )
592                            {
593                                logger.warning( "another process has deleted a message in the mail store: " + store.toString() );
594                                break readInbox;
595                            }
596
597                            if( message.isSet( Flags.Flag.DELETED )) continue readInbox; // unexpunged left over from last program run
598
599                            try
600                            {
601                                final LevelSwitchR lsr = new LevelSwitchR();
602                                final TimeUnit unit = TimeUnit.SECONDS; int retryDelay = 10;
603                                runMessage: for( ;; )
604                                {
605                                    Exception x = run( message );
606                                    if( x == null ) break;
607
608                                    logger.log( lsr.level(x,Level.INFO), "trouble reading message", x );
609                                    logger.info( "retry in " + unit + "=" + retryDelay + ", because: " + x );
610                                    if( stopLatch().tryAwait( retryDelay, unit )) break pollInbox;
611
612                                    if( unit.toMinutes(retryDelay) < 15 ) retryDelay <<= 1; // progressively longer, up to a limit
613                                }
614                            }
615                            catch( BadMessageException x )
616                            {
617                                logger.log( Level.FINER, /*message*/"", x );
618                                logger.info( "dropping this message, it caused: " + x );
619                            }
620                            if( !dryRun ) message.setFlag( Flags.Flag.DELETED, true ); // done with that message
621                        }
622                    }
623                    finally{ folder.close( /*expunge those messages marked DELETED*/!dryRun ); }
624
625                    if( !dryRun ) toSleep = false; // real work was done, it's safe to skip the sleep
626                }
627                finally{ store.close(); }
628            }
629            catch( RuntimeException x ) { throw x; }
630            catch( Exception x ) { xInboxPoll = x; }
631            Level level = lsrInboxPoll.level( xInboxPoll, Level.WARNING ); // clear switch if null, per LevelSwitchR
632            if( xInboxPoll != null ) logger.log( level, "trouble while polling the inbox", xInboxPoll );
633        }
634    }
635
636
637
638    /** Responds to a message.
639      *
640      *     @return any soft exception per CommandResponder.{@linkplain
641      *         CommandResponder#respond(String[]) respond}; or null if none occured.
642      *
643      *     @throws BadMessageException for unacceptable messages.
644      */
645    private Exception run( final Message message ) throws BadMessageException
646    {
647        List<Throwable> nonCriticalXList = null; // lazily created
648        final MimeMessage mimeMessage;
649        if( message instanceof MimeMessage ) mimeMessage = (MimeMessage)message;
650        else
651        {
652            assert false : "all incoming messages are MIME";
653            mimeMessage = null;
654        }
655
656      // Parse the critical headers, needed to respond
657      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
658     // final String fromAddressIndication; // address string(s), or some kind of placeholder
659        final InternetAddress serviceEmail;
660        final MimeMessage reply;
661
662        final IDPair voter; final InternetAddress voterEmail; // if authenticated, else null
663        final String voterEmailAuthenticationHeader = "X-TMDA-Confirm-Done";
664        try
665        {
666            logger.info( "inbox message: number=" + message.getMessageNumber() + ", Message-ID=" + PartX.getFirstHeader(message,"Message-ID") );
667
668         // /// fromAddressIndication ///
669         // fromAddressIndication = Arrays.toString( message.getFrom() );
670
671            /// serviceEmail ///
672            {
673                final Address[] messageAddressArray =
674                    message.getRecipients( Message.RecipientType.TO );
675
676                final String[] envelopeOldBareAddressArray =
677                    message.getHeader( "Old-Delivered-To" ); // may be null
678                InternetAddress matchingAddress = matchingAddress(
679                    envelopeOldBareAddressArray, messageAddressArray );
680                if( matchingAddress == null )
681                {
682                    final String[] envelopeBareAddressArray = message.getHeader( "Delivered-To" );
683                    if( envelopeBareAddressArray == null )
684                    {
685                        throw new BadDeliveryException(
686                         "missing envelope recipient header: 'Delivered-To'" );
687                    }
688
689                    matchingAddress = matchingAddress(
690                        envelopeBareAddressArray, messageAddressArray );
691                    if( matchingAddress == null )
692                    {
693                        logger.info( "ignoring probable CC/BCC message, because no envelope 'Delivered-To' " + Arrays.toString(envelopeBareAddressArray) + " nor 'Old-Delivered-To' " + Arrays.toString(envelopeOldBareAddressArray) + " matches a message 'To' recipient " + Arrays.toString(messageAddressArray) );
694                        return null;
695                    }
696                }
697                serviceEmail = matchingAddress;
698            }
699            logger.config( "request for voter service: " + serviceEmail );
700
701            /// voterEmail ///
702            final String[] returnPathArray = message.getHeader( "Return-Path" );
703            if( returnPathArray == null || returnPathArray.length == 0 )
704            {
705                throw new BadDeliveryException(
706                 "envelope without sender address: 'Return-Path'" );
707            }
708
709            if( PartX.getFirstHeader(message,voterEmailAuthenticationHeader) == null )
710            {
711                if( returnPathArray.length > 1 ) throw new BadMessageException( "unknown sender, multiple headers 'Return-Path'" ); // under sender control
712
713                final InternetAddress senderEmail = new InternetAddress( returnPathArray[0] );
714                if( "".equals( senderEmail.getAddress() ))
715                {
716                    logger.fine( "ignoring bounce from 'Return-Path' " + Arrays.toString(returnPathArray) );
717                    return null;
718                }
719
720                voterEmail = null; // unauthenticated, must be for the meta-service, or an unknown service
721            }
722            else // authenticated sender
723            {
724                if( returnPathArray.length > 1 ) // not under sender control, because TMDA's tmda-rfilter release_pending() ensures a single header here, so this should not happen
725                {
726                    throw new BadDeliveryException(
727                     "envelope with multiple sender addresses: 'Return-Path'" );
728                }
729                voterEmail = new InternetAddress( returnPathArray[0] ); // TMDA confirms the envelope sender address. ('Return-Path' is supposed to record it.) If tmda-filter-wrapper is used, then this envelope sender address is actually the first address of the 'From' header (and not necessarily the envelope sender, who might be merely a forwarder). In any case, voterEmail is the one that responded to the authentication challenge.
730            }
731            voter = IDPair.fromEmail( voterEmail.getAddress() );
732
733            /// reply ///
734            {
735                final Message r = message.reply( /*replyToAll*/false ); // replyToAll not needed; voter may CC the original message, but I need not CC the reply
736                if( !( r instanceof MimeMessage )) throw new VotorolaRuntimeException( "non-MIME reply created unexpectedly" ); // we depend on convenience methods of MimeMessage
737
738                reply = (MimeMessage)r;
739            }
740            reply.setFrom( serviceEmail );
741        }
742        catch( MessagingException x ) { throw new BadMessageException( x ); }
743
744      // Limit replies when sender unauthenticated, in order to avoid looping
745      // with another auto-responder, or sending too many replies to a falsified address.
746      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
747        if( voterEmail == null ) // otherwise, TMDA has imposed its own limiting
748        {
749          // ignore anything that looks like an auto-responder - avoid looping
750          // ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` `
751          // to be coded later, as needed - for algorithm, see TMDA's
752          // autorespond_to_sender(sender) in /usr/bin/tmda-rfilter - meantime,
753          // fall back on rate-limiting:
754          //
755
756          // failsafe rate-limiting - limit loops - limit attacks on false address
757          // ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` `
758            try
759            {
760                for( final Address address: reply.getAllRecipients() )
761                {
762                    Holder<Float> loadH = loadOnAddressMap.get( address );
763                    if( loadH == null )
764                    {
765                        loadH = new Holder<Float>();
766                        loadOnAddressMap.put( address, loadH );
767                    }
768                    else if( loadH.value > LoadOnAddressMap.MAX_LOAD )
769                    {
770                        throw new BadMessageException( "overloaded (" + loadH.value +
771                          ") by unauthenticated senders, temporarily dropping replies to address: "
772                          + address );
773                    }
774                    loadH.value += 1F;
775                }
776            }
777            catch( MessagingException x ) { throw new RuntimeException( x ); }
778        }
779
780      // Create the reply builder
781      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
782        final ReplyBuilder replyB;
783        {
784            Locale locale = null;
785            try
786            {
787                if( mimeMessage != null )
788                {
789                    final String[] languageTag = mimeMessage.getContentLanguage();
790                    if( languageTag != null && languageTag.length > 0 )
791                    {
792                        final Matcher m = LANGUAGE_TAG_PATTERN.matcher( languageTag[0] );
793                        if( m.matches() )
794                        {
795                            String primarySubtag = m.group( 1 );
796                            String secondarySubtag = m.group( 2 );
797                            String remainingSubtags = m.group( 3 );
798                            if( remainingSubtags != null )
799                            {
800                                locale = new Locale( primarySubtag, secondarySubtag.substring(1),
801                                  remainingSubtags.substring(1).replace('-','_') );
802                            }
803                            else if( secondarySubtag != null )
804                            {
805                                locale = new Locale( primarySubtag, secondarySubtag.substring(1) );
806                            }
807                            else locale = new Locale( primarySubtag );
808                        }
809                    }
810                }
811            }
812            catch( MessagingException x ) { nonCriticalXList = ThrowableX.listedThrowable( x, nonCriticalXList ); }
813
814            if( locale == null ) locale = Locale.getDefault();
815            replyB = new ReplyBuilder( locale );
816        }
817        final BundleFormatter bunA = new BundleFormatter( ResourceBundle.getBundle(
818          "votorola.a.locale.A", replyB.locale(), new BundleControlU() ));
819
820      // Begin writing the reply
821      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
822        {
823            String dateHead = null;
824            try { dateHead = PartX.getFirstHeader( message, "Date" ); }
825            catch( MessagingException x )
826            {
827                nonCriticalXList = ThrowableX.listedThrowable( x, nonCriticalXList );
828            }
829            if( voterEmail == null )
830            {
831                if( dateHead != null )
832                {
833                    replyB.lappendlnn( "s.mail.VOFaceMail.addr(1)", dateHead );
834                }
835                else replyB.lappendlnn( "s.mail.VOFaceMail.addr(1Date)", new Date() );
836            }
837            else
838            {
839                if( dateHead != null )
840                {
841                    replyB.lappendlnn( "s.mail.VOFaceMail.addr(1,2)", dateHead, voterEmail );
842                }
843                else
844                {
845                    replyB.lappendlnn( "s.mail.VOFaceMail.addr(1Date,2)",
846                      new Date(), voterEmail );
847                }
848            }
849        }
850
851      // Look up the requested service
852      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
853        final VoterService voterService;
854        try
855        {
856            final String name = metaService.serviceName( serviceEmail );
857            if( VoterService.isNonPoll( name ))
858            {
859                final VoterService s = vsRun.voterService( name);
860             // if( s == null ) throw new BadDeliveryException( "no such voter service here: " + serviceName );
861                voterService = s == null? metaService: s;
862            }
863            else voterService = vsRun.scopePoll().ensurePoll( name );
864        }
865        catch( RuntimeException x ) { throw x; }
866        catch( Exception x ) { return x; }
867
868
869      // Read the message text, and act on commands
870      // = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
871        {
872          // Read the message text
873          // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
874            final StringBuilder messageBuffer; // null if no message text
875            {
876                String string = null;
877                try
878                {
879                    final Part textPart = PartX.getPlainTextPart( message );
880                    if( textPart != null )
881                    {
882                        Object content = textPart.getContent();
883                        if( content instanceof String ) string = (String)content;
884                        else logger.info( "ignoring text/plain part with improper content" ); // uncertain if this can ever occur
885                    }
886                }
887                catch( MessagingException x ) { nonCriticalXList = ThrowableX.listedThrowable( x, nonCriticalXList ); }
888                catch( UnsupportedEncodingException x ) { nonCriticalXList = ThrowableX.listedThrowable( x, nonCriticalXList ); } // message with a strange character set
889                catch( IOException x ) { return x; }
890
891                if( string == null ) messageBuffer = null;
892                else messageBuffer = new StringBuilder( string );
893            }
894
895          // Report any non-critical exceptions
896          // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
897            if( nonCriticalXList != null )
898            {
899                final StringWriter stringWriter = new StringWriter();
900                final PrintWriter printWriter = new PrintWriter( stringWriter );
901                replyB.lappendlnn( "s.mail.VOFaceMail.nonCriticalX" );
902                for( Throwable x : nonCriticalXList )
903                {
904                    x.printStackTrace( printWriter );
905                    printWriter.println();
906                }
907                printWriter.flush();
908                replyB.append( stringWriter.toString() );
909                nonCriticalXList = null; // done with it
910            }
911
912          // - - -
913            if( messageBuffer == null ) replyB.lappendlnn( "s.mail.VOFaceMail.noTextPart" );
914            else
915            {
916              // Remove any message signature
917              // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
918                {
919                    Matcher m = SIGNATURE_DELIMITER_PATTERN.matcher( messageBuffer );
920                    if( m.find() ) messageBuffer.delete( m.start(), messageBuffer.length() );
921                }
922
923              // Parse the commands out of the message
924              // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
925                final CommandResponder.Session commandSession;
926                {
927                    int trustLevel = 0;
928                    if( voter != null )
929                    {
930                        try
931                        {
932                            trustLevel = vsRun.trustserver().getTraceNode(
933                              /*list ref*/null, voter ).trustLevel();
934                        }
935                        catch( IOException x ) { return x; }
936                        catch( SQLException x ) { return x; }
937                    }
938                    commandSession = new CommandResponder.Session( VOFaceMail.this,
939                      voter.email(), trustLevel, bunA, replyB );
940                }
941
942                int commandCount = 0;
943                final int[] ii = nextCommand( null, messageBuffer );
944                for( ;; ) // each command
945                {
946                    replyB.resetFormattingToDefaults(); // fail-safe, in case responder poorly coded
947                    if( ii[0] >= messageBuffer.length() )
948                    {
949                        if( commandCount == 0 )
950                        {
951                            replyB.lappend( "s.mail.VOFaceMail.emptyText" );
952                            final CommandResponder help =
953                              voterService.responderByClassName( CR_Help.class.getName() );
954                            if( help != null )
955                            {
956                                replyB.append( "  " );
957                                replyB.lappendlnn( "s.mail.VOFaceMail.instructionsHelp" );
958                                replyB.indent( 4 );
959                                replyB.append( help.commandName( commandSession ));
960                                replyB.exdent( 4 );
961                            }
962                            replyB.appendlnn();
963                        }
964                        break;
965                    }
966
967                    ++commandCount;
968                    if( commandCount > 50 )
969                    {
970                        replyB.append( '\n' );
971                        replyB.lappendlnn( "s.mail.VOFaceMail.tooManyCommands" );
972                        break;
973                    }
974
975                    if( replyB.length() > 3000000 )
976                    {
977                        replyB.append( '\n' );
978                        replyB.lappendlnn( "s.mail.VOFaceMail.replyTooLong" );
979                        break;
980                    }
981
982                  // Look up the command responder
983                  // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
984                    final String[] argArray;
985                    {
986                        final ArrayList<String> argList =
987                          new ArrayList<String>( /*initial capacity*/8 );
988                        final Matcher m = COMMAND_ARGUMENT_PATTERN.matcher( messageBuffer )
989                          .region( ii[0], ii[1] );
990                        while( m.find() ) argList.add( m.group( 1 ));
991
992                        argArray = new String[argList.size()];
993                        argList.toArray( argArray );
994                    }
995                    final CommandResponder commandResponder =
996                      voterService.responderForCommand( argArray, commandSession );
997
998                  // Echo the command
999                  // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1000                    replyB.setWrapping( false );
1001                    {
1002                        int i = ii[0];
1003                        int iN = ii[1];
1004
1005                        final int iCutoff; // to prevent echo of spam
1006                        if( voterEmail == null )
1007                        {
1008                            if( commandResponder == null ) iCutoff = i + 10; // almost certainly spam
1009                            else iCutoff = i + 50;
1010                        }
1011                        else iCutoff = Integer.MAX_VALUE; // no cutoff
1012
1013                        char ch = '\n'; // prime it
1014                        for(; i < iN; ++i )
1015                        {
1016                            if( ch == '\n' ) replyB.append( "> " );
1017
1018                            if( i >= iCutoff )
1019                            {
1020                                replyB.append( "..." );
1021                                break;
1022                            }
1023
1024                            ch = messageBuffer.charAt( i );
1025                            replyB.append( ch );
1026                        }
1027                    }
1028                    replyB.appendlnn().setWrapping( true );
1029
1030                  // Act on the command
1031                  // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1032                    try
1033                    {
1034                        Exception x = voterService.dispatch
1035                          ( argArray, commandSession, commandResponder );
1036                        if( x != null ) return x;
1037                    }
1038                    catch( CommandResponder.AnonymousIssueException x )
1039                    {
1040                        throw new BadDeliveryException(
1041                         "voter email address unconfirmed, missing header '"
1042                          + voterEmailAuthenticationHeader + "'", x );
1043                    }
1044
1045                  // - - -
1046                    nextCommand( ii, messageBuffer );
1047                }
1048            }
1049        }
1050
1051      // Transmit the reply
1052      // = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
1053        try
1054        {
1055            reply.setHeader( "X-Mailer", VOFaceMail.class.getName() );
1056            reply.setText( replyB.chomplnn().toString(), "UTF-8" );
1057        }
1058        catch( MessagingException x ) { throw new RuntimeException( x ); }
1059
1060        return mailSender.trySend( reply, mailSession );
1061    }
1062
1063
1064
1065   // ====================================================================================
1066
1067
1068    /** Thrown when an incoming message was badly delivered, indicating a configuration
1069      * problem.
1070      */
1071    private static final class BadDeliveryException extends VotorolaRuntimeException
1072    {
1073
1074     // public BadDeliveryException() {}
1075
1076
1077     // public BadDeliveryException( Throwable cause ) { super( cause ); }
1078
1079
1080        public BadDeliveryException( String message ) { super( message ); }
1081
1082
1083        public BadDeliveryException( String message, Throwable cause ) { super( message, cause ); }
1084
1085
1086    }
1087
1088
1089
1090   // ====================================================================================
1091
1092
1093    /** Thrown when an incoming message was composed in bad form, and cannot be accepted.
1094      */
1095    private static final class BadMessageException extends VotorolaException
1096    {
1097
1098     // public BadMessageException() {}
1099
1100
1101        public BadMessageException( Throwable cause ) { super( cause ); }
1102
1103
1104        public BadMessageException( String message ) { super( message ); }
1105
1106
1107     // public BadMessageException( String message, Throwable cause ) { super( message, cause ); }
1108
1109
1110    }
1111
1112
1113
1114   // ====================================================================================
1115
1116
1117      @ThreadRestricted("thread")
1118    private final LoadOnAddressMap loadOnAddressMap = new LoadOnAddressMap();
1119
1120
1121
1122    /** Time sensitive map of load imposed by unauthenticated users on particular
1123      * addresses.  The keys are the addresses that are burdened by the load, and the
1124      * values are the load in units of message equivalents (1.0 per message sent to the
1125      * address).  Calls to get(key) will clear the map automatically, such that counts
1126      * are maintained for a limited time only.
1127      */
1128    private static final class LoadOnAddressMap extends HashMap<Address,Holder<Float>>
1129    {
1130
1131        private void accessed()
1132        {
1133            long now = System.currentTimeMillis();
1134            if( now - lastClearTime > CLEAR_INTERVAL_MS )
1135            {
1136                clear();
1137                lastClearTime = now;
1138            }
1139        }
1140
1141
1142        private static final long CLEAR_INTERVAL_MS = 1000L * 60L * 60L; // ms * s * min = 1 hour
1143
1144
1145        private long lastClearTime = System.currentTimeMillis();
1146
1147
1148        private static final float MAX_LOAD = 8F; // message equivalents
1149
1150
1151       // - S c e n e --------------------------------------------------------------------
1152
1153
1154        public Holder<Float> get( Address key )
1155        {
1156            accessed();
1157            return super.get( key );
1158        }
1159
1160
1161    }
1162
1163
1164
1165}