mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-17 16:07:18 +02:00
add inline images for emails
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-6
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+131
-6
@@ -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)
|
||||
|
||||
+84
-19
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ApiResponse>}
|
||||
*/
|
||||
addAttachments: async (emailID, attachmentIDs) => {
|
||||
addAttachments: async (emailID, attachments) => {
|
||||
return await postJSON(this.getPath(`/email/${emailID}/attachment`), {
|
||||
ids: attachmentIDs
|
||||
attachments: attachments
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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 = '';
|
||||
};
|
||||
</script>
|
||||
@@ -201,8 +209,8 @@
|
||||
<Headline>Attachments: {emailName}</Headline>
|
||||
<BigButton on:click={openModal}>Add attachement</BigButton>
|
||||
<Table
|
||||
columns={['Name', 'Description', 'Filename']}
|
||||
sortable={['Name', 'Description', 'Filename']}
|
||||
columns={['Name', 'Description', 'Filename', { column: 'Inline', alignText: 'center' }]}
|
||||
sortable={['Name', 'Description', 'Filename', 'Inline']}
|
||||
pagination={tableParams}
|
||||
hasData={!!allSelectedAttachmentsChunk}
|
||||
{hasNextPage}
|
||||
@@ -214,6 +222,7 @@
|
||||
<TableCell value={attachment.name} />
|
||||
<TableCell value={attachment.description} />
|
||||
<TableCell value={attachment.fileName} />
|
||||
<TableCellCheck value={attachment.isInline} />
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<TableDropDownEllipsis>
|
||||
@@ -243,6 +252,13 @@
|
||||
>Attachment
|
||||
</TextFieldMultiSelect>
|
||||
</FormColumn>
|
||||
<FormColumn>
|
||||
<CheckboxField
|
||||
bind:value={formValues.isInline}
|
||||
toolTipText="Inline attachments can be referenced in email HTML using cid:filename.jpg"
|
||||
>Inline (for images)</CheckboxField
|
||||
>
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
<FormError message={addError} />
|
||||
<FormFooter {closeModal} {isSubmitting} />
|
||||
|
||||
Reference in New Issue
Block a user