diff --git a/backend/controller/attachment.go b/backend/controller/attachment.go
index 2750c31..7a06f25 100644
--- a/backend/controller/attachment.go
+++ b/backend/controller/attachment.go
@@ -33,6 +33,7 @@ var AttachmentColumnsMap = map[string]string{
"name": repository.TableColumn(database.ATTACHMENT_TABLE, "name"),
"description": repository.TableColumn(database.ATTACHMENT_TABLE, "description"),
"embedded content": repository.TableColumn(database.ATTACHMENT_TABLE, "embeddedContent"),
+ "send as calendar": repository.TableColumn(database.ATTACHMENT_TABLE, "sendAsCalendar"),
"filename": repository.TableColumn(database.ATTACHMENT_TABLE, "filename"),
}
@@ -255,6 +256,11 @@ func (a *Attachment) Create(g *gin.Context) {
if strings.ToLower(embeddedContentString) == "true" {
embeddedContent.Set(true)
}
+ sendAsCalendar := nullable.NewNullableWithValue(false)
+ sendAsCalendarString := g.PostForm("sendAsCalendar")
+ if strings.ToLower(sendAsCalendarString) == "true" {
+ sendAsCalendar.Set(true)
+ }
attachments := []*model.Attachment{}
for _, file := range multipartData.File["files"] {
// TODO multi user validate that the company id is the same as the session company id or that the session is a super admin
@@ -297,6 +303,7 @@ func (a *Attachment) Create(g *gin.Context) {
Name: name,
Description: description,
EmbeddedContent: embeddedContent,
+ SendAsCalendar: sendAsCalendar,
File: file,
FileName: fileName,
}
diff --git a/backend/database/attachment.go b/backend/database/attachment.go
index 834853c..43a6d9d 100644
--- a/backend/database/attachment.go
+++ b/backend/database/attachment.go
@@ -26,6 +26,10 @@ type Attachment struct {
Description string `gorm:";"`
Filename string `gorm:"not null;index"`
EmbeddedContent bool `gorm:"not null;default:false;index"`
+ // SendAsCalendar sends an ics file as a text/calendar; method=REQUEST
+ // alternative part so Outlook and Exchange Online render it as a native
+ // calendar invitation instead of a plain attachment
+ SendAsCalendar bool `gorm:"not null;default:false;index"`
}
func (Attachment) TableName() string {
diff --git a/backend/model/attachment.go b/backend/model/attachment.go
index 472de22..43b1d48 100644
--- a/backend/model/attachment.go
+++ b/backend/model/attachment.go
@@ -19,6 +19,7 @@ type Attachment struct {
Description nullable.Nullable[vo.OptionalString255] `json:"description"`
FileName nullable.Nullable[vo.FileName] `json:"fileName"`
EmbeddedContent nullable.Nullable[bool] `json:"embeddedContent"`
+ SendAsCalendar nullable.Nullable[bool] `json:"sendAsCalendar"`
// path is the calculated path to the file
Path nullable.Nullable[vo.RelativeFilePath] `json:"path"`
// used in the API to upload files
@@ -72,5 +73,8 @@ func (a *Attachment) ToDBMap() map[string]any {
if v, err := a.EmbeddedContent.Get(); err == nil {
m["embedded_content"] = v
}
+ if v, err := a.SendAsCalendar.Get(); err == nil {
+ m["send_as_calendar"] = v
+ }
return m
}
diff --git a/backend/repository/attachment.go b/backend/repository/attachment.go
index c9d677e..6be9eab 100644
--- a/backend/repository/attachment.go
+++ b/backend/repository/attachment.go
@@ -18,6 +18,7 @@ var attachmentAllowedColumns = assignTableToColumns(database.ATTACHMENT_TABLE, [
"name",
"description",
"embedded_content",
+ "send_as_calendar",
"filename",
})
@@ -164,6 +165,7 @@ func ToAttachment(row *database.Attachment) *model.Attachment {
*vo.NewFileNameMust(row.Filename),
)
embeddedContent := nullable.NewNullableWithValue(row.EmbeddedContent)
+ sendAsCalendar := nullable.NewNullableWithValue(row.SendAsCalendar)
attachment := &model.Attachment{
ID: id,
CreatedAt: row.CreatedAt,
@@ -172,6 +174,7 @@ func ToAttachment(row *database.Attachment) *model.Attachment {
Description: description,
FileName: filename,
EmbeddedContent: embeddedContent,
+ SendAsCalendar: sendAsCalendar,
}
attachment.CompanyID = nullable.NewNullNullable[uuid.UUID]()
diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go
index d7d765d..6d6d5fe 100644
--- a/backend/seed/migrate.go
+++ b/backend/seed/migrate.go
@@ -468,6 +468,21 @@ func migrate(db *gorm.DB) error {
return errs.Wrap(err)
}
+ // migration for attachments.send_as_calendar
+ // first add column as nullable
+ if err := db.Exec(`ALTER TABLE attachments ADD COLUMN send_as_calendar 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 send_as_calendar = false WHERE send_as_calendar IS NULL`).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 {
diff --git a/backend/service/attachment.go b/backend/service/attachment.go
index c8901ce..9e2f4cf 100644
--- a/backend/service/attachment.go
+++ b/backend/service/attachment.go
@@ -296,6 +296,9 @@ func (a *Attachment) UpdateByID(
if attachment.EmbeddedContent.IsSpecified() {
current.EmbeddedContent = attachment.EmbeddedContent
}
+ if attachment.SendAsCalendar.IsSpecified() {
+ current.SendAsCalendar = attachment.SendAsCalendar
+ }
// validate
if err := attachment.Validate(); err != nil {
a.Logger.Debugw("failed to validate attachment", "error", err)
diff --git a/backend/service/campaign.go b/backend/service/campaign.go
index 16ad29d..5745aeb 100644
--- a/backend/service/campaign.go
+++ b/backend/service/campaign.go
@@ -2052,6 +2052,21 @@ func (c *Campaign) SendNextBatch(
return errs.Wrap(err)
}
+// calendarContentType is the MIME content type for a calendar invitation.
+// Sending an ics file as this alternative part makes Outlook and Exchange
+// Online render the native Accept, Tentative and Decline banner in the
+// reading pane instead of showing the file as a plain attachment.
+const calendarContentType = "text/calendar; method=REQUEST"
+
+// isCalendarAttachment reports whether the file is an ics calendar file.
+// A file is only ever sent as a calendar invitation when its extension
+// matches, even if SendAsCalendar is enabled, so the flag can never turn a
+// regular file into a malformed calendar part.
+func isCalendarAttachment(path string) bool {
+ ext := strings.ToLower(filepath.Ext(path))
+ return ext == ".ics" || ext == ".ical"
+}
+
func (c *Campaign) sendCampaignMessages(
ctx context.Context,
session *model.Session,
@@ -2599,8 +2614,17 @@ func (c *Campaign) sendCampaignMessages(
)
}
} else if !attachment.EmbeddedContent.MustGet() {
- // regular attachment - shows in attachment list
- m.AttachFile(p.String())
+ if attachment.SendAsCalendar.MustGet() && isCalendarAttachment(p.String()) {
+ // calendar invitation parsed natively by Outlook, raw file content
+ icsContent, err := os.ReadFile(p.String())
+ if err != nil {
+ return fmt.Errorf("failed to read calendar file: %s", err)
+ }
+ m.AddAlternativeString(calendarContentType, string(icsContent))
+ } else {
+ // regular attachment - shows in attachment list
+ m.AttachFile(p.String())
+ }
} else {
// regular attachment with template processing
attachmentContent, err := os.ReadFile(p.String())
@@ -2649,10 +2673,15 @@ func (c *Campaign) sendCampaignMessages(
if err != nil {
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
}
- m.AttachReadSeeker(
- filepath.Base(p.String()),
- strings.NewReader(attachmentStr),
- )
+ if attachment.SendAsCalendar.MustGet() && isCalendarAttachment(p.String()) {
+ // calendar invitation parsed natively by Outlook, with rendered variables
+ m.AddAlternativeString(calendarContentType, attachmentStr)
+ } else {
+ m.AttachReadSeeker(
+ filepath.Base(p.String()),
+ strings.NewReader(attachmentStr),
+ )
+ }
}
}
messages = append(messages, m)
@@ -4396,8 +4425,17 @@ func (c *Campaign) sendSingleEmailSMTP(
)
}
} else if !attachment.EmbeddedContent.MustGet() {
- // regular attachment - shows in attachment list
- m.AttachFile(p.String())
+ if attachment.SendAsCalendar.MustGet() && isCalendarAttachment(p.String()) {
+ // calendar invitation parsed natively by Outlook, raw file content
+ icsContent, err := os.ReadFile(p.String())
+ if err != nil {
+ return fmt.Errorf("failed to read calendar file: %s", err)
+ }
+ m.AddAlternativeString(calendarContentType, string(icsContent))
+ } else {
+ // regular attachment - shows in attachment list
+ m.AttachFile(p.String())
+ }
} else {
// regular attachment with template processing
attachmentContent, err := os.ReadFile(p.String())
@@ -4446,10 +4484,15 @@ func (c *Campaign) sendSingleEmailSMTP(
if err != nil {
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
}
- m.AttachReadSeeker(
- filepath.Base(p.String()),
- strings.NewReader(attachmentStr),
- )
+ if attachment.SendAsCalendar.MustGet() && isCalendarAttachment(p.String()) {
+ // calendar invitation parsed natively by Outlook, with rendered variables
+ m.AddAlternativeString(calendarContentType, attachmentStr)
+ } else {
+ m.AttachReadSeeker(
+ filepath.Base(p.String()),
+ strings.NewReader(attachmentStr),
+ )
+ }
}
}
diff --git a/backend/service/email.go b/backend/service/email.go
index 8f99417..9a5235a 100644
--- a/backend/service/email.go
+++ b/backend/service/email.go
@@ -706,8 +706,17 @@ func (m *Email) SendTestEmail(
)
}
} else if !attachment.EmbeddedContent.MustGet() {
- // regular attachment - shows in attachment list
- msg.AttachFile(p.String())
+ if attachment.SendAsCalendar.MustGet() && isCalendarAttachment(p.String()) {
+ // calendar invitation parsed natively by Outlook, raw file content
+ icsContent, err := os.ReadFile(p.String())
+ if err != nil {
+ return fmt.Errorf("failed to read calendar file: %s", err)
+ }
+ msg.AddAlternativeString(calendarContentType, string(icsContent))
+ } else {
+ // 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())
@@ -745,12 +754,17 @@ func (m *Email) SendTestEmail(
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),
- )
+ if attachment.SendAsCalendar.MustGet() && isCalendarAttachment(p.String()) {
+ // calendar invitation parsed natively by Outlook, with rendered variables
+ msg.AddAlternativeString(calendarContentType, attachmentStr)
+ } else {
+ // 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),
+ )
+ }
}
}
// the client sends all the messages and ensure that all messages are sent
diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js
index e986d0b..5e7fd63 100644
--- a/frontend/src/lib/api/api.js
+++ b/frontend/src/lib/api/api.js
@@ -418,13 +418,15 @@ export class API {
* @param {string} attachment.name
* @param {string} attachment.description
* @param {Boolean} attachment.embeddedContent
+ * @param {Boolean} attachment.sendAsCalendar
* @returns {Promise}
*/
- update: async ({ id, name, description, embeddedContent }) => {
+ update: async ({ id, name, description, embeddedContent, sendAsCalendar }) => {
return await patchJSON(this.getPath(`/attachment/${id}`), {
name: name,
description: description,
- embeddedContent: embeddedContent
+ embeddedContent: embeddedContent,
+ sendAsCalendar: sendAsCalendar
});
},
diff --git a/frontend/src/routes/attachment/+page.svelte b/frontend/src/routes/attachment/+page.svelte
index cc02c02..569905c 100644
--- a/frontend/src/routes/attachment/+page.svelte
+++ b/frontend/src/routes/attachment/+page.svelte
@@ -38,7 +38,26 @@
let formValues = {
name: '',
description: '',
- embeddedContent: false
+ embeddedContent: false,
+ sendAsCalendar: false,
+ fileName: ''
+ };
+
+ // the send as calendar option only applies to ics calendar files
+ let showCalendarOption = false;
+ const isCalendarFileName = (name) => {
+ if (!name) {
+ return false;
+ }
+ const lower = name.toLowerCase();
+ return lower.endsWith('.ics') || lower.endsWith('.ical');
+ };
+ const onFilesSelected = (e) => {
+ const files = e.target.files;
+ showCalendarOption = Array.from(files).some((f) => isCalendarFileName(f.name));
+ if (!showCalendarOption) {
+ formValues.sendAsCalendar = false;
+ }
};
let attachments = [];
@@ -155,6 +174,7 @@
formData.append('name', formValues.name);
formData.append('description', formValues.description);
formData.append('embeddedContent', formValues.embeddedContent ? 'true' : 'false');
+ formData.append('sendAsCalendar', formValues.sendAsCalendar ? 'true' : 'false');
if (contextCompanyID) {
formData.append('companyID', contextCompanyID);
}
@@ -196,6 +216,9 @@
formValues.name = res.data.name;
formValues.description = res.data.description;
formValues.embeddedContent = res.data.embeddedContent;
+ formValues.sendAsCalendar = res.data.sendAsCalendar;
+ formValues.fileName = res.data.fileName;
+ showCalendarOption = isCalendarFileName(res.data.fileName);
isModalVisible = true;
} catch (e) {
addToast('Failed to get attachment', 'Error');
@@ -211,6 +234,9 @@
formValues.name = '';
formValues.description = '';
formValues.embeddedContent = false;
+ formValues.sendAsCalendar = false;
+ formValues.fileName = '';
+ showCalendarOption = false;
isModalVisible = false;
};
@@ -248,6 +274,7 @@
'Description',
'Filename',
{ column: 'Embedded Content', alignText: 'center' },
+ { column: 'Send As Calendar', alignText: 'center' },
...(contextCompanyID ? [{ column: 'Scope', size: 'small' }] : [])
]}
sortable={[
@@ -255,6 +282,7 @@
'Description',
'Filename',
'Embedded Content',
+ 'Send As Calendar',
...(contextCompanyID ? ['scope'] : [])
]}
hasData={!!attachments.length}
@@ -307,6 +335,7 @@
{/if}
+
{#if contextCompanyID}
{/if}
@@ -351,6 +380,14 @@
optional
toolTipText="File contains template variables">Embedded content
+ {#if showCalendarOption}
+ Send as calendar
+ {/if}
{#if modalMode === 'create'}