ability to store emails and attachments in file storage #PL-2368, email history: removed split #PL-2470

This commit is contained in:
Alexander Budarov 2013-09-20 11:15:49 +00:00
parent b842df0fc3
commit c6612c8f2b
22 changed files with 726 additions and 127 deletions

View File

@ -630,6 +630,7 @@ create table SYS_SENDING_MESSAGE (
ADDRESS_FROM varchar(100),
CAPTION varchar(500),
CONTENT_TEXT longvarchar,
CONTENT_TEXT_FILE_ID varchar(36),
DEADLINE timestamp,
STATUS integer,
DATE_SENT timestamp,
@ -640,6 +641,8 @@ create table SYS_SENDING_MESSAGE (
primary key (ID)
)^
alter table SYS_SENDING_MESSAGE add constraint FK_SYS_SENDING_MESSAGE_CONTENT_FILE foreign key (CONTENT_TEXT_FILE_ID) references SYS_FILE(ID)^
create table SYS_SENDING_ATTACHMENT (
ID varchar(36) not null,
CREATE_TS timestamp,
@ -652,6 +655,7 @@ create table SYS_SENDING_ATTACHMENT (
--
MESSAGE_ID varchar(36),
CONTENT longvarbinary,
CONTENT_FILE_ID varchar(36),
CONTENT_ID varchar(50),
NAME varchar(500),
DISPOSITION varchar(50),
@ -662,6 +666,7 @@ create table SYS_SENDING_ATTACHMENT (
)^
alter table SYS_SENDING_ATTACHMENT add constraint FK_SYS_SENDING_ATTACHMENT_SENDING_MESSAGE foreign key (MESSAGE_ID) references SYS_SENDING_MESSAGE (ID)^
alter table SYS_SENDING_ATTACHMENT add constraint FK_SYS_SENDING_ATTACHMENT_CONTENT_FILE foreign key (CONTENT_FILE_ID) references SYS_FILE (ID)^
CREATE INDEX SYS_SENDING_ATTACHMENT_MESSAGE_IDX
ON SYS_SENDING_ATTACHMENT(MESSAGE_ID )^

View File

@ -612,6 +612,7 @@ create table SYS_SENDING_MESSAGE (
ADDRESS_FROM varchar(100),
CAPTION varchar(500),
CONTENT_TEXT varchar(max),
CONTENT_TEXT_FILE_ID uniqueidentifier,
DEADLINE datetime,
STATUS int,
DATE_SENT datetime,
@ -619,7 +620,8 @@ create table SYS_SENDING_MESSAGE (
ATTEMPTS_MADE int,
ATTACHMENTS_NAME varchar(500),
--
primary key nonclustered (ID)
primary key nonclustered (ID),
constraint FK_SYS_SENDING_MESSAGE_CONTENT_FILE foreign key (CONTENT_TEXT_FILE_ID) references SYS_FILE(ID)
)^
create index IDX_SYS_SENDING_MESSAGE_STATUS on SYS_SENDING_MESSAGE (STATUS)^
@ -642,6 +644,7 @@ create table SYS_SENDING_ATTACHMENT (
--
MESSAGE_ID uniqueidentifier,
CONTENT image,
CONTENT_FILE_ID uniqueidentifier,
CONTENT_ID varchar(50),
NAME varchar(500),
DISPOSITION varchar(50),
@ -649,7 +652,8 @@ create table SYS_SENDING_ATTACHMENT (
--
primary key nonclustered (ID),
constraint FK_SYS_SENDING_ATTACHMENT_SENDING_MESSAGE foreign key (MESSAGE_ID) references SYS_SENDING_MESSAGE (ID)
constraint FK_SYS_SENDING_ATTACHMENT_SENDING_MESSAGE foreign key (MESSAGE_ID) references SYS_SENDING_MESSAGE (ID),
constraint FK_SYS_SENDING_ATTACHMENT_CONTENT_FILE foreign key (CONTENT_FILE_ID) references SYS_FILE (ID)
)^
create index SYS_SENDING_ATTACHMENT_MESSAGE_IDX on SYS_SENDING_ATTACHMENT (MESSAGE_ID)^

View File

@ -257,6 +257,7 @@ create table SYS_SENDING_ATTACHMENT (
MESSAGE_ID varchar2(32),
CONTENT blob,
CONTENT_ID varchar2(50),
CONTENT_FILE_ID varchar2(32),
NAME varchar2(500),
DISPOSITION varchar2(50),
TEXT_ENCODING varchar2(50),
@ -278,6 +279,7 @@ create table SYS_SENDING_MESSAGE (
ADDRESS_FROM varchar2(100),
CAPTION varchar2(500),
CONTENT_TEXT clob,
CONTENT_TEXT_FILE_ID varchar2(32),
DEADLINE timestamp,
STATUS integer,
DATE_SENT timestamp,
@ -586,8 +588,12 @@ alter table SYS_FOLDER add constraint FK_SYS_FOLDER_PARENT foreign key (PARENT_I
alter table SYS_SCHEDULED_EXECUTION add constraint SYS_SCHEDULED_EXECUTION_TASK foreign key (TASK_ID) references SYS_SCHEDULED_TASK(ID)^
alter table SYS_SENDING_MESSAGE add constraint FK_SYS_SENDING_MESSAGE_FILE foreign key (CONTENT_TEXT_FILE_ID) references SYS_FILE(ID)^
alter table SYS_SENDING_ATTACHMENT add constraint FK_SYS_SENDING_ATT_SEN_MES foreign key (MESSAGE_ID) references SYS_SENDING_MESSAGE(ID)^
alter table SYS_SENDING_ATTACHMENT add constraint FK_SYS_SENDING_ATTACH_FILE foreign key (CONTENT_FILE_ID) references SYS_FILE (ID)^
alter table SEC_CONSTRAINT add constraint SEC_CONSTRAINT_GROUP foreign key (GROUP_ID) references SEC_GROUP(ID)^
alter table SEC_ENTITY_LOG add constraint FK_SEC_ENTITY_LOG_USER foreign key (USER_ID) references SEC_USER(ID)^

View File

@ -590,6 +590,7 @@ create table SYS_SENDING_MESSAGE (
ADDRESS_FROM varchar(100),
CAPTION varchar(500),
CONTENT_TEXT text,
CONTENT_TEXT_FILE_ID uuid,
DEADLINE timestamp,
STATUS int,
DATE_SENT timestamp,
@ -597,7 +598,8 @@ create table SYS_SENDING_MESSAGE (
ATTEMPTS_MADE int,
ATTACHMENTS_NAME varchar(500),
--
primary key (ID)
primary key (ID),
constraint FK_SYS_SENDING_MESSAGE_CONTENT_FILE foreign key (CONTENT_TEXT_FILE_ID) references SYS_FILE(ID)
)^
create index IDX_SYS_SENDING_MESSAGE_STATUS on SYS_SENDING_MESSAGE (STATUS)^
@ -618,13 +620,15 @@ create table SYS_SENDING_ATTACHMENT (
--
MESSAGE_ID uuid,
CONTENT bytea,
CONTENT_FILE_ID uuid,
CONTENT_ID varchar(50),
NAME varchar(500),
DISPOSITION varchar(50),
TEXT_ENCODING varchar(50),
--
primary key (ID),
constraint FK_SYS_SENDING_ATTACHMENT_SENDING_MESSAGE foreign key (MESSAGE_ID) references SYS_SENDING_MESSAGE (ID)
constraint FK_SYS_SENDING_ATTACHMENT_SENDING_MESSAGE foreign key (MESSAGE_ID) references SYS_SENDING_MESSAGE (ID),
constraint FK_SYS_SENDING_ATTACHMENT_CONTENT_FILE foreign key (CONTENT_FILE_ID) references SYS_FILE (ID)
)^
create index SYS_SENDING_ATTACHMENT_MESSAGE_IDX on SYS_SENDING_ATTACHMENT (MESSAGE_ID)^

View File

@ -0,0 +1,10 @@
-- $Id$ --
-- Add linking columns from SendingMessage and SendingAttachment to sys$File
alter table SYS_SENDING_MESSAGE add column CONTENT_TEXT_FILE_ID uniqueidentifier^
alter table SYS_SENDING_MESSAGE add constraint FK_SYS_SENDING_MESSAGE_CONTENT_FILE
foreign key (CONTENT_TEXT_FILE_ID) references SYS_FILE(ID)^
alter table SYS_SENDING_ATTACHMENT add column CONTENT_FILE_ID uniqueidentifier^
alter table SYS_SENDING_ATTACHMENT add constraint FK_SYS_SENDING_ATTACHMENT_CONTENT_FILE
foreign key (CONTENT_FILE_ID) references SYS_FILE (ID)^

View File

@ -0,0 +1,10 @@
-- $Id$ --
-- Add linking columns from SendingMessage and SendingAttachment to sys$File
alter table SYS_SENDING_MESSAGE add column CONTENT_TEXT_FILE_ID uuid;
alter table SYS_SENDING_MESSAGE ADD constraint FK_SYS_SENDING_MESSAGE_CONTENT_FILE
foreign key (CONTENT_TEXT_FILE_ID) references SYS_FILE(ID);
alter table SYS_SENDING_ATTACHMENT add column CONTENT_FILE_ID uuid;
alter table SYS_SENDING_ATTACHMENT add constraint FK_SYS_SENDING_ATTACHMENT_CONTENT_FILE
foreign key (CONTENT_FILE_ID) references SYS_FILE (ID);

View File

@ -77,15 +77,13 @@ public class EmailSender implements EmailSenderAPI {
textBodyPart.setContent(textPart);
content.addBodyPart(textBodyPart);
if (sendingMessage.getAttachments() != null) {
for (SendingAttachment attachment : sendingMessage.getAttachments()) {
MimeBodyPart attachmentPart = createAttachmentPart(attachment);
for (SendingAttachment attachment : sendingMessage.getAttachments()) {
MimeBodyPart attachmentPart = createAttachmentPart(attachment);
if (attachment.getContentId() == null) {
content.addBodyPart(attachmentPart);
} else
textPart.addBodyPart(attachmentPart);
}
if (attachment.getContentId() == null) {
content.addBodyPart(attachmentPart);
} else
textPart.addBodyPart(attachmentPart);
}
msg.setContent(content);

View File

@ -22,6 +22,7 @@ public interface EmailSenderAPI {
/**
* Sends email with help of {@link org.springframework.mail.javamail.JavaMailSender}.
* Message body and attachments' content must be loaded from file storage.
* <p/>
* Use {@link EmailerAPI} instead if you need email to be delivered reliably and stored to email history.
*

View File

@ -4,6 +4,7 @@
*/
package com.haulmont.cuba.core.app;
import com.haulmont.cuba.core.entity.SendingMessage;
import com.haulmont.cuba.core.global.EmailAttachment;
import com.haulmont.cuba.core.global.EmailException;
import com.haulmont.cuba.core.global.EmailInfo;
@ -39,4 +40,9 @@ public class EmailServiceBean implements EmailService {
public void sendEmailAsync(EmailInfo info) {
emailer.sendEmailAsync(info);
}
@Override
public String loadContentText(SendingMessage sendingMessage) {
return emailer.loadContentText(sendingMessage);
}
}

View File

@ -4,15 +4,18 @@
*/
package com.haulmont.cuba.core.app;
import com.haulmont.chile.core.model.utils.InstanceUtils;
import com.haulmont.cuba.core.EntityManager;
import com.haulmont.cuba.core.Persistence;
import com.haulmont.cuba.core.Transaction;
import com.haulmont.cuba.core.TypedQuery;
import com.haulmont.cuba.core.entity.FileDescriptor;
import com.haulmont.cuba.core.entity.SendingAttachment;
import com.haulmont.cuba.core.entity.SendingMessage;
import com.haulmont.cuba.core.global.*;
import com.haulmont.cuba.core.sys.AppContext;
import com.haulmont.cuba.security.app.Authentication;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang.time.DateUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
@ -23,6 +26,7 @@ import javax.annotation.Nullable;
import javax.annotation.Resource;
import javax.inject.Inject;
import java.io.Serializable;
import java.io.UnsupportedEncodingException;
import java.util.*;
import java.util.concurrent.RejectedExecutionException;
@ -33,6 +37,9 @@ import java.util.concurrent.RejectedExecutionException;
@ManagedBean(EmailerAPI.NAME)
public class Emailer implements EmailerAPI {
protected static final String BODY_STORAGE_ENCODING = "UTF-8";
protected static final String BODY_FILE_EXTENSION = "txt";
private Log log = LogFactory.getLog(Emailer.class);
protected EmailerConfig config;
@ -63,6 +70,9 @@ public class Emailer implements EmailerAPI {
@Inject
protected Resources resources;
@Inject
protected FileStorageAPI fileStorage;
@Inject
public void setConfig(Configuration configuration) {
this.config = configuration.getConfig(EmailerConfig.class);
@ -167,27 +177,59 @@ public class Emailer implements EmailerAPI {
List<String> errorMessages = new ArrayList<>();
for (SendingMessage sendingMessage : messages) {
persistMessages(Collections.singletonList(sendingMessage), SendingStatus.SENDING);
SendingMessage persistedMessage = persistMessageIfPossible(sendingMessage);
try {
emailSender.sendEmail(sendingMessage);
markAsSent(sendingMessage);
if (persistedMessage != null) {
markAsSent(persistedMessage);
}
} catch (Exception e) {
log.warn("Unable to send email to '" + sendingMessage.getAddress() + "'", e);
failedAddresses.add(sendingMessage.getAddress());
errorMessages.add(e.getMessage());
markAsNonSent(sendingMessage);
if (persistedMessage != null) {
markAsNonSent(persistedMessage);
}
}
}
if (!failedAddresses.isEmpty()) {
throw new EmailException(
failedAddresses.toArray(new String[failedAddresses.size()]),
errorMessages.toArray(new String[errorMessages.size()])
);
throw new EmailException(failedAddresses, errorMessages);
}
}
/*
* Try to persist message and catch all errors to allow actual delivery
* in case of database or file storage failure.
*/
@Nullable
protected SendingMessage persistMessageIfPossible(SendingMessage sendingMessage) {
// A copy of sendingMessage is created
// to avoid additional overhead to load body and attachments back from FS
try {
SendingMessage clonedMessage = createClone(sendingMessage);
persistMessages(Collections.<SendingMessage>singletonList(clonedMessage), SendingStatus.SENDING);
return clonedMessage;
} catch (Exception e) {
log.error("Failed to persist message " + sendingMessage.getCaption(), e);
return null;
}
}
protected SendingMessage createClone(SendingMessage srcMessage) {
SendingMessage clonedMessage = (SendingMessage) InstanceUtils.copy(srcMessage);
List<SendingAttachment> clonedList = new ArrayList<>();
for (SendingAttachment srcAttach : srcMessage.getAttachments()) {
SendingAttachment clonedAttach = (SendingAttachment) InstanceUtils.copy(srcAttach);
clonedAttach.setMessage(null);
clonedAttach.setMessage(clonedMessage);
clonedList.add(clonedAttach);
}
clonedMessage.setAttachments(clonedList);
return clonedMessage;
}
@Override
public String processQueuedEmails() {
int callsToSkip = config.getDelayCallCount();
@ -281,30 +323,147 @@ public class Emailer implements EmailerAPI {
}
}
tx.commit();
return emailsToSend;
} finally {
tx.end();
}
for (SendingMessage message : emailsToSend) {
loadBodyAndAttachments(message);
}
return emailsToSend;
}
protected void persistMessages(List<SendingMessage> sendingMessageList, SendingStatus status) {
@Override
public String loadContentText(SendingMessage sendingMessage) {
SendingMessage msg;
Transaction tx = persistence.createTransaction();
try {
EntityManager em = persistence.getEntityManager();
for (SendingMessage message : sendingMessageList) {
message.setStatus(status);
em.persist(message);
if (message.getAttachments() != null) {
for (SendingAttachment attachment : message.getAttachments()) {
em.persist(attachment);
}
}
}
msg = em.reload(sendingMessage, "sendingMessage.loadContentText");
tx.commit();
} finally {
tx.end();
}
Objects.requireNonNull(msg, "Sending message not found: " + sendingMessage.getId());
if (msg.getContentTextFile() != null) {
byte[] bodyContent;
try {
bodyContent = fileStorage.loadFile(msg.getContentTextFile());
} catch (FileStorageException e) {
throw new RuntimeException(e);
}
String res = bodyTextFromByteArray(bodyContent);
return res;
} else {
return msg.getContentText();
}
}
protected void loadBodyAndAttachments(SendingMessage message) {
try {
if (message.getContentTextFile() != null) {
byte[] bodyContent = fileStorage.loadFile(message.getContentTextFile());
String body = bodyTextFromByteArray(bodyContent);
message.setContentText(body);
}
for (SendingAttachment attachment : message.getAttachments()) {
if (attachment.getContentFile() != null) {
byte[] content = fileStorage.loadFile(attachment.getContentFile());
attachment.setContent(content);
}
}
} catch (FileStorageException e) {
log.error("Failed to load body or attachments for " + message);
}
}
protected void persistMessages(List<SendingMessage> sendingMessageList, SendingStatus status) {
MessagePersistingContext context = new MessagePersistingContext();
try {
Transaction tx = persistence.createTransaction();
try {
EntityManager em = persistence.getEntityManager();
for (SendingMessage message : sendingMessageList) {
message.setStatus(status);
try {
persistSendingMessage(em, message, context);
} catch (FileStorageException e) {
throw new RuntimeException("Failed to store message " + message.getCaption(), e);
}
}
tx.commit();
} finally {
tx.end();
}
context.finished();
} finally {
removeOrphanFiles(context);
}
}
protected void removeOrphanFiles(MessagePersistingContext context) {
for (FileDescriptor file : context.files) {
try {
fileStorage.removeFile(file);
} catch (Exception e) {
log.error("Failed to remove file " + file);
}
}
}
protected void persistSendingMessage(EntityManager em, SendingMessage message,
MessagePersistingContext context) throws FileStorageException {
boolean useFileStorage = config.isFileStorageUsed();
if (useFileStorage) {
byte[] bodyBytes = bodyTextToBytes(message);
FileDescriptor contentTextFile = createBodyFileDescriptor(message, bodyBytes);
fileStorage.saveFile(contentTextFile, bodyBytes);
context.files.add(contentTextFile);
em.persist(contentTextFile);
message.setContentTextFile(contentTextFile);
message.setContentText(null);
}
em.persist(message);
for (SendingAttachment attachment : message.getAttachments()) {
if (useFileStorage) {
FileDescriptor contentFile = createAttachmentFileDescriptor(attachment);
fileStorage.saveFile(contentFile, attachment.getContent());
context.files.add(contentFile);
em.persist(contentFile);
attachment.setContentFile(contentFile);
attachment.setContent(null);
}
em.persist(attachment);
}
}
protected FileDescriptor createAttachmentFileDescriptor(SendingAttachment attachment) {
FileDescriptor contentFile = metadata.create(FileDescriptor.class);
contentFile.setCreateDate(timeSource.currentTimestamp());
contentFile.setName(attachment.getName());
contentFile.setExtension(FilenameUtils.getExtension(attachment.getName()));
contentFile.setSize(attachment.getContent().length);
return contentFile;
}
protected FileDescriptor createBodyFileDescriptor(SendingMessage message, byte[] bodyBytes) {
FileDescriptor contentTextFile = metadata.create(FileDescriptor.class);
contentTextFile.setCreateDate(timeSource.currentTimestamp());
contentTextFile.setName("Email_" + message.getId() + "." + BODY_FILE_EXTENSION);
contentTextFile.setExtension(BODY_FILE_EXTENSION);
contentTextFile.setSize(bodyBytes.length);
return contentTextFile;
}
protected void returnToQueue(SendingMessage sendingMessage) {
@ -378,6 +537,8 @@ public class Emailer implements EmailerAPI {
}
sendingMessage.setAttachments(sendingAttachments);
sendingMessage.setAttachmentsName(attachmentsName.toString());
} else {
sendingMessage.setAttachments(Collections.<SendingAttachment>emptyList());
}
replaceRecipientIfNecessary(sendingMessage);
@ -405,6 +566,86 @@ public class Emailer implements EmailerAPI {
return sendingAttachment;
}
protected byte[] bodyTextToBytes(SendingMessage message) {
byte[] bodyBytes;
try {
bodyBytes = message.getContentText().getBytes(BODY_STORAGE_ENCODING);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
return bodyBytes;
}
protected String bodyTextFromByteArray(byte[] bodyContent) {
try {
return new String(bodyContent, BODY_STORAGE_ENCODING);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
}
@Override
public void migrateEmailsToFileStorage(List<SendingMessage> messages) {
Transaction tx = persistence.createTransaction();
try {
EntityManager em = persistence.getEntityManager();
for (SendingMessage msg : messages) {
migrateMessage(em, msg);
}
tx.commit();
} finally {
tx.end();
}
}
@Override
public void migrateAttachmentsToFileStorage(List<SendingAttachment> attachments) {
Transaction tx = persistence.createTransaction();
try {
EntityManager em = persistence.getEntityManager();
for (SendingAttachment attachment : attachments) {
migrateAttachment(em, attachment);
}
tx.commit();
} finally {
tx.end();
}
}
protected void migrateMessage(EntityManager em, SendingMessage msg) {
msg = em.merge(msg);
byte[] bodyBytes = bodyTextToBytes(msg);
FileDescriptor bodyFile = createBodyFileDescriptor(msg, bodyBytes);
try {
fileStorage.saveFile(bodyFile, bodyBytes);
} catch (FileStorageException e) {
throw new RuntimeException(e);
}
em.persist(bodyFile);
msg.setContentTextFile(bodyFile);
msg.setContentText(null);
}
protected void migrateAttachment(EntityManager em, SendingAttachment attachment) {
attachment = em.merge(attachment);
FileDescriptor contentFile = createAttachmentFileDescriptor(attachment);
try {
fileStorage.saveFile(contentFile, attachment.getContent());
} catch (FileStorageException e) {
throw new RuntimeException(e);
}
em.persist(contentFile);
attachment.setContentFile(contentFile);
attachment.setContent(null);
}
protected static class EmailSendTask implements Runnable {
private SendingMessage sendingMessage;
@ -431,4 +672,12 @@ public class Emailer implements EmailerAPI {
}
}
}
protected static class MessagePersistingContext {
public final List<FileDescriptor> files = new ArrayList<>();
public void finished() {
files.clear();
}
}
}

View File

@ -4,6 +4,7 @@
*/
package com.haulmont.cuba.core.app;
import com.haulmont.cuba.core.entity.SendingAttachment;
import com.haulmont.cuba.core.entity.SendingMessage;
import com.haulmont.cuba.core.global.EmailAttachment;
import com.haulmont.cuba.core.global.EmailException;
@ -32,9 +33,9 @@ public interface EmailerAPI {
/**
* Send email synchronously.
*
* @param address comma or semicolon separated list of addresses
* @param caption email subject
* @param body email body
* @param address comma or semicolon separated list of addresses
* @param caption email subject
* @param body email body
* @param attachments email attachments
* @throws EmailException in case of any errors
*/
@ -51,13 +52,12 @@ public interface EmailerAPI {
/**
* Send email asynchronously, with limited number of attempts.
*
* @param info email details
* @param attemptsCount count of attempts to send (1 attempt per scheduler tick). If not specified,
* {@link com.haulmont.cuba.core.app.EmailerConfig#getDefaultSendingAttemptsCount()} is used
*
* @param deadline Emailer tries to send message till deadline.
* If deadline has come and message has not been sent, status of this message is changed to
* {@link com.haulmont.cuba.core.global.SendingStatus#NOTSENT}
* @param info email details
* @param attemptsCount count of attempts to send (1 attempt per scheduler tick). If not specified,
* {@link com.haulmont.cuba.core.app.EmailerConfig#getDefaultSendingAttemptsCount()} is used
* @param deadline Emailer tries to send message till deadline.
* If deadline has come and message has not been sent, status of this message is changed to
* {@link com.haulmont.cuba.core.global.SendingStatus#NOTSENT}
* @return list of created {@link SendingMessage}s
*/
List<SendingMessage> sendEmailAsync(EmailInfo info, @Nullable Integer attemptsCount, @Nullable Date deadline);
@ -77,4 +77,21 @@ public interface EmailerAPI {
* @return short message describing how many emails were sent, or error message
*/
String processQueuedEmails();
/**
* Migrate list of existing messages to be stored in file storage, in a single transaction.
*/
void migrateEmailsToFileStorage(List<SendingMessage> messages);
/**
* Migrate list of existing email attachments to be stored in file storage, in a single transaction.
*/
void migrateAttachmentsToFileStorage(List<SendingAttachment> attachments);
/**
* Loads content text for given message.
*
* @return email content text
*/
String loadContentText(SendingMessage sendingMessage);
}

View File

@ -27,6 +27,7 @@ public interface EmailerConfig extends Config {
@Property("cuba.email.fromAddress")
@Default("DoNotReply@localhost")
String getFromAddress();
void setFromAddress(String fromAddress);
/**
@ -77,6 +78,7 @@ public interface EmailerConfig extends Config {
@Property("cuba.email.delayCallCount")
@Default("2")
int getDelayCallCount();
void setDelayCallCount(int delayCallCount);
/**
@ -107,13 +109,29 @@ public interface EmailerConfig extends Config {
@Property("cuba.email.adminAddress")
@Default("admin@localhost")
String getAdminAddress();
void setAdminAddress(String adminAddress);
/**
* If this parameter is set to true, all email messages go to <code>cuba.email.adminAddress</code>.
* If this parameter is set to true, all email messages go to {@link #getAdminAddress()}.
*/
@Property("cuba.email.sendAllToAdmin")
@DefaultBoolean(false)
boolean getSendAllToAdmin();
void setSendAllToAdmin(boolean sendAllToAdmin);
/**
* When turned on, email body text and attachments will be stored in file storage
* instead of BLOB columns in database.
* Should be used if application stores lots of emails and/or email attachments.
*
* @see com.haulmont.cuba.core.entity.SendingMessage#contentTextFile
* @see com.haulmont.cuba.core.entity.SendingAttachment#contentFile
*/
@Property("cuba.email.useFileStorage")
@DefaultBoolean(false)
boolean isFileStorageUsed();
void setFileStorageUsed(boolean fileStorageUsed);
}

View File

@ -5,15 +5,25 @@
package com.haulmont.cuba.core.jmx;
import com.haulmont.cuba.core.EntityManager;
import com.haulmont.cuba.core.Persistence;
import com.haulmont.cuba.core.Transaction;
import com.haulmont.cuba.core.TypedQuery;
import com.haulmont.cuba.core.app.EmailerAPI;
import com.haulmont.cuba.core.app.EmailerConfig;
import com.haulmont.cuba.core.entity.SendingAttachment;
import com.haulmont.cuba.core.entity.SendingMessage;
import com.haulmont.cuba.core.global.Configuration;
import com.haulmont.cuba.core.global.EmailAttachment;
import com.haulmont.cuba.core.global.View;
import com.haulmont.cuba.security.app.Authenticated;
import org.apache.commons.lang.exception.ExceptionUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import javax.annotation.ManagedBean;
import javax.inject.Inject;
import java.util.List;
/**
* @author krivopustov
@ -25,8 +35,13 @@ public class Emailer implements EmailerMBean {
@Inject
protected EmailerAPI emailer;
@Inject
protected Persistence persistence;
protected EmailerConfig config;
protected Log log = LogFactory.getLog(Emailer.class);
@Inject
public void setConfiguration(Configuration configuration) {
this.config = configuration.getConfig(EmailerConfig.class);
@ -82,4 +97,80 @@ public class Emailer implements EmailerMBean {
return ExceptionUtils.getStackTrace(e);
}
}
@Override
public String migrateEmailsToFileStorage(String password) {
if (!"do migration".equals(password)) {
return "Wrong password";
}
int processed;
do {
try {
processed = migrateMessagesBatch();
log.info(String.format("Migrated %d emails", processed));
} catch (Exception e) {
throw new RuntimeException("Failed to migrate batch", e);
}
} while (processed > 0);
log.info("Finished migrating emails");
do {
try {
processed = migrateAttachmentsBatch();
log.info(String.format("Migrated %d attachments", processed));
} catch (Exception e) {
throw new RuntimeException("Failed to migrate batch", e);
}
} while (processed > 0);
log.info("Finished migrating attachments");
return "Finished";
}
protected int migrateMessagesBatch() {
List<SendingMessage> resultList;
Transaction tx = persistence.createTransaction();
try {
EntityManager em = persistence.getEntityManager();
String qstr = "select m from sys$SendingMessage m where m.contentText is not null";
TypedQuery<SendingMessage> query = em.createQuery(qstr, SendingMessage.class);
query.setMaxResults(50);
query.setViewName(View.MINIMAL);
resultList = query.getResultList();
tx.commit();
} finally {
tx.end();
}
if (!resultList.isEmpty()) {
emailer.migrateEmailsToFileStorage(resultList);
}
return resultList.size();
}
protected int migrateAttachmentsBatch() {
List<SendingAttachment> resultList;
Transaction tx = persistence.createTransaction();
try {
EntityManager em = persistence.getEntityManager();
String qstr = "select a from sys$SendingAttachment a where a.content is not null";
TypedQuery<SendingAttachment> query = em.createQuery(qstr, SendingAttachment.class);
query.setMaxResults(50);
query.setViewName(View.MINIMAL);
resultList = query.getResultList();
tx.commit();
} finally {
tx.end();
}
if (!resultList.isEmpty()) {
emailer.migrateAttachmentsToFileStorage(resultList);
}
return resultList.size();
}
}

View File

@ -4,6 +4,7 @@
*/
package com.haulmont.cuba.core.jmx;
import org.springframework.jmx.export.annotation.ManagedOperation;
import org.springframework.jmx.export.annotation.ManagedOperationParameter;
import org.springframework.jmx.export.annotation.ManagedOperationParameters;
@ -28,4 +29,7 @@ public interface EmailerMBean {
@ManagedOperationParameters({@ManagedOperationParameter(name = "addresses", description = "")})
String sendTestEmail(String addresses);
@ManagedOperation(description = "Migrate existing email history to use file storage")
String migrateEmailsToFileStorage(String password);
}

View File

@ -66,8 +66,8 @@
</view>
<view entity="sec$Role" name="role.lookup">
<property name="name"/>
</view>
<property name="name"/>
</view>
<view entity="sec$Role" name="role.browse">
<property name="name"/>
@ -151,20 +151,20 @@
<property name="updateTs"/>
</view>
<view class="com.haulmont.cuba.core.entity.SendingMessage" name="sendingMessage.browse.detail"
extends="sendingMessage.browse">
<property name="contentText"/>
</view>
<view class="com.haulmont.cuba.core.entity.SendingMessage" name="sendingMessage.loadFromQueue" extends="_local"
systemProperties="true">
<property name="attachments" view="sendingAttachment.loadFromQueue"/>
<property name="version"/>
<property name="contentTextFile" view="_local"/>
</view>
<view class="com.haulmont.cuba.core.entity.SendingAttachment" name="sendingAttachment.loadFromQueue"
extends="_local" systemProperties="true">
<property name="contentFile" view="_local"/>
</view>
<view class="com.haulmont.cuba.core.entity.SendingMessage" name="sendingMessage.loadContentText">
<property name="contentTextFile" view="_local"/>
<property name="contentText"/>
</view>
<view class="com.haulmont.cuba.core.entity.CategoryAttributeValue" name="categoryAttributeValue" extends="_local">
@ -200,7 +200,7 @@
<property name="userName"/>
<property name="since"/>
<property name="lastUsedTs"/>
</view>
</view>
<view class="com.haulmont.cuba.security.entity.LoggedEntity"
name="loggedAttrs">

View File

@ -5,8 +5,10 @@
package com.haulmont.cuba.core.app;
import com.google.common.collect.Lists;
import com.haulmont.cuba.core.CubaTestCase;
import com.haulmont.cuba.core.Transaction;
import com.haulmont.cuba.core.entity.SendingAttachment;
import com.haulmont.cuba.core.entity.SendingMessage;
import com.haulmont.cuba.core.global.*;
import com.haulmont.cuba.core.sys.CubaMailSender;
@ -53,10 +55,19 @@ public class EmailerTest extends CubaTestCase {
testMailSender.clearBuffer();
}
public void testSynchronous() throws Exception {
doTestSynchronous(false);
}
public void testSynchronousFS() throws Exception {
doTestSynchronous(true);
}
/*
* Test single recipient, text body, subject.
*/
public void testSynchronous() throws Exception {
private void doTestSynchronous(boolean useFs) throws Exception {
emailerConfig.setFileStorageUsed(useFs);
testMailSender.clearBuffer();
EmailInfo myInfo = new EmailInfo("testemail@example.com", "Test Email", "Test Body");
@ -93,6 +104,15 @@ public class EmailerTest extends CubaTestCase {
}
public void testAsynchronous() throws Exception {
doTestAsynchronous(false);
}
public void testAsynchronousFS() throws Exception {
doTestAsynchronous(true);
}
private void doTestAsynchronous(boolean useFs) throws Exception {
emailerConfig.setFileStorageUsed(useFs);
testMailSender.clearBuffer();
String body = "Test Email Body";
@ -183,8 +203,8 @@ public class EmailerTest extends CubaTestCase {
emailer.sendEmail("myemail@example.com", "Test Email", "Test Body 2");
fail("Must fail with EmailException");
} catch (EmailException e) {
assertEquals(1, e.getFailedAddresses().length);
assertEquals("myemail@example.com", e.getFailedAddresses()[0]);
assertEquals(1, e.getFailedAddresses().size());
assertEquals("myemail@example.com", e.getFailedAddresses().get(0));
assertTrue(testMailSender.isEmpty());
} finally {
testMailSender.workNormallyPlease();
@ -229,6 +249,15 @@ public class EmailerTest extends CubaTestCase {
}
public void testSentFromSecondAttempt() throws Exception {
doTestSentFromSecondAttempt(false);
}
public void testSentFromSecondAttemptFS() throws Exception {
doTestSentFromSecondAttempt(true);
}
private void doTestSentFromSecondAttempt(boolean useFs) {
emailerConfig.setFileStorageUsed(useFs);
testMailSender.clearBuffer();
String body = "Test Email Body";
@ -261,6 +290,15 @@ public class EmailerTest extends CubaTestCase {
}
public void testSeveralRecipients() throws Exception {
doTestSeveralRecipients(false);
}
public void testSeveralRecipientsFS() throws Exception {
doTestSeveralRecipients(true);
}
private void doTestSeveralRecipients(boolean useFs) throws MessagingException {
emailerConfig.setFileStorageUsed(useFs);
testMailSender.clearBuffer();
String body = "Test Email Body";
@ -325,6 +363,15 @@ public class EmailerTest extends CubaTestCase {
}
public void testTextAttachment() throws Exception {
doTestTextAttachment(false);
}
public void testTextAttachmentFS() throws Exception {
doTestTextAttachment(true);
}
private void doTestTextAttachment(boolean useFs) throws IOException, MessagingException {
emailerConfig.setFileStorageUsed(useFs);
testMailSender.clearBuffer();
String attachmentText = "Test Attachment Text";
@ -353,6 +400,15 @@ public class EmailerTest extends CubaTestCase {
}
public void testInlineImage() throws Exception {
doTestInlineImage(false);
}
public void testInlineImageFS() throws Exception {
doTestInlineImage(true);
}
private void doTestInlineImage(boolean useFs) throws IOException, MessagingException {
emailerConfig.setFileStorageUsed(useFs);
testMailSender.clearBuffer();
byte[] imageBytes = new byte[]{1, 2, 3, 4, 5};
@ -381,13 +437,22 @@ public class EmailerTest extends CubaTestCase {
}
public void testPdfAttachment() throws Exception {
doTestPdfAttachment(false);
}
public void testPdfAttachmentFS() throws Exception {
doTestPdfAttachment(true);
}
private void doTestPdfAttachment(boolean useFs) throws IOException, MessagingException {
emailerConfig.setFileStorageUsed(useFs);
testMailSender.clearBuffer();
byte[] pdfBytes = new byte[]{1, 2, 3, 4, 6};
String fileName = "invoice.pdf";
EmailAttachment imageAttach = new EmailAttachment(pdfBytes, fileName);
EmailAttachment pdfAttach = new EmailAttachment(pdfBytes, fileName);
EmailInfo myInfo = new EmailInfo("test@example.com", "Test", null, "Test", imageAttach);
EmailInfo myInfo = new EmailInfo("test@example.com", "Test", null, "Test", pdfAttach);
emailer.sendEmailAsync(myInfo);
emailer.processQueuedEmails();
@ -408,6 +473,68 @@ public class EmailerTest extends CubaTestCase {
assertTrue(contentType.contains("application/pdf"));
}
public void testLoadBody() throws Exception {
doTestLoadBody(false);
}
public void testLoadBodyFS() throws Exception {
doTestLoadBody(true);
}
private void doTestLoadBody(boolean useFs) throws Exception {
emailerConfig.setFileStorageUsed(useFs);
String body = "Hi! This is test email. Bye.";
EmailInfo emailInfo = new EmailInfo("test@example.com", "Test", body);
List<SendingMessage> messages = emailer.sendEmailAsync(emailInfo);
SendingMessage msg = reload(messages.get(0));
String actualBody = emailer.loadContentText(msg);
assertEquals(body, actualBody);
}
public void testMigration() throws Exception {
emailerConfig.setFileStorageUsed(false);
byte[] expectedBytes = new byte[]{1, 2, 3, 4, 6};
EmailAttachment fileAttachment = new EmailAttachment(expectedBytes, "invoice.pdf");
String body = "Hi! This is test email. Bye.";
EmailInfo emailInfo = new EmailInfo("test@example.com", "Test", body);
emailInfo.setAttachments(new EmailAttachment[]{fileAttachment});
List<SendingMessage> messages = emailer.sendEmailAsync(emailInfo);
SendingMessage msg;
SendingAttachment attachment;
// check DB storage
msg = reload(messages.get(0), "sendingMessage.loadFromQueue");
attachment = msg.getAttachments().get(0);
assertNotNull(msg.getContentText());
assertNull(msg.getContentTextFile());
assertNotNull(attachment.getContent());
assertNull(attachment.getContentFile());
emailer.migrateEmailsToFileStorage(Lists.newArrayList(msg));
emailer.migrateAttachmentsToFileStorage(Lists.newArrayList(attachment));
// check file storage
msg = reload(msg, "sendingMessage.loadFromQueue");
attachment = msg.getAttachments().get(0);
assertNull(msg.getContentText());
assertNotNull(msg.getContentTextFile());
assertEquals(body, emailer.loadContentText(msg));
assertNull(attachment.getContent());
assertNotNull(attachment.getContentFile());
FileStorageAPI fileStorage = AppBeans.get(FileStorageAPI.NAME);
byte[] actualBytes = fileStorage.loadFile(attachment.getContentFile());
assertByteArrayEquals(expectedBytes, actualBytes);
}
/* Utility */
private Date getDeadlineWhichDoesntMatter() {
return DateUtils.addHours(timeSource.currentTimestamp(), 2);
@ -451,10 +578,10 @@ public class EmailerTest extends CubaTestCase {
return (MimeBodyPart) textBodyPart.getBodyPart(1);
}
private SendingMessage reload(SendingMessage sendingMessage) {
private SendingMessage reload(SendingMessage sendingMessage, String... viewNames) {
Transaction tx = persistence.createTransaction();
try {
sendingMessage = persistence.getEntityManager().reload(sendingMessage);
sendingMessage = persistence.getEntityManager().reload(sendingMessage, viewNames);
tx.commit();
} finally {
tx.end();
@ -462,10 +589,10 @@ public class EmailerTest extends CubaTestCase {
return sendingMessage;
}
private void assertByteArrayEquals(byte[] data1, byte[] data2) {
assertEquals(data1.length, data2.length);
for (int i = 0; i < data1.length; i++) {
assertEquals(data1[i], data2[i]);
private void assertByteArrayEquals(byte[] expected, byte[] actual) {
assertEquals(expected.length, actual.length);
for (int i = 0; i < expected.length; i++) {
assertEquals(expected[i], actual[i]);
}
}

View File

@ -4,6 +4,7 @@
*/
package com.haulmont.cuba.core.app;
import com.haulmont.cuba.core.entity.SendingMessage;
import com.haulmont.cuba.core.global.EmailAttachment;
import com.haulmont.cuba.core.global.EmailException;
import com.haulmont.cuba.core.global.EmailInfo;
@ -24,11 +25,12 @@ public interface EmailService {
/**
* Send email synchronously.
*
* @param address comma or semicolon separated list of addresses
* @param caption email subject
* @param body email body
* @param attachment email attachments
* @throws com.haulmont.cuba.core.global.EmailException in case of any errors
* @param address comma or semicolon separated list of addresses
* @param caption email subject
* @param body email body
* @param attachment email attachments
* @throws com.haulmont.cuba.core.global.EmailException
* in case of any errors
*/
void sendEmail(String address, String caption, String body, EmailAttachment... attachment)
throws EmailException;
@ -36,19 +38,19 @@ public interface EmailService {
/**
* Send email synchronously.
*
* @param info email details
* @throws EmailException in case of any errors
* @param info email details
* @throws EmailException in case of any errors
*/
void sendEmail(EmailInfo info) throws EmailException;
/**
* Send email asynchronously, with limited number of attempts.
*
* @param info email details
* @param attemptsCount count of attempts to send (1 attempt = 1 emailer cron tick)
* @param deadline Emailer tries to send message till deadline.
* If deadline has come and message has not been sent, status of this message is changed to
* {@link com.haulmont.cuba.core.global.SendingStatus#NOTSENT}
* @param info email details
* @param attemptsCount count of attempts to send (1 attempt = 1 emailer cron tick)
* @param deadline Emailer tries to send message till deadline.
* If deadline has come and message has not been sent, status of this message is changed to
* {@link com.haulmont.cuba.core.global.SendingStatus#NOTSENT}
*/
void sendEmailAsync(EmailInfo info, @Nullable Integer attemptsCount, @Nullable Date deadline);
@ -58,4 +60,11 @@ public interface EmailService {
* @param info email details
*/
void sendEmailAsync(EmailInfo info);
/**
* Load content text for given message.
*
* @return email content text
*/
String loadContentText(SendingMessage sendingMessage);
}

View File

@ -25,10 +25,17 @@ public class SendingAttachment extends StandardEntity {
@Column(name = "MESSAGE_ID")
protected SendingMessage message;
/**
* Attachment data is stored either in this field or in {@link #contentFile}.
*/
@Basic(fetch = FetchType.LAZY)
@Column(name = "CONTENT")
protected byte[] content;
@JoinColumn(name = "CONTENT_FILE_ID")
@OneToOne(fetch = FetchType.LAZY)
protected FileDescriptor contentFile;
@Column(name = "NAME", length = 500)
protected String name;
@ -88,4 +95,12 @@ public class SendingAttachment extends StandardEntity {
public void setEncoding(String encoding) {
this.encoding = encoding;
}
public FileDescriptor getContentFile() {
return contentFile;
}
public void setContentFile(FileDescriptor contentFile) {
this.contentFile = contentFile;
}
}

View File

@ -35,9 +35,16 @@ public class SendingMessage extends StandardEntity {
@Column(name = "CAPTION")
protected String caption;
/**
* Email body is stored either in this field or in {@link #contentTextFile}.
*/
@Column(name = "CONTENT_TEXT")
protected String contentText;
@JoinColumn(name = "CONTENT_TEXT_FILE_ID")
@OneToOne(fetch = FetchType.LAZY)
protected FileDescriptor contentTextFile;
@Column(name = "STATUS")
protected Integer status;
@ -146,4 +153,12 @@ public class SendingMessage extends StandardEntity {
public void setAttemptsMade(Integer attemptsMade) {
this.attemptsMade = attemptsMade;
}
public FileDescriptor getContentTextFile() {
return contentTextFile;
}
public void setContentTextFile(FileDescriptor contentTextFile) {
this.contentTextFile = contentTextFile;
}
}

View File

@ -4,37 +4,42 @@
*/
package com.haulmont.cuba.core.global;
import java.util.List;
/**
* Email sending error.<br>
* Contains failed addresses and corresponding error messages.
*/
public class EmailException extends Exception
{
private static final long serialVersionUID = -2559499596752714382L;
private String[] failedAddresses;
private String[] messages;
@SupportedByClient
public class EmailException extends Exception {
public EmailException(String[] failedAddresses, String[] messages) {
if (failedAddresses == null || messages == null || failedAddresses.length != messages.length)
private static final long serialVersionUID = -9129158384759856382L;
private final List<String> failedAddresses;
/**
* List of error messages which prevented email to be sent.
*/
private final List<String> messages;
public EmailException(List<String> failedAddresses, List<String> messages) {
if (failedAddresses == null || messages == null || failedAddresses.size() != messages.size())
throw new IllegalArgumentException();
this.failedAddresses = failedAddresses;
this.messages = messages;
}
public String[] getFailedAddresses() {
public List<String> getFailedAddresses() {
return failedAddresses;
}
public String[] getMessages() {
public List<String> getMessages() {
return messages;
}
public String toString() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < failedAddresses.length; i++) {
sb.append(failedAddresses[i]).append(" : ").append(messages[i]).append("\n");
for (int i = 0; i < failedAddresses.size(); i++) {
sb.append(failedAddresses.get(i)).append(" : ").append(messages.get(i)).append("\n");
}
return sb.toString();
}

View File

@ -5,10 +5,13 @@
package com.haulmont.cuba.gui.app.core.sendingmessage.browse;
import com.haulmont.cuba.core.app.EmailService;
import com.haulmont.cuba.core.entity.SendingMessage;
import com.haulmont.cuba.gui.components.*;
import com.haulmont.cuba.gui.components.AbstractWindow;
import com.haulmont.cuba.gui.components.Component;
import com.haulmont.cuba.gui.components.FieldGroup;
import com.haulmont.cuba.gui.components.TextArea;
import com.haulmont.cuba.gui.data.CollectionDatasource;
import com.haulmont.cuba.gui.data.DataSupplier;
import com.haulmont.cuba.gui.data.Datasource;
import com.haulmont.cuba.gui.data.impl.DsListenerAdapter;
import com.haulmont.cuba.gui.xml.layout.ComponentsFactory;
@ -23,13 +26,13 @@ import java.util.UUID;
*/
public class SendingMessageBrowser extends AbstractWindow {
@Inject
protected Datasource<SendingMessage> selectedMessageDs;
protected static final String CONTENT_TEXT = "contentText";
@Inject
protected CollectionDatasource<SendingMessage, UUID> sendingMessageDs;
@Inject
protected DataSupplier dataSupplier;
protected EmailService emailService;
@Inject
protected FieldGroup fg;
@ -39,25 +42,31 @@ public class SendingMessageBrowser extends AbstractWindow {
@Override
public void init(Map<String, Object> params) {
fg.addCustomField("contentText", new FieldGroup.CustomFieldGenerator() {
fg.addCustomField(CONTENT_TEXT, new FieldGroup.CustomFieldGenerator() {
@Override
public Component generateField(Datasource datasource, String propertyId) {
final TextArea textArea = factory.createComponent(TextArea.NAME);
textArea.setDatasource(selectedMessageDs, "contentText");
textArea.setRows(20);
textArea.setHeight("350px");
textArea.setEditable(false);
return textArea;
TextArea contentTextArea = factory.createComponent(TextArea.NAME);
contentTextArea.setRows(20);
contentTextArea.setHeight("350px");
return contentTextArea;
}
});
fg.setEditable(CONTENT_TEXT, false);
sendingMessageDs.addListener(new DsListenerAdapter<SendingMessage>() {
@Override
public void itemChanged(Datasource<SendingMessage> ds, SendingMessage prevItem, SendingMessage item) {
if (item != null) {
item = dataSupplier.reload(item, selectedMessageDs.getView());
}
selectedMessageDs.setItem(item);
selectedItemChanged(item);
}
});
}
protected void selectedItemChanged(SendingMessage item) {
String contentText = null;
if (item != null) {
contentText = emailService.loadContentText(item);
}
fg.setEditable(CONTENT_TEXT, true);
fg.setFieldValue(CONTENT_TEXT, contentText);
fg.setEditable(CONTENT_TEXT, false);
}
}

View File

@ -12,17 +12,15 @@
<collectionDatasource
id="sendingMessageDs"
class="com.haulmont.cuba.core.entity.SendingMessage"
view="sendingMessage.browse" fetchMode="AUTO">
view="sendingMessage.browse">
<query>
<![CDATA[select sm from sys$SendingMessage sm]]>
</query>
</collectionDatasource>
<datasource id="selectedMessageDs" class="com.haulmont.cuba.core.entity.SendingMessage"
view="sendingMessage.browse.detail"/>
</dsContext>
<layout>
<split id="split" orientation="horizontal" pos="70" width="100%" height="100%">
<vbox expand="table" spacing="true" width="100%" height="100%">
<layout expand="hbox">
<hbox id="hbox" spacing="true" width="100%" expand="leftBox">
<vbox id="leftBox" expand="table" spacing="true" height="100%">
<filter id="genericFilter" datasource="sendingMessageDs">
<properties include=".*"/>
</filter>
@ -43,23 +41,21 @@
<rows datasource="sendingMessageDs"/>
</table>
</vbox>
<vbox expand="fg" margin="true">
<fieldGroup id="fg" datasource="selectedMessageDs" width="500px" editable="false" border="visible">
<column width="100%">
<field id="address"/>
<field id="from"/>
<field id="caption"/>
<field id="contentText" custom="true"/>
<field id="status"/>
<field id="attachmentsName"/>
<field id="dateSent"/>
<field id="updateTs"/>
<field id="deadline"/>
<field id="attemptsCount"/>
<field id="attemptsMade"/>
</column>
</fieldGroup>
</vbox>
</split>
<fieldGroup id="fg" datasource="sendingMessageDs" editable="false" border="visible" width="500px">
<column width="100%">
<field id="address"/>
<field id="from"/>
<field id="caption"/>
<field id="contentText" custom="true"/>
<field id="status"/>
<field id="attachmentsName"/>
<field id="dateSent"/>
<field id="updateTs"/>
<field id="deadline"/>
<field id="attemptsCount"/>
<field id="attemptsMade"/>
</column>
</fieldGroup>
</hbox>
</layout>
</window>