add send .ics as calendar invite

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2026-06-06 09:26:52 +02:00
parent b1bace4cce
commit 432d3d2c74
10 changed files with 156 additions and 23 deletions
+7
View File
@@ -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,
}
+4
View File
@@ -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 {
+4
View File
@@ -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
}
+3
View File
@@ -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]()
+15
View File
@@ -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 {
+3
View File
@@ -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)
+55 -12
View File
@@ -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),
)
}
}
}
+22 -8
View File
@@ -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 <img src="cid:filename.jpg"> 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 <img src="cid:filename.jpg"> in HTML
msg.EmbedReader(
filepath.Base(p.String()),
strings.NewReader(attachmentStr),
)
}
}
}
// the client sends all the messages and ensure that all messages are sent
+4 -2
View File
@@ -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<ApiResponse>}
*/
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
});
},
+39 -1
View File
@@ -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}
</TableCell>
<TableCellCheck value={attachment.embeddedContent} />
<TableCellCheck value={attachment.sendAsCalendar} />
{#if contextCompanyID}
<TableCellScope companyID={attachment.companyID} />
{/if}
@@ -351,6 +380,14 @@
optional
toolTipText="File contains template variables">Embedded content</CheckboxField
>
{#if showCalendarOption}
<CheckboxField
bind:value={formValues.sendAsCalendar}
defaultValue={false}
optional
toolTipText="Send ics files as a calendar invitation so Outlook shows Accept, Tentative and Decline">Send as calendar</CheckboxField
>
{/if}
{#if modalMode === 'create'}
<label for="file" class="flex flex-col py-2 w-60">
<p class="font-semibold text-slate-600 py-2">Files</p>
@@ -360,6 +397,7 @@
type="file"
name="files"
class="border-solid border-2 py-2 px-2 rounded-md file:px-4 file:py-2 file:text-white file:cursor-pointer file:text-sm file:font-semibold file:bg-cta-green hover:cursor-pointer file:hover:bg-cta-orange file:border-hidden file:rounded-md"
on:change={onFilesSelected}
multiple
/>
</label>