mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-06-08 07:33:52 +02:00
add send .ics as calendar invite
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user