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}