first commit
This commit is contained in:
24
calcom/packages/emails/README.md
Normal file
24
calcom/packages/emails/README.md
Normal 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.
|
||||
8
calcom/packages/emails/docker-compose.yml
Normal file
8
calcom/packages/emails/docker-compose.yml
Normal 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"
|
||||
515
calcom/packages/emails/email-manager.ts
Normal file
515
calcom/packages/emails/email-manager.ts
Normal 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));
|
||||
};
|
||||
2
calcom/packages/emails/index.ts
Normal file
2
calcom/packages/emails/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./email-manager";
|
||||
export { default as renderEmail } from "./src/renderEmail";
|
||||
109
calcom/packages/emails/lib/generateIcsString.ts
Normal file
109
calcom/packages/emails/lib/generateIcsString.ts
Normal 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;
|
||||
38
calcom/packages/emails/lib/getICalUID.ts
Normal file
38
calcom/packages/emails/lib/getICalUID.ts
Normal 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;
|
||||
16
calcom/packages/emails/lib/sanitizeDisplayName.ts
Normal file
16
calcom/packages/emails/lib/sanitizeDisplayName.ts
Normal 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, " ");
|
||||
};
|
||||
194
calcom/packages/emails/lib/test/generateIcsString.test.ts
Normal file
194
calcom/packages/emails/lib/test/generateIcsString.test.ts
Normal 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}`));
|
||||
});
|
||||
});
|
||||
});
|
||||
29
calcom/packages/emails/lib/test/getICalUID.test.ts
Normal file
29
calcom/packages/emails/lib/test/getICalUID.test.ts
Normal 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}`);
|
||||
});
|
||||
});
|
||||
21
calcom/packages/emails/package.json
Normal file
21
calcom/packages/emails/package.json
Normal 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": "*"
|
||||
}
|
||||
}
|
||||
41
calcom/packages/emails/src/components/AppsStatus.tsx
Normal file
41
calcom/packages/emails/src/components/AppsStatus.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
};
|
||||
206
calcom/packages/emails/src/components/BaseEmailHtml.tsx
Normal file
206
calcom/packages/emails/src/components/BaseEmailHtml.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
15
calcom/packages/emails/src/components/BaseTable.tsx
Normal file
15
calcom/packages/emails/src/components/BaseTable.tsx
Normal 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;
|
||||
75
calcom/packages/emails/src/components/CallToAction.tsx
Normal file
75
calcom/packages/emails/src/components/CallToAction.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
18
calcom/packages/emails/src/components/CallToActionIcon.tsx
Normal file
18
calcom/packages/emails/src/components/CallToActionIcon.tsx
Normal 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=""
|
||||
/>
|
||||
);
|
||||
22
calcom/packages/emails/src/components/CallToActionTable.tsx
Normal file
22
calcom/packages/emails/src/components/CallToActionTable.tsx
Normal 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>
|
||||
);
|
||||
79
calcom/packages/emails/src/components/EmailBodyLogo.tsx
Normal file
79
calcom/packages/emails/src/components/EmailBodyLogo.tsx
Normal 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;
|
||||
71
calcom/packages/emails/src/components/EmailCommonDivider.tsx
Normal file
71
calcom/packages/emails/src/components/EmailCommonDivider.tsx
Normal 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;
|
||||
91
calcom/packages/emails/src/components/EmailHead.tsx
Normal file
91
calcom/packages/emails/src/components/EmailHead.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;"> </td></tr></table><![endif]-->`}
|
||||
/>
|
||||
</td>
|
||||
</EmailCommonDivider>
|
||||
);
|
||||
|
||||
export default EmailSchedulingBodyDivider;
|
||||
@@ -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;
|
||||
49
calcom/packages/emails/src/components/Info.tsx
Normal file
49
calcom/packages/emails/src/components/Info.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
87
calcom/packages/emails/src/components/LocationInfo.tsx
Normal file
87
calcom/packages/emails/src/components/LocationInfo.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
100
calcom/packages/emails/src/components/ManageLink.tsx
Normal file
100
calcom/packages/emails/src/components/ManageLink.tsx
Normal 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;
|
||||
}
|
||||
6
calcom/packages/emails/src/components/RawHtml.tsx
Normal file
6
calcom/packages/emails/src/components/RawHtml.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @see https://gist.github.com/zomars/4c366a0118a5b7fb391529ab1f27527a */
|
||||
const RawHtml = ({ html = "" }) => (
|
||||
<script dangerouslySetInnerHTML={{ __html: `</script>${html}<script>` }} />
|
||||
);
|
||||
|
||||
export default RawHtml;
|
||||
15
calcom/packages/emails/src/components/Row.tsx
Normal file
15
calcom/packages/emails/src/components/Row.tsx
Normal 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;
|
||||
3
calcom/packages/emails/src/components/Separator.tsx
Normal file
3
calcom/packages/emails/src/components/Separator.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const Separator = () => (
|
||||
<p style={{ width: "16px", height: "16px", display: "inline-block" }}> </p>
|
||||
);
|
||||
@@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
191
calcom/packages/emails/src/components/V2BaseEmailHtml.tsx
Normal file
191
calcom/packages/emails/src/components/V2BaseEmailHtml.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
74
calcom/packages/emails/src/components/WhenInfo.tsx
Normal file
74
calcom/packages/emails/src/components/WhenInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
calcom/packages/emails/src/components/WhoInfo.tsx
Normal file
46
calcom/packages/emails/src/components/WhoInfo.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
calcom/packages/emails/src/components/index.ts
Normal file
14
calcom/packages/emails/src/components/index.ts
Normal 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";
|
||||
22
calcom/packages/emails/src/renderEmail.ts
Normal file
22
calcom/packages/emails/src/renderEmail.ts
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
104
calcom/packages/emails/src/templates/BaseScheduledEmail.tsx
Normal file
104
calcom/packages/emails/src/templates/BaseScheduledEmail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
127
calcom/packages/emails/src/templates/BrokenIntegrationEmail.tsx
Normal file
127
calcom/packages/emails/src/templates/BrokenIntegrationEmail.tsx
Normal 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
|
||||
<a
|
||||
href={
|
||||
props.eventTypeId ? `${WEBAPP_URL}/event-types/${props.eventTypeId}` : `${WEBAPP_URL}/event-types`
|
||||
}>
|
||||
change your location on the event type
|
||||
</a>
|
||||
or try
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
85
calcom/packages/emails/src/templates/DisabledAppEmail.tsx
Normal file
85
calcom/packages/emails/src/templates/DisabledAppEmail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
19
calcom/packages/emails/src/templates/FeedbackEmail.tsx
Normal file
19
calcom/packages/emails/src/templates/FeedbackEmail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
49
calcom/packages/emails/src/templates/ForgotPasswordEmail.tsx
Normal file
49
calcom/packages/emails/src/templates/ForgotPasswordEmail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
204
calcom/packages/emails/src/templates/MonthlyDigestEmail.tsx
Normal file
204
calcom/packages/emails/src/templates/MonthlyDigestEmail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
103
calcom/packages/emails/src/templates/OrgAutoInviteEmail.tsx
Normal file
103
calcom/packages/emails/src/templates/OrgAutoInviteEmail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
There’s 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>
|
||||
);
|
||||
};
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OrganizerRequestEmail } from "./OrganizerRequestEmail";
|
||||
|
||||
export const OrganizerRequestReminderEmail = (props: React.ComponentProps<typeof OrganizerRequestEmail>) => (
|
||||
<OrganizerRequestEmail title="event_still_awaiting_approval" {...props} />
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
251
calcom/packages/emails/src/templates/TeamInviteEmail.tsx
Normal file
251
calcom/packages/emails/src/templates/TeamInviteEmail.tsx
Normal 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'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'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);
|
||||
}
|
||||
};
|
||||
60
calcom/packages/emails/src/templates/VerifyAccountEmail.tsx
Normal file
60
calcom/packages/emails/src/templates/VerifyAccountEmail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
48
calcom/packages/emails/src/templates/VerifyEmailByCode.tsx
Normal file
48
calcom/packages/emails/src/templates/VerifyEmailByCode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
103
calcom/packages/emails/src/templates/VerifyEmailChangeEmail.tsx
Normal file
103
calcom/packages/emails/src/templates/VerifyEmailChangeEmail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
calcom/packages/emails/src/templates/index.ts
Normal file
38
calcom/packages/emails/src/templates/index.ts
Normal 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";
|
||||
143
calcom/packages/emails/tailwind.config.js
Normal file
143
calcom/packages/emails/tailwind.config.js
Normal 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,
|
||||
},
|
||||
};
|
||||
103
calcom/packages/emails/templates/_base-email.ts
Normal file
103
calcom/packages/emails/templates/_base-email.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
56
calcom/packages/emails/templates/account-verify-email.ts
Normal file
56
calcom/packages/emails/templates/account-verify-email.ts
Normal 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, "");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
33
calcom/packages/emails/templates/attendee-cancelled-email.ts
Normal file
33
calcom/packages/emails/templates/attendee-cancelled-email.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
}
|
||||
23
calcom/packages/emails/templates/attendee-declined-email.ts
Normal file
23
calcom/packages/emails/templates/attendee-declined-email.ts
Normal 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"
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
32
calcom/packages/emails/templates/attendee-request-email.ts
Normal file
32
calcom/packages/emails/templates/attendee-request-email.ts
Normal 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,
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
107
calcom/packages/emails/templates/attendee-scheduled-email.ts
Normal file
107
calcom/packages/emails/templates/attendee-scheduled-email.ts
Normal 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")}`;
|
||||
}
|
||||
}
|
||||
58
calcom/packages/emails/templates/attendee-verify-email.ts
Normal file
58
calcom/packages/emails/templates/attendee-verify-email.ts
Normal 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, "");
|
||||
}
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
@@ -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: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
91
calcom/packages/emails/templates/broken-integration-email.ts
Normal file
91
calcom/packages/emails/templates/broken-integration-email.ts
Normal 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")}`;
|
||||
}
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
657
calcom/packages/emails/templates/confirm-email.html
Normal file
657
calcom/packages/emails/templates/confirm-email.html
Normal 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 you’re 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">© 2022 Cal.com. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
59
calcom/packages/emails/templates/disabled-app-email.ts
Normal file
59
calcom/packages/emails/templates/disabled-app-email.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
39
calcom/packages/emails/templates/feedback-email.ts
Normal file
39
calcom/packages/emails/templates/feedback-email.ts
Normal 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}
|
||||
`;
|
||||
}
|
||||
}
|
||||
50
calcom/packages/emails/templates/forgot-password-email.ts
Normal file
50
calcom/packages/emails/templates/forgot-password-email.ts
Normal 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, "");
|
||||
}
|
||||
}
|
||||
24
calcom/packages/emails/templates/monthly-digest-email.ts
Normal file
24
calcom/packages/emails/templates/monthly-digest-email.ts
Normal 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: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
39
calcom/packages/emails/templates/org-auto-join-invite.ts
Normal file
39
calcom/packages/emails/templates/org-auto-join-invite.ts
Normal 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: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}.
|
||||
|
||||
There’s 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
Reference in New Issue
Block a user