001    package de.deepamehta.plugins.mail;
002    
003    import java.io.File;
004    import java.util.Collection;
005    import java.util.HashSet;
006    import java.util.List;
007    import java.util.Map;
008    import java.util.logging.Level;
009    import java.util.logging.Logger;
010    
011    import javax.mail.internet.InternetAddress;
012    import javax.ws.rs.GET;
013    import javax.ws.rs.HeaderParam;
014    import javax.ws.rs.POST;
015    import javax.ws.rs.Path;
016    import javax.ws.rs.PathParam;
017    import javax.ws.rs.Produces;
018    import javax.ws.rs.QueryParam;
019    import javax.ws.rs.WebApplicationException;
020    import javax.ws.rs.core.MediaType;
021    
022    import org.apache.commons.mail.EmailAttachment;
023    import org.apache.commons.mail.EmailException;
024    import org.apache.commons.mail.HtmlEmail;
025    import org.codehaus.jettison.json.JSONException;
026    import org.jsoup.nodes.Document;
027    
028    import de.deepamehta.core.Association;
029    import de.deepamehta.core.CompositeValue;
030    import de.deepamehta.core.RelatedTopic;
031    import de.deepamehta.core.ResultSet;
032    import de.deepamehta.core.Topic;
033    import de.deepamehta.core.model.AssociationModel;
034    import de.deepamehta.core.model.CompositeValueModel;
035    import de.deepamehta.core.model.SimpleValue;
036    import de.deepamehta.core.model.TopicModel;
037    import de.deepamehta.core.model.TopicRoleModel;
038    import de.deepamehta.core.osgi.PluginActivator;
039    import de.deepamehta.core.service.ClientState;
040    import de.deepamehta.core.service.Directives;
041    import de.deepamehta.core.service.PluginService;
042    import de.deepamehta.core.service.annotation.ConsumesService;
043    import de.deepamehta.core.service.event.PostCreateTopicListener;
044    import de.deepamehta.core.storage.spi.DeepaMehtaTransaction;
045    import de.deepamehta.plugins.accesscontrol.model.ACLEntry;
046    import de.deepamehta.plugins.accesscontrol.model.AccessControlList;
047    import de.deepamehta.plugins.accesscontrol.model.Operation;
048    import de.deepamehta.plugins.accesscontrol.model.UserRole;
049    import de.deepamehta.plugins.accesscontrol.service.AccessControlService;
050    import de.deepamehta.plugins.files.ResourceInfo;
051    import de.deepamehta.plugins.files.service.FilesService;
052    import de.deepamehta.plugins.mail.service.MailService;
053    
054    @Path("/mail")
055    @Produces(MediaType.APPLICATION_JSON)
056    public class MailPlugin extends PluginActivator implements MailService, PostCreateTopicListener {
057    
058        private static Logger log = Logger.getLogger(MailPlugin.class.getName());
059    
060        // URI constants
061    
062        public static final String AGGREGATION = "dm4.core.aggregation";
063    
064        public static final String COMPOSITION = "dm4.core.composition";
065    
066        public static final String CHILD = "dm4.core.child";
067    
068        public static final String CHILD_TYPE = "dm4.core.child_type";
069    
070        public static final String TOPIC_TYPE = "dm4.core.topic_type";
071    
072        public static final String PARENT = "dm4.core.parent";
073    
074        public static final String PARENT_TYPE = "dm4.core.parent_type";
075    
076        public static final String FILE = "dm4.files.file";
077    
078        public static final String ATTACHMENTS = "attachments";
079    
080        public static final String BODY = "dm4.mail.body";
081    
082        public static final String EMAIL_ADDRESS = "dm4.contacts.email_address";
083    
084        public static final String DATE = "dm4.mail.date";
085    
086        public static final String FROM = "dm4.mail.from";
087    
088        public static final String MAIL = "dm4.mail";
089    
090        public static final String MESSAGE_ID = "dm4.mail.id";
091    
092        public static final String RECIPIENT = "dm4.mail.recipient";
093    
094        public static final String RECIPIENT_TYPE = "dm4.mail.recipient.type";
095    
096        public static final String SENDER = "dm4.mail.sender";
097    
098        public static final String SIGNATURE = "dm4.mail.signature";
099    
100        public static final String SUBJECT = "dm4.mail.subject";
101    
102        public static final String USER_ACCOUNT = "dm4.accesscontrol.user_account";
103    
104        // service references
105    
106        private AccessControlService acService;
107    
108        private FilesService fileService = null;
109    
110        // package internal helpers
111    
112        MailConfigurationCache config = null;
113    
114        ImageCidEmbedment cidEmbedment = null;
115    
116        Autocomplete autocomplete = null;
117    
118        boolean isInitialized;
119    
120        /**
121         * @see #associateRecipient(long, long, RecipientType, de.deepamehta.core.service.ClientState)
122         */
123        @POST
124        @Path("{mail}/recipient/{address}")
125        public Association associateRecipient(//
126                @PathParam("mail") long mailId,//
127                @PathParam("address") long addressId,//
128                @HeaderParam("Cookie") ClientState cookie) {
129            return associateRecipient(mailId, addressId, config.getDefaultRecipientType(), cookie);
130        }
131    
132        @Override
133        public Association associateRecipient(long mailId, long addressId, RecipientType type, ClientState clientState) {
134            log.info("associate " + mailId + " with recipient address " + addressId);
135    
136            // create value of the recipient association
137            CompositeValueModel value = new CompositeValueModel()//
138                    .putRef(RECIPIENT_TYPE, type.getUri())//
139                    .putRef(EMAIL_ADDRESS, addressId);
140    
141            // get and update or create a new recipient association
142            RelatedTopic recipient = getContactOfEmail(addressId);
143            Association association = getRecipientAssociation(mailId, addressId, recipient.getId());
144    
145            if (association == null) { // create a recipient association
146                return associateRecipient(mailId, recipient, value, clientState);
147            } else { // update address and type references
148                association.setCompositeValue(value, clientState, new Directives());
149                return association;
150            }
151        }
152    
153        @Override
154        public void associateValidatedRecipients(long mailId, List<Topic> recipients, ClientState cookie) {
155            for (Topic recipient : recipients) {
156                Topic topic = dms.getTopic(recipient.getId(), true);
157                if (topic.getCompositeValue().has(EMAIL_ADDRESS)) {
158                    String personal = recipient.getSimpleValue().toString();
159                    for (Topic email : topic.getCompositeValue().getTopics(EMAIL_ADDRESS)) {
160                        String address = email.getSimpleValue().toString();
161                        try {
162                            new InternetAddress(address, personal).validate();
163                        } catch (Exception e) {
164                            log.log(Level.INFO, "email address '" + address + "' of contact '" + //
165                                    personal + "'" + " is invalid: " + e.getMessage());
166                            continue; // check the next one
167                        }
168                        // associate validated email address as BCC recipient
169                        associateRecipient(mailId, email.getId(), RecipientType.BCC, cookie);
170                    }
171                }
172            }
173        }
174    
175        /**
176         * @see #associateSender(long, long, de.deepamehta.core.service.ClientState)
177         */
178        @POST
179        @Path("{mail}/sender/{address}")
180        public Association mailSender(//
181                @PathParam("mail") long mailId,//
182                @PathParam("address") long addressId,//
183                @HeaderParam("Cookie") ClientState cookie) {
184            return associateSender(mailId, addressId, cookie);
185        }
186    
187        @Override
188        public Association associateSender(long mailId, long addressId, ClientState clientState) {
189            log.info("associate " + mailId + " with sender " + addressId);
190    
191            // create value of sender association
192            CompositeValueModel value = new CompositeValueModel().putRef(EMAIL_ADDRESS, addressId);
193    
194            // find existing sender association
195            RelatedTopic sender = getContactOfEmail(addressId);
196            RelatedTopic oldSender = getSender(mailId, false);
197    
198            if (oldSender == null) { // create the first sender association
199                return associateSender(mailId, sender, value, clientState);
200            } else { // update or delete the old sender
201                Association association = getSenderAssociation(mailId, oldSender.getId());
202                DeepaMehtaTransaction tx = dms.beginTx();
203                try {
204                    if (sender.getId() != oldSender.getId()) { // delete the old one
205                        dms.deleteAssociation(association.getId());
206                        association = associateSender(mailId, sender, value, clientState);
207                    } else { // update composite
208                        association.setCompositeValue(value, clientState, new Directives());
209                    }
210                    tx.success();
211                } finally {
212                    tx.finish();
213                }
214                return association;
215            }
216        }
217    
218        /**
219         * Returns the parent of each search type substring match.
220         * 
221         * @param term
222         *            String to search.
223         * @param cookie
224         *            Actual cookie.
225         * @return Parent model of each result topic.
226         */
227        @GET
228        @Path("/autocomplete/{term}")
229        public List<TopicModel> search(@PathParam("term") String term, //
230                @HeaderParam("Cookie") ClientState cookie) {
231            String query = "*" + term + "*";
232            log.info("autocomplete " + query);
233            return autocomplete.search(query, cookie);
234        }
235    
236        /**
237         * Creates a copy of the mail.
238         * 
239         * @param mailId
240         *            ID of the mail topic to clone.
241         * @param includeRecipients
242         *            Copy recipients of the origin?
243         * @param cookie
244         *            Actual cookie.
245         * @return Cloned mail topic with associated sender and recipients.
246         */
247        @POST
248        @Path("/{mail}/copy")
249        public Topic copyMail(//
250                @PathParam("mail") long mailId,//
251                @QueryParam("recipients") boolean includeRecipients,//
252                @HeaderParam("Cookie") ClientState cookie) {
253            log.info("copy mail " + mailId);
254            DeepaMehtaTransaction tx = dms.beginTx();
255            try {
256                Topic mail = dms.getTopic(mailId, true);
257                TopicModel model = new TopicModel(MAIL, mail.getCompositeValue().getModel() // copy
258                        .put(DATE, "").put(MESSAGE_ID, "")); // nullify date and ID
259                Topic clone = dms.createTopic(model, cookie);
260    
261                // copy sender association
262                RelatedTopic sender = getSender(mail, true);
263                associateSender(clone.getId(), sender, sender.getRelatingAssociation()//
264                        .getCompositeValue().getModel(), cookie);
265    
266                // copy recipient associations
267                if (includeRecipients) {
268                    for (RelatedTopic recipient : mail.getRelatedTopics(RECIPIENT,//
269                            PARENT, CHILD, null, false, true, 0)) {
270                        for (Association association : dms.getAssociations(mail.getId(),//
271                                recipient.getId())) {
272                            if (association.getTypeUri().equals(RECIPIENT) == false) {
273                                continue; // sender or something else found
274                            }
275                            CompositeValue value = dms.getAssociation(association.getId(), true).getCompositeValue();
276                            associateRecipient(clone.getId(), recipient, value.getModel(), cookie);
277                        }
278                    }
279                }
280                tx.success();
281                return clone;
282            } finally {
283                tx.finish();
284            }
285        }
286    
287        /**
288         * Creates a new mail to all email addresses of the contact topic.
289         * 
290         * @param recipientId
291         *            ID of a recipient contact topic.
292         * @param cookie
293         *            Actual cookie.
294         * @return Mail topic with associated recipient.
295         */
296        @POST
297        @Path("/write/{recipient}")
298        public Topic writeTo(@PathParam("recipient") long recipientId,//
299                @HeaderParam("Cookie") ClientState cookie) {
300            log.info("write a mail to recipient " + recipientId);
301            DeepaMehtaTransaction tx = dms.beginTx();
302            try {
303                Topic mail = dms.createTopic(new TopicModel(MAIL), cookie);
304                Topic recipient = dms.getTopic(recipientId, true);
305                if (recipient.getCompositeValue().has(EMAIL_ADDRESS)) {
306                    for (Topic address : recipient.getCompositeValue().getTopics(EMAIL_ADDRESS)) {
307                        associateRecipient(mail.getId(), address.getId(),//
308                                config.getDefaultRecipientType(), cookie);
309                    }
310                }
311                tx.success();
312                return mail;
313            } finally {
314                tx.finish();
315            }
316        }
317    
318        /**
319         * @return Default recipient type.
320         */
321        @GET
322        @Path("/recipient/default")
323        public String getDefaultRecipientType() {
324            return config.getDefaultRecipientType().getUri();
325        }
326    
327        /**
328         * @return Recipient types.
329         */
330        @GET
331        @Path("/recipient/types")
332        public ResultSet<RelatedTopic> getRecipientTypes() {
333            return config.getRecipientTypes();
334        }
335    
336        /**
337         * @see #getSearchParentTypes
338         */
339        @GET
340        @Path("/search/parents")
341        public ResultSet<Topic> listSearchParentTypes() {
342            Collection<Topic> parents = getSearchParentTypes();
343            return new ResultSet<Topic>(parents.size(), new HashSet<Topic>(parents));
344        }
345    
346        @Override
347        public Collection<Topic> getSearchParentTypes() {
348            return config.getSearchParentTypes();
349        }
350    
351        /**
352         * Load the configuration.
353         * 
354         * Useful after type and configuration changes with the web-client.
355         */
356        @GET
357        @Path("/config/load")
358        public Topic loadConfiguration() {
359            log.info("load mail configuration");
360            config = new MailConfigurationCache(dms);
361            autocomplete = new Autocomplete(dms, config);
362            return config.getTopic();
363        }
364    
365        /**
366         * Sets the default sender and signature of a mail topic after creation.
367         */
368        @Override
369        public void postCreateTopic(Topic topic, ClientState clientState, Directives directives) {
370            if (topic.getTypeUri().equals(MAIL)) {
371                if (topic.getCompositeValue().has(FROM) == false) { // new mail
372                    associateDefaultSender(topic, clientState);
373                } else { // copied mail
374                    Topic from = topic.getCompositeValue().getTopic(FROM);
375                    if (from.getSimpleValue().booleanValue() == false) { // sender?
376                        associateDefaultSender(topic, clientState);
377                    }
378                }
379            }
380        }
381    
382        /**
383         * @see #send(Mail)
384         */
385        @POST
386        @Path("/{mail}/send")
387        public StatusReport send(//
388                @PathParam("mail") long mailId) {
389            log.info("send mail " + mailId);
390            return send(new Mail(mailId, dms));
391        }
392    
393        @Override
394        public StatusReport send(Mail mail) {
395            StatusReport statusReport = new StatusReport(mail.getTopic());
396    
397            HtmlEmail email = new HtmlEmail();
398            email.setDebug(true); // => System.out.println(SMTP communication);
399            email.setHostName(config.getSmtpHost());
400    
401            try {
402                InternetAddress sender = mail.getSender();
403                email.setFrom(sender.getAddress(), sender.getPersonal());
404            } catch (Exception e) {
405                reportException(statusReport, Level.INFO, MailError.SENDER, e);
406            }
407    
408            try {
409                String subject = mail.getSubject();
410                if (subject.isEmpty()) { // caught immediately
411                    throw new IllegalArgumentException("Subject of mail is empty");
412                }
413                email.setSubject(subject);
414            } catch (Exception e) {
415                reportException(statusReport, Level.INFO, MailError.CONTENT, e);
416            }
417    
418            try {
419                Document body = cidEmbedment.embedImages(email, mail.getBody());
420                String text = body.text();
421                if (text.isEmpty()) { // caught immediately
422                    throw new IllegalArgumentException("Text body of mail is empty");
423                }
424                email.setTextMsg(text);
425                email.setHtmlMsg(body.html());
426            } catch (Exception e) {
427                reportException(statusReport, Level.INFO, MailError.CONTENT, e);
428            }
429    
430            for (Long fileId : mail.getAttachmentIds()) {
431                try {
432                    String path = fileService.getFile(fileId).getAbsolutePath();
433                    EmailAttachment attachment = new EmailAttachment();
434                    attachment.setPath(path);
435                    log.fine("attach " + path);
436                    email.attach(attachment);
437                } catch (Exception e) {
438                    reportException(statusReport, Level.INFO, MailError.ATTACHMENTS, e);
439                }
440            }
441    
442            RecipientsByType recipients = new RecipientsByType();
443            try {
444                recipients = mail.getRecipients();
445                try {
446                    mapRecipients(email, recipients);
447                } catch (Exception e) {
448                    reportException(statusReport, Level.SEVERE, MailError.RECIPIENT_TYPE, e);
449                }
450            } catch (InvalidRecipients e) {
451                for (String recipient : e.getRecipients()) {
452                    log.log(Level.INFO, MailError.RECIPIENTS.getMessage() + ": " + recipient);
453                    statusReport.addError(MailError.RECIPIENTS, recipient);
454                }
455            }
456    
457            if (statusReport.hasErrors()) {
458                statusReport.setMessage("Mail can NOT be sent");
459            } else { // send, update message ID and return status with attached mail
460                try {
461                    String messageId = email.send();
462                    statusReport.setMessage("Mail was SUCCESSFULLY sent to " + //
463                            recipients.getCount() + " mail addresses");
464                    mail.setMessageId(messageId);
465                } catch (EmailException e) {
466                    statusReport.setMessage("Sending mail FAILED");
467                    reportException(statusReport, Level.SEVERE, MailError.SEND, e);
468                } catch (Exception e) { // error after send
469                    reportException(statusReport, Level.SEVERE, MailError.UPDATE, e);
470                }
471            }
472            return statusReport;
473        }
474    
475        /**
476         * Initialize.
477         */
478        @Override
479        public void init() {
480            isInitialized = true;
481            configureIfReady();
482        }
483    
484        /**
485         * Reference file service and create the attachment directory if not exists.
486         */
487        @Override
488        @ConsumesService({ "de.deepamehta.plugins.accesscontrol.service.AccessControlService",
489                "de.deepamehta.plugins.files.service.FilesService" })
490        public void serviceArrived(PluginService service) {
491            if (service instanceof AccessControlService) {
492                acService = (AccessControlService) service;
493            } else if (service instanceof FilesService) {
494                fileService = (FilesService) service;
495                cidEmbedment = new ImageCidEmbedment(fileService);
496            }
497            configureIfReady();
498        }
499    
500        private void configureIfReady() {
501            if (isInitialized && acService != null && fileService != null) {
502                createAttachmentDirectory();
503                checkACLsOfMigration();
504                loadConfiguration();
505            }
506        }
507    
508        @Override
509        public void serviceGone(PluginService service) {
510            if (service == acService) {
511                acService = null;
512            } else if (service == fileService) {
513                fileService = null;
514                cidEmbedment = null;
515            }
516        }
517    
518        private void checkACLsOfMigration() {
519            Topic config = dms.getTopic("uri", new SimpleValue("dm4.mail.config"), false);
520            if (acService.getCreator(config) == null) {
521                log.info("initial ACL update of configuration");
522                Topic admin = acService.getUsername("admin");
523                String adminName = admin.getSimpleValue().toString();
524                acService.setCreator(config, adminName);
525                acService.setOwner(config, adminName);
526                acService.setACL(config, new AccessControlList(new ACLEntry(Operation.WRITE, UserRole.OWNER)));
527            }
528        }
529    
530        private void createAttachmentDirectory() {
531            // TODO move the initialization to migration "0"
532            try {
533                ResourceInfo resourceInfo = fileService.getResourceInfo(ATTACHMENTS);
534                String kind = resourceInfo.toJSON().getString("kind");
535                if (kind.equals("directory") == false) {
536                    String repoPath = System.getProperty("dm4.filerepo.path");
537                    String message = "attachment storage directory " + repoPath + File.separator + ATTACHMENTS
538                            + " can not be used";
539                    throw new IllegalStateException(message);
540                }
541            } catch (WebApplicationException e) { // !exists
542                // catch fileService info request error => create directory
543                if (e.getResponse().getStatus() != 404) {
544                    throw e;
545                } else {
546                    log.info("create attachment directory");
547                    fileService.createFolder(ATTACHMENTS, "/");
548                }
549            } catch (JSONException e) {
550                throw new RuntimeException(e);
551            }
552        }
553    
554        /**
555         * @param mail
556         * @param clientState
557         * 
558         * @see MailPlugin#postCreateTopic(Topic, ClientState, Directives)
559         */
560        private void associateDefaultSender(Topic mail, ClientState clientState) {
561            Topic creator = null;
562            RelatedTopic sender = null;
563    
564            Topic creatorName = acService.getUsername(acService.getCreator(mail));
565            if (creatorName != null) {
566                creator = creatorName.getRelatedTopic(null, CHILD, PARENT, USER_ACCOUNT, false, false);
567            }
568    
569            // get user account specific sender
570            if (creator != null) {
571                sender = getSender(creator, true);
572            }
573    
574            // get the configured default sender instead
575            if (sender == null) {
576                sender = config.getDefaultSender();
577            }
578    
579            if (sender != null) {
580                DeepaMehtaTransaction tx = dms.beginTx();
581                try {
582                    CompositeValueModel value = sender.getRelatingAssociation()//
583                            .getCompositeValue().getModel();
584                    associateSender(mail.getId(), sender, value, clientState);
585                    long addressId = value.getTopic(EMAIL_ADDRESS).getId();
586                    RelatedTopic signature = getContactSignature(sender, addressId);
587                    if (signature != null) {
588                        mail.getCompositeValue().getModel().add(SIGNATURE, signature.getModel());
589                    }
590                    tx.success();
591                } finally {
592                    tx.finish();
593                }
594            }
595        }
596    
597        private Association associateRecipient(long topicId, Topic recipient, CompositeValueModel value,
598                ClientState clientState) {
599            return dms.createAssociation(new AssociationModel(RECIPIENT,//
600                    new TopicRoleModel(recipient.getId(), CHILD),//
601                    new TopicRoleModel(topicId, PARENT), value), clientState);
602        }
603    
604        private Association associateSender(long topicId, Topic sender, CompositeValueModel value, ClientState clientState) {
605            return dms.createAssociation(new AssociationModel(SENDER,//
606                    new TopicRoleModel(sender.getId(), CHILD),//
607                    new TopicRoleModel(topicId, PARENT), value), clientState);
608        }
609    
610        private RelatedTopic getContactOfEmail(long addressId) {
611            return dms.getTopic(addressId, false).getRelatedTopic(COMPOSITION, CHILD, PARENT, null, false, false);
612        }
613    
614        private RelatedTopic getContactSignature(Topic topic, long addressId) {
615            for (RelatedTopic signature : topic.getRelatedTopics(SENDER, CHILD, PARENT, SIGNATURE, true, true, 0)) {
616                CompositeValue value = signature.getRelatingAssociation().getCompositeValue();
617                if (addressId == value.getTopic(EMAIL_ADDRESS).getId()) {
618                    return signature;
619                }
620            }
621            return null;
622        }
623    
624        private Association getRecipientAssociation(long topicId, long addressId, long recipientId) {
625            for (Association recipient : dms.getAssociations(topicId, recipientId)) {
626                Association association = dms.getAssociation(recipient.getId(), true);
627                Topic address = association.getCompositeValue().getTopic(EMAIL_ADDRESS);
628                if (association.getTypeUri().equals(RECIPIENT) && address.getId() == addressId) {
629                    return association;
630                }
631            }
632            return null;
633        }
634    
635        private RelatedTopic getSender(long topicId, boolean fetchRelatingComposite) {
636            return getSender(dms.getTopic(topicId, false), fetchRelatingComposite);
637        }
638    
639        private RelatedTopic getSender(Topic topic, boolean fetchRelatingComposite) {
640            return topic.getRelatedTopic(SENDER, PARENT, CHILD, null, false, fetchRelatingComposite);
641        }
642    
643        private Association getSenderAssociation(long topicId, long senderId) {
644            return dms.getAssociation(SENDER, topicId, senderId, PARENT, CHILD, true);
645        }
646    
647        private void mapRecipients(HtmlEmail email, Map<RecipientType, List<InternetAddress>> recipients)
648                throws EmailException {
649            for (RecipientType type : recipients.keySet()) {
650                switch (type) {
651                case BCC:
652                    email.setBcc(recipients.get(type));
653                    break;
654                case CC:
655                    email.setCc(recipients.get(type));
656                    break;
657                case TO:
658                    email.setTo(recipients.get(type));
659                    break;
660                default:
661                    throw new IllegalArgumentException(type.toString());
662                }
663            }
664        }
665    
666        private void reportException(StatusReport report, Level level, MailError error, Exception e) {
667            String message = e.getMessage();
668            Throwable cause = e.getCause();
669            if (cause != null) {
670                message += ": " + cause.getMessage();
671            }
672            String logMessage = error.getMessage() + ": " + message;
673            if (level == Level.WARNING || level == Level.SEVERE) {
674                log.log(level, logMessage, e); // log the exception trace
675            } else {
676                log.log(level, logMessage); // log only the message
677            }
678            report.addError(error, message);
679        }
680    }