2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
# JSX email templates
- `components` Holds reusable patterns
- `templates` A template equals a type of email sent
## Usage
```ts
import { renderEmail } from "@calcom/emails";
await renderEmail("TeamInviteEmail", {
language: t,
from: "teampro@example.com",
to: "pro@example.com",
teamName: "Team Pro",
joinLink: "https://cal.com",
});
```
The first argument is the template name as defined inside `templates/index.ts`. The second argument are the template props.
## Development
You can use an API endpoint to preview the email HTML, there's already one on `/apps/web/pages/api/email.ts` feel free to change the template to the one you're currently working on.

View File

@@ -0,0 +1,8 @@
# Starts mailhog SMTP server on port 1025, web interface on port 8025
version: "3.6"
services:
mailhog:
image: "mailhog/mailhog:latest"
ports:
- "1025:1025"
- "8025:8025"

View File

@@ -0,0 +1,515 @@
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import type { TFunction } from "next-i18next";
import type { EventNameObjectType } from "@calcom/core/event";
import { getEventName } from "@calcom/core/event";
import type BaseEmail from "@calcom/emails/templates/_base-email";
import { formatCalEvent } from "@calcom/lib/formatCalendarEvent";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { MonthlyDigestEmailData } from "./src/templates/MonthlyDigestEmail";
import type { OrganizationAdminNoSlotsEmailInput } from "./src/templates/OrganizationAdminNoSlots";
import type { EmailVerifyLink } from "./templates/account-verify-email";
import AccountVerifyEmail from "./templates/account-verify-email";
import type { OrganizationNotification } from "./templates/admin-organization-notification";
import AdminOrganizationNotification from "./templates/admin-organization-notification";
import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email";
import AttendeeCancelledEmail from "./templates/attendee-cancelled-email";
import AttendeeCancelledSeatEmail from "./templates/attendee-cancelled-seat-email";
import AttendeeDailyVideoDownloadRecordingEmail from "./templates/attendee-daily-video-download-recording-email";
import AttendeeDailyVideoDownloadTranscriptEmail from "./templates/attendee-daily-video-download-transcript-email";
import AttendeeDeclinedEmail from "./templates/attendee-declined-email";
import AttendeeLocationChangeEmail from "./templates/attendee-location-change-email";
import AttendeeRequestEmail from "./templates/attendee-request-email";
import AttendeeRescheduledEmail from "./templates/attendee-rescheduled-email";
import AttendeeScheduledEmail from "./templates/attendee-scheduled-email";
import type { EmailVerifyCode } from "./templates/attendee-verify-email";
import AttendeeVerifyEmail from "./templates/attendee-verify-email";
import AttendeeWasRequestedToRescheduleEmail from "./templates/attendee-was-requested-to-reschedule-email";
import BookingRedirectEmailNotification from "./templates/booking-redirect-notification";
import type { IBookingRedirect } from "./templates/booking-redirect-notification";
import BrokenIntegrationEmail from "./templates/broken-integration-email";
import type { ChangeOfEmailVerifyLink } from "./templates/change-account-email-verify";
import ChangeOfEmailVerifyEmail from "./templates/change-account-email-verify";
import DisabledAppEmail from "./templates/disabled-app-email";
import type { Feedback } from "./templates/feedback-email";
import FeedbackEmail from "./templates/feedback-email";
import type { PasswordReset } from "./templates/forgot-password-email";
import ForgotPasswordEmail from "./templates/forgot-password-email";
import MonthlyDigestEmail from "./templates/monthly-digest-email";
import NoShowFeeChargedEmail from "./templates/no-show-fee-charged-email";
import OrganizationAdminNoSlotsEmail from "./templates/organization-admin-no-slots-email";
import type { OrganizationCreation } from "./templates/organization-creation-email";
import OrganizationCreationEmail from "./templates/organization-creation-email";
import type { OrganizationEmailVerify } from "./templates/organization-email-verification";
import OrganizationEmailVerification from "./templates/organization-email-verification";
import OrganizerAttendeeCancelledSeatEmail from "./templates/organizer-attendee-cancelled-seat-email";
import OrganizerCancelledEmail from "./templates/organizer-cancelled-email";
import OrganizerDailyVideoDownloadRecordingEmail from "./templates/organizer-daily-video-download-recording-email";
import OrganizerDailyVideoDownloadTranscriptEmail from "./templates/organizer-daily-video-download-transcript-email";
import OrganizerLocationChangeEmail from "./templates/organizer-location-change-email";
import OrganizerPaymentRefundFailedEmail from "./templates/organizer-payment-refund-failed-email";
import OrganizerRequestEmail from "./templates/organizer-request-email";
import OrganizerRequestReminderEmail from "./templates/organizer-request-reminder-email";
import OrganizerRequestedToRescheduleEmail from "./templates/organizer-requested-to-reschedule-email";
import OrganizerRescheduledEmail from "./templates/organizer-rescheduled-email";
import OrganizerScheduledEmail from "./templates/organizer-scheduled-email";
import SlugReplacementEmail from "./templates/slug-replacement-email";
import type { TeamInvite } from "./templates/team-invite-email";
import TeamInviteEmail from "./templates/team-invite-email";
const sendEmail = (prepare: () => BaseEmail) => {
return new Promise((resolve, reject) => {
try {
const email = prepare();
resolve(email.sendEmail());
} catch (e) {
reject(console.error(`${prepare.constructor.name}.sendEmail failed`, e));
}
});
};
export const sendScheduledEmails = async (
calEvent: CalendarEvent,
eventNameObject?: EventNameObjectType,
hostEmailDisabled?: boolean,
attendeeEmailDisabled?: boolean
) => {
const formattedCalEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
if (!hostEmailDisabled) {
emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent: formattedCalEvent })));
if (formattedCalEvent.team) {
for (const teamMember of formattedCalEvent.team.members) {
emailsToSend.push(
sendEmail(() => new OrganizerScheduledEmail({ calEvent: formattedCalEvent, teamMember }))
);
}
}
}
if (!attendeeEmailDisabled) {
emailsToSend.push(
...formattedCalEvent.attendees.map((attendee) => {
return sendEmail(
() =>
new AttendeeScheduledEmail(
{
...formattedCalEvent,
...(formattedCalEvent.hideCalendarNotes && { additionalNotes: undefined }),
...(eventNameObject && {
title: getEventName({ ...eventNameObject, t: attendee.language.translate }),
}),
},
attendee
)
);
})
);
}
await Promise.all(emailsToSend);
};
// for rescheduled round robin booking that assigned new members
export const sendRoundRobinScheduledEmails = async (calEvent: CalendarEvent, members: Person[]) => {
const formattedCalEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
for (const teamMember of members) {
emailsToSend.push(
sendEmail(() => new OrganizerScheduledEmail({ calEvent: formattedCalEvent, teamMember }))
);
}
await Promise.all(emailsToSend);
};
export const sendRoundRobinRescheduledEmails = async (calEvent: CalendarEvent, members: Person[]) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
for (const teamMember of members) {
emailsToSend.push(
sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent, teamMember }))
);
}
await Promise.all(emailsToSend);
};
export const sendRoundRobinCancelledEmails = async (calEvent: CalendarEvent, members: Person[]) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
for (const teamMember of members) {
emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent, teamMember })));
}
await Promise.all(emailsToSend);
};
export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent })));
if (calendarEvent.team) {
for (const teamMember of calendarEvent.team.members) {
emailsToSend.push(
sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent, teamMember }))
);
}
}
emailsToSend.push(
...calendarEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeRescheduledEmail(calendarEvent, attendee));
})
);
await Promise.all(emailsToSend);
};
export const sendRescheduledSeatEmail = async (calEvent: CalendarEvent, attendee: Person) => {
const calendarEvent = formatCalEvent(calEvent);
const clonedCalEvent = cloneDeep(calendarEvent);
const emailsToSend: Promise<unknown>[] = [
sendEmail(() => new AttendeeRescheduledEmail(clonedCalEvent, attendee)),
sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent })),
];
await Promise.all(emailsToSend);
};
export const sendScheduledSeatsEmails = async (
calEvent: CalendarEvent,
invitee: Person,
newSeat: boolean,
showAttendees: boolean,
hostEmailDisabled?: boolean,
attendeeEmailDisabled?: boolean
) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
if (!hostEmailDisabled) {
emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent: calendarEvent, newSeat })));
if (calendarEvent.team) {
for (const teamMember of calendarEvent.team.members) {
emailsToSend.push(
sendEmail(() => new OrganizerScheduledEmail({ calEvent: calendarEvent, newSeat, teamMember }))
);
}
}
}
if (!attendeeEmailDisabled) {
emailsToSend.push(
sendEmail(
() =>
new AttendeeScheduledEmail(
{
...calendarEvent,
...(calendarEvent.hideCalendarNotes && { additionalNotes: undefined }),
},
invitee,
showAttendees
)
)
);
}
await Promise.all(emailsToSend);
};
export const sendCancelledSeatEmails = async (calEvent: CalendarEvent, cancelledAttendee: Person) => {
const formattedCalEvent = formatCalEvent(calEvent);
const clonedCalEvent = cloneDeep(formattedCalEvent);
await Promise.all([
sendEmail(() => new AttendeeCancelledSeatEmail(clonedCalEvent, cancelledAttendee)),
sendEmail(() => new OrganizerAttendeeCancelledSeatEmail({ calEvent: formattedCalEvent })),
]);
};
export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(sendEmail(() => new OrganizerRequestEmail({ calEvent: calendarEvent })));
if (calendarEvent.team?.members) {
for (const teamMember of calendarEvent.team.members) {
emailsToSend.push(sendEmail(() => new OrganizerRequestEmail({ calEvent: calendarEvent, teamMember })));
}
}
await Promise.all(emailsToSend);
};
export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee: Person) => {
const calendarEvent = formatCalEvent(calEvent);
await sendEmail(() => new AttendeeRequestEmail(calendarEvent, attendee));
};
export const sendDeclinedEmails = async (calEvent: CalendarEvent) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(
...calendarEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeDeclinedEmail(calendarEvent, attendee));
})
);
await Promise.all(emailsToSend);
};
export const sendCancelledEmails = async (
calEvent: CalendarEvent,
eventNameObject: Pick<EventNameObjectType, "eventName">
) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent })));
if (calendarEvent.team?.members) {
for (const teamMember of calendarEvent.team.members) {
emailsToSend.push(
sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent, teamMember }))
);
}
}
emailsToSend.push(
...calendarEvent.attendees.map((attendee) => {
return sendEmail(
() =>
new AttendeeCancelledEmail(
{
...calendarEvent,
title: getEventName({
...eventNameObject,
t: attendee.language.translate,
attendeeName: attendee.name,
host: calendarEvent.organizer.name,
eventType: calendarEvent.type,
...(calendarEvent.responses && { bookingFields: calendarEvent.responses }),
...(calendarEvent.location && { location: calendarEvent.location }),
}),
},
attendee
)
);
})
);
await Promise.all(emailsToSend);
};
export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(sendEmail(() => new OrganizerRequestReminderEmail({ calEvent: calendarEvent })));
if (calendarEvent.team?.members) {
for (const teamMember of calendarEvent.team.members) {
emailsToSend.push(
sendEmail(() => new OrganizerRequestReminderEmail({ calEvent: calendarEvent, teamMember }))
);
}
}
};
export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => {
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeAwaitingPaymentEmail(calEvent, attendee));
})
);
await Promise.all(emailsToSend);
};
export const sendOrganizerPaymentRefundFailedEmail = async (calEvent: CalendarEvent) => {
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(sendEmail(() => new OrganizerPaymentRefundFailedEmail({ calEvent })));
if (calEvent.team?.members) {
for (const teamMember of calEvent.team.members) {
emailsToSend.push(sendEmail(() => new OrganizerPaymentRefundFailedEmail({ calEvent, teamMember })));
}
}
await Promise.all(emailsToSend);
};
export const sendPasswordResetEmail = async (passwordResetEvent: PasswordReset) => {
await sendEmail(() => new ForgotPasswordEmail(passwordResetEvent));
};
export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
await sendEmail(() => new TeamInviteEmail(teamInviteEvent));
};
export const sendOrganizationCreationEmail = async (organizationCreationEvent: OrganizationCreation) => {
await sendEmail(() => new OrganizationCreationEmail(organizationCreationEvent));
};
export const sendOrganizationAdminNoSlotsNotification = async (
orgInviteEvent: OrganizationAdminNoSlotsEmailInput
) => {
await sendEmail(() => new OrganizationAdminNoSlotsEmail(orgInviteEvent));
};
export const sendEmailVerificationLink = async (verificationInput: EmailVerifyLink) => {
await sendEmail(() => new AccountVerifyEmail(verificationInput));
};
export const sendEmailVerificationCode = async (verificationInput: EmailVerifyCode) => {
await sendEmail(() => new AttendeeVerifyEmail(verificationInput));
};
export const sendChangeOfEmailVerificationLink = async (verificationInput: ChangeOfEmailVerifyLink) => {
await sendEmail(() => new ChangeOfEmailVerifyEmail(verificationInput));
};
export const sendRequestRescheduleEmail = async (
calEvent: CalendarEvent,
metadata: { rescheduleLink: string }
) => {
const emailsToSend: Promise<unknown>[] = [];
const calendarEvent = formatCalEvent(calEvent);
emailsToSend.push(sendEmail(() => new OrganizerRequestedToRescheduleEmail(calendarEvent, metadata)));
emailsToSend.push(sendEmail(() => new AttendeeWasRequestedToRescheduleEmail(calendarEvent, metadata)));
await Promise.all(emailsToSend);
};
export const sendLocationChangeEmails = async (calEvent: CalendarEvent) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(sendEmail(() => new OrganizerLocationChangeEmail({ calEvent: calendarEvent })));
if (calendarEvent.team?.members) {
for (const teamMember of calendarEvent.team.members) {
emailsToSend.push(
sendEmail(() => new OrganizerLocationChangeEmail({ calEvent: calendarEvent, teamMember }))
);
}
}
emailsToSend.push(
...calendarEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeLocationChangeEmail(calendarEvent, attendee));
})
);
await Promise.all(emailsToSend);
};
export const sendFeedbackEmail = async (feedback: Feedback) => {
await sendEmail(() => new FeedbackEmail(feedback));
};
export const sendBrokenIntegrationEmail = async (evt: CalendarEvent, type: "video" | "calendar") => {
const calendarEvent = formatCalEvent(evt);
await sendEmail(() => new BrokenIntegrationEmail(calendarEvent, type));
};
export const sendDisabledAppEmail = async ({
email,
appName,
appType,
t,
title = undefined,
eventTypeId = undefined,
}: {
email: string;
appName: string;
appType: string[];
t: TFunction;
title?: string;
eventTypeId?: number;
}) => {
await sendEmail(() => new DisabledAppEmail(email, appName, appType, t, title, eventTypeId));
};
export const sendSlugReplacementEmail = async ({
email,
name,
teamName,
t,
slug,
}: {
email: string;
name: string;
teamName: string | null;
t: TFunction;
slug: string;
}) => {
await sendEmail(() => new SlugReplacementEmail(email, name, teamName, slug, t));
};
export const sendNoShowFeeChargedEmail = async (attendee: Person, evt: CalendarEvent) => {
await sendEmail(() => new NoShowFeeChargedEmail(evt, attendee));
};
export const sendDailyVideoRecordingEmails = async (calEvent: CalendarEvent, downloadLink: string) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(
sendEmail(() => new OrganizerDailyVideoDownloadRecordingEmail(calendarEvent, downloadLink))
);
for (const attendee of calendarEvent.attendees) {
emailsToSend.push(
sendEmail(() => new AttendeeDailyVideoDownloadRecordingEmail(calendarEvent, attendee, downloadLink))
);
}
await Promise.all(emailsToSend);
};
export const sendDailyVideoTranscriptEmails = async (calEvent: CalendarEvent, transcripts: string[]) => {
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(sendEmail(() => new OrganizerDailyVideoDownloadTranscriptEmail(calEvent, transcripts)));
for (const attendee of calEvent.attendees) {
emailsToSend.push(
sendEmail(() => new AttendeeDailyVideoDownloadTranscriptEmail(calEvent, attendee, transcripts))
);
}
await Promise.all(emailsToSend);
};
export const sendOrganizationEmailVerification = async (sendOrgInput: OrganizationEmailVerify) => {
await sendEmail(() => new OrganizationEmailVerification(sendOrgInput));
};
export const sendMonthlyDigestEmails = async (eventData: MonthlyDigestEmailData) => {
await sendEmail(() => new MonthlyDigestEmail(eventData));
};
export const sendAdminOrganizationNotification = async (input: OrganizationNotification) => {
await sendEmail(() => new AdminOrganizationNotification(input));
};
export const sendBookingRedirectNotification = async (bookingRedirect: IBookingRedirect) => {
await sendEmail(() => new BookingRedirectEmailNotification(bookingRedirect));
};

View File

@@ -0,0 +1,2 @@
export * from "./email-manager";
export { default as renderEmail } from "./src/renderEmail";

View File

@@ -0,0 +1,109 @@
import type { DateArray, ParticipationStatus, ParticipationRole, EventStatus } from "ics";
import { createEvent } from "ics";
import type { TFunction } from "next-i18next";
import { RRule } from "rrule";
import dayjs from "@calcom/dayjs";
import { getRichDescription } from "@calcom/lib/CalEventParser";
import { getWhen } from "@calcom/lib/CalEventParser";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
export enum BookingAction {
Create = "create",
Cancel = "cancel",
Reschedule = "reschedule",
RequestReschedule = "request_reschedule",
LocationChange = "location_change",
}
const generateIcsString = ({
event,
title,
subtitle,
status,
role,
isRequestReschedule,
t,
}: {
event: CalendarEvent;
title: string;
subtitle: string;
status: EventStatus;
role: "attendee" | "organizer";
isRequestReschedule?: boolean;
t?: TFunction;
}) => {
const location = getVideoCallUrlFromCalEvent(event) || event.location;
// Taking care of recurrence rule
let recurrenceRule: string | undefined = undefined;
const partstat: ParticipationStatus = "ACCEPTED";
const icsRole: ParticipationRole = "REQ-PARTICIPANT";
if (event.recurringEvent?.count) {
// ics appends "RRULE:" already, so removing it from RRule generated string
recurrenceRule = new RRule(event.recurringEvent).toString().replace("RRULE:", "");
}
const getTextBody = (title: string, subtitle: string): string => {
let body: string;
if (isRequestReschedule && role === "attendee" && t) {
body = `
${title}
${getWhen(event, t)}
${subtitle}`;
}
body = `
${title}
${subtitle}
${getRichDescription(event, t)}
`.trim();
return body;
};
const icsEvent = createEvent({
uid: event.iCalUID || event.uid!,
sequence: event.iCalSequence || 0,
start: dayjs(event.startTime)
.utc()
.toArray()
.slice(0, 6)
.map((v, i) => (i === 1 ? v + 1 : v)) as DateArray,
startInputType: "utc",
productId: "calcom/ics",
title: event.title,
description: getTextBody(title, subtitle),
duration: { minutes: dayjs(event.endTime).diff(dayjs(event.startTime), "minute") },
organizer: { name: event.organizer.name, email: event.organizer.email },
...{ recurrenceRule },
attendees: [
...event.attendees.map((attendee: Person) => ({
name: attendee.name,
email: attendee.email,
partstat,
role: icsRole,
rsvp: true,
})),
...(event.team?.members
? event.team?.members.map((member: Person) => ({
name: member.name,
email: member.email,
partstat,
role: icsRole,
rsvp: true,
}))
: []),
],
location: location ?? undefined,
method: "REQUEST",
status,
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
};
export default generateIcsString;

View File

@@ -0,0 +1,38 @@
import short from "short-uuid";
import { v4 as uuidv4 } from "uuid";
import { APP_NAME } from "@calcom/lib/constants";
/**
* This function returns the iCalUID if a uid is passed or if it is present in the event that is passed
* @param uid - the uid of the event
* @param event - an event that already has an iCalUID or one that has a uid
* @param defaultToEventUid - if true, will default to the event.uid if present
*
* @returns the iCalUID whether already present or generated
*/
const getICalUID = ({
uid,
event,
defaultToEventUid,
attendeeId,
}: {
uid?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event?: { iCalUID?: string | null; uid?: string | null; [key: string]: any };
defaultToEventUid?: boolean;
attendeeId?: number;
}) => {
if (event?.iCalUID) return event.iCalUID;
if (defaultToEventUid && event?.uid) return `${event.uid}@${APP_NAME}`;
if (uid) return `${uid}@${APP_NAME}`;
const translator = short();
uid = translator.fromUUID(uuidv4());
return `${uid}${attendeeId ? `${attendeeId}` : ""}@${APP_NAME}`;
};
export default getICalUID;

View File

@@ -0,0 +1,16 @@
export const sanitizeDisplayName = (nameAndEmail: string) => {
const match = nameAndEmail.match(/^(.*?)\s<(.*)>$/);
if (match) {
const sanitizedName = sanitize(match[1]);
return `${sanitizedName} <${match[2]}>`;
}
return nameAndEmail;
};
const sanitize = (name: string) => {
const charsToReplace = /[;,"<>():]/g;
return name.replace(charsToReplace, " ").replace(/\s+/g, " ");
};

View File

@@ -0,0 +1,194 @@
import { describe, expect } from "vitest";
import dayjs from "@calcom/dayjs";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { test } from "@calcom/web/test/fixtures/fixtures";
import { buildCalendarEvent, buildPerson } from "../../../lib/test/builder";
import { buildVideoCallData } from "../../../lib/test/builder";
import generateIcsString from "../generateIcsString";
const assertHasIcsString = (icsString: string | undefined) => {
if (!icsString) throw new Error("icsString is undefined");
expect(icsString).toBeDefined();
return icsString;
};
const testIcsStringContains = ({
icsString,
event,
status,
}: {
icsString: string;
event: CalendarEvent;
status: string;
}) => {
const DTSTART = event.startTime.split(".")[0].replace(/[-:]/g, "");
const startTime = dayjs(event.startTime);
const endTime = dayjs(event.endTime);
const duration = endTime.diff(startTime, "minute");
expect(icsString).toEqual(expect.stringContaining(`UID:${event.iCalUID}`));
// Sometimes the deeply equal stringMatching error appears. Don't want to add flakey tests
// expect(icsString).toEqual(expect.stringContaining(`SUMMARY:${event.title}`));
expect(icsString).toEqual(expect.stringContaining(`DTSTART:${DTSTART}`));
expect(icsString).toEqual(
expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}:mailto:${event.organizer.email}`)
);
expect(icsString).toEqual(expect.stringContaining(`DURATION:PT${duration}M`));
expect(icsString).toEqual(expect.stringContaining(`STATUS:${status}`));
// Getting an error expected icsString to deeply equal stringMatching
// for (const attendee of event.attendees) {
// expect(icsString).toEqual(
// expect.stringMatching(
// `RSVP=TRUE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=${attendee.name}:mailto:${attendee.email}`
// )
// );
// }
};
describe("generateIcsString", () => {
describe("booking actions", () => {
test("when bookingAction is Create", () => {
const event = buildCalendarEvent({
iCalSequence: 0,
attendees: [buildPerson()],
});
const title = "new_event_scheduled_recurring";
const subtitle = "emailed_you_and_any_other_attendees";
const status = "CONFIRMED";
const icsString = generateIcsString({
event: event,
title,
subtitle,
role: "organizer",
status,
});
const assertedIcsString = assertHasIcsString(icsString);
testIcsStringContains({ icsString: assertedIcsString, event, status });
});
test("when bookingAction is Cancel", () => {
const event = buildCalendarEvent({
iCalSequence: 0,
attendees: [buildPerson()],
});
const title = "event_request_cancelled";
const subtitle = "emailed_you_and_any_other_attendees";
const status = "CANCELLED";
const icsString = generateIcsString({
event: event,
title,
subtitle,
role: "organizer",
status,
});
const assertedIcsString = assertHasIcsString(icsString);
testIcsStringContains({ icsString: assertedIcsString, event, status });
});
test("when bookingAction is Reschedule", () => {
const event = buildCalendarEvent({
iCalSequence: 0,
attendees: [buildPerson()],
});
const title = "event_type_has_been_rescheduled";
const subtitle = "emailed_you_and_any_other_attendees";
const status = "CONFIRMED";
const icsString = generateIcsString({
event: event,
title,
subtitle,
role: "organizer",
status,
});
const assertedIcsString = assertHasIcsString(icsString);
testIcsStringContains({ icsString: assertedIcsString, event, status });
});
test("when bookingAction is RequestReschedule", () => {
const event = buildCalendarEvent({
iCalSequence: 0,
attendees: [buildPerson()],
});
const title = "request_reschedule_title_organizer";
const subtitle = "request_reschedule_subtitle_organizer";
const status = "CANCELLED";
const icsString = generateIcsString({
event: event,
title,
subtitle,
role: "organizer",
status,
});
const assertedIcsString = assertHasIcsString(icsString);
testIcsStringContains({ icsString: assertedIcsString, event, status });
});
});
describe("set location", () => {
test("Location is a video link", () => {
const videoCallData = buildVideoCallData();
const event = buildCalendarEvent(
{
iCalSequence: 0,
attendees: [buildPerson()],
videoCallData,
},
true
);
const title = "request_reschedule_title_organizer";
const subtitle = "request_reschedule_subtitle_organizer";
const status = "CANCELLED";
const icsString = generateIcsString({
event: event,
title,
subtitle,
role: "organizer",
status,
});
const assertedIcsString = assertHasIcsString(icsString);
expect(icsString).toEqual(expect.stringContaining(`LOCATION:${videoCallData.url}`));
});
// Could be custom link, address, or phone number
test("Location is a string", () => {
const event = buildCalendarEvent(
{
iCalSequence: 0,
attendees: [buildPerson()],
location: "+1234567890",
},
true
);
const title = "request_reschedule_title_organizer";
const subtitle = "request_reschedule_subtitle_organizer";
const status = "CANCELLED";
const icsString = generateIcsString({
event: event,
title,
subtitle,
role: "organizer",
status,
});
const assertedIcsString = assertHasIcsString(icsString);
expect(icsString).toEqual(expect.stringContaining(`LOCATION:${event.location}`));
});
});
});

View File

@@ -0,0 +1,29 @@
import { describe, expect } from "vitest";
import { APP_NAME } from "@calcom/lib/constants";
import { buildCalendarEvent } from "@calcom/lib/test/builder";
import { test } from "@calcom/web/test/fixtures/fixtures";
import getICalUID from "../getICalUID";
describe("getICalUid", () => {
test("returns iCalUID when passing a uid", () => {
const iCalUID = getICalUID({ uid: "123" });
expect(iCalUID).toEqual(`123@${APP_NAME}`);
});
test("returns iCalUID when passing an event", () => {
const event = buildCalendarEvent({ iCalUID: `123@${APP_NAME}` });
const iCalUID = getICalUID({ event });
expect(iCalUID).toEqual(`123@${APP_NAME}`);
});
test("returns new iCalUID when passing in an event with no iCalUID but has an uid", () => {
const event = buildCalendarEvent({ iCalUID: "" });
const iCalUID = getICalUID({ event, defaultToEventUid: true });
expect(iCalUID).toEqual(`${event.uid}@${APP_NAME}`);
});
test("returns new iCalUID when passing in an event with no iCalUID and uses uid passed", () => {
const event = buildCalendarEvent({ iCalUID: "" });
const iCalUID = getICalUID({ event, uid: "123" });
expect(iCalUID).toEqual(`123@${APP_NAME}`);
});
});

View File

@@ -0,0 +1,21 @@
{
"name": "@calcom/emails",
"sideEffects": false,
"version": "0.0.0",
"private": true,
"scripts": {
"dx": "docker compose up -d || docker-compose up -d"
},
"dependencies": {
"@calcom/dayjs": "*",
"@calcom/lib": "*",
"next-i18next": "^13.2.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"rrule": "^2.7.1"
},
"devDependencies": {
"@calcom/tsconfig": "*",
"@calcom/types": "*"
}
}

View File

@@ -0,0 +1,41 @@
import type { TFunction } from "next-i18next";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { Info } from "./Info";
export const AppsStatus = (props: { calEvent: CalendarEvent; t: TFunction }) => {
const { t } = props;
if (!props.calEvent.appsStatus) return null;
return (
<Info
label={t("apps_status")}
description={
<ul style={{ lineHeight: "24px" }} data-testid="appsStatus">
{props.calEvent.appsStatus.map((status) => (
<li key={status.type} style={{ fontWeight: 400 }}>
{status.appName}{" "}
{status.success >= 1 && `${status.success > 1 ? `(x${status.success})` : ""}`}
{status.failures >= 1 && `${status.failures > 1 ? `(x${status.failures})` : ""}`}
{status.warnings && status.warnings.length >= 1 && (
<ul style={{ fontSize: "14px" }}>
{status.warnings.map((warning, i) => (
<li key={i}>{warning}</li>
))}
</ul>
)}
{status.errors.length >= 1 && (
<ul>
{status.errors.map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
)}
</li>
))}
</ul>
}
withSpacer
/>
);
};

View File

@@ -0,0 +1,206 @@
/* eslint-disable @next/next/no-head-element */
import BaseTable from "./BaseTable";
import EmailBodyLogo from "./EmailBodyLogo";
import EmailHead from "./EmailHead";
import EmailScheduledBodyHeaderContent from "./EmailScheduledBodyHeaderContent";
import EmailSchedulingBodyDivider from "./EmailSchedulingBodyDivider";
import type { BodyHeadType } from "./EmailSchedulingBodyHeader";
import EmailSchedulingBodyHeader from "./EmailSchedulingBodyHeader";
import RawHtml from "./RawHtml";
import Row from "./Row";
const Html = (props: { children: React.ReactNode }) => (
<>
<RawHtml html="<!doctype html>" />
<html>{props.children}</html>
</>
);
export const BaseEmailHtml = (props: {
children: React.ReactNode;
callToAction?: React.ReactNode;
subject: string;
title?: string;
subtitle?: React.ReactNode | string;
headerType?: BodyHeadType;
hideLogo?: boolean;
}) => {
return (
<Html>
<EmailHead title={props.subject} />
<body style={{ wordSpacing: "normal", backgroundColor: "#F3F4F6" }}>
<div style={{ backgroundColor: "#F3F4F6" }}>
<RawHtml
html={`<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->`}
/>
<div style={{ margin: "0px auto", maxWidth: 600 }}>
<Row align="center" border="0" style={{ width: "100%" }}>
<td
style={{
direction: "ltr",
fontSize: "0px",
padding: "0px",
paddingTop: "40px",
textAlign: "center",
}}>
<RawHtml
html={`<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->`}
/>
</td>
</Row>
</div>
<div
style={{
margin: "0px auto",
maxWidth: 600,
borderRadius: "8px",
border: "1px solid #E5E7EB",
padding: "2px",
backgroundColor: "#FFFFFF",
}}>
{props.headerType && (
<EmailSchedulingBodyHeader headerType={props.headerType} headStyles={{ border: 0 }} />
)}
{props.title && (
<EmailScheduledBodyHeaderContent
headStyles={{ border: 0 }}
title={props.title}
subtitle={props.subtitle}
/>
)}
{(props.headerType || props.title || props.subtitle) && (
<EmailSchedulingBodyDivider headStyles={{ border: 0 }} />
)}
<RawHtml
html={`<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" className="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->`}
/>
<div
style={{
background: "#FFFFFF",
backgroundColor: "#FFFFFF",
margin: "0px auto",
maxWidth: 600,
}}>
<Row
align="center"
border="0"
style={{ background: "#FFFFFF", backgroundColor: "#FFFFFF", width: "100%" }}>
<td
style={{
direction: "ltr",
fontSize: 0,
padding: 0,
textAlign: "center",
}}>
<RawHtml
html={`<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td className="" style="vertical-align:top;width:598px;" ><![endif]-->`}
/>
<div
className="mj-column-per-100 mj-outlook-group-fix"
style={{
fontSize: 0,
textAlign: "left",
direction: "ltr",
display: "inline-block",
verticalAlign: "top",
width: "100%",
}}>
<Row border="0" style={{ verticalAlign: "top" }} width="100%">
<td align="left" style={{ fontSize: 0, padding: "10px 25px", wordBreak: "break-word" }}>
<div
style={{
fontFamily: "Roboto, Helvetica, sans-serif",
fontSize: 16,
fontWeight: 500,
lineHeight: 1,
textAlign: "left",
color: "#101010",
}}>
{props.children}
</div>
</td>
</Row>
</div>
<RawHtml html="<!--[if mso | IE]></td></tr></table><![endif]-->" />
</td>
</Row>
</div>
{props.callToAction && <EmailSchedulingBodyDivider headStyles={{ border: 0 }} />}
<RawHtml
html={`<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" className="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->`}
/>
<div
style={{
background: "#FFFFFF",
backgroundColor: "#FFFFFF",
margin: "0px auto",
maxWidth: 600,
}}>
<Row
align="center"
border="0"
style={{ background: "#FFFFFF", backgroundColor: "#FFFFFF", width: "100%" }}>
<td
style={{
direction: "ltr",
fontSize: 0,
padding: 0,
textAlign: "center",
}}>
<RawHtml
html={`<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td className="" style="vertical-align:top;width:598px;" ><![endif]-->`}
/>
{props.callToAction && (
<div
className="mj-column-per-100 mj-outlook-group-fix"
style={{
fontSize: 0,
textAlign: "left",
direction: "ltr",
display: "inline-block",
verticalAlign: "top",
width: "100%",
}}>
<BaseTable border="0" style={{ verticalAlign: "top" }} width="100%">
<tbody>
<tr>
<td
align="center"
vertical-align="middle"
style={{ fontSize: 0, padding: "10px 25px", wordBreak: "break-word" }}>
{props.callToAction}
</td>
</tr>
<tr>
<td
align="left"
style={{ fontSize: 0, padding: "10px 25px", wordBreak: "break-word" }}>
<div
style={{
fontFamily: "Roboto, Helvetica, sans-serif",
fontSize: 13,
lineHeight: 1,
textAlign: "left",
color: "#000000",
}}
/>
</td>
</tr>
</tbody>
</BaseTable>
</div>
)}
<RawHtml html="<!--[if mso | IE]></td></tr></table><![endif]-->" />
</td>
</Row>
</div>
</div>
{!Boolean(props.hideLogo) && <EmailBodyLogo />}
<RawHtml html="<!--[if mso | IE]></td></tr></table><![endif]-->" />
</div>
</body>
</Html>
);
};

View File

@@ -0,0 +1,15 @@
type BaseTableProps = Omit<
React.DetailedHTMLProps<React.TableHTMLAttributes<HTMLTableElement>, HTMLTableElement>,
"border"
> &
Partial<Pick<HTMLTableElement, "align" | "border">>;
const BaseTable = ({ children, ...rest }: BaseTableProps) => (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<table cellPadding="0" cellSpacing="0" role="presentation" {...rest}>
{children}
</table>
);
export default BaseTable;

View File

@@ -0,0 +1,75 @@
import { CallToActionIcon } from "./CallToActionIcon";
export const CallToAction = (props: {
label: string;
href: string;
secondary?: boolean;
startIconName?: string;
endIconName?: string;
}) => {
const { label, href, secondary, startIconName, endIconName } = props;
const calculatePadding = () => {
const paddingTop = "0.625rem";
const paddingBottom = "0.625rem";
let paddingLeft = "1rem";
let paddingRight = "1rem";
if (startIconName) {
paddingLeft = "0.875rem";
} else if (endIconName) {
paddingRight = "0.875rem";
}
return `${paddingTop} ${paddingRight} ${paddingBottom} ${paddingLeft}`;
};
return (
<p
style={{
display: "inline-block",
background: secondary ? "#FFFFFF" : "#292929",
border: secondary ? "1px solid #d1d5db" : "",
color: "#ffffff",
fontFamily: "Roboto, Helvetica, sans-serif",
fontSize: "0.875rem",
fontWeight: 500,
lineHeight: "1rem",
margin: 0,
textDecoration: "none",
textTransform: "none",
padding: calculatePadding(),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
msoPaddingAlt: "0px",
borderRadius: "6px",
boxSizing: "border-box",
height: "2.25rem",
}}>
<a
style={{
color: secondary ? "#292929" : "#FFFFFF",
textDecoration: "none",
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "auto",
}}
href={href}
target="_blank"
rel="noreferrer">
{startIconName && (
<CallToActionIcon
style={{
marginRight: "0.5rem",
marginLeft: 0,
}}
iconName={startIconName}
/>
)}
{label}
{endIconName && <CallToActionIcon iconName={endIconName} />}
</a>
</p>
);
};

View File

@@ -0,0 +1,18 @@
import React from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
export const CallToActionIcon = ({ iconName, style }: { iconName: string; style?: React.CSSProperties }) => (
<img
src={`${WEBAPP_URL}/emails/${iconName}.png`}
srcSet={`${WEBAPP_URL}/emails/${iconName}.svg`}
width="1rem"
style={{
height: "1rem",
width: "1rem",
marginLeft: "0.5rem",
...style,
}}
alt=""
/>
);

View File

@@ -0,0 +1,22 @@
export const CallToActionTable = (props: { children: React.ReactNode }) => (
<table>
<tbody>
<tr>
<td
align="center"
role="presentation"
style={{
border: "none",
borderRadius: "3px",
cursor: "auto",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
msoPaddingAlt: "10px 25px",
}}
valign="middle">
{props.children}
</td>
</tr>
</tbody>
</table>
);

View File

@@ -0,0 +1,79 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import RawHtml from "./RawHtml";
import Row from "./Row";
const CommentIE = ({ html = "" }) => <RawHtml html={`<!--[if mso | IE]>${html}<![endif]-->`} />;
const EmailBodyLogo = () => {
const image = `${WEBAPP_URL}/emails/logo.png`;
return (
<>
<CommentIE
html={`</td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"></td>`}
/>
<div style={{ margin: "0px auto", maxWidth: 600 }}>
<Row align="center" border="0" style={{ width: "100%" }}>
<td
style={{
direction: "ltr",
fontSize: "0px",
padding: "0px",
textAlign: "center",
}}>
<CommentIE
html={`<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td style="vertical-align:top;width:600px;" >`}
/>
<div
className="mj-column-per-100 mj-outlook-group-fix"
style={{
fontSize: "0px",
textAlign: "left",
direction: "ltr",
display: "inline-block",
verticalAlign: "top",
width: "100%",
}}>
<Row border="0" style={{ verticalAlign: "top" }} width="100%">
<td
align="center"
style={{
fontSize: "0px",
padding: "10px 25px",
paddingTop: "32px",
wordBreak: "break-word",
}}>
<Row border="0" style={{ borderCollapse: "collapse", borderSpacing: "0px" }}>
<td style={{ width: "89px" }}>
<a href={WEBAPP_URL} target="_blank" rel="noreferrer">
<img
height="19"
src={image}
style={{
border: "0",
display: "block",
outline: "none",
textDecoration: "none",
height: "19px",
width: "100%",
fontSize: "13px",
}}
width="89"
alt=""
/>
</a>
</td>
</Row>
</td>
</Row>
</div>
<CommentIE html="</td></tr></table>" />
</td>
</Row>
</div>
</>
);
};
export default EmailBodyLogo;

View File

@@ -0,0 +1,71 @@
import RawHtml from "./RawHtml";
import Row from "./Row";
const EmailCommonDivider = ({
children,
mutipleRows = false,
headStyles,
}: {
children: React.ReactNode;
mutipleRows?: boolean;
headStyles?: React.DetailedHTMLProps<
React.TdHTMLAttributes<HTMLTableCellElement>,
HTMLTableCellElement
>["style"];
}) => {
return (
<>
<RawHtml
html={`<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->`}
/>
<div
style={{
background: "#FFFFFF",
backgroundColor: "#FFFFFF",
margin: "0px auto",
maxWidth: 600,
}}>
<Row
align="center"
border="0"
style={{
background: "#FFFFFF",
backgroundColor: "#FFFFFF",
width: "100%",
}}>
<td
style={{
borderLeft: "1px solid #E1E1E1",
borderRight: "1px solid #E1E1E1",
direction: "ltr",
fontSize: 0,
padding: "15px 0px 0 0px",
textAlign: "center",
...headStyles,
}}>
<RawHtml
html={`<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->`}
/>
<div
className="mj-column-per-100 mj-outlook-group-fix"
style={{
fontSize: 0,
textAlign: "left",
direction: "ltr",
display: "inline-block",
verticalAlign: "top",
width: "100%",
}}>
<Row border="0" style={{ verticalAlign: "top" }} width="100%" multiple={mutipleRows}>
{children}
</Row>
</div>
<RawHtml html="<!--[if mso | IE]></td></tr></table><![endif]-->" />
</td>
</Row>
</div>
</>
);
};
export default EmailCommonDivider;

View File

@@ -0,0 +1,91 @@
/* eslint-disable @next/next/no-head-element */
import RawHtml from "./RawHtml";
const EmailHead = ({ title = "" }) => {
return (
<head>
<title>{title}</title>
<RawHtml
html={`<!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]-->`}
/>
<meta httpEquiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
{`
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
`}
</style>
<RawHtml html="<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->" />
<RawHtml
html={`<!--[if lte mso 11]><style type="text/css">.mj-outlook-group-fix { width:100% !important; }</style><![endif]-->`}
/>
<RawHtml
html={`<!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700" rel="stylesheet" type="text/css"/>
<style type="text/css">@import url(https://fonts.googleapis.com/css?family=Roboto:400,500,700);</style><!--<![endif]-->`}
/>
<style type="text/css">
{`
@media only screen and (min-width: 480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
`}
</style>
<style media="screen and (min-width:480px)">
{`
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
`}
</style>
<style type="text/css">
{`
@media only screen and (max-width: 480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
`}
</style>
</head>
);
};
export default EmailHead;

View File

@@ -0,0 +1,56 @@
import type { CSSProperties } from "react";
import EmailCommonDivider from "./EmailCommonDivider";
const EmailScheduledBodyHeaderContent = (props: {
title: string;
subtitle?: React.ReactNode;
headStyles?: CSSProperties;
}) => (
<EmailCommonDivider headStyles={{ padding: 0, ...props.headStyles }} mutipleRows>
<tr>
<td
align="center"
style={{
fontSize: 0,
padding: "10px 25px",
paddingTop: 24,
paddingBottom: 0,
wordBreak: "break-word",
}}>
<div
data-testid="heading"
style={{
fontFamily: "Roboto, Helvetica, sans-serif",
fontSize: 24,
fontWeight: 700,
lineHeight: "24px",
textAlign: "center",
color: "#111827",
}}>
{props.title}
</div>
</td>
</tr>
{props.subtitle && (
<tr>
<td align="center" style={{ fontSize: 0, padding: "10px 25px", wordBreak: "break-word" }}>
<div
data-testid="subHeading"
style={{
fontFamily: "Roboto, Helvetica, sans-serif",
fontSize: 16,
fontWeight: 400,
lineHeight: "24px",
textAlign: "center",
color: "#4B5563",
}}>
{props.subtitle}
</div>
</td>
</tr>
)}
</EmailCommonDivider>
);
export default EmailScheduledBodyHeaderContent;

View File

@@ -0,0 +1,31 @@
import { CSSProperties } from "react";
import EmailCommonDivider from "./EmailCommonDivider";
import RawHtml from "./RawHtml";
export const EmailSchedulingBodyDivider = (props: { headStyles?: CSSProperties }) => (
<EmailCommonDivider headStyles={props.headStyles}>
<td
align="center"
style={{
fontSize: 0,
padding: "10px 25px",
paddingBottom: 15,
wordBreak: "break-word",
}}>
<p
style={{
borderTop: "solid 1px #E1E1E1",
fontSize: 1,
margin: "0px auto",
width: "100%",
}}
/>
<RawHtml
html={`<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 1px #E1E1E1;font-size:1px;margin:0px auto;width:548px;" role="presentation" width="548px" ><tr><td style="height:0;line-height:0;"> &nbsp;</td></tr></table><![endif]-->`}
/>
</td>
</EmailCommonDivider>
);
export default EmailSchedulingBodyDivider;

View File

@@ -0,0 +1,70 @@
import type { CSSProperties } from "react";
import { BASE_URL, IS_PRODUCTION } from "@calcom/lib/constants";
import EmailCommonDivider from "./EmailCommonDivider";
import Row from "./Row";
export type BodyHeadType = "checkCircle" | "xCircle" | "calendarCircle" | "teamCircle";
export const getHeadImage = (headerType: BodyHeadType): string => {
switch (headerType) {
case "checkCircle":
return IS_PRODUCTION
? `${BASE_URL}/emails/checkCircle@2x.png`
: "https://app.cal.com/emails/checkCircle@2x.png";
case "xCircle":
return IS_PRODUCTION
? `${BASE_URL}/emails/xCircle@2x.png`
: "https://app.cal.com/emails/xCircle@2x.png";
case "calendarCircle":
return IS_PRODUCTION
? `${BASE_URL}/emails/calendarCircle@2x.png`
: "https://app.cal.com/emails/calendarCircle@2x.png";
case "teamCircle":
return IS_PRODUCTION
? `${BASE_URL}/emails/teamCircle@2x.png`
: "https://app.cal.com/emails/teamCircle@2x.png";
}
};
const EmailSchedulingBodyHeader = (props: { headerType: BodyHeadType; headStyles?: CSSProperties }) => {
const image = getHeadImage(props.headerType);
return (
<>
<EmailCommonDivider
headStyles={{ padding: "30px 30px 0 30px", borderTop: "1px solid #E1E1E1", ...props.headStyles }}>
<td
align="center"
style={{
fontSize: "0px",
padding: "10px 25px",
wordBreak: "break-word",
}}>
<Row border="0" role="presentation" style={{ borderCollapse: "collapse", borderSpacing: "0px" }}>
<td style={{ width: 64 }}>
<img
height="64"
src={image}
style={{
border: "0",
display: "block",
outline: "none",
textDecoration: "none",
height: "64px",
width: "100%",
fontSize: "13px",
}}
width="64"
alt=""
/>
</td>
</Row>
</td>
</EmailCommonDivider>
</>
);
};
export default EmailSchedulingBodyHeader;

View File

@@ -0,0 +1,49 @@
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
const Spacer = () => <p style={{ height: 6 }} />;
export const Info = (props: {
label: string;
description: React.ReactNode | undefined | null;
extraInfo?: React.ReactNode;
withSpacer?: boolean;
lineThrough?: boolean;
formatted?: boolean;
}) => {
if (!props.description || props.description === "") return null;
const descriptionCSS = "color: '#101010'; font-weight: 400; line-height: 24px; margin: 0;";
const safeDescription = markdownToSafeHTML(props.description.toString()) || "";
return (
<>
{props.withSpacer && <Spacer />}
<div>
<p style={{ color: "#101010" }}>{props.label}</p>
<p
style={{
color: "#101010",
fontWeight: 400,
lineHeight: "24px",
whiteSpace: "pre-wrap",
textDecoration: props.lineThrough ? "line-through" : undefined,
}}>
{props.formatted ? (
<p
className="dark:text-darkgray-600 mt-2 text-sm text-gray-500 [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{
__html: safeDescription
.replaceAll("<p>", `<p style="${descriptionCSS}">`)
.replaceAll("<li>", `<li style="${descriptionCSS}">`),
}}
/>
) : (
props.description
)}
</p>
{props.extraInfo}
</div>
</>
);
};

View File

@@ -0,0 +1,87 @@
import type { TFunction } from "next-i18next";
import { guessEventLocationType } from "@calcom/app-store/locations";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { Info } from "./Info";
export function LocationInfo(props: { calEvent: CalendarEvent; t: TFunction }) {
const { t } = props;
// We would not be able to determine provider name for DefaultEventLocationTypes
const providerName = guessEventLocationType(props.calEvent.location)?.label;
const location = props.calEvent.location;
let meetingUrl = location?.search(/^https?:/) !== -1 ? location : undefined;
if (props.calEvent) {
meetingUrl = getVideoCallUrlFromCalEvent(props.calEvent) || meetingUrl;
}
const isPhone = location?.startsWith("+");
// Because of location being a value here, we can determine the app that generated the location only for Dynamic Link based apps where the value is integrations:*
// For static link based location apps, the value is that URL itself. So, it is not straightforward to determine the app that generated the location.
// If we know the App we can always provide the name of the app like we do it for Google Hangout/Google Meet
if (meetingUrl) {
return (
<Info
label={t("where")}
withSpacer
description={
<a
href={meetingUrl}
target="_blank"
title={t("meeting_url")}
style={{ color: "#101010" }}
rel="noreferrer">
{providerName || "Link"}
</a>
}
extraInfo={
meetingUrl && (
<div style={{ color: "#494949", fontWeight: 400, lineHeight: "24px" }}>
<>
{t("meeting_url")}:{" "}
<a href={meetingUrl} title={t("meeting_url")} style={{ color: "#3E3E3E" }}>
{meetingUrl}
</a>
</>
</div>
)
}
/>
);
}
if (isPhone) {
return (
<Info
label={t("where")}
withSpacer
description={
<a href={`tel:${location}`} title="Phone" style={{ color: "#3E3E3E" }}>
{location}
</a>
}
/>
);
}
return (
<Info
label={t("where")}
withSpacer
description={providerName || location}
extraInfo={
(providerName === "Zoom" || providerName === "Google") && props.calEvent.requiresConfirmation ? (
<p style={{ color: "#494949", fontWeight: 400, lineHeight: "24px" }}>
<>{t("meeting_url_provided_after_confirmed")}</>
</p>
) : null
}
/>
);
}

View File

@@ -0,0 +1,100 @@
import { getCancelLink, getRescheduleLink, getBookingUrl } from "@calcom/lib/CalEventParser";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
export function ManageLink(props: { calEvent: CalendarEvent; attendee: Person }) {
// Only the original attendee can make changes to the event
// Guests cannot
const t = props.attendee.language.translate;
const cancelLink = getCancelLink(props.calEvent);
const rescheduleLink = getRescheduleLink(props.calEvent);
const bookingLink = getBookingUrl(props.calEvent);
const isOriginalAttendee = props.attendee.email === props.calEvent.attendees[0]?.email;
const isOrganizer = props.calEvent.organizer.email === props.attendee.email;
const hasCancelLink = Boolean(cancelLink);
const hasRescheduleLink = Boolean(rescheduleLink);
const hasBookingLink = Boolean(bookingLink);
const isRecurringEvent = props.calEvent.recurringEvent;
const shouldDisplayRescheduleLink = Boolean(hasRescheduleLink && !isRecurringEvent);
if (
(isOriginalAttendee || isOrganizer) &&
(hasCancelLink || (!isRecurringEvent && hasRescheduleLink) || hasBookingLink)
) {
return (
<div
style={{
fontFamily: "Roboto, Helvetica, sans-serif",
fontSize: "16px",
fontWeight: 500,
lineHeight: "0px",
textAlign: "left",
color: "#101010",
}}>
<p
style={{
fontWeight: 400,
lineHeight: "24px",
textAlign: "center",
width: "100%",
}}>
<>{t("need_to_make_a_change")}</>
{shouldDisplayRescheduleLink && (
<span>
<a
href={rescheduleLink}
style={{
color: "#374151",
marginLeft: "5px",
marginRight: "5px",
textDecoration: "underline",
}}>
<>{t("reschedule")}</>
</a>
{hasCancelLink && <>{t("or_lowercase")}</>}
</span>
)}
{hasCancelLink && (
<span>
<a
href={cancelLink}
style={{
color: "#374151",
marginLeft: "5px",
textDecoration: "underline",
}}>
<>{t("cancel")}</>
</a>
</span>
)}
{props.calEvent.platformClientId && hasBookingLink && (
<span>
{(hasCancelLink || shouldDisplayRescheduleLink) && (
<span
style={{
marginLeft: "5px",
}}>
{t("or_lowercase")}
</span>
)}
<a
href={bookingLink}
style={{
color: "#374151",
marginLeft: "5px",
textDecoration: "underline",
}}>
<>{t("check_here")}</>
</a>
</span>
)}
</p>
</div>
);
}
// Don't have the rights to the manage link
return null;
}

View File

@@ -0,0 +1,6 @@
/** @see https://gist.github.com/zomars/4c366a0118a5b7fb391529ab1f27527a */
const RawHtml = ({ html = "" }) => (
<script dangerouslySetInnerHTML={{ __html: `</script>${html}<script>` }} />
);
export default RawHtml;

View File

@@ -0,0 +1,15 @@
import type { ComponentProps } from "react";
import BaseTable from "./BaseTable";
const Row = ({
children,
multiple = false,
...rest
}: { children: React.ReactNode; multiple?: boolean } & ComponentProps<typeof BaseTable>) => (
<BaseTable {...rest}>
<tbody>{multiple ? children : <tr>{children}</tr>}</tbody>
</BaseTable>
);
export default Row;

View File

@@ -0,0 +1,3 @@
export const Separator = () => (
<p style={{ width: "16px", height: "16px", display: "inline-block" }}>&nbsp;</p>
);

View File

@@ -0,0 +1,33 @@
import type { TFunction } from "next-i18next";
import getLabelValueMapFromResponses from "@calcom/lib/getLabelValueMapFromResponses";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { Info } from "./Info";
export function UserFieldsResponses(props: { calEvent: CalendarEvent; t: TFunction; isOrganizer?: boolean }) {
const { t, isOrganizer = false } = props;
const labelValueMap = getLabelValueMapFromResponses(props.calEvent, isOrganizer);
if (!labelValueMap) return null;
return (
<>
{Object.keys(labelValueMap).map((key) =>
labelValueMap[key] !== "" ? (
<Info
key={key}
label={t(key)}
description={
typeof labelValueMap[key] === "boolean"
? labelValueMap[key]
? t("yes")
: t("no")
: `${labelValueMap[key] ? labelValueMap[key] : ""}`
}
withSpacer
/>
) : null
)}
</>
);
}

View File

@@ -0,0 +1,191 @@
/* eslint-disable @next/next/no-head-element */
import BaseTable from "./BaseTable";
import EmailBodyLogo from "./EmailBodyLogo";
import EmailHead from "./EmailHead";
import EmailScheduledBodyHeaderContent from "./EmailScheduledBodyHeaderContent";
import EmailSchedulingBodyDivider from "./EmailSchedulingBodyDivider";
import EmailSchedulingBodyHeader, { BodyHeadType } from "./EmailSchedulingBodyHeader";
import RawHtml from "./RawHtml";
import Row from "./Row";
const Html = (props: { children: React.ReactNode }) => (
<>
<RawHtml html="<!doctype html>" />
<html>{props.children}</html>
</>
);
export const V2BaseEmailHtml = (props: {
children: React.ReactNode;
callToAction?: React.ReactNode;
subject: string;
title?: string;
subtitle?: React.ReactNode;
headerType?: BodyHeadType;
}) => {
return (
<Html>
<EmailHead title={props.subject} />
<body style={{ wordSpacing: "normal", backgroundColor: "#F3F4F6" }}>
<div style={{ backgroundColor: "#F3F4F6" }}>
<RawHtml
html={`<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->`}
/>
<div style={{ margin: "0px auto", maxWidth: 600 }}>
<Row align="center" border="0" style={{ width: "100%" }}>
<td
style={{
direction: "ltr",
fontSize: "0px",
padding: "0px",
paddingTop: "40px",
textAlign: "center",
}}>
<RawHtml
html={`<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->`}
/>
</td>
</Row>
</div>
{props.headerType && <EmailSchedulingBodyHeader headerType={props.headerType} />}
{props.title && <EmailScheduledBodyHeaderContent title={props.title} subtitle={props.subtitle} />}
{(props.headerType || props.title || props.subtitle) && <EmailSchedulingBodyDivider />}
<RawHtml
html={`<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" className="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->`}
/>
<div
style={{
margin: "0px auto",
maxWidth: 600,
}}>
<Row align="center" border="0" style={{ width: "100%" }}>
<td
style={{
direction: "ltr",
fontSize: 0,
padding: 0,
textAlign: "center",
}}>
<RawHtml
html={`<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td className="" style="vertical-align:top;width:598px;" ><![endif]-->`}
/>
<div
className="mj-column-per-100 mj-outlook-group-fix"
style={{
fontSize: 0,
textAlign: "left",
direction: "ltr",
display: "inline-block",
verticalAlign: "top",
width: "100%",
border: "1px solid #E1E1E1",
borderRadius: "6px",
}}>
<Row
border="0"
style={{
verticalAlign: "top",
borderRadius: "6px",
background: "#FFFFFF",
backgroundColor: "#FFFFFF",
}}
width="100%">
<td
align="left"
style={{
fontSize: 0,
padding: "40px",
wordBreak: "break-word",
}}>
<div
style={{
fontFamily: "Roboto, Helvetica, sans-serif",
fontSize: 16,
fontWeight: 500,
lineHeight: 1,
textAlign: "left",
color: "#3E3E3E",
}}>
{props.children}
</div>
</td>
</Row>
</div>
<RawHtml html="<!--[if mso | IE]></td></tr></table><![endif]-->" />
</td>
</Row>
</div>
{props.callToAction && <EmailSchedulingBodyDivider />}
<RawHtml
html={`<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" className="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->`}
/>
<div
style={{
margin: "0px auto",
maxWidth: 600,
}}>
<Row align="center" border="0" style={{ width: "100%" }}>
<td
style={{
direction: "ltr",
fontSize: 0,
padding: 0,
textAlign: "center",
}}>
<RawHtml
html={`<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td className="" style="vertical-align:top;width:598px;" ><![endif]-->`}
/>
{props.callToAction && (
<div
className="mj-column-per-100 mj-outlook-group-fix"
style={{
fontSize: 0,
textAlign: "left",
direction: "ltr",
display: "inline-block",
verticalAlign: "top",
width: "100%",
}}>
<BaseTable border="0" style={{ verticalAlign: "top" }} width="100%">
<tbody>
<tr>
<td
align="center"
vertical-align="middle"
style={{ fontSize: 0, padding: "10px 25px", wordBreak: "break-word" }}>
{props.callToAction}
</td>
</tr>
<tr>
<td
align="left"
style={{ fontSize: 0, padding: "10px 25px", wordBreak: "break-word" }}>
<div
style={{
fontFamily: "Roboto, Helvetica, sans-serif",
fontSize: 13,
lineHeight: 1,
textAlign: "left",
color: "#000000",
}}
/>
</td>
</tr>
</tbody>
</BaseTable>
</div>
)}
<RawHtml html="<!--[if mso | IE]></td></tr></table><![endif]-->" />
</td>
</Row>
</div>
<EmailBodyLogo />
<RawHtml html="<!--[if mso | IE]></td></tr></table><![endif]-->" />
</div>
</body>
</Html>
);
};

View File

@@ -0,0 +1,74 @@
import type { TFunction } from "next-i18next";
import { RRule } from "rrule";
import dayjs from "@calcom/dayjs";
// TODO: Use browser locale, implement Intl in Dayjs maybe?
import "@calcom/dayjs/locales";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import type { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { Info } from "./Info";
export function getRecurringWhen({
recurringEvent,
attendee,
}: {
recurringEvent?: RecurringEvent | null;
attendee: Pick<Person, "language">;
}) {
if (recurringEvent) {
const t = attendee.language.translate;
const rruleOptions = new RRule(recurringEvent).options;
const recurringEventConfig: RecurringEvent = {
freq: rruleOptions.freq,
count: rruleOptions.count || 1,
interval: rruleOptions.interval,
};
return `${getEveryFreqFor({ t, recurringEvent: recurringEventConfig })}`;
}
return "";
}
export function WhenInfo(props: {
calEvent: CalendarEvent;
timeZone: string;
t: TFunction;
locale: string;
timeFormat: TimeFormat;
}) {
const { timeZone, t, calEvent: { recurringEvent } = {}, locale, timeFormat } = props;
function getRecipientStart(format: string) {
return dayjs(props.calEvent.startTime).tz(timeZone).locale(locale).format(format);
}
function getRecipientEnd(format: string) {
return dayjs(props.calEvent.endTime).tz(timeZone).locale(locale).format(format);
}
const recurringInfo = getRecurringWhen({
recurringEvent: props.calEvent.recurringEvent,
attendee: props.calEvent.attendees[0],
});
return (
<div>
<Info
label={`${t("when")} ${recurringInfo !== "" ? ` - ${recurringInfo}` : ""}`}
lineThrough={
!!props.calEvent.cancellationReason && !props.calEvent.cancellationReason.includes("$RCH$")
}
description={
<span data-testid="when">
{recurringEvent?.count ? `${t("starting")} ` : ""}
{getRecipientStart(`dddd, LL | ${timeFormat}`)} - {getRecipientEnd(timeFormat)}{" "}
<span style={{ color: "#4B5563" }}>({timeZone})</span>
</span>
}
withSpacer
/>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import type { TFunction } from "next-i18next";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { Info } from "./Info";
const PersonInfo = ({ name = "", email = "", role = "" }) => (
<div style={{ color: "#101010", fontWeight: 400, lineHeight: "24px" }}>
{name} - {role}{" "}
<span style={{ color: "#4B5563" }}>
<a href={`mailto:${email}`} style={{ color: "#4B5563" }}>
{email}
</a>
</span>
</div>
);
export function WhoInfo(props: { calEvent: CalendarEvent; t: TFunction }) {
const { t } = props;
return (
<Info
label={t("who")}
description={
<>
<PersonInfo
name={props.calEvent.organizer.name}
role={t("organizer")}
email={props.calEvent.organizer.email}
/>
{props.calEvent.team?.members.map((member) => (
<PersonInfo key={member.name} name={member.name} role={t("team_member")} email={member.email} />
))}
{props.calEvent.attendees.map((attendee) => (
<PersonInfo
key={attendee.id || attendee.name}
name={attendee.name}
role={t("guest")}
email={attendee.email}
/>
))}
</>
}
withSpacer
/>
);
}

View File

@@ -0,0 +1,14 @@
export { BaseEmailHtml } from "./BaseEmailHtml";
export { V2BaseEmailHtml } from "./V2BaseEmailHtml";
export { CallToAction } from "./CallToAction";
export { CallToActionTable } from "./CallToActionTable";
export { UserFieldsResponses } from "./UserFieldsResponses";
export { Info } from "./Info";
export { CallToActionIcon } from "./CallToActionIcon";
export { LocationInfo } from "./LocationInfo";
export { ManageLink } from "./ManageLink";
export { default as RawHtml } from "./RawHtml";
export { WhenInfo } from "./WhenInfo";
export { WhoInfo } from "./WhoInfo";
export { AppsStatus } from "./AppsStatus";
export { Separator } from "./Separator";

View File

@@ -0,0 +1,22 @@
import * as templates from "./templates";
async function renderEmail<K extends keyof typeof templates>(
template: K,
props: React.ComponentProps<(typeof templates)[K]>
) {
const Component = templates[template];
const ReactDOMServer = (await import("react-dom/server")).default;
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
ReactDOMServer.renderToStaticMarkup(Component(props))
// Remove `<RawHtml />` injected scripts
.replace(/<script><\/script>/g, "")
.replace(
"<html>",
`<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">`
)
);
}
export default renderEmail;

View File

@@ -0,0 +1,120 @@
"use client";
import { Trans, type TFunction } from "next-i18next";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { BaseEmailHtml, CallToAction } from "../components";
type AdminOrganizationNotification = {
language: TFunction;
orgSlug: string;
webappIPAddress: string;
};
const dnsTable = (type: string, name: string, value: string, t: TFunction) => (
<table
role="presentation"
border={0}
cellSpacing="0"
cellPadding="0"
style={{
verticalAlign: "top",
marginTop: "10px",
borderRadius: "6px",
borderCollapse: "separate",
border: "solid black 1px",
}}
width="100%">
<tbody>
<thead>
<tr
style={{
backgroundColor: "black",
color: "white",
fontSize: "14px",
lineHeight: "24px",
}}>
<td
align="center"
width="33%"
style={{ borderTopLeftRadius: "5px", borderRight: "1px solid white" }}>
{t("type")}
</td>
<td align="center" width="33%" style={{ borderRight: "1px solid white" }}>
{t("name")}
</td>
<td align="center" style={{ borderTopRightRadius: "5px" }}>
{t("value")}
</td>
</tr>
</thead>
<tr style={{ lineHeight: "24px" }}>
<td align="center" style={{ borderBottomLeftRadius: "5px", borderRight: "1px solid black" }}>
{type}
</td>
<td align="center" style={{ borderRight: "1px solid black" }}>
{name}
</td>
<td align="center" style={{ borderBottomRightRadius: "5px" }}>
{value}
</td>
</tr>
</tbody>
</table>
);
export const AdminOrganizationNotificationEmail = ({
orgSlug,
webappIPAddress,
language,
}: AdminOrganizationNotification) => {
const webAppUrl = WEBAPP_URL.replace("https://", "")?.replace("http://", "").replace(/(:.*)/, "");
return (
<BaseEmailHtml
subject={language("admin_org_notification_email_subject", { appName: APP_NAME })}
callToAction={
<CallToAction
label={language("admin_org_notification_email_cta")}
href={`${WEBAPP_URL}/settings/admin/organizations`}
endIconName="white-arrow-right"
/>
}>
<p
style={{
fontWeight: 600,
fontSize: "24px",
lineHeight: "38px",
}}>
<>{language("admin_org_notification_email_title")}</>
</p>
<p style={{ fontWeight: 400 }}>
<>{language("hi_admin")}!</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<Trans i18nKey="admin_org_notification_email_body_part1" t={language} values={{ orgSlug }}>
An organization with slug {`"${orgSlug}"`} was created.
<br />
<br />
Please be sure to configure your DNS registry to point the subdomain corresponding to the new
organization to where the main app is running. Otherwise the organization will not work.
<br />
<br />
Here are just the very basic options to configure a subdomain to point to their app so it loads the
organization profile page.
<br />
<br />
You can do it either with the A Record:
</Trans>
</p>
{dnsTable("A", orgSlug, webappIPAddress, language)}
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
{language("admin_org_notification_email_body_part2")}
</p>
{dnsTable("CNAME", orgSlug, webAppUrl, language)}
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
{language("admin_org_notification_email_body_part3")}
</p>
</BaseEmailHtml>
);
};

View File

@@ -0,0 +1,34 @@
import { CallToAction, CallToActionTable } from "../components";
import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
function ManageLink(props: React.ComponentProps<typeof AttendeeScheduledEmail>) {
const manageText = props.attendee.language.translate("pay_now");
if (!props.calEvent.paymentInfo?.link) return null;
return (
<CallToActionTable>
<CallToAction label={manageText} href={props.calEvent.paymentInfo.link} endIconName="linkIcon" />
</CallToActionTable>
);
}
export const AttendeeAwaitingPaymentEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => {
return props.calEvent.paymentInfo?.paymentOption === "HOLD" ? (
<AttendeeScheduledEmail
title="meeting_awaiting_payment_method"
headerType="calendarCircle"
subject="awaiting_payment_subject"
callToAction={<ManageLink {...props} />}
{...props}
/>
) : (
<AttendeeScheduledEmail
title="meeting_awaiting_payment"
headerType="calendarCircle"
subject="awaiting_payment_subject"
callToAction={<ManageLink {...props} />}
{...props}
/>
);
};

View File

@@ -0,0 +1,11 @@
import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
export const AttendeeCancelledEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => (
<AttendeeScheduledEmail
title="event_request_cancelled"
headerType="xCircle"
subject="event_cancelled_subject"
callToAction={null}
{...props}
/>
);

View File

@@ -0,0 +1,12 @@
import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
export const AttendeeCancelledSeatEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => (
<AttendeeScheduledEmail
title="no_longer_attending"
headerType="xCircle"
subject="event_no_longer_attending_subject"
subtitle=""
callToAction={null}
{...props}
/>
);

View File

@@ -0,0 +1,13 @@
import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
export const AttendeeDeclinedEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => (
<AttendeeScheduledEmail
title={
props.calEvent.recurringEvent?.count ? "event_request_declined_recurring" : "event_request_declined"
}
headerType="xCircle"
subject="event_declined_subject"
callToAction={null}
{...props}
/>
);

View File

@@ -0,0 +1,10 @@
import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
export const AttendeeLocationChangeEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => (
<AttendeeScheduledEmail
title="event_location_changed"
headerType="calendarCircle"
subject="location_changed_event_type_subject"
{...props}
/>
);

View File

@@ -0,0 +1,31 @@
import AttendeeScheduledEmailClass from "../../templates/attendee-rescheduled-email";
import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
export const AttendeeRequestEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => {
const date = new AttendeeScheduledEmailClass(props.calEvent, props.attendee).getFormattedDate();
return (
<AttendeeScheduledEmail
title={props.calEvent.attendees[0].language.translate(
props.calEvent.recurringEvent?.count ? "booking_submitted_recurring" : "booking_submitted"
)}
subtitle={
<>
{props.calEvent.attendees[0].language.translate(
props.calEvent.recurringEvent?.count
? "user_needs_to_confirm_or_reject_booking_recurring"
: "user_needs_to_confirm_or_reject_booking",
{ user: props.calEvent.organizer.name }
)}
</>
}
headerType="calendarCircle"
subject={props.calEvent.attendees[0].language.translate("booking_submitted_subject", {
title: props.calEvent.title,
date,
})}
callToAction={null}
{...props}
/>
);
};

View File

@@ -0,0 +1,10 @@
import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
export const AttendeeRescheduledEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => (
<AttendeeScheduledEmail
title="event_has_been_rescheduled"
headerType="calendarCircle"
subject="event_type_has_been_rescheduled_on_time_date"
{...props}
/>
);

View File

@@ -0,0 +1,20 @@
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { BaseScheduledEmail } from "./BaseScheduledEmail";
export const AttendeeScheduledEmail = (
props: {
calEvent: CalendarEvent;
attendee: Person;
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
return (
<BaseScheduledEmail
locale={props.attendee.language.locale}
timeZone={props.attendee.timeZone}
t={props.attendee.language.translate}
timeFormat={props.attendee?.timeFormat}
{...props}
/>
);
};

View File

@@ -0,0 +1,29 @@
import { CallToAction, CallToActionTable } from "../components";
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const AttendeeWasRequestedToRescheduleEmail = (
props: { metadata: { rescheduleLink: string } } & React.ComponentProps<typeof OrganizerScheduledEmail>
) => {
const t = props.attendee.language.translate;
return (
<OrganizerScheduledEmail
t={t}
title="request_reschedule_booking"
subtitle={
<>
{t("request_reschedule_subtitle", {
organizer: props.calEvent.organizer.name,
})}
</>
}
headerType="calendarCircle"
subject="rescheduled_event_type_subject"
callToAction={
<CallToActionTable>
<CallToAction label="Book a new time" href={props.metadata.rescheduleLink} endIconName="linkIcon" />
</CallToActionTable>
}
{...props}
/>
);
};

View File

@@ -0,0 +1,104 @@
import type { TFunction } from "next-i18next";
import dayjs from "@calcom/dayjs";
import { formatPrice } from "@calcom/lib/price";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import {
BaseEmailHtml,
Info,
LocationInfo,
ManageLink,
WhenInfo,
WhoInfo,
AppsStatus,
UserFieldsResponses,
} from "../components";
export const BaseScheduledEmail = (
props: {
calEvent: CalendarEvent;
attendee: Person;
timeZone: string;
includeAppsStatus?: boolean;
t: TFunction;
locale: string;
timeFormat: TimeFormat | undefined;
isOrganizer?: boolean;
} & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
const { t, timeZone, locale, timeFormat: timeFormat_ } = props;
const timeFormat = timeFormat_ ?? TimeFormat.TWELVE_HOUR;
function getRecipientStart(format: string) {
return dayjs(props.calEvent.startTime).tz(timeZone).format(format);
}
function getRecipientEnd(format: string) {
return dayjs(props.calEvent.endTime).tz(timeZone).format(format);
}
const subject = t(props.subject || "confirmed_event_type_subject", {
eventType: props.calEvent.type,
name: props.calEvent.team?.name || props.calEvent.organizer.name,
date: `${getRecipientStart("h:mma")} - ${getRecipientEnd("h:mma")}, ${t(
getRecipientStart("dddd").toLowerCase()
)}, ${t(getRecipientStart("MMMM").toLowerCase())} ${getRecipientStart("D, YYYY")}`,
});
return (
<BaseEmailHtml
hideLogo={Boolean(props.calEvent.platformClientId)}
headerType={props.headerType || "checkCircle"}
subject={props.subject || subject}
title={t(
props.title
? props.title
: props.calEvent.recurringEvent?.count
? "your_event_has_been_scheduled_recurring"
: "your_event_has_been_scheduled"
)}
callToAction={
props.callToAction === null
? null
: props.callToAction || <ManageLink attendee={props.attendee} calEvent={props.calEvent} />
}
subtitle={props.subtitle || <>{t("emailed_you_and_any_other_attendees")}</>}>
{props.calEvent.cancellationReason && (
<Info
label={t(
props.calEvent.cancellationReason.startsWith("$RCH$")
? "reason_for_reschedule"
: "cancellation_reason"
)}
description={
!!props.calEvent.cancellationReason && props.calEvent.cancellationReason.replace("$RCH$", "")
} // Removing flag to distinguish reschedule from cancellation
withSpacer
/>
)}
<Info label={t("rejection_reason")} description={props.calEvent.rejectionReason} withSpacer />
<Info label={t("what")} description={props.calEvent.title} withSpacer />
<WhenInfo timeFormat={timeFormat} calEvent={props.calEvent} t={t} timeZone={timeZone} locale={locale} />
<WhoInfo calEvent={props.calEvent} t={t} />
<LocationInfo calEvent={props.calEvent} t={t} />
<Info label={t("description")} description={props.calEvent.description} withSpacer formatted />
<Info label={t("additional_notes")} description={props.calEvent.additionalNotes} withSpacer />
{props.includeAppsStatus && <AppsStatus calEvent={props.calEvent} t={t} />}
<UserFieldsResponses t={t} calEvent={props.calEvent} isOrganizer={props.isOrganizer} />
{props.calEvent.paymentInfo?.amount && (
<Info
label={props.calEvent.paymentInfo.paymentOption === "HOLD" ? t("no_show_fee") : t("price")}
description={formatPrice(
props.calEvent.paymentInfo.amount,
props.calEvent.paymentInfo.currency,
props.attendee.language.locale
)}
withSpacer
/>
)}
</BaseEmailHtml>
);
};

View File

@@ -0,0 +1,33 @@
import type { IBookingRedirect } from "../../templates/booking-redirect-notification";
import { BaseEmailHtml } from "../components";
export const BookingRedirectEmailNotification = (
props: IBookingRedirect & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
return (
<BaseEmailHtml
subject={props.language("booking_redirect_email_subject")}
title={props.language("booking_redirect_email_title")}>
<p
style={{
color: "black",
fontSize: "16px",
lineHeight: "24px",
fontWeight: "400",
}}>
{props.language("booking_redirect_email_description", {
toName: props.toName,
})}
{props.dates}
<br />
<div
style={{
display: "flex",
justifyContent: "center",
marginTop: "16px",
}}
/>
</p>
</BaseEmailHtml>
);
};

View File

@@ -0,0 +1,127 @@
"use client";
import type { TFunction } from "next-i18next";
import { Trans } from "react-i18next";
import { AppStoreLocationType } from "@calcom/app-store/locations";
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { BaseScheduledEmail } from "./BaseScheduledEmail";
// https://stackoverflow.com/questions/56263980/get-key-of-an-enum-from-its-value-in-typescript
export function getEnumKeyByEnumValue(myEnum: any, enumValue: number | string): string {
const keys = Object.keys(myEnum).filter((x) => myEnum[x] == enumValue);
return keys.length > 0 ? keys[0] : "";
}
const BrokenVideoIntegration = (props: { location: string; eventTypeId?: number | null; t: TFunction }) => {
return (
<Trans i18nKey="broken_video_action" t={props.t}>
We could not add the <span>{props.location}</span> meeting link to your scheduled event. Contact your
invitees or update your calendar event to add the details. You can either&nbsp;
<a
href={
props.eventTypeId ? `${WEBAPP_URL}/event-types/${props.eventTypeId}` : `${WEBAPP_URL}/event-types`
}>
change your location on the event type
</a>
&nbsp;or try&nbsp;
<a href={`${WEBAPP_URL}/apps/installed`}>removing and adding the app again.</a>
</Trans>
);
};
const BrokenCalendarIntegration = (props: {
calendar: string;
eventTypeId?: number | null;
t: TFunction;
}) => {
const { t } = props;
return (
<Trans i18nKey="broken_calendar_action" t={props.t}>
We could not update your <span>{props.calendar}</span>.{" "}
<a href={`${WEBAPP_URL}/apps/installed`}>
Please check your calendar settings or remove and add your calendar again
</a>
</Trans>
);
};
export const BrokenIntegrationEmail = (
props: {
calEvent: CalendarEvent;
attendee: Person;
type: "video" | "calendar";
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
const { calEvent, type } = props;
const t = calEvent.organizer.language.translate;
const locale = calEvent.organizer.language.locale;
const timeFormat = calEvent.organizer?.timeFormat;
if (type === "video") {
let location = calEvent.location ? getEnumKeyByEnumValue(AppStoreLocationType, calEvent.location) : " ";
if (location === "Daily") {
location = "Cal Video";
}
if (location === "GoogleMeet") {
location = `${location.slice(0, 5)} ${location.slice(5)}`;
}
return (
<BaseScheduledEmail
timeZone={calEvent.organizer.timeZone}
t={t}
timeFormat={timeFormat}
locale={locale}
subject={t("broken_integration")}
title={t("problem_adding_video_link")}
subtitle={<BrokenVideoIntegration location={location} eventTypeId={calEvent.eventTypeId} t={t} />}
headerType="xCircle"
{...props}
/>
);
}
if (type === "calendar") {
// The calendar name is stored as name_calendar
const [mainHostDestinationCalendar] = calEvent.destinationCalendar ?? [];
let calendar = mainHostDestinationCalendar
? mainHostDestinationCalendar?.integration.split("_")
: "calendar";
if (Array.isArray(calendar)) {
const calendarCap = calendar.map((name) => name.charAt(0).toUpperCase() + name.slice(1));
calendar = `${calendarCap[0]} ${calendarCap[1]}`;
}
return (
<BaseScheduledEmail
timeZone={calEvent.organizer.timeZone}
t={t}
timeFormat={timeFormat}
locale={locale}
subject={t("broken_integration")}
title={t("problem_updating_calendar")}
subtitle={<BrokenCalendarIntegration calendar={calendar} eventTypeId={calEvent.eventTypeId} t={t} />}
headerType="xCircle"
{...props}
/>
);
}
return (
<BaseScheduledEmail
timeZone={calEvent.organizer.timeZone}
t={t}
timeFormat={timeFormat}
locale={locale}
subject={t("broken_integration")}
title={t("problem_updating_calendar")}
headerType="xCircle"
{...props}
/>
);
};

View File

@@ -0,0 +1,107 @@
import type { TFunction } from "next-i18next";
import { Trans } from "next-i18next";
import { WEBAPP_URL, APP_NAME, COMPANY_NAME } from "@calcom/lib/constants";
import { V2BaseEmailHtml, CallToAction } from "../components";
interface DailyVideoDownloadRecordingEmailProps {
language: TFunction;
downloadLink: string;
title: string;
date: string;
name: string;
}
export const DailyVideoDownloadRecordingEmail = (
props: DailyVideoDownloadRecordingEmailProps & Partial<React.ComponentProps<typeof V2BaseEmailHtml>>
) => {
const image = `${WEBAPP_URL}/emails/logo.png`;
return (
<V2BaseEmailHtml
subject={props.language("download_your_recording", {
title: props.title,
date: props.date,
})}>
<div style={{ width: "89px", marginBottom: "35px" }}>
<a href={WEBAPP_URL} target="_blank" rel="noreferrer">
<img
height="19"
src={image}
style={{
border: "0",
display: "block",
outline: "none",
textDecoration: "none",
height: "19px",
width: "100%",
fontSize: "13px",
}}
width="89"
alt=""
/>
</a>
</div>
<p
style={{
fontSize: "32px",
fontWeight: "600",
lineHeight: "38.5px",
marginBottom: "40px",
color: "black",
}}>
<>{props.language("download_your_recording")}</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("hi_user_name", { name: props.name })},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "40px" }}>
<>{props.language("recording_from_your_recent_call", { appName: APP_NAME })}</>
</p>
<div
style={{
backgroundColor: "#F3F4F6",
padding: "32px",
marginBottom: "40px",
}}>
<p
style={{
fontSize: "18px",
lineHeight: "20px",
fontWeight: 600,
marginBottom: "8px",
color: "black",
}}>
<>{props.title}</>
</p>
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "24px",
marginTop: "0px",
color: "black",
}}>
{props.date}
</p>
<CallToAction label={props.language("download_recording")} href={props.downloadLink} />
</div>
<p style={{ fontWeight: 500, lineHeight: "20px", marginTop: "8px" }}>
<Trans i18nKey="link_valid_for_12_hrs">
Note: The download link is valid only for 12 hours. You can generate new download link by following
instructions
<a href="https://cal.com/docs/enterprise-features/teams/cal-video-recordings"> here</a>
</Trans>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginTop: "32px", marginBottom: "8px" }}>
<>{props.language("happy_scheduling")},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginTop: "0px" }}>
<>{props.language("the_calcom_team", { companyName: COMPANY_NAME })}</>
</p>
</V2BaseEmailHtml>
);
};

View File

@@ -0,0 +1,103 @@
import type { TFunction } from "next-i18next";
import { WEBAPP_URL, APP_NAME, COMPANY_NAME } from "@calcom/lib/constants";
import { V2BaseEmailHtml, CallToAction } from "../components";
interface DailyVideoDownloadTranscriptEmailProps {
language: TFunction;
transcriptDownloadLinks: Array<string>;
title: string;
date: string;
name: string;
}
export const DailyVideoDownloadTranscriptEmail = (
props: DailyVideoDownloadTranscriptEmailProps & Partial<React.ComponentProps<typeof V2BaseEmailHtml>>
) => {
const image = `${WEBAPP_URL}/emails/logo.png`;
return (
<V2BaseEmailHtml
subject={props.language("download_transcript_email_subject", {
title: props.title,
date: props.date,
})}>
<div style={{ width: "89px", marginBottom: "35px" }}>
<a href={WEBAPP_URL} target="_blank" rel="noreferrer">
<img
height="19"
src={image}
style={{
border: "0",
display: "block",
outline: "none",
textDecoration: "none",
height: "19px",
width: "100%",
fontSize: "13px",
}}
width="89"
alt=""
/>
</a>
</div>
<p
style={{
fontSize: "32px",
fontWeight: "600",
lineHeight: "38.5px",
marginBottom: "40px",
color: "black",
}}>
<>{props.language("download_your_transcripts")}</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("hi_user_name", { name: props.name })},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "40px" }}>
<>{props.language("transcript_from_previous_call", { appName: APP_NAME })}</>
</p>
{props.transcriptDownloadLinks.map((downloadLink, index) => {
return (
<div
key={downloadLink}
style={{
backgroundColor: "#F3F4F6",
padding: "32px",
marginBottom: "40px",
}}>
<p
style={{
fontSize: "18px",
lineHeight: "20px",
fontWeight: 600,
marginBottom: "8px",
color: "black",
}}>
<>{props.title}</>
</p>
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "24px",
marginTop: "0px",
color: "black",
}}>
{props.date} Transcript {index + 1}
</p>
<CallToAction label={props.language("download_transcript")} href={downloadLink} />
</div>
);
})}
<p style={{ fontWeight: 400, lineHeight: "24px", marginTop: "32px", marginBottom: "8px" }}>
<>{props.language("happy_scheduling")},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginTop: "0px" }}>
<>{props.language("the_calcom_team", { companyName: COMPANY_NAME })}</>
</p>
</V2BaseEmailHtml>
);
};

View File

@@ -0,0 +1,85 @@
import type { TFunction } from "next-i18next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { BaseEmailHtml, CallToAction } from "../components";
export const DisabledAppEmail = (
props: {
appName: string;
appType: string[];
t: TFunction;
title?: string;
eventTypeId?: number;
} & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
const { title, appName, eventTypeId, t, appType } = props;
return (
<BaseEmailHtml subject={t("app_disabled", { appName: appName })}>
{appType.some((type) => type === "payment") ? (
<>
<p>
<>{t("disabled_app_affects_event_type", { appName: appName, eventType: title })}</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{t("payment_disabled_still_able_to_book")}</>
</p>
<hr style={{ marginBottom: "24px" }} />
<CallToAction
label={t("edit_event_type")}
href={`${WEBAPP_URL}/event-types/${eventTypeId}?tabName=apps`}
/>
</>
) : title && eventTypeId ? (
<>
<p>
<>{(t("app_disabled_with_event_type"), { appName: appName, title: title })}</>
</p>
<hr style={{ marginBottom: "24px" }} />
<CallToAction
label={t("edit_event_type")}
href={`${WEBAPP_URL}/event-types/${eventTypeId}?tabName=apps`}
/>
</>
) : appType.some((type) => type === "video") ? (
<>
<p>
<>{t("app_disabled_video", { appName: appName })}</>
</p>
<hr style={{ marginBottom: "24px" }} />
<CallToAction label={t("navigate_installed_apps")} href={`${WEBAPP_URL}/apps/installed`} />
</>
) : appType.some((type) => type === "calendar") ? (
<>
<p>
<>{t("admin_has_disabled", { appName: appName })}</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{t("disabled_calendar")}</>
</p>
<hr style={{ marginBottom: "24px" }} />
<CallToAction label={t("navigate_installed_apps")} href={`${WEBAPP_URL}/apps/installed`} />
</>
) : (
<>
<p>
<>{t("admin_has_disabled", { appName: appName })}</>
</p>
<hr style={{ marginBottom: "24px" }} />
<CallToAction label={t("navigate_installed_apps")} href={`${WEBAPP_URL}/apps/installed`} />
</>
)}
</BaseEmailHtml>
);
};

View File

@@ -0,0 +1,19 @@
import { BaseEmailHtml, Info } from "../components";
export interface Feedback {
username: string;
email: string;
rating: string;
comment: string;
}
export const FeedbackEmail = (props: Feedback & Partial<React.ComponentProps<typeof BaseEmailHtml>>) => {
return (
<BaseEmailHtml subject="Feedback" title="Feedback">
<Info label="Username" description={props.username} withSpacer />
<Info label="Email" description={props.email} withSpacer />
<Info label="Rating" description={props.rating} withSpacer />
<Info label="Comment" description={props.comment} withSpacer />
</BaseEmailHtml>
);
};

View File

@@ -0,0 +1,49 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import { BaseEmailHtml, CallToAction } from "../components";
export type PasswordReset = {
language: TFunction;
user: {
name?: string | null;
email: string;
};
resetLink: string;
};
export const ForgotPasswordEmail = (
props: PasswordReset & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
return (
<BaseEmailHtml subject={props.language("reset_password_subject", { appName: APP_NAME })}>
<p>
<>{props.language("hi_user_name", { name: props.user.name })}!</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("someone_requested_password_reset")}</>
</p>
<CallToAction label={props.language("change_password")} href={props.resetLink} />
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("password_reset_instructions")}</>
</p>
</div>
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>
{props.language("have_any_questions")}{" "}
<a
href={`mailto:${SUPPORT_MAIL_ADDRESS}`}
style={{ color: "#3E3E3E" }}
target="_blank"
rel="noreferrer">
<>{props.language("contact_our_support_team")}</>
</a>
</>
</p>
</div>
</BaseEmailHtml>
);
};

View File

@@ -0,0 +1,204 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, SENDER_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import { BaseEmailHtml, CallToAction } from "../components";
export type MonthlyDigestEmailData = {
language: TFunction;
Created: number;
Completed: number;
Rescheduled: number;
Cancelled: number;
mostBookedEvents: {
eventTypeId?: number | null;
eventTypeName?: string | null;
count?: number | null;
}[];
membersWithMostBookings: {
userId: number | null;
user: {
id: number;
name: string | null;
email: string;
avatar: string | null;
username: string | null;
};
count: number;
}[];
admin: { email: string; name: string };
team: { name: string; id: number };
};
export const MonthlyDigestEmail = (
props: MonthlyDigestEmailData & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
const EventsDetails = () => {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: "50px",
marginTop: "30px",
marginBottom: "30px",
}}>
<div>
<p
style={{
fontWeight: 500,
fontSize: "48px",
lineHeight: "48px",
}}>
{props.Created}
</p>
<p style={{ fontSize: "16px", fontWeight: 500, lineHeight: "20px" }}>
{props.language("events_created")}
</p>
</div>
<div>
<p
style={{
fontWeight: 500,
fontSize: "48px",
lineHeight: "48px",
}}>
{props.Completed}
</p>
<p style={{ fontSize: "16px", fontWeight: 500, lineHeight: "20px" }}>
{props.language("completed")}
</p>
</div>
<div>
<p
style={{
fontWeight: 500,
fontSize: "48px",
lineHeight: "48px",
}}>
{props.Rescheduled}
</p>
<p style={{ fontSize: "16px", fontWeight: 500, lineHeight: "20px" }}>
{props.language("rescheduled")}
</p>
</div>
<div>
<p
style={{
fontWeight: 500,
fontSize: "48px",
lineHeight: "48px",
}}>
{props.Cancelled}
</p>
<p style={{ fontSize: "16px", fontWeight: 500, lineHeight: "20px" }}>
{props.language("cancelled")}
</p>
</div>
</div>
);
};
return (
<BaseEmailHtml subject={props.language("verify_email_subject", { appName: APP_NAME })}>
<div>
<p
style={{
fontWeight: 600,
fontSize: "32px",
lineHeight: "38px",
width: "100%",
marginBottom: "30px",
}}>
{props.language("your_monthly_digest")}
</p>
<p style={{ fontWeight: "normal", fontSize: "16px", lineHeight: "24px" }}>
{props.language("hi_user_name", { name: props.admin.name })}!
</p>
<p style={{ fontWeight: "normal", fontSize: "16px", lineHeight: "24px" }}>
{props.language("summary_of_events_for_your_team_for_the_last_30_days", {
teamName: props.team.name,
})}
</p>
<EventsDetails />
<div
style={{
width: "100%",
}}>
<div
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: "1px solid #D1D5DB",
fontSize: "16px",
}}>
<p style={{ fontWeight: 500 }}>{props.language("most_popular_events")}</p>
<p style={{ fontWeight: 500 }}>{props.language("bookings")}</p>
</div>
{props.mostBookedEvents
? props.mostBookedEvents.map((ev, idx) => (
<div
key={ev.eventTypeId}
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: `${idx === props.mostBookedEvents.length - 1 ? "" : "1px solid #D1D5DB"}`,
}}>
<p style={{ fontWeight: "normal" }}>{ev.eventTypeName}</p>
<p style={{ fontWeight: "normal" }}>{ev.count}</p>
</div>
))
: null}
</div>
<div style={{ width: "100%", marginTop: "30px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: "1px solid #D1D5DB",
}}>
<p style={{ fontWeight: 500 }}>{props.language("most_booked_members")}</p>
<p style={{ fontWeight: 500 }}>{props.language("bookings")}</p>
</div>
{props.membersWithMostBookings
? props.membersWithMostBookings.map((it, idx) => (
<div
key={it.userId}
style={{
display: "flex",
justifyContent: "space-between",
borderBottom: `${
idx === props.membersWithMostBookings.length - 1 ? "" : "1px solid #D1D5DB"
}`,
}}>
<p style={{ fontWeight: "normal" }}>{it.user.name}</p>
<p style={{ fontWeight: "normal" }}>{it.count}</p>
</div>
))
: null}
</div>
<div style={{ marginTop: "30px", marginBottom: "30px" }}>
<CallToAction
label="View all stats"
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/insights?teamId=${props.team.id}`}
endIconName="white-arrow-right"
/>
</div>
</div>
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>
{props.language("happy_scheduling")}, <br />
<a
href={`mailto:${SUPPORT_MAIL_ADDRESS}`}
style={{ color: "#3E3E3E" }}
target="_blank"
rel="noreferrer">
<>{props.language("the_calcom_team", { companyName: SENDER_NAME })}</>
</a>
</>
</p>
</div>
</BaseEmailHtml>
);
};

View File

@@ -0,0 +1,37 @@
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { BaseScheduledEmail } from "./BaseScheduledEmail";
export const NoShowFeeChargedEmail = (
props: {
calEvent: CalendarEvent;
attendee: Person;
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
const { calEvent } = props;
const t = props.attendee.language.translate;
const locale = props.attendee.language.locale;
const timeFormat = props.attendee?.timeFormat;
if (!calEvent.paymentInfo?.amount) throw new Error("No payment info");
return (
<BaseScheduledEmail
locale={locale}
title={t("no_show_fee_charged_text_body")}
headerType="calendarCircle"
timeFormat={timeFormat}
subtitle={
<>
{t("no_show_fee_charged_subtitle", {
amount: calEvent.paymentInfo.amount / 100,
formatParams: { amount: { currency: calEvent.paymentInfo?.currency } },
})}
</>
}
timeZone={props.attendee.timeZone}
{...props}
t={t}
/>
);
};

View File

@@ -0,0 +1,103 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, WEBAPP_URL, IS_PRODUCTION } from "@calcom/lib/constants";
import { V2BaseEmailHtml, CallToAction } from "../components";
type TeamInvite = {
language: TFunction;
from: string;
to: string;
orgName: string;
joinLink: string;
};
export const OrgAutoInviteEmail = (
props: TeamInvite & Partial<React.ComponentProps<typeof V2BaseEmailHtml>>
) => {
return (
<V2BaseEmailHtml
subject={props.language("user_invited_you", {
user: props.from,
team: props.orgName,
appName: APP_NAME,
entity: "organization",
})}>
<p style={{ fontSize: "24px", marginBottom: "16px", textAlign: "center" }}>
<>
{props.language("organization_admin_invited_heading", {
orgName: props.orgName,
})}
</>
</p>
<img
style={{
borderRadius: "16px",
height: "270px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
src={
IS_PRODUCTION
? `${WEBAPP_URL}/emails/calendar-email-hero.png`
: "http://localhost:3000/emails/calendar-email-hero.png"
}
alt=""
/>
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "32px",
marginTop: "32px",
lineHeightStep: "24px",
}}>
<>
{props.language("organization_admin_invited_body", {
orgName: props.orgName,
})}
</>
</p>
<div style={{ display: "flex", justifyContent: "center" }}>
<CallToAction
label={props.language("email_user_cta", {
entity: "organization",
})}
href={props.joinLink}
endIconName="linkIcon"
/>
</div>
<div className="">
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "32px",
marginTop: "32px",
lineHeightStep: "24px",
}}>
<>
{props.language("email_no_user_signoff", {
appName: APP_NAME,
entity: props.language("organization").toLowerCase(),
})}
</>
</p>
</div>
<div style={{ borderTop: "1px solid #E1E1E1", marginTop: "32px", paddingTop: "32px" }}>
<p style={{ fontWeight: 400, margin: 0 }}>
<>
{props.language("have_any_questions")}{" "}
<a href="mailto:support@cal.com" style={{ color: "#3E3E3E" }} target="_blank" rel="noreferrer">
<>{props.language("contact")}</>
</a>{" "}
{props.language("our_support_team")}
</>
</p>
</div>
</V2BaseEmailHtml>
);
};

View File

@@ -0,0 +1,65 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, SUPPORT_MAIL_ADDRESS, COMPANY_NAME } from "@calcom/lib/constants";
import { BaseEmailHtml } from "../components";
export type OrganizationEmailVerify = {
language: TFunction;
user: {
email: string;
};
code: string;
};
export const OrganisationAccountVerifyEmail = (
props: OrganizationEmailVerify & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
return (
<BaseEmailHtml subject={props.language("organization_verify_header", { appName: APP_NAME })}>
<p
style={{
fontWeight: 600,
fontSize: "32px",
lineHeight: "38px",
}}>
<>{props.language("organization_verify_header")}</>
</p>
<p style={{ fontWeight: 400 }}>
<>{props.language("hi_user_name", { name: props.user.email })}!</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("organization_verify_email_body")}</>
</p>
<div style={{ display: "flex" }}>
<div
style={{
borderRadius: "6px",
backgroundColor: "#101010",
padding: "6px 2px 6px 8px",
flexShrink: 1,
}}>
<b style={{ fontWeight: 400, lineHeight: "24px", color: "white", letterSpacing: "6px" }}>
{props.code}
</b>
</div>
</div>
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>
{props.language("happy_scheduling")} <br />
<a
href={`mailto:${SUPPORT_MAIL_ADDRESS}`}
style={{ color: "#3E3E3E" }}
target="_blank"
rel="noreferrer">
<>{props.language("the_calcom_team", { companyName: COMPANY_NAME })}</>
</a>
</>
</p>
</div>
</BaseEmailHtml>
);
};

View File

@@ -0,0 +1,55 @@
import type { TFunction } from "next-i18next";
import { Trans } from "next-i18next";
import { BaseEmailHtml, CallToAction } from "../components";
export type OrganizationAdminNoSlotsEmailInput = {
language: TFunction;
to: {
email: string;
};
user: string;
slug: string;
startTime: string;
editLink: string;
};
export const OrganizationAdminNoSlotsEmail = (
props: OrganizationAdminNoSlotsEmailInput & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
return (
<BaseEmailHtml subject={`No availability found for ${props.user}`}>
<p
style={{
fontWeight: 600,
fontSize: "32px",
lineHeight: "38px",
}}>
<>{props.language("org_admin_no_slots|heading", { name: props.user })}</>
</p>
<p style={{ fontWeight: 400, fontSize: "16px", lineHeight: "24px" }}>
<Trans i18nKey="org_admin_no_slots|content" values={{ username: props.user, slug: props.slug }}>
Hello Organization Admins,
<br />
<br />
Please note: It has been brought to our attention that {props.user} has not had any availability
when a user has visited {props.user}/{props.slug}
<br />
<br />
Theres a few reasons why this could be happening
<br />
The user does not have any calendars connected
<br />
Their schedules attached to this event are not enabled
</Trans>
</p>
<div style={{ marginTop: "3rem", marginBottom: "0.75rem" }}>
<CallToAction
label={props.language("org_admin_no_slots|cta")}
href={props.editLink}
endIconName="linkIcon"
/>
</div>
</BaseEmailHtml>
);
};

View File

@@ -0,0 +1,109 @@
import { Trans } from "next-i18next";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import type { OrganizationCreation } from "../../templates/organization-creation-email";
import { V2BaseEmailHtml } from "../components";
export const OrganizationCreationEmail = (
props: OrganizationCreation & Partial<React.ComponentProps<typeof V2BaseEmailHtml>>
) => {
const { prevLink, newLink, orgName: teamName } = props;
const prevLinkWithoutProtocol = props.prevLink?.replace(/https?:\/\//, "");
const newLinkWithoutProtocol = props.newLink?.replace(/https?:\/\//, "");
const isNewUser = props.ownerOldUsername === null;
return (
<V2BaseEmailHtml subject={props.language(`email_organization_created|subject`)}>
<p style={{ fontSize: "24px", marginBottom: "16px", textAlign: "center" }}>
<>{props.language(`You have created ${props.orgName} organization.`)}</>
</p>
<img
style={{
borderRadius: "16px",
height: "270px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
src={`${WEBAPP_URL}/emails/calendar-email-hero.png`}
alt=""
/>
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "32px",
marginTop: "32px",
lineHeightStep: "24px",
}}>
You have been added as an owner of the organization. To publish your new organization, visit{" "}
<a href={`${WEBAPP_URL}/upgrade`}>{WEBAPP_URL}/upgrade</a>
</p>
<p
data-testid="organization-link-info"
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "32px",
marginTop: "48px",
lineHeightStep: "24px",
}}>
{isNewUser ? (
<Trans>
Enjoy your new organization link: <a href={`${newLink}`}>{newLinkWithoutProtocol}</a>
</Trans>
) : (
<Trans i18nKey="email|existing_user_added_link_changed">
Your link has been changed from <a href={prevLink ?? ""}>{prevLinkWithoutProtocol}</a> to{" "}
<a href={newLink ?? ""}>{newLinkWithoutProtocol}</a> but don&apos;t worry, all previous links
still work and redirect appropriately.
<br />
<br />
Please note: All of your personal event types have been moved into the <strong>
{teamName}
</strong>{" "}
organisation, which may also include potential personal link.
<br />
<br />
Please log in and make sure you have no private events on your new organisational account.
<br />
<br />
For personal events we recommend creating a new account with a personal email address.
<br />
<br />
Enjoy your new clean link: <a href={`${newLink}?orgRedirection=true`}>{newLinkWithoutProtocol}</a>
</Trans>
)}
</p>
<div className="">
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "32px",
marginTop: "32px",
lineHeightStep: "24px",
}}>
<>
{props.language("email_no_user_signoff", {
appName: APP_NAME,
})}
</>
</p>
</div>
<div style={{ borderTop: "1px solid #E1E1E1", marginTop: "32px", paddingTop: "32px" }}>
<p style={{ fontWeight: 400, margin: 0 }}>
<>
{props.language("have_any_questions")}{" "}
<a href="mailto:support@cal.com" style={{ color: "#3E3E3E" }} target="_blank" rel="noreferrer">
<>{props.language("contact")}</>
</a>{" "}
{props.language("our_support_team")}
</>
</p>
</div>
</V2BaseEmailHtml>
);
};

View File

@@ -0,0 +1,14 @@
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const OrganizerAttendeeCancelledSeatEmail = (
props: React.ComponentProps<typeof OrganizerScheduledEmail>
) => (
<OrganizerScheduledEmail
title="attendee_no_longer_attending"
headerType="xCircle"
subject="event_cancelled_subject"
callToAction={null}
attendeeCancelled
{...props}
/>
);

View File

@@ -0,0 +1,11 @@
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const OrganizerCancelledEmail = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => (
<OrganizerScheduledEmail
title="event_request_cancelled"
headerType="xCircle"
subject="event_cancelled_subject"
callToAction={null}
{...props}
/>
);

View File

@@ -0,0 +1,11 @@
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const OrganizerLocationChangeEmail = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => (
<OrganizerScheduledEmail
title="event_location_changed"
headerType="calendarCircle"
subject="location_changed_event_type_subject"
callToAction={null}
{...props}
/>
);

View File

@@ -0,0 +1,71 @@
import { BaseEmailHtml } from "../components";
import type { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const OrganizerPaymentRefundFailedEmail = (
props: React.ComponentProps<typeof OrganizerScheduledEmail>
) => {
const t = props.calEvent.organizer.language.translate;
return (
<BaseEmailHtml
headerType="xCircle"
subject="refund_failed_subject"
title={t("a_refund_failed")}
callToAction={null}
subtitle={
<>
{t("check_with_provider_and_user", {
user: props.calEvent.attendees[0].name,
})}
</>
}>
<RefundInformation {...props} />
</BaseEmailHtml>
);
};
function RefundInformation(props: React.ComponentProps<typeof OrganizerPaymentRefundFailedEmail>) {
const { paymentInfo } = props.calEvent;
const t = props.calEvent.organizer.language.translate;
if (!paymentInfo) return null;
return (
<>
{paymentInfo.reason && (
<tr>
<td align="center" style={{ fontSize: "0px", padding: "10px 25px", wordBreak: "break-word" }}>
<div
style={{
fontFamily: "Roboto, Helvetica, sans-serif",
fontSize: "16px",
fontWeight: 400,
lineHeight: "24px",
textAlign: "center",
color: "#494949",
}}>
{t("error_message", { errorMessage: paymentInfo.reason }).toString()}
</div>
</td>
</tr>
)}
{paymentInfo.id && (
<tr>
<td align="center" style={{ fontSize: "0px", padding: "10px 25px", wordBreak: "break-word" }}>
<div
style={{
fontFamily: "Roboto, Helvetica, sans-serif",
fontSize: "16px",
fontWeight: 400,
lineHeight: "24px",
textAlign: "center",
color: "#494949",
}}>
Payment {paymentInfo.id}
</div>
</td>
</tr>
)}
</>
);
}

View File

@@ -0,0 +1,41 @@
import { WEBAPP_URL } from "@calcom/lib/constants";
import { symmetricEncrypt } from "@calcom/lib/crypto";
import { CallToAction, Separator, CallToActionTable } from "../components";
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const OrganizerRequestEmail = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => {
const seedData = { bookingUid: props.calEvent.uid, userId: props.calEvent.organizer.id };
const token = symmetricEncrypt(JSON.stringify(seedData), process.env.CALENDSO_ENCRYPTION_KEY || "");
//TODO: We should switch to using org domain if available
const actionHref = `${WEBAPP_URL}/api/link/?token=${encodeURIComponent(token)}`;
return (
<OrganizerScheduledEmail
title={
props.title || props.calEvent.recurringEvent?.count
? "event_awaiting_approval_recurring"
: "event_awaiting_approval"
}
subtitle={<>{props.calEvent.organizer.language.translate("someone_requested_an_event")}</>}
headerType="calendarCircle"
subject="event_awaiting_approval_subject"
callToAction={
<CallToActionTable>
<CallToAction
label={props.calEvent.organizer.language.translate("confirm")}
href={`${actionHref}&action=accept`}
startIconName="confirmIcon"
/>
<Separator />
<CallToAction
label={props.calEvent.organizer.language.translate("reject")}
href={`${actionHref}&action=reject`}
startIconName="rejectIcon"
secondary
/>
</CallToActionTable>
}
{...props}
/>
);
};

View File

@@ -0,0 +1,5 @@
import { OrganizerRequestEmail } from "./OrganizerRequestEmail";
export const OrganizerRequestReminderEmail = (props: React.ComponentProps<typeof OrganizerRequestEmail>) => (
<OrganizerRequestEmail title="event_still_awaiting_approval" {...props} />
);

View File

@@ -0,0 +1,22 @@
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const OrganizerRequestedToRescheduleEmail = (
props: React.ComponentProps<typeof OrganizerScheduledEmail>
) => (
<OrganizerScheduledEmail
title={props.calEvent.organizer.language.translate("request_reschedule_title_organizer", {
attendee: props.calEvent.attendees[0].name,
})}
subtitle={
<>
{props.calEvent.organizer.language.translate("request_reschedule_subtitle_organizer", {
attendee: props.calEvent.attendees[0].name,
})}
</>
}
headerType="calendarCircle"
subject="rescheduled_event_type_subject"
callToAction={null}
{...props}
/>
);

View File

@@ -0,0 +1,10 @@
import { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export const OrganizerRescheduledEmail = (props: React.ComponentProps<typeof OrganizerScheduledEmail>) => (
<OrganizerScheduledEmail
title="event_has_been_rescheduled"
headerType="calendarCircle"
subject="event_type_has_been_rescheduled_on_time_date"
{...props}
/>
);

View File

@@ -0,0 +1,55 @@
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { BaseScheduledEmail } from "./BaseScheduledEmail";
export const OrganizerScheduledEmail = (
props: {
calEvent: CalendarEvent;
attendee: Person;
newSeat?: boolean;
attendeeCancelled?: boolean;
teamMember?: Person;
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
let subject;
let title;
if (props.newSeat) {
subject = "new_seat_subject";
} else {
subject = "confirmed_event_type_subject";
}
if (props.calEvent.recurringEvent?.count) {
title = "new_event_scheduled_recurring";
} else if (props.newSeat) {
title = "new_seat_title";
} else {
title = "new_event_scheduled";
}
const t = props.teamMember?.language.translate || props.calEvent.organizer.language.translate;
const locale = props.teamMember?.language.locale || props.calEvent.organizer.language.locale;
const timeFormat = props.teamMember?.timeFormat || props.calEvent.organizer?.timeFormat;
return (
<BaseScheduledEmail
locale={locale}
timeZone={props.teamMember?.timeZone || props.calEvent.organizer.timeZone}
t={t}
subject={t(subject)}
title={t(title)}
includeAppsStatus
timeFormat={timeFormat}
isOrganizer
subtitle={
<>
{props.attendeeCancelled
? t("attendee_no_longer_attending_subtitle", { name: props.attendee.name })
: ""}
</>
}
{...props}
/>
);
};

View File

@@ -0,0 +1,82 @@
"use client";
import type { TFunction } from "next-i18next";
import { Trans } from "next-i18next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { BaseEmailHtml, CallToAction } from "../components";
export const SlugReplacementEmail = (
props: {
slug: string;
name: string;
teamName: string;
t: TFunction;
} & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
const { slug, name, teamName, t } = props;
return (
<BaseEmailHtml
subject={t("email_subject_slug_replacement", { slug: slug })}
headerType="teamCircle"
title={t("event_replaced_notice")}>
<>
<Trans i18nKey="hi_user_name" name={name}>
<p style={{ fontWeight: 400, lineHeight: "24px", display: "inline-block" }}>Hi {name}</p>
<p style={{ display: "inline" }}>,</p>
</Trans>
<Trans i18nKey="email_body_slug_replacement_notice" slug={slug}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
An administrator on the <strong>{teamName}</strong> team has replaced your event type{" "}
<strong>/{slug}</strong> with a managed event type that they control.
</p>
</Trans>
<Trans i18nKey="email_body_slug_replacement_info">
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
Your link will continue to work but some settings for it may have changed. You can review it in
event types.
</p>
</Trans>
<table
role="presentation"
border={0}
style={{ verticalAlign: "top", marginTop: "25px" }}
width="100%">
<tbody>
<tr>
<td align="center">
<CallToAction
label={t("review_event_type")}
href={`${WEBAPP_URL}/event-types`}
endIconName="white-arrow-right"
/>
</td>
</tr>
</tbody>
</table>
<p
style={{
borderTop: "solid 1px #E1E1E1",
fontSize: 1,
margin: "35px auto",
width: "100%",
}}
/>
<Trans i18nKey="email_body_slug_replacement_suggestion">
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
If you have any questions about the event type, please reach out to your administrator.
<br />
<br />
Happy scheduling, <br />
The Cal.com team
</p>
</Trans>
{/*<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{t("email_body_slug_replacement_suggestion")}</>
</p>*/}
</>
</BaseEmailHtml>
);
};

View File

@@ -0,0 +1,251 @@
import type { TFunction } from "next-i18next";
import { Trans } from "next-i18next";
import { APP_NAME, WEBAPP_URL, IS_PRODUCTION } from "@calcom/lib/constants";
import { getSubject, getTypeOfInvite } from "../../templates/team-invite-email";
import { V2BaseEmailHtml, CallToAction } from "../components";
type TeamInvite = {
language: TFunction;
from: string;
to: string;
teamName: string;
joinLink: string;
isCalcomMember: boolean;
isAutoJoin: boolean;
isOrg: boolean;
parentTeamName: string | undefined;
isExistingUserMovedToOrg: boolean;
prevLink: string | null;
newLink: string | null;
};
export const TeamInviteEmail = (
props: TeamInvite & Partial<React.ComponentProps<typeof V2BaseEmailHtml>>
) => {
const typeOfInvite = getTypeOfInvite(props);
const heading = getHeading();
const content = getContent();
return (
<V2BaseEmailHtml subject={getSubject(props)}>
<p style={{ fontSize: "24px", marginBottom: "16px", textAlign: "center" }}>
<>{heading}</>
</p>
<img
style={{
borderRadius: "16px",
height: "270px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
src={
IS_PRODUCTION
? `${WEBAPP_URL}/emails/calendar-email-hero.png`
: "http://localhost:3000/emails/calendar-email-hero.png"
}
alt=""
/>
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "32px",
marginTop: "32px",
lineHeightStep: "24px",
}}>
<>{content}</>
</p>
<div style={{ display: "flex", justifyContent: "center" }}>
<CallToAction
label={props.language(
props.isCalcomMember ? (props.isAutoJoin ? "login" : "email_user_cta") : "create_your_account"
)}
href={props.joinLink}
endIconName="linkIcon"
/>
</div>
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "32px",
marginTop: "48px",
lineHeightStep: "24px",
}}
/>
<div className="">
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "32px",
marginTop: "32px",
lineHeightStep: "24px",
}}>
<>
{props.language("email_no_user_signoff", {
appName: APP_NAME,
})}
</>
</p>
</div>
<div style={{ borderTop: "1px solid #E1E1E1", marginTop: "32px", paddingTop: "32px" }}>
<p style={{ fontWeight: 400, margin: 0 }}>
<>
{props.language("have_any_questions")}{" "}
<a href="mailto:support@cal.com" style={{ color: "#3E3E3E" }} target="_blank" rel="noreferrer">
<>{props.language("contact")}</>
</a>{" "}
{props.language("our_support_team")}
</>
</p>
</div>
</V2BaseEmailHtml>
);
function getHeading() {
const autoJoinType = props.isAutoJoin ? "added" : "invited";
const variables = {
appName: APP_NAME,
parentTeamName: props.parentTeamName,
};
if (typeOfInvite === "TO_ORG") {
return props.language(`email_team_invite|heading|${autoJoinType}_to_org`, variables);
}
if (typeOfInvite === "TO_SUBTEAM") {
return props.language(`email_team_invite|heading|${autoJoinType}_to_subteam`, variables);
}
return props.language(`email_team_invite|heading|invited_to_regular_team`, variables);
}
function getContent() {
const autoJoinType = props.isAutoJoin ? "added" : "invited";
const variables = {
invitedBy: props.from.toString(),
appName: APP_NAME,
teamName: props.teamName,
parentTeamName: props.parentTeamName,
prevLink: props.prevLink,
newLink: props.newLink,
orgName: props.parentTeamName ?? props.isOrg ? props.teamName : "",
prevLinkWithoutProtocol: props.prevLink?.replace(/https?:\/\//, ""),
newLinkWithoutProtocol: props.newLink?.replace(/https?:\/\//, ""),
};
const {
prevLink,
newLink,
teamName,
invitedBy,
appName,
parentTeamName,
prevLinkWithoutProtocol,
newLinkWithoutProtocol,
} = variables;
if (typeOfInvite === "TO_ORG") {
if (props.isExistingUserMovedToOrg) {
return (
<>
{autoJoinType == "added" ? (
<>
<Trans i18nKey="email_team_invite|content|added_to_org">
{invitedBy} has added you to the <strong>{teamName}</strong> organization.
</Trans>{" "}
<Trans
i18nKey="email_team_invite|content_addition|existing_user_added"
values={{ prevLink: props.prevLink, newLink: props.newLink, teamName: props.teamName }}>
Your link has been changed from <a href={prevLink ?? ""}>{prevLinkWithoutProtocol}</a> to{" "}
<a href={newLink ?? ""}>{newLinkWithoutProtocol}</a> but don&apos;t worry, all previous
links still work and redirect appropriately.
<br />
<br />
Please note: All of your personal event types have been moved into the{" "}
<strong>{teamName}</strong> organisation, which may also include potential personal link.
<br />
<br />
Please log in and make sure you have no private events on your new organisational account.
<br />
<br />
For personal events we recommend creating a new account with a personal email address.
<br />
<br />
Enjoy your new clean link:{" "}
<a href={`${newLink}?orgRedirection=true`}>{newLinkWithoutProtocol}</a>
</Trans>
</>
) : (
<>
<Trans i18nKey="email_team_invite|content|invited_to_org">
{invitedBy} has invited you to join the <strong>{teamName}</strong> organization.
</Trans>{" "}
<Trans
i18nKey="existing_user_added_link_will_change"
values={{ prevLink: props.prevLink, newLink: props.newLink, teamName: props.teamName }}>
On accepting the invite, your link will change to your organization domain but don&apos;t
worry, all previous links will still work and redirect appropriately.
<br />
<br />
Please note: All of your personal event types will be moved into the{" "}
<strong>{teamName}</strong> organisation, which may also include potential personal link.
<br />
<br />
For personal events we recommend creating a new account with a personal email address.
</Trans>
</>
)}
</>
);
}
return (
<>
{autoJoinType === "added" ? (
<Trans i18nKey="email_team_invite|content|added_to_org">
{invitedBy} has added you to the <strong>{teamName}</strong> organization.
</Trans>
) : (
<Trans i18nKey="email_team_invite|content|invited_to_org">
{invitedBy} has invited you to join the <strong>{teamName}</strong> organization.
</Trans>
)}{" "}
<Trans>
{appName} is the event-juggling scheduler that enables you and your team to schedule meetings
without the email tennis.
</Trans>
</>
);
}
if (typeOfInvite === "TO_SUBTEAM") {
return (
<>
{autoJoinType === "added" ? (
<Trans i18nKey="email_team_invite|content|added_to_subteam">
{invitedBy} has added you to the team <strong>{teamName}</strong> in their organization{" "}
<strong>{parentTeamName}</strong>.
</Trans>
) : (
<Trans i18nKey="email_team_invite|content|invited_to_subteam">
{invitedBy} has invited you to the team <strong>{teamName}</strong> in their organization{" "}
<strong>{parentTeamName}</strong>.
</Trans>
)}{" "}
<Trans>
{appName} is the event-juggling scheduler that enables you and your team to schedule meetings
without the email tennis.
</Trans>
</>
);
}
// Regular team doesn't support auto-join. So, they have to be invited always
return props.language(`email_team_invite|content|invited_to_regular_team`, variables);
}
};

View File

@@ -0,0 +1,60 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, SENDER_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import { BaseEmailHtml, CallToAction } from "../components";
export type EmailVerifyLink = {
language: TFunction;
user: {
name?: string | null;
email: string;
};
verificationEmailLink: string;
};
export const VerifyAccountEmail = (
props: EmailVerifyLink & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
return (
<BaseEmailHtml subject={props.language("verify_email_subject", { appName: APP_NAME })}>
<p
style={{
fontWeight: 600,
fontSize: "32px",
lineHeight: "38px",
}}>
<>{props.language("verify_email_email_header")}</>
</p>
<p style={{ fontWeight: 400 }}>
<>{props.language("hi_user_name", { name: props.user.name })}!</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("verify_email_email_body", { appName: APP_NAME })}</>
</p>
<CallToAction label={props.language("verify_email_email_button")} href={props.verificationEmailLink} />
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("verify_email_email_link_text")}</>
<br />
<a href={props.verificationEmailLink}>{props.verificationEmailLink}</a>
</p>
</div>
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>
{props.language("happy_scheduling")}, <br />
<a
href={`mailto:${SUPPORT_MAIL_ADDRESS}`}
style={{ color: "#3E3E3E" }}
target="_blank"
rel="noreferrer">
<>{props.language("the_calcom_team", { companyName: SENDER_NAME })}</>
</a>
</>
</p>
</div>
</BaseEmailHtml>
);
};

View File

@@ -0,0 +1,48 @@
import { APP_NAME, SENDER_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import type { EmailVerifyCode } from "../../templates/attendee-verify-email";
import { BaseEmailHtml } from "../components";
export const VerifyEmailByCode = (
props: EmailVerifyCode & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
return (
<BaseEmailHtml
subject={props.language(`verify_email_subject${props.isVerifyingEmail ? "_verifying_email" : ""}`, {
appName: APP_NAME,
})}>
<p
style={{
fontWeight: 600,
fontSize: "32px",
lineHeight: "38px",
}}>
<>{props.language("verify_email_email_header")}</>
</p>
<p style={{ fontWeight: 400 }}>
<>{props.language("hi_user_name", { name: props.user.name })}!</>
</p>
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("verify_email_by_code_email_body")}</>
<br />
<p>{props.verificationEmailCode}</p>
</p>
</div>
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>
{props.language("happy_scheduling")}, <br />
<a
href={`mailto:${SUPPORT_MAIL_ADDRESS}`}
style={{ color: "#3E3E3E" }}
target="_blank"
rel="noreferrer">
<>{props.language("the_calcom_team", { companyName: SENDER_NAME })}</>
</a>
</>
</p>
</div>
</BaseEmailHtml>
);
};

View File

@@ -0,0 +1,103 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, SENDER_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import { BaseEmailHtml, CallToAction } from "../components";
export type EmailVerifyLink = {
language: TFunction;
user: {
name?: string | null;
emailFrom: string;
emailTo: string;
};
verificationEmailLink: string;
};
export const VerifyEmailChangeEmail = (
props: EmailVerifyLink & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
return (
<BaseEmailHtml subject={props.language("change_of_email", { appName: APP_NAME })}>
<p
style={{
fontWeight: 600,
fontSize: "24px",
lineHeight: "32px",
}}>
<>{props.language("change_of_email", { appName: APP_NAME })}</>
</p>
<p style={{ fontWeight: 400 }}>
<>{props.language("hi_user_name", { name: props.user.name })}!</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("verify_email_change_description", { appName: APP_NAME })}</>
</p>
<div
style={{
marginTop: "2rem",
marginBottom: "2rem",
display: "flex",
justifyContent: "space-between",
}}>
<div
style={{
width: "100%",
}}>
<span
style={{
display: "block",
fontSize: "14px",
lineHeight: 0.5,
}}>
{props.language("old_email_address")}
</span>
<p
style={{
color: `#6B7280`,
lineHeight: 1,
fontWeight: 400,
}}>
{props.user.emailFrom}
</p>
</div>
<div
style={{
width: "100%",
}}>
<span
style={{
display: "block",
fontSize: "14px",
lineHeight: 0.5,
}}>
{props.language("new_email_address")}
</span>
<p
style={{
color: `#6B7280`,
lineHeight: 1,
fontWeight: 400,
}}>
{props.user.emailTo}
</p>
</div>
</div>
<CallToAction label={props.language("verify_email_email_button")} href={props.verificationEmailLink} />
<div style={{ lineHeight: "6px" }}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>
{props.language("happy_scheduling")}, <br />
<a
href={`mailto:${SUPPORT_MAIL_ADDRESS}`}
style={{ color: "#3E3E3E" }}
target="_blank"
rel="noreferrer">
<>{props.language("the_calcom_team", { companyName: SENDER_NAME })}</>
</a>
</>
</p>
</div>
</BaseEmailHtml>
);
};

View File

@@ -0,0 +1,38 @@
export { AttendeeAwaitingPaymentEmail } from "./AttendeeAwaitingPaymentEmail";
export { AttendeeCancelledEmail } from "./AttendeeCancelledEmail";
export { AttendeeCancelledSeatEmail } from "./AttendeeCancelledSeatEmail";
export { AttendeeDeclinedEmail } from "./AttendeeDeclinedEmail";
export { AttendeeLocationChangeEmail } from "./AttendeeLocationChangeEmail";
export { AttendeeRequestEmail } from "./AttendeeRequestEmail";
export { AttendeeWasRequestedToRescheduleEmail } from "./AttendeeWasRequestedToRescheduleEmail";
export { AttendeeRescheduledEmail } from "./AttendeeRescheduledEmail";
export { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
export { DisabledAppEmail } from "./DisabledAppEmail";
export { SlugReplacementEmail } from "./SlugReplacementEmail";
export { FeedbackEmail } from "./FeedbackEmail";
export { ForgotPasswordEmail } from "./ForgotPasswordEmail";
export { OrganizerCancelledEmail } from "./OrganizerCancelledEmail";
export { OrganizerLocationChangeEmail } from "./OrganizerLocationChangeEmail";
export { OrganizerPaymentRefundFailedEmail } from "./OrganizerPaymentRefundFailedEmail";
export { OrganizerRequestEmail } from "./OrganizerRequestEmail";
export { OrganizerRequestReminderEmail } from "./OrganizerRequestReminderEmail";
export { OrganizerRequestedToRescheduleEmail } from "./OrganizerRequestedToRescheduleEmail";
export { OrganizerRescheduledEmail } from "./OrganizerRescheduledEmail";
export { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export { TeamInviteEmail } from "./TeamInviteEmail";
export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail";
export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail";
export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail";
export { VerifyAccountEmail } from "./VerifyAccountEmail";
export { VerifyEmailByCode } from "./VerifyEmailByCode";
export * from "@calcom/app-store/routing-forms/emails/components";
export { DailyVideoDownloadRecordingEmail } from "./DailyVideoDownloadRecordingEmail";
export { DailyVideoDownloadTranscriptEmail } from "./DailyVideoDownloadTranscriptEmail";
export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail";
export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail";
export { MonthlyDigestEmail } from "./MonthlyDigestEmail";
export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail";
export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification";
export { VerifyEmailChangeEmail } from "./VerifyEmailChangeEmail";
export { OrganizationCreationEmail } from "./OrganizationCreationEmail";
export { OrganizationAdminNoSlotsEmail } from "./OrganizationAdminNoSlots";

View File

@@ -0,0 +1,143 @@
module.exports = {
mode: "jit",
theme: {
screens: {
sm: { max: "600px" },
},
extend: {
spacing: {
screen: "100vw",
full: "100%",
px: "1px",
0: "0",
2: "2px",
3: "3px",
4: "4px",
5: "5px",
6: "6px",
7: "7px",
8: "8px",
9: "9px",
10: "10px",
11: "11px",
12: "12px",
14: "14px",
16: "16px",
20: "20px",
24: "24px",
28: "28px",
32: "32px",
36: "36px",
40: "40px",
44: "44px",
48: "48px",
52: "52px",
56: "56px",
60: "60px",
64: "64px",
72: "72px",
80: "80px",
96: "96px",
600: "600px",
"1/2": "50%",
"1/3": "33.333333%",
"2/3": "66.666667%",
"1/4": "25%",
"2/4": "50%",
"3/4": "75%",
"1/5": "20%",
"2/5": "40%",
"3/5": "60%",
"4/5": "80%",
"1/6": "16.666667%",
"2/6": "33.333333%",
"3/6": "50%",
"4/6": "66.666667%",
"5/6": "83.333333%",
"1/12": "8.333333%",
"2/12": "16.666667%",
"3/12": "25%",
"4/12": "33.333333%",
"5/12": "41.666667%",
"6/12": "50%",
"7/12": "58.333333%",
"8/12": "66.666667%",
"9/12": "75%",
"10/12": "83.333333%",
"11/12": "91.666667%",
},
borderRadius: {
none: "0px",
sm: "2px",
DEFAULT: "4px",
md: "6px",
lg: "8px",
xl: "12px",
"2xl": "16px",
"3xl": "24px",
full: "9999px",
},
fontFamily: {
sans: ["ui-sans-serif", "system-ui", "-apple-system", '"Segoe UI"', "sans-serif"],
serif: ["ui-serif", "Georgia", "Cambria", '"Times New Roman"', "Times", "serif"],
mono: ["ui-monospace", "Menlo", "Consolas", "monospace"],
},
fontSize: {
0: "0",
xs: "12px",
sm: "14px",
base: "16px",
lg: "18px",
xl: "20px",
"2xl": "24px",
"3xl": "30px",
"4xl": "36px",
"5xl": "48px",
"6xl": "60px",
"7xl": "72px",
"8xl": "96px",
"9xl": "128px",
},
inset: (theme) => ({
...theme("spacing"),
}),
letterSpacing: (theme) => ({
...theme("spacing"),
}),
lineHeight: (theme) => ({
...theme("spacing"),
}),
maxHeight: (theme) => ({
...theme("spacing"),
}),
maxWidth: (theme) => ({
...theme("spacing"),
xs: "160px",
sm: "192px",
md: "224px",
lg: "256px",
xl: "288px",
"2xl": "336px",
"3xl": "384px",
"4xl": "448px",
"5xl": "512px",
"6xl": "576px",
"7xl": "640px",
}),
minHeight: (theme) => ({
...theme("spacing"),
}),
minWidth: (theme) => ({
...theme("spacing"),
}),
},
},
corePlugins: {
animation: false,
backgroundOpacity: false,
borderOpacity: false,
divideOpacity: false,
placeholderOpacity: false,
textOpacity: false,
},
};

View File

@@ -0,0 +1,103 @@
import { decodeHTML } from "entities";
import { createTransport } from "nodemailer";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { serverConfig } from "@calcom/lib/serverConfig";
import { setTestEmail } from "@calcom/lib/testEmails";
import prisma from "@calcom/prisma";
import { sanitizeDisplayName } from "../lib/sanitizeDisplayName";
export default class BaseEmail {
name = "";
protected getTimezone() {
return "";
}
protected getLocale(): string {
return "";
}
protected getFormattedRecipientTime({ time, format }: { time: string; format: string }) {
return dayjs(time).tz(this.getTimezone()).locale(this.getLocale()).format(format);
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {};
}
public async sendEmail() {
const emailsDisabled = await getFeatureFlag(prisma, "emails");
/** If email kill switch exists and is active, we prevent emails being sent. */
if (emailsDisabled) {
console.warn("Skipped Sending Email due to active Kill Switch");
return new Promise((r) => r("Skipped Sending Email due to active Kill Switch"));
}
if (process.env.INTEGRATION_TEST_MODE === "true") {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-expect-error
setTestEmail(await this.getNodeMailerPayload());
console.log(
"Skipped Sending Email as process.env.NEXT_PUBLIC_UNIT_TESTS is set. Emails are available in globalThis.testEmails"
);
return new Promise((r) => r("Skipped sendEmail for Unit Tests"));
}
const payload = await this.getNodeMailerPayload();
const from = "from" in payload ? (payload.from as string) : "";
const to = "to" in payload ? (payload.to as string) : "";
const sanitizedFrom = sanitizeDisplayName(from);
const sanitizedTo = sanitizeDisplayName(to);
const parseSubject = z.string().safeParse(payload?.subject);
const payloadWithUnEscapedSubject = {
headers: this.getMailerOptions().headers,
...payload,
...{
from: sanitizedFrom,
to: sanitizedTo,
},
...(parseSubject.success && { subject: decodeHTML(parseSubject.data) }),
};
await new Promise((resolve, reject) =>
createTransport(this.getMailerOptions().transport).sendMail(
payloadWithUnEscapedSubject,
(_err, info) => {
if (_err) {
const err = getErrorFromUnknown(_err);
this.printNodeMailerError(err);
reject(err);
} else {
resolve(info);
}
}
)
).catch((e) =>
console.error(
"sendEmail",
`from: ${"from" in payloadWithUnEscapedSubject ? payloadWithUnEscapedSubject.from : ""}`,
`subject: ${"subject" in payloadWithUnEscapedSubject ? payloadWithUnEscapedSubject.subject : ""}`,
e
)
);
return new Promise((resolve) => resolve("send mail async"));
}
protected getMailerOptions() {
return {
transport: serverConfig.transport,
from: serverConfig.from,
headers: serverConfig.headers,
};
}
protected printNodeMailerError(error: Error): void {
/** Don't clog the logs with unsent emails in E2E */
if (process.env.NEXT_PUBLIC_IS_E2E) return;
console.error(`${this.name}_ERROR`, error);
}
}

View File

@@ -0,0 +1,56 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, COMPANY_NAME, EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import BaseEmail from "./_base-email";
export type EmailVerifyLink = {
language: TFunction;
user: {
name?: string | null;
email: string;
};
verificationEmailLink: string;
isSecondaryEmailVerification?: boolean;
};
export default class AccountVerifyEmail extends BaseEmail {
verifyAccountInput: EmailVerifyLink;
constructor(passwordEvent: EmailVerifyLink) {
super();
this.name = "SEND_ACCOUNT_VERIFY_EMAIL";
this.verifyAccountInput = passwordEvent;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const emailSubjectKey = this.verifyAccountInput.isSecondaryEmailVerification
? "verify_email_email_header"
: "verify_email_subject";
return {
to: `${this.verifyAccountInput.user.name} <${this.verifyAccountInput.user.email}>`,
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
subject: this.verifyAccountInput.language(emailSubjectKey, {
appName: APP_NAME,
}),
html: await renderEmail("VerifyAccountEmail", this.verifyAccountInput),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.verifyAccountInput.language("verify_email_subject", { appName: APP_NAME })}
${this.verifyAccountInput.language("verify_email_email_header")}
${this.verifyAccountInput.language("hi_user_name", { name: this.verifyAccountInput.user.name })},
${this.verifyAccountInput.language("verify_email_email_body", { appName: APP_NAME })}
${this.verifyAccountInput.language("verify_email_email_link_text")}
${this.verifyAccountInput.verificationEmailLink}
${this.verifyAccountInput.language("happy_scheduling")} ${this.verifyAccountInput.language(
"the_calcom_team",
{ companyName: COMPANY_NAME }
)}
`.replace(/(<([^>]+)>)/gi, "");
}
}

View File

@@ -0,0 +1,43 @@
import type { TFunction } from "next-i18next";
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import BaseEmail from "./_base-email";
export type OrganizationNotification = {
t: TFunction;
instanceAdmins: { email: string }[];
ownerEmail: string;
orgSlug: string;
webappIPAddress: string;
};
export default class AdminOrganizationNotification extends BaseEmail {
input: OrganizationNotification;
constructor(input: OrganizationNotification) {
super();
this.name = "SEND_ADMIN_ORG_NOTIFICATION";
this.input = input;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: this.input.instanceAdmins.map((admin) => admin.email).join(","),
subject: `${this.input.t("admin_org_notification_email_subject")}`,
html: await renderEmail("AdminOrganizationNotificationEmail", {
orgSlug: this.input.orgSlug,
webappIPAddress: this.input.webappIPAddress,
language: this.input.t,
}),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `${this.input.t("hi_admin")}, ${this.input.t("admin_org_notification_email_title").toLowerCase()}
${this.input.t("admin_org_notification_email_body")}`.trim();
}
}

View File

@@ -0,0 +1,21 @@
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeAwaitingPaymentEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `${this.attendee.language.translate("complete_your_booking_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: await renderEmail("AttendeeAwaitingPaymentEmail", {
calEvent: this.calEvent,
attendee: this.attendee,
}),
text: this.getTextBody("meeting_awaiting_payment"),
};
}
}

View File

@@ -0,0 +1,33 @@
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeCancelledEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("event_request_cancelled"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
status: "CANCELLED",
role: "attendee",
}),
method: "REQUEST",
},
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `${this.t("event_cancelled_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: await renderEmail("AttendeeCancelledEmail", {
calEvent: this.calEvent,
attendee: this.attendee,
}),
text: this.getTextBody("event_request_cancelled", "emailed_you_and_any_other_attendees"),
};
}
}

View File

@@ -0,0 +1,21 @@
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeCancelledSeatEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `${this.t("event_no_longer_attending_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: await renderEmail("AttendeeCancelledSeatEmail", {
calEvent: this.calEvent,
attendee: this.attendee,
}),
text: this.getTextBody("event_request_cancelled", "emailed_you_and_any_other_attendees"),
};
}
}

View File

@@ -0,0 +1,72 @@
// TODO: We should find a way to keep App specific email templates within the App itself
import type { TFunction } from "next-i18next";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
import BaseEmail from "./_base-email";
export default class AttendeeDailyVideoDownloadRecordingEmail extends BaseEmail {
calEvent: CalendarEvent;
attendee: Person;
downloadLink: string;
t: TFunction;
constructor(calEvent: CalendarEvent, attendee: Person, downloadLink: string) {
super();
this.name = "SEND_RECORDING_DOWNLOAD_LINK";
this.calEvent = calEvent;
this.attendee = attendee;
this.downloadLink = downloadLink;
this.t = attendee.language.translate;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email],
subject: `${this.t("download_recording_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: await renderEmail("DailyVideoDownloadRecordingEmail", {
title: this.calEvent.title,
date: this.getFormattedDate(),
downloadLink: this.downloadLink,
language: this.t,
name: this.attendee.name,
}),
};
}
protected getTimezone(): string {
return this.attendee.timeZone;
}
protected getLocale(): string {
return this.attendee.language.locale;
}
protected getInviteeStart(format: string) {
return this.getFormattedRecipientTime({
time: this.calEvent.startTime,
format,
});
}
protected getInviteeEnd(format: string) {
return this.getFormattedRecipientTime({
time: this.calEvent.endTime,
format,
});
}
protected getFormattedDate() {
const inviteeTimeFormat = this.attendee.timeFormat || TimeFormat.TWELVE_HOUR;
return `${this.getInviteeStart(inviteeTimeFormat)} - ${this.getInviteeEnd(inviteeTimeFormat)}, ${this.t(
this.getInviteeStart("dddd").toLowerCase()
)}, ${this.t(this.getInviteeStart("MMMM").toLowerCase())} ${this.getInviteeStart("D, YYYY")}`;
}
}

View File

@@ -0,0 +1,71 @@
import type { TFunction } from "next-i18next";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
import BaseEmail from "./_base-email";
export default class AttendeeDailyVideoDownloadTranscriptEmail extends BaseEmail {
calEvent: CalendarEvent;
attendee: Person;
transcriptDownloadLinks: Array<string>;
t: TFunction;
constructor(calEvent: CalendarEvent, attendee: Person, transcriptDownloadLinks: string[]) {
super();
this.name = "SEND_TRANSCRIPT_DOWNLOAD_LINK";
this.calEvent = calEvent;
this.attendee = attendee;
this.transcriptDownloadLinks = transcriptDownloadLinks;
this.t = attendee.language.translate;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email],
subject: `${this.t("download_transcript_email_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: await renderEmail("DailyVideoDownloadTranscriptEmail", {
title: this.calEvent.title,
date: this.getFormattedDate(),
transcriptDownloadLinks: this.transcriptDownloadLinks,
language: this.t,
name: this.attendee.name,
}),
};
}
protected getTimezone(): string {
return this.attendee.timeZone;
}
protected getLocale(): string {
return this.attendee.language.locale;
}
protected getInviteeStart(format: string) {
return this.getFormattedRecipientTime({
time: this.calEvent.startTime,
format,
});
}
protected getInviteeEnd(format: string) {
return this.getFormattedRecipientTime({
time: this.calEvent.endTime,
format,
});
}
protected getFormattedDate() {
const inviteeTimeFormat = this.attendee.timeFormat || TimeFormat.TWELVE_HOUR;
return `${this.getInviteeStart(inviteeTimeFormat)} - ${this.getInviteeEnd(inviteeTimeFormat)}, ${this.t(
this.getInviteeStart("dddd").toLowerCase()
)}, ${this.t(this.getInviteeStart("MMMM").toLowerCase())} ${this.getInviteeStart("D, YYYY")}`;
}
}

View File

@@ -0,0 +1,23 @@
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeDeclinedEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `${this.t("event_declined_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: await renderEmail("AttendeeDeclinedEmail", {
calEvent: this.calEvent,
attendee: this.attendee,
}),
text: this.getTextBody(
this.calEvent.recurringEvent?.count ? "event_request_declined_recurring" : "event_request_declined"
),
};
}
}

View File

@@ -0,0 +1,34 @@
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeLocationChangeEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("event_location_changed"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: "attendee",
status: "CONFIRMED",
}),
method: "REQUEST",
},
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `${this.t("location_changed_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
date: this.getFormattedDate(),
})}`,
html: await renderEmail("AttendeeLocationChangeEmail", {
calEvent: this.calEvent,
attendee: this.attendee,
}),
text: this.getTextBody("event_location_changed"),
};
}
}

View File

@@ -0,0 +1,32 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeRequestEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const toAddresses = this.calEvent.attendees.map((attendee) => attendee.email);
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email],
subject: `${this.calEvent.attendees[0].language.translate("booking_submitted_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: await renderEmail("AttendeeRequestEmail", {
calEvent: this.calEvent,
attendee: this.attendee,
}),
text: this.getTextBody(
this.calEvent.attendees[0].language.translate("booking_submitted", {
name: this.calEvent.attendees[0].name,
}),
this.calEvent.attendees[0].language.translate("user_needs_to_confirm_or_reject_booking", {
user: this.calEvent.organizer.name,
})
),
};
}
}

View File

@@ -0,0 +1,33 @@
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("event_type_has_been_rescheduled"),
subtitle: this.t("emailed_you_and_any_other_attendees"),
role: "attendee",
status: "CONFIRMED",
}),
method: "REQUEST",
},
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email],
subject: `${this.attendee.language.translate("event_type_has_been_rescheduled_on_time_date", {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: await renderEmail("AttendeeRescheduledEmail", {
calEvent: this.calEvent,
attendee: this.attendee,
}),
text: this.getTextBody("event_has_been_rescheduled", "emailed_you_and_any_other_attendees"),
};
}
}

View File

@@ -0,0 +1,107 @@
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import type { TFunction } from "next-i18next";
import { getRichDescription } from "@calcom/lib/CalEventParser";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
import generateIcsString from "../lib/generateIcsString";
import BaseEmail from "./_base-email";
export default class AttendeeScheduledEmail extends BaseEmail {
calEvent: CalendarEvent;
attendee: Person;
showAttendees: boolean | undefined;
t: TFunction;
constructor(calEvent: CalendarEvent, attendee: Person, showAttendees?: boolean | undefined) {
super();
if (!showAttendees && calEvent.seatsPerTimeSlot) {
this.calEvent = cloneDeep(calEvent);
this.calEvent.attendees = [attendee];
} else {
this.calEvent = calEvent;
}
this.name = "SEND_BOOKING_CONFIRMATION";
this.attendee = attendee;
this.t = attendee.language.translate;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const clonedCalEvent = cloneDeep(this.calEvent);
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.calEvent.recurringEvent?.count
? this.t("your_event_has_been_scheduled_recurring")
: this.t("your_event_has_been_scheduled"),
role: "attendee",
subtitle: this.t("emailed_you_and_any_other_attendees"),
status: "CONFIRMED",
}),
method: "REQUEST",
},
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email],
subject: `${this.calEvent.title}`,
html: await renderEmail("AttendeeScheduledEmail", {
calEvent: clonedCalEvent,
attendee: this.attendee,
}),
text: this.getTextBody(),
};
}
protected getTextBody(title = "", subtitle = "emailed_you_and_any_other_attendees"): string {
return `
${this.t(
title
? title
: this.calEvent.recurringEvent?.count
? "your_event_has_been_scheduled_recurring"
: "your_event_has_been_scheduled"
)}
${this.t(subtitle)}
${getRichDescription(this.calEvent, this.t)}
`.trim();
}
protected getTimezone(): string {
// Timezone is based on the first attendee in the attendee list
// as the first attendee is the one who created the booking
return this.calEvent.attendees[0].timeZone;
}
protected getLocale(): string {
return this.calEvent.attendees[0].language.locale;
}
protected getInviteeStart(format: string) {
return this.getFormattedRecipientTime({
time: this.calEvent.startTime,
format,
});
}
protected getInviteeEnd(format: string) {
return this.getFormattedRecipientTime({
time: this.calEvent.endTime,
format,
});
}
public getFormattedDate() {
const inviteeTimeFormat = this.calEvent.organizer.timeFormat || TimeFormat.TWELVE_HOUR;
return `${this.getInviteeStart(inviteeTimeFormat)} - ${this.getInviteeEnd(inviteeTimeFormat)}, ${this.t(
this.getInviteeStart("dddd").toLowerCase()
)}, ${this.t(this.getInviteeStart("MMMM").toLowerCase())} ${this.getInviteeStart("D, YYYY")}`;
}
}

View File

@@ -0,0 +1,58 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, COMPANY_NAME, EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import BaseEmail from "./_base-email";
export type EmailVerifyCode = {
language: TFunction;
user: {
name?: string | null;
email: string;
};
verificationEmailCode: string;
isVerifyingEmail?: boolean;
};
export default class AttendeeVerifyEmail extends BaseEmail {
verifyAccountInput: EmailVerifyCode;
constructor(passwordEvent: EmailVerifyCode) {
super();
this.name = "SEND_ACCOUNT_VERIFY_EMAIL";
this.verifyAccountInput = passwordEvent;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: `${this.verifyAccountInput.user.name} <${this.verifyAccountInput.user.email}>`,
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
subject: this.verifyAccountInput.language(
`verify_email_subject${this.verifyAccountInput.isVerifyingEmail ? "_verifying_email" : ""}`,
{
appName: APP_NAME,
}
),
html: await renderEmail("VerifyEmailByCode", this.verifyAccountInput),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.verifyAccountInput.language(
`verify_email_subject${this.verifyAccountInput.isVerifyingEmail ? "_verifying_email" : ""}`,
{ appName: APP_NAME }
)}
${this.verifyAccountInput.language("verify_email_email_header")}
${this.verifyAccountInput.language("hi_user_name", { name: this.verifyAccountInput.user.name })},
${this.verifyAccountInput.language("verify_email_by_code_email_body")}
${this.verifyAccountInput.verificationEmailCode}
${this.verifyAccountInput.language("happy_scheduling")} ${this.verifyAccountInput.language(
"the_calcom_team",
{ companyName: COMPANY_NAME }
)}
`.replace(/(<([^>]+)>)/gi, "");
}
}

View File

@@ -0,0 +1,75 @@
import { getManageLink } from "@calcom/lib/CalEventParser";
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { renderEmail } from "..";
import generateIcsString from "../lib/generateIcsString";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
export default class AttendeeWasRequestedToRescheduleEmail extends OrganizerScheduledEmail {
private metadata: { rescheduleLink: string };
constructor(calEvent: CalendarEvent, metadata: { rescheduleLink: string }) {
super({ calEvent });
this.metadata = metadata;
this.t = this.calEvent.attendees[0].language.translate;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const toAddresses = [this.calEvent.attendees[0].email];
return {
icalEvent: {
filename: "event.ics",
content: generateIcsString({
event: this.calEvent,
title: this.t("request_reschedule_booking"),
subtitle: this.t("request_reschedule_subtitle", {
organizer: this.calEvent.organizer.name,
}),
role: "attendee",
status: "CANCELLED",
}),
method: "REQUEST",
},
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.t("requested_to_reschedule_subject_attendee", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
})}`,
html: await renderEmail("AttendeeWasRequestedToRescheduleEmail", {
calEvent: this.calEvent,
attendee: this.calEvent.attendees[0],
metadata: this.metadata,
}),
text: this.getTextBody(),
};
}
// @OVERRIDE
protected getWhen(): string {
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.t("when")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;text-decoration: line-through;">
${this.t(this.getOrganizerStart("dddd").toLowerCase())}, ${this.t(
this.getOrganizerStart("MMMM").toLowerCase()
)} ${this.getOrganizerStart("D")}, ${this.getOrganizerStart("YYYY")} | ${this.getOrganizerStart(
"h:mma"
)} - ${this.getOrganizerEnd("h:mma")} <span style="color: #888888">(${this.getTimezone()})</span>
</p>
</div>`;
}
protected getTextBody(): string {
return `
${this.t("request_reschedule_booking")}
${this.t("request_reschedule_subtitle", {
organizer: this.calEvent.organizer.name,
})},
${this.getWhen()}
${this.t("need_to_reschedule_or_cancel")}
${getManageLink(this.calEvent, this.t)}
`.replace(/(<([^>]+)>)/gi, "");
}
}

View File

@@ -0,0 +1,36 @@
import type { TFunction } from "next-i18next";
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "..";
import BaseEmail from "./_base-email";
export interface IBookingRedirect {
language: TFunction;
fromEmail: string;
toEmail: string;
toName: string;
dates: string;
}
export default class BookingRedirectNotification extends BaseEmail {
bookingRedirect: IBookingRedirect;
constructor(bookingRedirect: IBookingRedirect) {
super();
this.name = "BOOKING_REDIRECT_NOTIFICATION";
this.bookingRedirect = bookingRedirect;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: `${this.bookingRedirect.toName} <${this.bookingRedirect.toEmail}>`,
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
subject: this.bookingRedirect.language("booking_redirect_email_subject"),
html: await renderEmail("BookingRedirectEmailNotification", {
...this.bookingRedirect,
}),
text: "",
};
}
}

View File

@@ -0,0 +1,91 @@
import type { TFunction } from "next-i18next";
import { getRichDescription } from "@calcom/lib/CalEventParser";
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { renderEmail } from "..";
import BaseEmail from "./_base-email";
export default class BrokenIntegrationEmail extends BaseEmail {
type: "calendar" | "video";
calEvent: CalendarEvent;
t: TFunction;
constructor(calEvent: CalendarEvent, type: "calendar" | "video") {
super();
this.name = "SEND_BROKEN_INTEGRATION";
this.calEvent = calEvent;
this.t = this.calEvent.organizer.language.translate;
this.type = type;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const toAddresses = [this.calEvent.organizer.email];
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `[Action Required] ${this.t("confirmed_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: this.getFormattedDate(),
})}`,
html: await renderEmail("BrokenIntegrationEmail", {
calEvent: this.calEvent,
attendee: this.calEvent.organizer,
type: this.type,
}),
text: this.getTextBody(),
};
}
protected getTextBody(
title = "",
subtitle = "emailed_you_and_any_other_attendees",
extraInfo = "",
callToAction = ""
): string {
return `
${this.t(
title || this.calEvent.recurringEvent?.count ? "new_event_scheduled_recurring" : "new_event_scheduled"
)}
${this.t(subtitle)}
${extraInfo}
${getRichDescription(this.calEvent, this.t, true)}
${callToAction}
`.trim();
}
protected getTimezone(): string {
return this.calEvent.organizer.timeZone;
}
protected getLocale(): string {
return this.calEvent.organizer.language.locale;
}
protected getOrganizerStart(format: string) {
return this.getFormattedRecipientTime({
time: this.calEvent.startTime,
format,
});
}
protected getOrganizerEnd(format: string) {
return this.getFormattedRecipientTime({
time: this.calEvent.endTime,
format,
});
}
protected getFormattedDate() {
const organizerTimeFormat = this.calEvent.organizer.timeFormat || TimeFormat.TWELVE_HOUR;
return `${this.getOrganizerStart(organizerTimeFormat)} - ${this.getOrganizerEnd(
organizerTimeFormat
)}, ${this.t(this.getOrganizerStart("dddd").toLowerCase())}, ${this.t(
this.getOrganizerStart("MMMM").toLowerCase()
)} ${this.getOrganizerStart("D, YYYY")}`;
}
}

View File

@@ -0,0 +1,55 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, COMPANY_NAME, EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import BaseEmail from "./_base-email";
export type ChangeOfEmailVerifyLink = {
language: TFunction;
user: {
name?: string | null;
emailFrom: string;
emailTo: string;
};
verificationEmailLink: string;
};
export default class ChangeOfEmailVerifyEmail extends BaseEmail {
changeEvent: ChangeOfEmailVerifyLink;
constructor(changeEvent: ChangeOfEmailVerifyLink) {
super();
this.name = "SEND_ACCOUNT_VERIFY_EMAIL";
this.changeEvent = changeEvent;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: `${this.changeEvent.user.name} <${this.changeEvent.user.emailTo}>`,
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
subject: this.changeEvent.language("change_of_email", {
appName: APP_NAME,
}),
html: await renderEmail("VerifyEmailChangeEmail", this.changeEvent),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.changeEvent.language("verify_email_subject", { appName: APP_NAME })}
${this.changeEvent.language("verify_email_email_header")}
${this.changeEvent.language("hi_user_name", { name: this.changeEvent.user.name })},
${this.changeEvent.language("verify_email_change_description", { appName: APP_NAME })}
${this.changeEvent.language("old_email_address")}
${this.changeEvent.user.emailFrom},
${this.changeEvent.language("new_email_address")}
${this.changeEvent.user.emailTo},
${this.changeEvent.verificationEmailLink}
${this.changeEvent.language("happy_scheduling")} ${this.changeEvent.language("the_calcom_team", {
companyName: COMPANY_NAME,
})}
`.replace(/(<([^>]+)>)/gi, "");
}
}

View File

@@ -0,0 +1,657 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<!-- <head> -->
<title>${headerContent}</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix {
width: 100% !important;
}
</style>
<![endif]-->
<!--[if !mso]><!-->
<link
href="https://fonts.googleapis.com/css?family=Roboto:400,500,700"
rel="stylesheet"
type="text/css" />
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Roboto:400,500,700);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width: 480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width: 480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
<!-- </head> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title></title>
<style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Inter:400,700&display=swap");
#outlook a {
padding: 0;
}
body {
width: 100% !important;
height: 100%;
-webkit-text-size-adjust: none;
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
word-break: break-word;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
a {
color: #3b82f6;
}
a img {
border: none;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Roboto", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: 0.4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
/* Buttons ------------------------------ */
.button {
background-color: #000;
border-top: 10px solid #000;
border-right: 18px solid #000;
border-bottom: 10px solid #000;
border-left: 18px solid #000;
display: inline-block;
color: #fff !important;
text-decoration: none;
border-radius: 0;
/* box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); */
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #f4f4f7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #cbcccf;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: 0.5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #cbcccf;
text-align: center;
padding: 25px 0 10px;
}
/* Data table ------------------------------ */
body {
background-color: #f2f4f6;
color: #51545e;
}
p {
color: #51545e;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #f2f4f6;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #a8aaaf;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #ffffff;
}
.email-footer {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #a8aaaf;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #eaeaec;
}
.content-cell {
padding: 45px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body_inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #fff !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3,
span,
.purchase_item {
color: #fff !important;
}
.attributes_content {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
:root {
color-scheme: light dark;
supported-color-schemes: light dark;
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
</head>
<body>
<span class="preheader">This link will expire in 10 min.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<!-- <tr>
<td class="email-masthead">
<a href="{{base_url}}" class="f-fallback email-masthead_name">
Cal.com
</a>
</td>
</tr> -->
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%">
<tbody>
<tr>
<td style="direction: ltr; font-size: 0px; padding: 0px; text-align: center">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
">
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%">
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 10px 25px;
padding-top: 32px;
word-break: break-word;
">
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="border-collapse: collapse; border-spacing: 0px">
<tbody>
<tr>
<td style="width: 89px">
<a href="{{base_url}}" target="_blank">
<img
height="19"
src="https://app.cal.com/emails/logo.png"
style="
border: 0;
display: block;
outline: none;
text-decoration: none;
height: 19px;
width: 100%;
font-size: 13px;
"
width="89" />
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
<td style="direction: rtl; font-size: 0px; padding: 0px; text-align: center">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: right;
direction: rtl;
display: inline-block;
vertical-align: top;
width: 100%;
">
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%">
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 10px 25px;
padding-top: 32px;
word-break: break-word;
">
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="border-collapse: collapse; border-spacing: 0px">
<tbody>
<tr>
<td style="width: 89px">
<a href="{{base_url}}" target="_blank">
<img
height="19"
src="https://app.cal.com/emails/logo.png"
style="
border: 0;
display: block;
outline: none;
text-decoration: none;
height: 19px;
width: 100%;
font-size: 13px;
"
width="89" />
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!-- Email Body -->
<tr>
<td class="email-body" width="570" cellpadding="0" cellspacing="0">
<table
class="email-body_inner"
align="center"
width="570"
cellpadding="0"
cellspacing="0"
role="presentation">
<!-- Body content -->
<tr>
<td class="content-cell">
<div class="f-fallback">
<p>
Click the button below to log in to Cal.com<br />
This link will expire in 10 minutes.
</p>
<!-- Action -->
<table
class="body-action"
align="center"
width="100%"
cellpadding="0"
cellspacing="0"
role="presentation">
<tr>
<td align="center">
<!-- Border based button
https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
<table
width="100%"
border="0"
cellspacing="0"
cellpadding="0"
role="presentation">
<tr>
<td>
<a href="{{signin_url}}" class="f-fallback button" target="_blank"
>Log into Cal.com</a
>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>Confirming this request will securely log you in using {{email}}.</p>
<p>Enjoy your new scheduling soultion by,<br />The Cal.com Team</p>
<!-- Sub copy -->
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">
If youre having trouble with the button above, copy and paste the URL below
into your web browser.
</p>
<p class="f-fallback sub">{{signin_url}}</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table
class="email-footer"
align="center"
width="570"
cellpadding="0"
cellspacing="0"
role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; 2022 Cal.com. All rights reserved.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,59 @@
import type { TFunction } from "next-i18next";
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "..";
import BaseEmail from "./_base-email";
export default class DisabledAppEmail extends BaseEmail {
email: string;
appName: string;
appType: string[];
t: TFunction;
title?: string;
eventTypeId?: number;
constructor(
email: string,
appName: string,
appType: string[],
t: TFunction,
title?: string,
eventTypeId?: number
) {
super();
this.email = email;
this.appName = appName;
this.appType = appType;
this.t = t;
this.title = title;
this.eventTypeId = eventTypeId;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: this.email,
subject:
this.title && this.eventTypeId
? this.t("disabled_app_affects_event_type", { appName: this.appName, eventType: this.title })
: this.t("admin_has_disabled", { appName: this.appName }),
html: await renderEmail("DisabledAppEmail", {
title: this.title,
appName: this.appName,
eventTypeId: this.eventTypeId,
appType: this.appType,
t: this.t,
}),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return this.appType.some((type) => type === "payment")
? this.t("disable_payment_app", { appName: this.appName, title: this.title })
: this.appType.some((type) => type === "video")
? this.t("app_disabled_video", { appName: this.appName })
: this.t("app_disabled", { appName: this.appName });
}
}

View File

@@ -0,0 +1,39 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import BaseEmail from "./_base-email";
export interface Feedback {
username: string;
email: string;
rating: string;
comment: string;
}
export default class FeedbackEmail extends BaseEmail {
feedback: Feedback;
constructor(feedback: Feedback) {
super();
this.feedback = feedback;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: process.env.SEND_FEEDBACK_EMAIL,
subject: `User Feedback`,
html: await renderEmail("FeedbackEmail", this.feedback),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
User: ${this.feedback.username}
Email: ${this.feedback.email}
Rating: ${this.feedback.rating}
Comment: ${this.feedback.comment}
`;
}
}

View File

@@ -0,0 +1,50 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import BaseEmail from "./_base-email";
export type PasswordReset = {
language: TFunction;
user: {
name?: string | null;
email: string;
};
resetLink: string;
};
export default class ForgotPasswordEmail extends BaseEmail {
passwordEvent: PasswordReset;
constructor(passwordEvent: PasswordReset) {
super();
this.name = "SEND_PASSWORD_RESET_EMAIL";
this.passwordEvent = passwordEvent;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: `${this.passwordEvent.user.name} <${this.passwordEvent.user.email}>`,
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
subject: this.passwordEvent.language("reset_password_subject", {
appName: APP_NAME,
}),
html: await renderEmail("ForgotPasswordEmail", this.passwordEvent),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.passwordEvent.language("reset_password_subject", { appName: APP_NAME })}
${this.passwordEvent.language("hi_user_name", { name: this.passwordEvent.user.name })},
${this.passwordEvent.language("someone_requested_password_reset")}
${this.passwordEvent.language("change_password")}: ${this.passwordEvent.resetLink}
${this.passwordEvent.language("password_reset_instructions")}
${this.passwordEvent.language("have_any_questions")} ${this.passwordEvent.language(
"contact_our_support_team"
)}
`.replace(/(<([^>]+)>)/gi, "");
}
}

View File

@@ -0,0 +1,24 @@
import { APP_NAME, EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "../";
import type { MonthlyDigestEmailData } from "../src/templates/MonthlyDigestEmail";
import BaseEmail from "./_base-email";
export default class MonthlyDigestEmail extends BaseEmail {
eventData: MonthlyDigestEmailData;
constructor(eventData: MonthlyDigestEmailData) {
super();
this.eventData = eventData;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: this.eventData.admin.email,
subject: `${APP_NAME}: Your monthly digest`,
html: await renderEmail("MonthlyDigestEmail", this.eventData),
text: "",
};
}
}

View File

@@ -0,0 +1,24 @@
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
export default class NoShowFeeChargedEmail extends AttendeeScheduledEmail {
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
if (!this.calEvent.paymentInfo?.amount) throw new Error("No payment into");
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
subject: `${this.attendee.language.translate("no_show_fee_charged_email_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
amount: this.calEvent.paymentInfo.amount / 100,
formatParams: { amount: { currency: this.calEvent.paymentInfo?.currency } },
})}`,
html: await renderEmail("NoShowFeeChargedEmail", {
calEvent: this.calEvent,
attendee: this.attendee,
}),
text: this.getTextBody("no_show_fee_charged_text_body"),
};
}
}

View File

@@ -0,0 +1,39 @@
import type { TFunction } from "next-i18next";
import { APP_NAME, EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "..";
import BaseEmail from "./_base-email";
export type OrgAutoInvite = {
language: TFunction;
from: string;
to: string;
orgName: string;
joinLink: string;
};
export default class OrgAutoJoinEmail extends BaseEmail {
orgAutoInviteEvent: OrgAutoInvite;
constructor(orgAutoInviteEvent: OrgAutoInvite) {
super();
this.name = "SEND_TEAM_INVITE_EMAIL";
this.orgAutoInviteEvent = orgAutoInviteEvent;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
to: this.orgAutoInviteEvent.to,
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
subject: this.orgAutoInviteEvent.language("user_invited_you", {
user: this.orgAutoInviteEvent.from,
team: this.orgAutoInviteEvent.orgName,
appName: APP_NAME,
entity: this.orgAutoInviteEvent.language("organization").toLowerCase(),
}),
html: await renderEmail("OrgAutoInviteEmail", this.orgAutoInviteEvent),
text: "",
};
}
}

View File

@@ -0,0 +1,51 @@
import type { TFunction } from "next-i18next";
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import renderEmail from "../src/renderEmail";
import BaseEmail from "./_base-email";
export type OrganizationAdminNoSlotsEmailInput = {
language: TFunction;
to: {
email: string;
};
user: string;
slug: string;
startTime: string;
editLink: string;
};
export default class OrganizationAdminNoSlotsEmail extends BaseEmail {
adminNoSlots: OrganizationAdminNoSlotsEmailInput;
constructor(adminNoSlots: OrganizationAdminNoSlotsEmailInput) {
super();
this.name = "SEND_ORG_ADMIN_NO_SLOTS_EMAIL_EMAIL";
this.adminNoSlots = adminNoSlots;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: this.adminNoSlots.to.email,
subject: this.adminNoSlots.language("org_admin_no_slots|subject", { name: this.adminNoSlots.user }),
html: await renderEmail("OrganizationAdminNoSlotsEmail", this.adminNoSlots),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
Hi Admins,
It has been brought to our attention that ${this.adminNoSlots.user} has not had availability users have visited ${this.adminNoSlots.user}/${this.adminNoSlots.slug}.
Theres a few reasons why this could be happening
The user does not have any calendars connected
Their schedules attached to this event are not enabled
We recommend checking their availability to resolve this
`;
}
}

Some files were not shown because too many files have changed in this diff Show More