/* * blog/javaclue/javamail/BounceFinder.java * * Copyright (C) 2009 JackW * * This program is free software: you can redistribute it and/or modify it under the terms of the * GNU Lesser General Public License as published by the Free Software Foundation, either version 3 * of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License along with this library. * If not, see <http://www.gnu.org/licenses/>. */ package blog.javaclue.javamail; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.util.Date; import java.util.List; import java.util.StringTokenizer; import javax.mail.Address; import javax.mail.internet.AddressException; import javax.mail.internet.InternetAddress; import org.apache.log4j.Logger; import blog.javaclue.javamail.SmtpScanner.BOUNCE_TYPES; /** * Scan email header and body, and match rules to determine the bounce type. * * @author jackw */ public final class BounceFinder { static final Logger logger = Logger.getLogger(BounceFinder.class); static final boolean isDebugEnabled = logger.isDebugEnabled(); private final SmtpScanner rfcScan; static final String TEN_DASHES = "----------"; static final String ORIGMSG_SEPARATOR = "-----Original Message-----"; static final String REPLY_SEPARATOR = "---------Reply Separator---------"; static final String LF = System.getProperty("line.separator", "\n"); public final static String VERP_BOUNCE_ADDR_XHEADER = "X-VERP_Bounce_Addr"; /** * default constructor */ public BounceFinder() throws IOException { rfcScan = SmtpScanner.getInstance(); } /** * Scans email properties to find out the bounce type. It also checks VERP * headers to get original recipient. * * @param msgBean * a MessageBean instance */ public String parse(MessageBean msgBean) { if (isDebugEnabled) logger.debug("Entering parse() method..."); String bounceType = null; // retrieve attachments into an array, it also gathers rfc822/Delivery Status. BodypartUtil.retrieveAttachments(msgBean); // scan message for Enhanced Mail System Status Code (rfc1893/rfc3464) BodypartBean aNode = null; if (msgBean.getReport() != null) { /* * multipart/report mime type is present, retrieve DSN/MDN report. */ MessageNode mNode = msgBean.getReport(); // locate message/delivery-status section aNode = BodypartUtil.retrieveDlvrStatus(mNode.getBodypartNode(), mNode.getLevel()); if (aNode != null) { // first scan message/delivery-status byte[] attchValue = (byte[]) aNode.getValue(); if (attchValue != null) { if (isDebugEnabled) { logger.debug("parse() - scan message/report status -----<" + LF + new String(attchValue) + ">-----"); } if (bounceType == null) { bounceType = rfcScan.scanBody(new String(attchValue)); } parseDsn(attchValue, msgBean); msgBean.setDsnDlvrStat(new String(attchValue)); } } else if ((aNode = BodypartUtil.retrieveMDNReceipt(mNode.getBodypartNode(), mNode.getLevel())) != null) { // got message/disposition-notification byte[] attchValue = (byte[]) aNode.getValue(); if (attchValue != null) { if (isDebugEnabled) { logger.debug("parse() - display message/report status -----<" + LF + new String(attchValue) + ">-----"); } if (bounceType == null) { bounceType = BOUNCE_TYPES.MDN_RECEIPT.toString(); } // MDN comes with original and final recipients parseDsn(attchValue, msgBean); msgBean.setDsnDlvrStat(new String(attchValue)); } } else { // missing message/* section, try text/plain List<BodypartBean> nodes = BodypartUtil.retrieveReportText(mNode.getBodypartNode(), mNode.getLevel()); if (!nodes.isEmpty()) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); for (BodypartBean bodyPart : nodes) { byte[] attchValue = (byte[]) bodyPart.getValue(); try { baos.write(attchValue); } catch (IOException e) { logger.error("IOException caught", e); } } try { baos.close(); } catch (IOException e) {} byte[] attchValue = baos.toByteArray(); if (attchValue != null) { if (isDebugEnabled) { logger.debug("parse() - scan message/report text -----<" + LF + new String(attchValue) + ">-----"); } if (bounceType == null) { bounceType = rfcScan.scanBody(new String(attchValue)); } parseDsn(attchValue, msgBean); msgBean.setDsnText(new String(attchValue)); } } } // locate possible message/rfc822 section under multipart/report aNode = BodypartUtil.retrieveMessageRfc822(mNode.getBodypartNode(), mNode.getLevel()); if (aNode != null && msgBean.getRfc822() == null) { msgBean.setRfc822(new MessageNode(aNode, mNode.getLevel())); } // locate possible text/rfc822-headers section under multipart/report aNode = BodypartUtil.retrieveRfc822Headers(mNode.getBodypartNode(), mNode.getLevel()); if (aNode != null && msgBean.getRfc822() == null) { msgBean.setRfc822(new MessageNode(aNode, mNode.getLevel())); } } if (msgBean.getRfc822() != null) { /* * message/rfc822 is present, retrieve RFC report. */ MessageNode mNode = msgBean.getRfc822(); aNode = BodypartUtil.retrieveRfc822Text(mNode.getBodypartNode(), mNode.getLevel()); if (aNode != null) { StringBuffer sb = new StringBuffer(); // get original message headers List<MsgHeader> vheader = aNode.getHeaders(); for (int i = 0; vheader != null && i < vheader.size(); i++) { MsgHeader header = vheader.get(i); sb.append(header.getName() + ": " + header.getValue() + LF); } boolean foundAll = false; String rfcHeaders = sb.toString(); if (!StringUtil.isEmpty(rfcHeaders)) { // rfc822 headers if (isDebugEnabled) { logger.debug("parse() - scan rfc822 headers -----<" + LF + rfcHeaders + ">-----"); } foundAll = parseRfc(rfcHeaders, msgBean); msgBean.setDsnRfc822(rfcHeaders); } byte[] attchValue = (byte[]) aNode.getValue(); if (attchValue != null) { // rfc822 text String rfcText = new String(attchValue); sb.append(rfcText); String mtype = aNode.getMimeType(); if (mtype.startsWith("text/") || mtype.startsWith("message/")) { if (foundAll == false) { if (isDebugEnabled) { logger.debug("parse() - scan rfc822 text -----<" + LF + rfcText + ">-----"); } parseRfc(rfcText, msgBean); msgBean.setDsnRfc822(sb.toString()); } } if (msgBean.getDsnText() == null) { msgBean.setDsnText(rfcText); } else { msgBean.setDsnText(msgBean.getDsnText() + LF + LF + "RFC822 Text:" + LF + rfcText); } } if (bounceType == null) { bounceType = rfcScan.scanBody(sb.toString()); } } } // end of RFC Scan String body = msgBean.getBody(); if (msgBean.getRfc822() != null && bounceType == null) { // message/rfc822 is present, scan message body for rfc1893 status code // TODO: may cause false positives. need to revisit this. if (isDebugEnabled) logger.debug("parse() - scan body text -----<" + LF + body + ">-----"); bounceType = rfcScan.scanBody(body); } // check CC/BCC if (bounceType == null) { // if the "real_to" address is not found in envelope, but is // included in CC or BCC: set bounceType to CC_USER for (int i = 0; msgBean.getTo() != null && i < msgBean.getTo().length; i++) { Address to = msgBean.getTo()[i]; if (containsNoAddress(msgBean.getToEnvelope(), to)) { if (containsAddress(msgBean.getCc(), to) || containsAddress(msgBean.getBcc(), to)) { bounceType = BOUNCE_TYPES.CC_USER.toString(); break; } } } } // check VERP bounce address, set bounce type to SOFT_BOUNCE if VERP recipient found List<MsgHeader> headers = msgBean.getHeaders(); for (MsgHeader header : headers) { if (VERP_BOUNCE_ADDR_XHEADER.equals(header.getName())) { logger.info("parse() - VERP Recipient found: ==>" + header.getValue() + "<=="); if (msgBean.getOrigRcpt() != null && !StringUtil.isEmpty(header.getValue()) && !msgBean.getOrigRcpt().equalsIgnoreCase(header.getValue())) { logger.warn("parse() - replace original recipient: " + msgBean.getOrigRcpt() + " with VERP recipient: " + header.getValue()); } if (!StringUtil.isEmpty(header.getValue())) { // VERP Bounce - always override msgBean.setOrigRcpt(header.getValue()); } else { logger.warn("parse() - " + VERP_BOUNCE_ADDR_XHEADER + " Header found, but it has no value."); } if (bounceType == null) { // a bounced mail shouldn't have Return-Path String rPath = msgBean.getReturnPath() == null ? "" : msgBean.getReturnPath(); if (StringUtil.isEmpty(rPath) || "<>".equals(rPath.trim())) { bounceType = BOUNCE_TYPES.SOFT_BOUNCE.toString(); } } break; } } // if it's hard or soft bounce and no final recipient was found, scan // message body for final recipient using known patterns. if (BOUNCE_TYPES.HARD_BOUNCE.toString().equals(bounceType) || BOUNCE_TYPES.SOFT_BOUNCE.toString().equals(bounceType)) { if (StringUtil.isEmpty(msgBean.getFinalRcpt()) && StringUtil.isEmpty(msgBean.getOrigRcpt())) { String finalRcpt = BounceAddressFinder.getInstance().find(body); if (!StringUtil.isEmpty(finalRcpt)) { logger.info("parse() - Final Recipient found from message body: " + finalRcpt); msgBean.setFinalRcpt(finalRcpt); } } } if (bounceType == null) { // use default bounceType = SmtpScanner.BOUNCETYPE.GENERIC.toString(); } logger.info("parse() - bounceType: " + bounceType); return bounceType; } private boolean containsAddress(Address[] addrs, Address to) { if (to != null && addrs != null && addrs.length > 0) { for (int i = 0; i < addrs.length; i++) { if (to.equals(addrs[i])) { return true; } } } return false; } private boolean containsNoAddress(Address[] addrs, Address to) { if (to != null && addrs != null && addrs.length > 0) { for (int i = 0; i < addrs.length; i++) { if (to.equals(addrs[i])) { return false; } } } return true; } /** * Parse the message/delivery-status to retrieve DSN fields. Also used by * message/disposition-notification to retrieve final recipient. * * @param attchValue - * delivery status text * @param msgBean - * MessageBean object */ private void parseDsn(byte[] attchValue, MessageBean msgBean) { // retrieve Final-Recipient, Action, and Status ByteArrayInputStream bais = new ByteArrayInputStream(attchValue); BufferedReader br = new BufferedReader(new InputStreamReader(bais)); String line = null; try { while ((line = br.readLine()) != null) { if (isDebugEnabled) logger.debug("parseDsn() - Line: " + line); line = line.trim(); if (line.toLowerCase().startsWith("final-recipient:")) { // "Final-Recipient" ":" address-type ";" generic-address // address-type = rfc822 / unknown StringTokenizer st = new StringTokenizer(line, " ;"); while (st.hasMoreTokens()) { String token = st.nextToken().trim(); if (token.indexOf("@") > 0) { msgBean.setFinalRcpt(token); logger.info("parseDsn() - Final_Recipient found: ==>" + token + "<=="); break; } } } else if (line.toLowerCase().startsWith("original-recipient:")) { // "Original-Recipient" ":" address-type ";" generic-address StringTokenizer st = new StringTokenizer(line, " ;"); while (st.hasMoreTokens()) { String token = st.nextToken().trim(); if (token.indexOf("@") > 0) { msgBean.setOrigRcpt(token); logger.info("parseDsn() - Original_Recipient found: ==>" + token + "<=="); break; } } } else if (line.toLowerCase().startsWith("action:")) { /** * "Action" ":" action-value = * 1) failed - could not be delivered to the recipient. * 2) delayed - the reporting MTA has so far been unable to deliver * or relay the message. * 3) delivered - the message was successfully delivered. * 4) relayed - the message has been relayed or gatewayed. * 5) expanded - delivered and forwarded by reporting MTA to multiple * additional recipient addresses. */ String action = line.substring(7).trim(); msgBean.setDsnAction(action); if (isDebugEnabled) logger.debug("parseDsn() - Action found: ==>" + action + "<=="); } else if (line.toLowerCase().startsWith("status:")) { // "Status" ":" status-code (digit "." 1*3digit "." 1*3 digit) String status = line.substring(7).trim(); if (status.indexOf(" ") > 0) { status = status.substring(0, status.indexOf(" ")); } msgBean.setDsnStatus(status); if (isDebugEnabled) logger.debug("parseDsn() - Status found: ==>" + status + "<=="); } else if (line.toLowerCase().startsWith("diagnostic-code:")) { // "Diagnostic-Code" ":" diagnostic-code String diagcode = line.substring(16).trim(); msgBean.setDiagnosticCode(diagcode); if (isDebugEnabled) logger.debug("parseDsn() - Diagnostic-Code: found: ==>" + diagcode + "<=="); } } } catch (IOException e) { logger.error("IOException caught during parseDsn()", e); } } /** * parse message/rfc822 to retrieve original email properties: final * recipient, original subject and original SMTP message-id. * * @param rfc_text - * rfc822 text * @param msgBean - * MessageBean object * @return true if all three properties were found */ private boolean parseRfc(String rfc_text, MessageBean msgBean) { // retrieve original To address ByteArrayInputStream bais = new ByteArrayInputStream(rfc_text.getBytes()); BufferedReader br = new BufferedReader(new InputStreamReader(bais)); int lineCount = 0; boolean gotToAddr = false, gotSubj = false, gotSmtpId = false; // allows to quit scan once all three headers are found String line = null; try { while ((line = br.readLine()) != null) { if (isDebugEnabled) logger.debug("parseRfc() - Line: " + line); line = line.trim(); if (line.toLowerCase().startsWith("to:")) { // "To" ":" generic-address String token = line.substring(3).trim(); if (StringUtil.isEmpty(msgBean.getFinalRcpt())) { msgBean.setFinalRcpt(token); } else if (StringUtil.compareEmailAddrs(msgBean.getFinalRcpt(), token) != 0) { logger.error("parseRfc() - Final_Rcpt from RFC822: " + token + " is different from DSN's: " + msgBean.getFinalRcpt()); } logger.info("parseRfc() - Final_Recipient(RFC822 To) found: ==>" + token + "<=="); gotToAddr = true; } else if (line.toLowerCase().startsWith("subject:")) { // "Subject" ":" subject text String token = line.substring(8).trim(); if (StringUtil.isEmpty(msgBean.getOrigSubject())) { msgBean.setOrigSubject(token); } logger.info("parseRfc() - Original_Subject(RFC822 To) found: ==>" + token + "<=="); gotSubj = true; } else if (line.toLowerCase().startsWith("message-id:")) { // "Message-Id" ":" SMTP message id String token = line.substring(11).trim(); if (StringUtil.isEmpty(msgBean.getSmtpMessageId())) { msgBean.setRfcMessageId(token); } logger.info("parseRfc() - Smtp Message-Id(RFC822 To) found: ==>" + token + "<=="); gotSmtpId = true; } if (gotToAddr && gotSubj && gotSmtpId) { return true; } if (++lineCount > 100 && line.indexOf(":") < 0) { break; // check if it's a header after 100 lines } } // end of while } catch (IOException e) { logger.error("IOException caught during parseRfc()", e); } return false; } public static void main(String[] args) { try { BounceFinder parser = new BounceFinder(); MessageBean mBean = new MessageBean(); try { mBean.setFrom(InternetAddress.parse("event.alert@localhost", false)); mBean.setTo(InternetAddress.parse("abc@domain.com", false)); } catch (AddressException e) { logger.error("AddressException caught", e); } mBean.setSubject("A Exception occured"); mBean.setValue(new Date()+ " 5.2.2 Invalid user account."); mBean.setMailboxUser("testUser"); String bType = parser.parse(mBean); System.out.println("### Bounce Type: " + bType); } catch (Exception e) { e.printStackTrace(); } } }
After an email is found to be a rejected message, the bounce finder class will also try to find the email address of the intended recipient, as this could be very important in case you want to remove the address from your mailing list. You can get the address by calling getOrigRcpt() or getFinalRcpt() of MessageBean class.
Since there are still many popular mail servers that are not strictly follow the RFC standards, a bounce address finder class is provided to locate the original recipient from message body, in case the bounce finder class failed to find it from RFC components. The patterns are not exact science, rather they are derived from limited samples of rejected emails. Take that into account when you adapt this bounce address finder.
/* * blog/javaclue/javamail/BounceAddressFinder.java * * Copyright (C) 2009 JackW * * This program is free software: you can redistribute it and/or modify it under the terms of the * GNU Lesser General Public License as published by the Free Software Foundation, either version 3 * of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License along with this library. * If not, see <http://www.gnu.org/licenses/>. */ package blog.javaclue.javamail; import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.log4j.Logger; public final class BounceAddressFinder { static final Logger logger = Logger.getLogger(BounceAddressFinder.class); static final boolean isDebugEnabled = false; //logger.isDebugEnabled(); private final List<MyPattern> patternList = new ArrayList<MyPattern>(); private static BounceAddressFinder addressFinder = null; private BounceAddressFinder() { if (patternList.isEmpty()) { loadPatterns(); } } public static synchronized BounceAddressFinder getInstance() { if (addressFinder == null) { addressFinder = new BounceAddressFinder(); } return addressFinder; } public String find(String body) { if (body != null && body.trim().length() > 0) { for (MyPattern myPattern : patternList) { Matcher m = myPattern.getPattern().matcher(body); if (m.find()) { if (isDebugEnabled) { for (int i = 1; i <= m.groupCount(); i++) { logger.debug(myPattern.getPatternName() + ", group(" + i + ") - " + m.group(i)); } } return m.group(m.groupCount()); } } } return null; } private static final class MyPattern { private final String patternName; private final String patternRegex; private final Pattern pattern; MyPattern(String name, String value) { this.patternName = name; this.patternRegex = value; pattern = Pattern.compile(patternRegex, Pattern.DOTALL | Pattern.CASE_INSENSITIVE); } public Pattern getPattern() { return pattern; } public String getPatternName() { return patternName; } public String getPatternRegex() { return patternRegex; } } private final void loadPatterns() { String bodyGmail = "Delivery .{4,10} following recipient(?:s)? failed[\\.|\\s](?:permanently:)?\\s+" + "<?(" + StringUtil.getEmailRegex() + ")>?\\s+"; patternList.add(new MyPattern("Gmail",bodyGmail)); String bodyAol = "\\-{3,6} The following address(?:es|\\(es\\))? had (?:permanent fatal errors|delivery problems) \\-{3,6}\\s+" + "<?(" + StringUtil.getEmailRegex() + ")>?(?:\\s|;)"; patternList.add(new MyPattern("AOL",bodyAol)); String bodyYahoo = "This .{1,10} permanent error.\\s+I(?:'ve| have) given up\\. Sorry it did(?:n't| not) work out\\.\\s+" + "<?(" + StringUtil.getEmailRegex() + ")>?"; patternList.add(new MyPattern("Yahoo",bodyYahoo)); String bodyPostfix = "message\\s.*could\\s+not\\s+be\\s+.{0,10}delivered\\s+to\\s.*(?:recipient(?:s)?|destination(?:s)?)" + ".{80,180}\\sinclude\\s+this\\s+problem\\s+report.{60,120}" + "\\s+<(" + StringUtil.getEmailRegex() + ")>"; patternList.add(new MyPattern("Postfix",bodyPostfix)); String bodyFailed = "Failed\\s+to\\s+deliver\\s+to\\s+\\'(" + StringUtil.getEmailRegex() + ")\\'" + ".{1,20}\\smodule.{5,100}\\sreports"; patternList.add(new MyPattern("Failed",bodyFailed)); String bodyFirewall = "Your\\s+message\\s+to:\\s+(" + StringUtil.getEmailRegex() + ")\\s+" + ".{1,10}\\sblocked\\s+by\\s.{1,20}\\sSpam\\s+Firewall"; patternList.add(new MyPattern("SpamFirewall",bodyFirewall)); String bodyFailure = "message\\s.{8,20}\\scould\\s+not\\s+be\\s+delivered\\s.{10,40}\\srecipients" + ".{6,20}\\spermanent\\s+error.{10,20}\\saddress(?:\\(es\\))?\\s+failed:" + "\\s+(" + StringUtil.getEmailRegex() + ")\\s"; patternList.add(new MyPattern("Failure",bodyFailure)); String bodyUnable = "Unable to deliver message to the following address(?:\\(es\\))?.{0,5}" + "\\s+<(" + StringUtil.getEmailRegex() + ")>"; patternList.add(new MyPattern("Unable",bodyUnable)); String bodyEtrust = "\\scould not deliver the e(?:\\-)?mail below because\\s.{10,20}\\srecipient(?:s)?\\s.{1,10}\\srejected"+ ".{60,200}\\s(" + StringUtil.getEmailRegex() + ")"; patternList.add(new MyPattern("eTrust",bodyEtrust)); String bodyReport = "\\scollection of report(?:s)? about email delivery\\s.+\\sFAILED:\\s.{1,1000}" + "Final Recipient:.{0,20};\\s*(" + StringUtil.getEmailRegex() + ")"; patternList.add(new MyPattern("Report",bodyReport)); String bodyNotReach = "Your message.{1,400}did not reach the following recipient(?:\\(s\\))?:" + "\\s+(" + StringUtil.getEmailRegex() + ")"; patternList.add(new MyPattern("NotReach",bodyNotReach)); String bodyFailed2 = "Could not deliver message to the following recipient(?:\\(s\\))?:" + "\\s+Failed Recipient:\\s+(" + StringUtil.getEmailRegex() + ")\\s"; patternList.add(new MyPattern("Failed2",bodyFailed2)); String bodyExceeds = "User(?:'s)?\\s+mailbox\\s+exceeds\\s+allowed\\s+size:\\s+" + "(" + StringUtil.getEmailRegex() + ")\\s+"; patternList.add(new MyPattern("Exceeds",bodyExceeds)); String bodyDelayed = "Message\\s+delivery\\s+to\\s+\\'(" + StringUtil.getEmailRegex() + ")\\'" + "\\s+delayed.{1,20}\\smodule.{5,100}\\sreports"; patternList.add(new MyPattern("Delayed",bodyDelayed)); String bodyInvalid = "Invalid\\s+Address(?:es)?.{1,20}\\b(?:TO|addr)\\b.{1,20}\\s+<?(" + StringUtil.getEmailRegex() + ")>?\\s+"; patternList.add(new MyPattern("Invalid",bodyInvalid)); } }
Is that Java is suited to deal with bounces. Since in our present system we are dealing in Perl 250 mails per second. Your code is what? Thank you
ReplyDeleteYes Java is suited to deal with bounces. I don't have exact numbers, but java is very scalable with threads. Based on my experience I would say that it should be able to deal with your load (250 mails/second) easily with 10 through 50 threads, depending on your hardware.
ReplyDeleteThank you for your reply. I was almost certain. I wanted to have confirmation. Your code is very well done. If I have to add threads, I added before or after the method getMessage (). Because I thought we did not need. Since the connection to the server is Singleton.
ReplyDeleteI assume you are pulling mails from a pop3 or imap server. In that case I would add threads after getMessages() since you could have pulled in many messages in a single fetch.
ReplyDeleteI am very interested in what you've done for detecting bounce mails. I wondered how about non English bounce mails? Can they also be detected as well? Anyway, thanks for sharing !
ReplyDeleteI've just used your framework for detecting bounces. Works extremely well, but I've noticed one flaw...
ReplyDeleteMultiple bounces in single envelope by MSExchange. Do you have any update supporting this?
Hi, I was looking to your code and wondering if it is safe to run BunceFinder in separate threads, each one reading from a different email address. I´m pretty sure it is, but a confirmation from the author would be great! Thanks for sharing!
ReplyDeleteHi Juan, yes it is safe to run it in separate threads, and I would recommend using the getInstance() method as it is synchronized. Happy coding...
ReplyDeleteHi Jack, How do you ensure that there are no false Bounce Alarms, what if an email contains numbers like 550, 551 or bullet points containing 5.1.1 in there message body as content ?
ReplyDeleteHi, false positives were dealt with in Part 2, please refer to Part 2 for more details.
ReplyDelete