mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-06 04:17:54 +02:00
Merge branch 'develop' into feat-arm-support
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [1.27.0] - 2026-01-22
|
||||
- Added support for attachments as inline images in emails and the email editor
|
||||
- Fix delete orphans recipients when recipient is in active campaign
|
||||
- Fix error in update options
|
||||
- Fix faulty migration
|
||||
- Fix added AllowDenyIDs on get campaign by id endpoint for consistency
|
||||
|
||||
## [1.26.0] - 2025-12-19
|
||||
- Added ContentRaw variable to API Senders
|
||||
- Fix proxy capturing on 302 responses
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -434,45 +434,6 @@ func (r *Recipient) GetOrphaned(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteAllOrphaned deletes all recipients that are not in any group
|
||||
func (r *Recipient) DeleteAllOrphaned(
|
||||
ctx context.Context,
|
||||
companyID *uuid.UUID,
|
||||
) (int64, error) {
|
||||
// build optimized LEFT JOIN delete query for orphaned recipients
|
||||
var companyFilter string
|
||||
var args []interface{}
|
||||
|
||||
if companyID != nil {
|
||||
companyFilter = fmt.Sprintf("AND r.company_id = ?")
|
||||
args = append(args, companyID)
|
||||
} else {
|
||||
companyFilter = fmt.Sprintf("AND r.company_id IS NULL")
|
||||
}
|
||||
|
||||
// use raw SQL for optimized LEFT JOIN delete
|
||||
query := fmt.Sprintf(`
|
||||
DELETE FROM %s
|
||||
WHERE id IN (
|
||||
SELECT r.id FROM %s r
|
||||
LEFT JOIN %s rgr ON r.id = rgr.recipient_id
|
||||
WHERE rgr.recipient_id IS NULL %s
|
||||
)`,
|
||||
database.RECIPIENT_TABLE,
|
||||
database.RECIPIENT_TABLE,
|
||||
database.RECIPIENT_GROUP_RECIPIENT_TABLE,
|
||||
companyFilter,
|
||||
)
|
||||
|
||||
result := r.DB.Exec(query, args...)
|
||||
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
// GetByID gets a recipient by id
|
||||
func (r *Recipient) GetByID(
|
||||
ctx context.Context,
|
||||
|
||||
+23
-7
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -149,14 +149,33 @@ func (o *Option) SetOptionByKey(
|
||||
)
|
||||
}
|
||||
case data.OptionKeyLogLevel:
|
||||
// is allow listed
|
||||
fallthrough
|
||||
// validate log level value
|
||||
if v != "debug" && v != "info" && v != "warn" && v != "error" {
|
||||
o.Logger.Debugw("invalid log level value",
|
||||
"value", v,
|
||||
)
|
||||
return validate.WrapErrorWithField(
|
||||
errs.NewValidationError(
|
||||
errors.New("invalid log level"),
|
||||
),
|
||||
"log level",
|
||||
)
|
||||
}
|
||||
case data.OptionKeyDBLogLevel:
|
||||
// is allow listed
|
||||
fallthrough
|
||||
// validate db log level value
|
||||
if v != "silent" && v != "info" && v != "warn" && v != "error" {
|
||||
o.Logger.Debugw("invalid db log level value",
|
||||
"value", v,
|
||||
)
|
||||
return validate.WrapErrorWithField(
|
||||
errs.NewValidationError(
|
||||
errors.New("invalid db log level"),
|
||||
),
|
||||
"db log level",
|
||||
)
|
||||
}
|
||||
case data.OptionKeyAdminSSOLogin:
|
||||
// is allow listed
|
||||
fallthrough
|
||||
case data.OptionKeyDisplayMode:
|
||||
// validate display mode value
|
||||
if v != data.OptionValueDisplayModeWhitebox && v != data.OptionValueDisplayModeBlackbox {
|
||||
|
||||
@@ -437,15 +437,30 @@ func (r *Recipient) DeleteAllOrphaned(
|
||||
r.AuditLogNotAuthorized(ae)
|
||||
return 0, errs.ErrAuthorizationFailed
|
||||
}
|
||||
// delete orphaned recipients
|
||||
count, err := r.RecipientRepository.DeleteAllOrphaned(
|
||||
|
||||
// get all orphaned recipients
|
||||
orphanedRecipients, err := r.RecipientRepository.GetOrphaned(
|
||||
ctx,
|
||||
companyID,
|
||||
&repository.RecipientOption{}, // no pagination, get all
|
||||
)
|
||||
if err != nil {
|
||||
r.Logger.Errorw("failed to delete orphaned recipients - failed to delete orphaned recipients", "error", err)
|
||||
r.Logger.Errorw("failed to get orphaned recipients", "error", err)
|
||||
return 0, errs.Wrap(err)
|
||||
}
|
||||
|
||||
// delete each orphaned recipient using core deletion logic
|
||||
var count int64
|
||||
for _, recipient := range orphanedRecipients.Rows {
|
||||
recipientID := recipient.ID.MustGet()
|
||||
err = r.deleteRecipientByID(ctx, &recipientID)
|
||||
if err != nil {
|
||||
r.Logger.Errorw("failed to delete orphaned recipient", "error", err, "recipientID", recipientID.String())
|
||||
return count, errs.Wrap(err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
ae.Details["count"] = count
|
||||
r.AuditLogAuthorized(ae)
|
||||
return count, nil
|
||||
@@ -686,26 +701,13 @@ func getStringFromOptional(field nullable.Nullable[vo.OptionalString127]) string
|
||||
return ""
|
||||
}
|
||||
|
||||
// Delete deletes a recipient
|
||||
func (r *Recipient) DeleteByID(
|
||||
// deleteRecipientByID is the core deletion logic without permission checks or audit logging
|
||||
func (r *Recipient) deleteRecipientByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
) error {
|
||||
ae := NewAuditEvent("Recipient.DeleteByID", session)
|
||||
ae.Details["id"] = id.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
r.LogAuthError(err)
|
||||
return err
|
||||
}
|
||||
if !isAuthorized {
|
||||
r.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
// remove recipient from all groups
|
||||
err = r.RecipientGroupRepository.RemoveRecipientByIDFromAllGroups(ctx, id)
|
||||
err := r.RecipientGroupRepository.RemoveRecipientByIDFromAllGroups(ctx, id)
|
||||
if err != nil {
|
||||
r.Logger.Errorw("failed to delete recipient - failed to remove recipient from all groups",
|
||||
"error", err,
|
||||
@@ -758,7 +760,34 @@ func (r *Recipient) DeleteByID(
|
||||
r.Logger.Errorw("failed to delete recipient - failed to delete recipient", "error", err)
|
||||
return err
|
||||
}
|
||||
r.AuditLogAuthorized(ae)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByID deletes a recipient
|
||||
func (r *Recipient) DeleteByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
) error {
|
||||
ae := NewAuditEvent("Recipient.DeleteByID", session)
|
||||
ae.Details["id"] = id.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
r.LogAuthError(err)
|
||||
return err
|
||||
}
|
||||
if !isAuthorized {
|
||||
r.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
err = r.deleteRecipientByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
export let domainMap = new BiMap({});
|
||||
export let selectedDomain = '';
|
||||
export let externalVimMode = null; // allow external control of vim mode
|
||||
export let attachments = []; // array of email attachments for inline image preview
|
||||
let localVimMode = externalVimMode !== null ? externalVimMode : $vimModeEnabled;
|
||||
let editor = null;
|
||||
let previewFrame = null;
|
||||
@@ -287,6 +288,54 @@
|
||||
};
|
||||
|
||||
const replaceTemplateVariables = async (text) => {
|
||||
// replace inline image cid: references with data URLs for preview in shadow DOM
|
||||
if (attachments && attachments.length > 0) {
|
||||
// find all cid: references in the text
|
||||
const cidRegex = /src=["']cid:([^"']+)["']/gi;
|
||||
const matches = [];
|
||||
let match;
|
||||
while ((match = cidRegex.exec(text)) !== null) {
|
||||
matches.push(match[1]);
|
||||
}
|
||||
|
||||
// fetch and replace each cid: reference with data URL
|
||||
for (const filename of matches) {
|
||||
// find the attachment with this filename that is inline
|
||||
const attachment = attachments.find((att) => att.isInline && att.fileName === filename);
|
||||
if (attachment && attachment.id) {
|
||||
try {
|
||||
// fetch attachment content from API
|
||||
const response = await fetch(`/api/v1/attachment/${attachment.id}/content`);
|
||||
const json = await response.json();
|
||||
|
||||
if (json.success && json.data && json.data.file) {
|
||||
// determine mime type from filename extension
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
const mimeTypes = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
bmp: 'image/bmp'
|
||||
};
|
||||
const mimeType = mimeTypes[ext] || 'image/png';
|
||||
|
||||
// create data URL from base64 data
|
||||
const dataUrl = `data:${mimeType};base64,${json.data.file}`;
|
||||
|
||||
// replace cid: with data URL (escape filename for regex)
|
||||
const escapedFilename = filename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
text = text.replace(new RegExp(`cid:${escapedFilename}`, 'g'), dataUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch attachment for preview:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let param = '?id=905f286e-486b-434b-8ecc-d82456a07f7b';
|
||||
let _baseURL = `https://${baseURL}`;
|
||||
let _url = `https://${baseURL}${param}`;
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
mailEnvelopeFrom: null,
|
||||
mailHeaderFrom: null,
|
||||
mailHeaderSubject: null,
|
||||
addTrackingPixel: false
|
||||
addTrackingPixel: false,
|
||||
attachments: []
|
||||
};
|
||||
let isDeleteAlertVisible = false;
|
||||
let deleteValues = {
|
||||
@@ -259,7 +260,8 @@
|
||||
mailEnvelopeFrom: null,
|
||||
mailHeaderFrom: null,
|
||||
mailHeaderSubject: null,
|
||||
addTrackingPixel: false
|
||||
addTrackingPixel: false,
|
||||
attachments: []
|
||||
};
|
||||
form.reset();
|
||||
isModalVisible = false;
|
||||
@@ -310,6 +312,7 @@
|
||||
formValues.mailHeaderFrom = email.mailHeaderFrom;
|
||||
formValues.mailHeaderSubject = email.mailHeaderSubject;
|
||||
formValues.addTrackingPixel = email.addTrackingPixel;
|
||||
formValues.attachments = email.attachments || [];
|
||||
};
|
||||
|
||||
const openSendTestModal = async (id) => {
|
||||
@@ -437,7 +440,12 @@
|
||||
</Table>
|
||||
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
|
||||
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting} {modalMode}>
|
||||
<Editor contentType="email" {domainMap} bind:value={formValues.content}>
|
||||
<Editor
|
||||
contentType="email"
|
||||
{domainMap}
|
||||
bind:value={formValues.content}
|
||||
attachments={formValues.attachments}
|
||||
>
|
||||
<div class="flex flex-col lg:flex-row w-full pl-4">
|
||||
<div class="flex flex-col lg:flex-row justify-between w-1/3">
|
||||
<TextField
|
||||
|
||||
@@ -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