package mireka.pop.store; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; import javax.annotation.concurrent.GuardedBy; import mireka.smtp.EnhancedStatus; import mireka.transmission.LocalMailSystemException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Maildrop provides the physical storage for a specific maildrop (assigned to a * user) using the file system. This implementation uses a single directory, * where it stores each mail in separate files in addition to some control * files. All operations are as atomic as possible, preventing leaving the * maildrop in an inconsistent state. */ public class Maildrop { private final Logger logger = LoggerFactory.getLogger(Maildrop.class); private final String name; private final File dir; @GuardedBy("this") private boolean isTransactionRunning; @GuardedBy("this") private int appenderCount; @GuardedBy("this") /** * true if data from disk is already loaded */ private boolean isInitialized; /** * Sorted list of messages ordered by the numeric value of the file name. * Items must not be appended if a transaction is running. */ @GuardedBy("this") private List<Message> messages; @GuardedBy("this") private final List<Message> pendingMessages = new ArrayList<Maildrop.Message>(); @GuardedBy("this") private final UidManager uidManager; public Maildrop(String name, File maildropDir) { this.name = name; this.dir = maildropDir; this.uidManager = new UidManager(maildropDir); } public synchronized void beginTransaction() throws MaildropLockedException, MaildropPopException { if (isTransactionRunning) throw new MaildropLockedException(); try { initialize(); } catch (MaildropException e) { logger.error("Cannot read maildrop content from disk", e); throw new MaildropPopException("SYS/PERM", "Corrupted mailbox"); } isTransactionRunning = true; } /** * Reads persistent maildrop data from the disk if it is not already read * * @throws MaildropException */ private void initialize() throws MaildropException { if (isInitialized) return; if (!isFullyConstructed()) constructNewMaildropDir(); uidManager.init(); readDirectory(); isInitialized = true; } private boolean isFullyConstructed() { return new File(dir, "constructed").exists(); } private void constructNewMaildropDir() throws MaildropException { if (!dir.isDirectory()) { boolean success = dir.mkdir(); if (!success) throw new MaildropException("Cannot create maildrop directory " + dir); } uidManager.createInitialUidFile(); File constructedFile = new File(dir, "constructed"); try { boolean created = constructedFile.createNewFile(); if (!created) throw new RuntimeException("Assertion failed"); } catch (IOException e) { throw new MaildropException( "The 'successful' marker file cannot be created, " + "maildrop is invalid " + constructedFile); } } private void readDirectory() throws MaildropException { int countOfDeletedTemporaryFiles = 0; messages = new ArrayList<Message>(); File[] files = dir.listFiles(); if (files == null) throw new MaildropException( "Cannot list mail files in maildrop at " + dir); for (File file : files) { String name = file.getName(); if (name.startsWith("temp.mail.")) { boolean success = file.delete(); if (success) { countOfDeletedTemporaryFiles++; } else { throw new MaildropException( "Temporary mail file cannot be " + "deleted, maildrop is invalid " + file); } } if (!Character.isDigit(name.charAt(0))) continue; if (!name.endsWith(".eml")) throw new MaildropException("Invalid mail file name: " + file); String baseName = name.substring(0, name.length() - 4); Message message = new Message(); try { message.id = Long.valueOf(baseName); } catch (NumberFormatException e) { throw new MaildropException("Invalid mail file name: " + file); } message.length = file.length(); if (message.length == 0) throw new MaildropException("Cannot determine length of file " + file); messages.add(message); } Collections.sort(messages, new MessageIdComparator()); if (countOfDeletedTemporaryFiles > 0) logger.warn(countOfDeletedTemporaryFiles + " temporary mail files were deleted in " + dir); } public synchronized long getCountOfMessages() { if (!isTransactionRunning) throw new IllegalStateException("Assertion failed"); long result = 0; for (Message message : messages) { if (!message.deleted) result++; } return result; } public synchronized long getTotalOctets() { if (!isTransactionRunning) throw new IllegalStateException("Assertion failed"); long result = 0; for (Message message : messages) { if (!message.deleted) result += message.length; } return result; } public synchronized List<ScanListing> getScanListings() { if (!isTransactionRunning) throw new IllegalStateException("Assertion failed"); List<ScanListing> result = new ArrayList<ScanListing>(); for (int i = 0; i < messages.size(); i++) { Message message = messages.get(i); if (!message.deleted) result.add(new ScanListing(i + 1, message.length)); } return result; } public synchronized ScanListing getScanListing(int messageNumber) throws MaildropPopException, IllegalStateException { Message message = getMessageByMessageNumber(messageNumber); return new ScanListing(messageNumber, message.length); } public synchronized List<UidListing> getUidListings() { if (!isTransactionRunning) throw new IllegalStateException("Assertion failed"); List<UidListing> result = new ArrayList<UidListing>(); for (int i = 0; i < messages.size(); i++) { Message message = messages.get(i); if (!message.deleted) result.add(new UidListing(i + 1, message.id)); } return result; } public synchronized UidListing getUidListing(int messageNumber) throws MaildropPopException, IllegalStateException { Message message = getMessageByMessageNumber(messageNumber); return new UidListing(messageNumber, message.id); } public synchronized InputStream getMailAsStream(int messageNumber) throws MaildropPopException, IllegalStateException { Message message = getMessageByMessageNumber(messageNumber); try { return new FileInputStream(message.getFile()); } catch (FileNotFoundException e) { logger.error("Cannot open mail file " + message.getFile(), e); throw new MaildropPopException("SYS/PERM", "Corrupted mailbox"); } } /** * Throws an exception if the maildrop is not in transaction mode, the * message number is invalid, or refers to a deleted mail. */ private Message getMessageByMessageNumber(int messageNumber) throws MaildropPopException, IllegalStateException { if (!isTransactionRunning) throw new IllegalStateException(); if (messageNumber < 1 || messageNumber > messages.size()) throw new MaildropPopException(null, "No such message"); Message message = messages.get(messageNumber - 1); if (message.deleted) throw new MaildropPopException(null, "Deleted message"); return message; } public synchronized void delete(int messageNumber) throws MaildropPopException, IllegalStateException { Message message = getMessageByMessageNumber(messageNumber); message.deleted = true; } public synchronized void resetDeletions() { resetDeletedFlags(); } private void resetDeletedFlags() { for (Message message : messages) { message.deleted = false; } } public synchronized void commitTransaction() throws MaildropPopException, IllegalStateException { if (!isTransactionRunning) throw new IllegalStateException(); try { removeDeletedMessages(); } finally { appendPendingMessages(); isTransactionRunning = false; } } private void removeDeletedMessages() throws MaildropPopException { List<Message> keptMessages = new ArrayList<Maildrop.Message>(); int cFailedDeletions = 0; for (Message message : messages) { if (message.deleted) { boolean success = message.getFile().delete(); if (!success) { message.deleted = false; keptMessages.add(message); cFailedDeletions++; if (cFailedDeletions <= 3) logger.error("Mail cannot be deleted: " + message.getFile()); } } else { keptMessages.add(message); } } messages = keptMessages; if (cFailedDeletions > 3) logger.error((cFailedDeletions - 3) + " additional mails cannot be deleted"); if (cFailedDeletions != 0) throw new MaildropPopException("SYS/PERM", "Cannot remove " + cFailedDeletions + " deleted mails"); } private void appendPendingMessages() { messages.addAll(pendingMessages); pendingMessages.clear(); } public synchronized void rollbackTransaction() throws IllegalStateException { if (!isTransactionRunning) throw new IllegalStateException(); resetDeletedFlags(); appendPendingMessages(); isTransactionRunning = false; } public synchronized MaildropAppender allocateAppender() throws LocalMailSystemException { try { initialize(); } catch (MaildropException e) { throw new LocalMailSystemException(e, EnhancedStatus.TRANSIENT_LOCAL_ERROR_IN_PROCESSING); } appenderCount++; return new Appender(); } public synchronized void checkReleasedState() { if (isTransactionRunning) throw new IllegalStateException("Properly released maildrop was " + "expected, but a transaction is still running: " + name + ", " + dir); if (appenderCount >= 1) throw new IllegalStateException("Properly released maildrop was " + "expected, but an appender is still not released: " + name + ", " + dir); } /** * @x.category GETSET */ public String getName() { return name; } private class Message { /** * UID */ long id; /** * Mail length in bytes. */ long length; /** * True if the mail is marked for deletion in the currently running * transaction. */ boolean deleted; File getFile() { return new File(dir, id + ".eml"); } } /** * This class compares {@link Message} instances based on there UID. */ private static class MessageIdComparator implements Comparator<Message> { @Override public int compare(Message o1, Message o2) { return Long.signum(o1.id - o2.id); } } private class Appender implements MaildropAppender { private AppenderStatus status = AppenderStatus.NEW; private long uid; private File tempFile; private File finalFile; private FileOutputStream outputStream; @Override public OutputStream getOutputStream() throws LocalMailSystemException { if (status != AppenderStatus.NEW) throw new IllegalStateException(); synchronized (Maildrop.this) { try { allocateFileNames(); } catch (InvalidUidFileException e) { throw new LocalMailSystemException(e, EnhancedStatus.TRANSIENT_LOCAL_ERROR_IN_PROCESSING); } } try { outputStream = new FileOutputStream(tempFile); } catch (FileNotFoundException e) { throw new LocalMailSystemException(e, EnhancedStatus.TRANSIENT_LOCAL_ERROR_IN_PROCESSING); } status = AppenderStatus.OPEN; return outputStream; } private void allocateFileNames() throws InvalidUidFileException { uid = uidManager.allocateUid(); tempFile = new File(dir, "temp.mail." + uid + ".eml"); finalFile = new File(dir, uid + ".eml"); } @Override public void commit() throws LocalMailSystemException { if (status == AppenderStatus.CLOSED) return; try { if (status == AppenderStatus.NEW) return; // status is OPEN try { outputStream.close(); } catch (IOException e) { throw new LocalMailSystemException(e, EnhancedStatus.TRANSIENT_LOCAL_ERROR_IN_PROCESSING); } Message message = new Message(); message.id = uid; message.length = tempFile.length(); if (message.length == 0) throw new LocalMailSystemException( "Cannot retrieve file length " + tempFile, EnhancedStatus.TRANSIENT_LOCAL_ERROR_IN_PROCESSING); boolean success = tempFile.renameTo(finalFile); if (!success) { if (tempFile.delete()) { throw new LocalMailSystemException( "Cannot move temporary file to final " + "destination, but at least it could be deleted " + tempFile, EnhancedStatus.TRANSIENT_LOCAL_ERROR_IN_PROCESSING); } else { throw new LocalMailSystemException( "Cannot move temporary file to final destination, nor it can be deleted " + tempFile, EnhancedStatus.TRANSIENT_LOCAL_ERROR_IN_PROCESSING); } } synchronized (Maildrop.this) { pendingMessages.add(message); if (!isTransactionRunning) { appendPendingMessages(); } } logger.debug("Message " + message.id + " is added to maildrop " + name); } finally { synchronized (Maildrop.this) { if (appenderCount < 1) throw new RuntimeException("Assertion failed"); appenderCount--; } status = AppenderStatus.CLOSED; } } public void rollback() { if (status == AppenderStatus.CLOSED) return; try { if (status == AppenderStatus.NEW) return; // status is OPEN try { outputStream.close(); } catch (IOException e) { logger.error("Cannot close temporary file " + tempFile, e); return; } boolean success = tempFile.delete(); if (!success) { logger.error("Cannot delete temporary file " + tempFile); return; } logger.debug("Maildrop '" + name + "' appender transaction for UID " + uid + " is rolled back, temporary file is deleted."); return; } finally { synchronized (Maildrop.this) { if (appenderCount < 1) throw new RuntimeException("Assertion failed"); appenderCount--; } status = AppenderStatus.CLOSED; } } } private enum AppenderStatus { NEW, OPEN, CLOSED }; }