Merge branch 'feat-inline-attachment' into develop

This commit is contained in:
Ronni Skansing
2026-01-22 16:45:06 +01:00
11 changed files with 380 additions and 58 deletions

View File

@@ -27,8 +27,16 @@ var EmailOrderByMap = map[string]string{
}
// AddAttachmentsToEmailRequest is a request to add attachments to a message
// supports both old format (ids array) and new format (attachments array with isInline)
type AddAttachmentsToEmailRequest struct {
Attachments []string `json:"ids"` // attachment IDs
IDs []string `json:"ids"` // deprecated: old format for backward compatibility
Attachments []AttachmentWithInline `json:"attachments"` // new format with isInline support
}
// 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
@@ -66,13 +74,24 @@ func (m *Email) AddAttachments(g *gin.Context) {
if !ok {
return
}
// handle backward compatibility: if old format (ids) is used, convert to new format
if len(request.IDs) > 0 && len(request.Attachments) == 0 {
for _, idStr := range request.IDs {
request.Attachments = append(request.Attachments, AttachmentWithInline{
ID: idStr,
IsInline: false, // default to false for backward compatibility
})
}
}
if len(request.Attachments) == 0 {
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 +99,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 {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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{},
}
}

View File

@@ -2,6 +2,7 @@ package seed
import (
"crypto/rand"
"strings"
"github.com/go-errors/errors"
"github.com/google/uuid"
@@ -350,20 +351,35 @@ func SeedSettings(
return nil
}
// Migration approach
// Migration migrates db
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
errMsg := strings.ToLower(err.Error())
if !strings.Contains(errMsg, "duplicate") && !strings.Contains(errMsg, "already exists") {
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
errMsg := strings.ToLower(err.Error())
if !strings.Contains(errMsg, "duplicate") && !strings.Contains(errMsg, "already exists") {
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)
}

View File

@@ -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 <img src="cid:filename.jpg"> 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 <img src="cid:filename.jpg"> 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)

View File

@@ -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 <img src="cid:filename.jpg"> 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 <img src="cid:filename.jpg"> 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,
})
}
}