diff --git a/backend/controller/email.go b/backend/controller/email.go
index 4d3fc4f..3646599 100644
--- a/backend/controller/email.go
+++ b/backend/controller/email.go
@@ -28,7 +28,13 @@ var EmailOrderByMap = map[string]string{
// AddAttachmentsToEmailRequest is a request to add attachments to a message
type AddAttachmentsToEmailRequest struct {
- Attachments []string `json:"ids"` // attachment IDs
+ Attachments []AttachmentWithInline `json:"attachments"`
+}
+
+// AttachmentWithInline represents an attachment ID with inline flag
+type AttachmentWithInline struct {
+ ID string `json:"id"`
+ IsInline bool `json:"isInline"`
}
// RemoveAttachmentFromEmailRequest is a request to remove an attachment from a message
@@ -70,9 +76,9 @@ func (m *Email) AddAttachments(g *gin.Context) {
m.Response.BadRequestMessage(g, "No attachments provided")
return
}
- attachmentIDs := []*uuid.UUID{}
- for _, idParam := range request.Attachments {
- id, err := uuid.Parse(idParam)
+ attachments := []service.AttachmentWithInline{}
+ for _, att := range request.Attachments {
+ id, err := uuid.Parse(att.ID)
if err != nil {
m.Logger.Debugw(errs.MsgFailedToParseUUID,
"error", err,
@@ -80,14 +86,17 @@ func (m *Email) AddAttachments(g *gin.Context) {
m.Response.BadRequestMessage(g, "Invalid attachment ID")
return
}
- attachmentIDs = append(attachmentIDs, &id)
+ attachments = append(attachments, service.AttachmentWithInline{
+ ID: &id,
+ IsInline: att.IsInline,
+ })
}
// add attachments to email
err := m.EmailService.AddAttachments(
g.Request.Context(),
session,
id,
- attachmentIDs,
+ attachments,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
diff --git a/backend/database/emailAttachment.go b/backend/database/emailAttachment.go
index 69da4e9..41f10c0 100644
--- a/backend/database/emailAttachment.go
+++ b/backend/database/emailAttachment.go
@@ -9,6 +9,7 @@ import (
type EmailAttachment struct {
EmailID *uuid.UUID `gorm:"primary_key;not null;index;type:uuid;unique_index:idx_message_attachment;"`
AttachmentID *uuid.UUID `gorm:"primary_key;not null;index;type:uuid;unique_index:idx_message_attachment;"`
+ IsInline bool `gorm:"not null;default:false;"`
}
func (EmailAttachment) TableName() string {
diff --git a/backend/model/email.go b/backend/model/email.go
index a5c285e..906d0da 100644
--- a/backend/model/email.go
+++ b/backend/model/email.go
@@ -22,8 +22,8 @@ type Email struct {
AddTrackingPixel nullable.Nullable[bool] `json:"addTrackingPixel"`
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
- Attachments []*Attachment `json:"attachments"`
- Company *Company `json:"company"`
+ Attachments []*EmailAttachment `json:"attachments"`
+ Company *Company `json:"company"`
}
// Validate checks if the mail has a valid state
@@ -132,3 +132,10 @@ type EmailOverview struct {
Company *Company `json:"company"`
}
+
+// EmailAttachment represents an attachment associated with an email
+// with additional metadata about how it should be displayed
+type EmailAttachment struct {
+ *Attachment
+ IsInline bool `json:"isInline"` // if true, use Content-Disposition: inline and set Content-ID for cid: references
+}
diff --git a/backend/repository/email.go b/backend/repository/email.go
index c2733de..6b51ab6 100644
--- a/backend/repository/email.go
+++ b/backend/repository/email.go
@@ -54,11 +54,13 @@ func (m *Email) AddAttachment(
ctx context.Context,
emailID *uuid.UUID,
attachmentID *uuid.UUID,
+ isInline bool,
) error {
result := m.DB.Create(
&database.EmailAttachment{
EmailID: emailID,
AttachmentID: attachmentID,
+ IsInline: isInline,
},
)
if result.Error != nil {
@@ -103,6 +105,19 @@ func (m *Email) GetAttachmentIDsByEmailID(
return attachmentIDs, nil
}
+// GetEmailAttachments gets all email-attachment relationships for an email including isInline status
+func (m *Email) GetEmailAttachments(
+ ctx context.Context,
+ emailID uuid.UUID,
+) ([]database.EmailAttachment, error) {
+ var emailAttachments []database.EmailAttachment
+ result := m.DB.Where("email_id = ?", emailID).Find(&emailAttachments)
+ if result.Error != nil {
+ return nil, result.Error
+ }
+ return emailAttachments, nil
+}
+
// RemoveAttachment removes an attachments from a email by attachment ID
func (m *Email) RemoveAttachmentsByAttachmentID(
ctx context.Context,
@@ -342,10 +357,8 @@ func ToEmail(row *database.Email) *model.Email {
content := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.Content))
addTrackingPixel := nullable.NewNullableWithValue(row.AddTrackingPixel)
- attachments := []*model.Attachment{}
- for _, attachment := range row.Attachments {
- attachments = append(attachments, ToAttachment(attachment))
- }
+ // attachments are loaded separately via loadEmailAttachmentsWithContext
+ // which properly loads EmailAttachment with isInline status from junction table
return &model.Email{
ID: id,
CreatedAt: row.CreatedAt,
@@ -357,7 +370,7 @@ func ToEmail(row *database.Email) *model.Email {
Content: content,
AddTrackingPixel: addTrackingPixel,
CompanyID: companyID,
- Attachments: attachments,
+ Attachments: []*model.EmailAttachment{},
}
}
diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go
index 008da78..b1b6ae1 100644
--- a/backend/seed/migrate.go
+++ b/backend/seed/migrate.go
@@ -352,18 +352,31 @@ func SeedSettings(
// Migration approach
func migrate(db *gorm.DB) error {
- // First add column as nullable
+ // migration for attachments.embedded_content
+ // first add column as nullable
if err := db.Exec(`ALTER TABLE attachments ADD COLUMN embedded_content BOOLEAN`).Error; err != nil {
+ // column might already exist, ignore error
+ if err.Error() != "duplicate column name: embedded_content" {
+ return errs.Wrap(err)
+ }
+ }
+
+ // update existing rows
+ if err := db.Exec(`UPDATE attachments SET embedded_content = false WHERE embedded_content IS NULL`).Error; err != nil {
return errs.Wrap(err)
}
- // Update existing rows
- if err := db.Exec(`UPDATE attachments SET embedded_content = false`).Error; err != nil {
- return errs.Wrap(err)
+ // migration for email_attachments.is_inline
+ // first add column as nullable
+ if err := db.Exec(`ALTER TABLE email_attachments ADD COLUMN is_inline BOOLEAN`).Error; err != nil {
+ // column might already exist, ignore error
+ if err.Error() != "duplicate column name: is_inline" {
+ return errs.Wrap(err)
+ }
}
- // Then make it not nullable
- if err := db.Exec(`ALTER TABLE attachments MODIFY COLUMN embedded_content BOOLEAN NOT NULL DEFAULT false`).Error; err != nil {
+ // update existing rows - default to false (regular attachments)
+ if err := db.Exec(`UPDATE email_attachments SET is_inline = false WHERE is_inline IS NULL`).Error; err != nil {
return errs.Wrap(err)
}
diff --git a/backend/service/campaign.go b/backend/service/campaign.go
index 1a2dfac..de8eec7 100644
--- a/backend/service/campaign.go
+++ b/backend/service/campaign.go
@@ -2129,19 +2129,82 @@ func (c *Campaign) sendCampaignMessages(
m.SetBodyString("text/html", bodyBuffer.String())
// attachments
attachments := email.Attachments
- for _, attachment := range attachments {
+ for _, emailAttachment := range attachments {
+ attachment := emailAttachment.Attachment
p, err := c.MailService.AttachmentService.GetPath(attachment)
if err != nil {
return fmt.Errorf("failed to get attachment path: %s", err)
}
- if !attachment.EmbeddedContent.MustGet() {
+
+ // check if attachment should be inline (for cid: references)
+ if emailAttachment.IsInline {
+ // inline attachment - embedded in email body, can be referenced via cid:filename
+ if !attachment.EmbeddedContent.MustGet() {
+ // simple inline image - no template processing needed
+ m.EmbedFile(p.String())
+ } else {
+ // inline image with template processing
+ attachmentContent, err := os.ReadFile(p.String())
+ if err != nil {
+ return errs.Wrap(err)
+ }
+ // setup attachment for executing as email template
+ attachmentAsEmail := model.Email{
+ ID: email.ID,
+ CreatedAt: email.CreatedAt,
+ UpdatedAt: email.UpdatedAt,
+ Name: email.Name,
+ MailEnvelopeFrom: email.MailEnvelopeFrom,
+ MailHeaderFrom: email.MailHeaderFrom,
+ MailHeaderSubject: email.MailHeaderSubject,
+ Content: email.Content,
+ AddTrackingPixel: email.AddTrackingPixel,
+ CompanyID: email.CompanyID,
+ Attachments: email.Attachments,
+ Company: email.Company,
+ }
+ attachmentAsEmail.Content = nullable.NewNullableWithValue(
+ *vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
+ )
+ // generate custom campaign URL for attachment
+ recipientID := campaignRecipient.ID.MustGet()
+ customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID)
+ if err != nil {
+ c.Logger.Errorw("failed to get campaign url for attachment", "error", err)
+ return errs.Wrap(err)
+ }
+
+ attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL(
+ ctx,
+ urlIdentifier.Name.MustGet(),
+ urlPath,
+ domain,
+ campaignRecipient,
+ &attachmentAsEmail,
+ nil,
+ customCampaignURL,
+ campaignCompanyID,
+ )
+ if err != nil {
+ return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
+ }
+ // use EmbedReader for inline images - sets Content-Disposition: inline and Content-ID header
+ // the filename becomes the Content-ID, so use
in HTML
+ m.EmbedReader(
+ filepath.Base(p.String()),
+ strings.NewReader(attachmentStr),
+ )
+ }
+ } else if !attachment.EmbeddedContent.MustGet() {
+ // regular attachment - shows in attachment list
m.AttachFile(p.String())
} else {
+ // regular attachment with template processing
attachmentContent, err := os.ReadFile(p.String())
if err != nil {
return errs.Wrap(err)
}
- // hacky setup of attachment for executing as email template
+ // setup attachment for executing as email template
attachmentAsEmail := model.Email{
ID: email.ID,
CreatedAt: email.CreatedAt,
@@ -2156,7 +2219,6 @@ func (c *Campaign) sendCampaignMessages(
Attachments: email.Attachments,
Company: email.Company,
}
- // really hacky / unsafe
attachmentAsEmail.Content = nullable.NewNullableWithValue(
*vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
)
@@ -3716,14 +3778,77 @@ func (c *Campaign) sendSingleEmailSMTP(
// handle attachments
attachments := email.Attachments
- for _, attachment := range attachments {
+ for _, emailAttachment := range attachments {
+ attachment := emailAttachment.Attachment
p, err := c.MailService.AttachmentService.GetPath(attachment)
if err != nil {
return fmt.Errorf("failed to get attachment path: %s", err)
}
- if !attachment.EmbeddedContent.MustGet() {
+
+ // check if attachment should be inline (for cid: references)
+ if emailAttachment.IsInline {
+ // inline attachment - embedded in email body, can be referenced via cid:filename
+ if !attachment.EmbeddedContent.MustGet() {
+ // simple inline image - no template processing needed
+ m.EmbedFile(p.String())
+ } else {
+ // inline image with template processing
+ attachmentContent, err := os.ReadFile(p.String())
+ if err != nil {
+ return errs.Wrap(err)
+ }
+ // setup attachment for executing as email template
+ attachmentAsEmail := model.Email{
+ ID: email.ID,
+ CreatedAt: email.CreatedAt,
+ UpdatedAt: email.UpdatedAt,
+ Name: email.Name,
+ MailEnvelopeFrom: email.MailEnvelopeFrom,
+ MailHeaderFrom: email.MailHeaderFrom,
+ MailHeaderSubject: email.MailHeaderSubject,
+ Content: email.Content,
+ AddTrackingPixel: email.AddTrackingPixel,
+ CompanyID: email.CompanyID,
+ Attachments: email.Attachments,
+ Company: email.Company,
+ }
+ attachmentAsEmail.Content = nullable.NewNullableWithValue(
+ *vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
+ )
+ // generate custom campaign URL for attachment
+ recipientID := campaignRecipient.ID.MustGet()
+ customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID)
+ if err != nil {
+ c.Logger.Errorw("failed to get campaign url for attachment", "error", err)
+ return errs.Wrap(err)
+ }
+
+ attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL(
+ ctx,
+ urlIdentifier.Name.MustGet(),
+ urlPath,
+ domain,
+ campaignRecipient,
+ &attachmentAsEmail,
+ nil,
+ customCampaignURL,
+ campaignCompanyID,
+ )
+ if err != nil {
+ return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
+ }
+ // use EmbedReader for inline images - sets Content-Disposition: inline and Content-ID header
+ // the filename becomes the Content-ID, so use
in HTML
+ m.EmbedReader(
+ filepath.Base(p.String()),
+ strings.NewReader(attachmentStr),
+ )
+ }
+ } else if !attachment.EmbeddedContent.MustGet() {
+ // regular attachment - shows in attachment list
m.AttachFile(p.String())
} else {
+ // regular attachment with template processing
attachmentContent, err := os.ReadFile(p.String())
if err != nil {
return errs.Wrap(err)
diff --git a/backend/service/email.go b/backend/service/email.go
index aebd944..ef1e055 100644
--- a/backend/service/email.go
+++ b/backend/service/email.go
@@ -37,12 +37,18 @@ type Email struct {
AttachmentPath string
}
+// AttachmentWithInline represents an attachment with inline flag
+type AttachmentWithInline struct {
+ ID *uuid.UUID
+ IsInline bool
+}
+
// AddAttachments adds an attachments to a message
func (m *Email) AddAttachments(
ctx context.Context,
session *model.Session,
messageID *uuid.UUID,
- attachmentIDs []*uuid.UUID,
+ attachments []AttachmentWithInline,
) error {
ae := NewAuditEvent("Email.AddAttachments", session)
ae.Details["messageId"] = messageID.String()
@@ -69,13 +75,13 @@ func (m *Email) AddAttachments(
}
// add attachment to message
attachmentIdsStr := []string{}
- for _, attachmentID := range attachmentIDs {
- attachmentIdsStr = append(attachmentIdsStr, attachmentID.String())
+ for _, attachment := range attachments {
+ attachmentIdsStr = append(attachmentIdsStr, attachment.ID.String())
// get the message to ensure it exists and the user is privliged
_, err = m.AttachmentService.GetByID(
ctx,
session,
- attachmentID,
+ attachment.ID,
)
if err != nil {
m.Logger.Errorw("failed to add attachment to email", "error", err)
@@ -84,7 +90,8 @@ func (m *Email) AddAttachments(
err = m.EmailRepository.AddAttachment(
ctx,
messageID,
- attachmentID,
+ attachment.ID,
+ attachment.IsInline,
)
if err != nil {
m.Logger.Errorw("failed to add attachment to email", "error", err)
@@ -640,19 +647,73 @@ func (m *Email) SendTestEmail(
msg.SetBodyString("text/html", bodyBuffer.String())
// attachments
attachments := email.Attachments
- for _, attachment := range attachments {
+ for _, emailAttachment := range attachments {
+ attachment := emailAttachment.Attachment
p, err := m.AttachmentService.GetPath(attachment)
if err != nil {
return fmt.Errorf("failed to get attachment path: %s", err)
}
- if !attachment.EmbeddedContent.MustGet() {
+
+ // check if attachment should be inline (for cid: references)
+ if emailAttachment.IsInline {
+ // inline attachment - embedded in email body, can be referenced via cid:filename
+ if !attachment.EmbeddedContent.MustGet() {
+ // simple inline image - no template processing needed
+ msg.EmbedFile(p.String())
+ } else {
+ // inline image with template processing
+ attachmentContent, err := os.ReadFile(p.String())
+ if err != nil {
+ return errs.Wrap(err)
+ }
+ // setup attachment for executing as email template
+ attachmentAsEmail := model.Email{
+ ID: email.ID,
+ CreatedAt: email.CreatedAt,
+ UpdatedAt: email.UpdatedAt,
+ Name: email.Name,
+ MailEnvelopeFrom: email.MailEnvelopeFrom,
+ MailHeaderFrom: email.MailHeaderFrom,
+ MailHeaderSubject: email.MailHeaderSubject,
+ Content: email.Content,
+ AddTrackingPixel: email.AddTrackingPixel,
+ CompanyID: email.CompanyID,
+ Attachments: email.Attachments,
+ Company: email.Company,
+ }
+ attachmentAsEmail.Content = nullable.NewNullableWithValue(
+ *vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
+ )
+ attachmentStr, err := m.TemplateService.CreateMailBody(
+ ctx,
+ "id",
+ "/",
+ testDomain,
+ campaignRecipient,
+ &attachmentAsEmail,
+ nil,
+ companyID,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to setup attachment with embedded content: %s", err)
+ }
+ // use EmbedReader for inline images - sets Content-Disposition: inline and Content-ID header
+ // the filename becomes the Content-ID, so use
in HTML
+ msg.EmbedReader(
+ filepath.Base(p.String()),
+ strings.NewReader(attachmentStr),
+ )
+ }
+ } else if !attachment.EmbeddedContent.MustGet() {
+ // regular attachment - shows in attachment list
msg.AttachFile(p.String())
} else {
+ // inline attachment - embedded in email body, can be referenced via cid:filename
attachmentContent, err := os.ReadFile(p.String())
if err != nil {
return errs.Wrap(err)
}
- // hacky setup of attachment for executing as email template
+ // setup attachment for executing as email template
attachmentAsEmail := model.Email{
ID: email.ID,
CreatedAt: email.CreatedAt,
@@ -667,7 +728,6 @@ func (m *Email) SendTestEmail(
Attachments: email.Attachments,
Company: email.Company,
}
- // really hacky / unsafe
attachmentAsEmail.Content = nullable.NewNullableWithValue(
*vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
)
@@ -684,7 +744,9 @@ func (m *Email) SendTestEmail(
if err != nil {
return fmt.Errorf("failed to setup attachment with embedded content: %s", err)
}
- msg.AttachReadSeeker(
+ // use EmbedReader for inline images - sets Content-Disposition: inline and Content-ID header
+ // the filename becomes the Content-ID, so use
in HTML
+ msg.EmbedReader(
filepath.Base(p.String()),
strings.NewReader(attachmentStr),
)
@@ -907,25 +969,25 @@ func (m *Email) loadEmailAttachmentsWithContext(
email *model.Email,
companyID *uuid.UUID,
) error {
- // get all attachment IDs associated with this email
- attachmentIDs, err := m.EmailRepository.GetAttachmentIDsByEmailID(ctx, email.ID.MustGet())
+ // get all email-attachment relationships with isInline status
+ emailAttachments, err := m.EmailRepository.GetEmailAttachments(ctx, email.ID.MustGet())
if err != nil {
return errs.Wrap(err)
}
// if no attachments, nothing to do
- if len(attachmentIDs) == 0 {
- email.Attachments = []*model.Attachment{}
+ if len(emailAttachments) == 0 {
+ email.Attachments = []*model.EmailAttachment{}
return nil
}
// get attachments with proper context filtering
- contextFilteredAttachments := []*model.Attachment{}
- for _, attachmentID := range attachmentIDs {
- attachment, err := m.AttachmentService.AttachmentRepository.GetByID(ctx, attachmentID)
+ contextFilteredAttachments := []*model.EmailAttachment{}
+ for _, ea := range emailAttachments {
+ attachment, err := m.AttachmentService.AttachmentRepository.GetByID(ctx, ea.AttachmentID)
if err != nil {
// if attachment doesn't exist, log and continue
- m.Logger.Debugw("attachment not found", "attachmentID", attachmentID, "error", err)
+ m.Logger.Debugw("attachment not found", "attachmentID", ea.AttachmentID, "error", err)
continue
}
@@ -947,7 +1009,10 @@ func (m *Email) loadEmailAttachmentsWithContext(
}
if isAttachmentAccessible {
- contextFilteredAttachments = append(contextFilteredAttachments, attachment)
+ contextFilteredAttachments = append(contextFilteredAttachments, &model.EmailAttachment{
+ Attachment: attachment,
+ IsInline: ea.IsInline,
+ })
}
}
diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js
index 0ec90ac..d7ccbeb 100644
--- a/frontend/src/lib/api/api.js
+++ b/frontend/src/lib/api/api.js
@@ -1528,12 +1528,12 @@ export class API {
* Add attachments to a email.
*
* @param {string} emailID
- * @param {string[]} attachmentIDs
+ * @param {Array<{id: string, isInline: boolean}>} attachments - Array of attachment objects with id and isInline flag
* @returns {Promise}
*/
- addAttachments: async (emailID, attachmentIDs) => {
+ addAttachments: async (emailID, attachments) => {
return await postJSON(this.getPath(`/email/${emailID}/attachment`), {
- ids: attachmentIDs
+ attachments: attachments
});
},
diff --git a/frontend/src/routes/email/[id]/attachments/+page.svelte b/frontend/src/routes/email/[id]/attachments/+page.svelte
index 3bc9a21..714a2a2 100644
--- a/frontend/src/routes/email/[id]/attachments/+page.svelte
+++ b/frontend/src/routes/email/[id]/attachments/+page.svelte
@@ -12,6 +12,8 @@
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import TextFieldMultiSelect from '$lib/components/TextFieldMultiSelect.svelte';
+ import CheckboxField from '$lib/components/CheckboxField.svelte';
+ import TableCellCheck from '$lib/components/table/TableCellCheck.svelte';
import Modal from '$lib/components/Modal.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import { BiMap } from '$lib/utils/maps';
@@ -34,7 +36,8 @@
// bindings
let formValues = {
emailID: '',
- attachmentIDs: []
+ attachmentIDs: [],
+ isInline: false
};
// local state
@@ -140,16 +143,19 @@
const onClickAddAttachment = async () => {
try {
isSubmitting = true;
- const idsToAdd = formValues.attachmentIDs.map((id) => {
- return availableAttachmentMap.byValue(id);
+ const attachmentsToAdd = formValues.attachmentIDs.map((id) => {
+ return {
+ id: availableAttachmentMap.byValue(id),
+ isInline: formValues.isInline
+ };
});
- const res = await api.email.addAttachments($page.params.id, idsToAdd);
+ const res = await api.email.addAttachments($page.params.id, attachmentsToAdd);
if (!res.success) {
addError = res.error;
return;
}
addError = '';
- const msg = idsToAdd.length > 1 ? 'attachments' : 'attachment';
+ const msg = attachmentsToAdd.length > 1 ? 'attachments' : 'attachment';
addToast(`Added ${msg}`, 'Success');
refreshData();
showAddAttachmentModal = false;
@@ -186,12 +192,14 @@
const openModal = () => {
showAddAttachmentModal = true;
formValues.attachmentIDs = [];
+ formValues.isInline = false;
addError = '';
};
const closeModal = () => {
showAddAttachmentModal = false;
formValues.attachmentIDs = [];
+ formValues.isInline = false;
addError = '';
};
@@ -201,8 +209,8 @@
Attachments: {emailName}
Add attachement
+
@@ -243,6 +252,13 @@
>Attachment
+
+ Inline (for images)
+