diff --git a/e2e/cypress/integration/calendar-timezone.ts b/e2e/cypress/integration/calendar-timezone.ts
new file mode 100644
index 000000000..f8576f6d4
--- /dev/null
+++ b/e2e/cypress/integration/calendar-timezone.ts
@@ -0,0 +1,408 @@
+///
+// --------- BEGIN RUNBOX LICENSE ---------
+// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com).
+//
+// This file is part of Runbox 7.
+//
+// Runbox 7 is free software: You can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the
+// Free Software Foundation, either version 3 of the License, or (at your
+// option) any later version.
+//
+// Runbox 7 is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Runbox 7. If not, see .
+// ---------- END RUNBOX LICENSE ----------
+
+import { futureDateStr, buildIcs, makeVevent, osloVtimezone, londonVtimezone, expectedDisplayTime, dateStrMonth, dateStrYear, dateStrDay } from '../support/ics-helpers';
+
+describe('Calendar timezone handling', () => {
+ beforeEach(() => {
+ cy.request('/rest/e2e/resetCalendarEvents');
+ cy.intercept('GET', '/rest/v1/calendar/events_raw').as('eventsLoad');
+ cy.visit('/calendar');
+ cy.get('.calendarListItem').should('have.length', 1).and('contain', 'Mock Calendar');
+ // Wait for the initial sync's reloadEvents() to complete so that no
+ // in-flight GET can race with event creation via the dialog.
+ cy.wait('@eventsLoad');
+ });
+
+ function selectIcs(ics: string) {
+ cy.get('input[type=file]').selectFile({
+ contents: Cypress.Buffer.from(ics),
+ fileName: 'test.ics',
+ mimeType: 'text/calendar',
+ }, { force: true });
+ }
+
+ function doImport() {
+ cy.get('mat-select').click();
+ cy.contains('mat-option', 'Mock Calendar').click();
+ cy.contains('button', 'Import events').click();
+ cy.get('simple-snack-bar').should('contain', 'events imported');
+ }
+
+ // Navigate the calendar to the month containing the event so that
+ // month-view assertions work regardless of when the test runs.
+ function navigateToEventMonth(dateStr: string) {
+ const targetMonth = dateStrMonth(dateStr);
+ const targetYear = dateStrYear(dateStr);
+ cy.then(() => {
+ const now = new Date();
+ const currentMonth = now.getMonth() + 1;
+ const currentYear = now.getFullYear();
+ const monthsAhead = (targetYear - currentYear) * 12 + (targetMonth - currentMonth);
+ const buttonId = monthsAhead >= 0 ? '#nextPeriodButton' : '#previousPeriodButton';
+ const clicks = Math.abs(monthsAhead);
+ for (let i = 0; i < clicks; i++) {
+ cy.get(buttonId).click();
+ }
+ if (clicks > 0) {
+ cy.wait(500);
+ }
+ });
+ }
+
+ it('should display floating time event in import preview', () => {
+ const dateStr = futureDateStr(15);
+ selectIcs(buildIcs([
+ makeVevent(`${dateStr}T140000`, `${dateStr}T150000`, 'Floating Time Meeting', 'tztest-floating-001',
+ ['LOCATION:Oslo Office']),
+ ]));
+ cy.get('app-calendar-event-card .upcomingEventCard')
+ .should('contain', 'Floating Time Meeting')
+ .and('contain', 'Oslo Office');
+ });
+
+ it('should display UTC event in import preview', () => {
+ const dateStr = futureDateStr(15);
+ selectIcs(buildIcs([
+ makeVevent(`${dateStr}T130000Z`, `${dateStr}T140000Z`, 'UTC Meeting', 'tztest-utc-001'),
+ ]));
+ cy.get('app-calendar-event-card .upcomingEventCard')
+ .should('contain', 'UTC Meeting');
+ });
+
+ it('should display TZID event in import preview', () => {
+ const dateStr = futureDateStr(15);
+ selectIcs(buildIcs([
+ makeVevent(`TZID=Europe/Oslo:${dateStr}T140000`, `TZID=Europe/Oslo:${dateStr}T150000`,
+ 'TZID Oslo Meeting', 'tztest-tzid-001'),
+ ]));
+ cy.get('app-calendar-event-card .upcomingEventCard')
+ .should('contain', 'TZID Oslo Meeting');
+ });
+
+ it('should display all-day event in import preview', () => {
+ const dateStr = futureDateStr(15);
+ selectIcs(buildIcs([
+ makeVevent(`VALUE=DATE:${dateStr}`, `VALUE=DATE:${futureDateStr(16)}`,
+ 'All Day Event', 'tztest-allday-001'),
+ ]));
+ cy.get('app-calendar-event-card .upcomingEventCard')
+ .should('contain', 'All Day Event');
+ });
+
+ it('should display recurring event in import preview', () => {
+ const dateStr = futureDateStr(15);
+ selectIcs(buildIcs([
+ makeVevent(`${dateStr}T100000`, `${dateStr}T110000`, 'Weekly Standup', 'tztest-recurring-001',
+ ['RRULE:FREQ=WEEKLY;COUNT=4']),
+ ]));
+ cy.get('app-calendar-event-card .upcomingEventCard')
+ .should('contain', 'Weekly Standup');
+ });
+
+ it('should display citadel-path TZID event in import preview', () => {
+ const dateStr = futureDateStr(15);
+ selectIcs(buildIcs([
+ makeVevent(
+ `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T140000`,
+ `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T150000`,
+ 'Citadel Path Event', 'tztest-citadel-001'),
+ ], osloVtimezone));
+ cy.get('app-calendar-event-card .upcomingEventCard')
+ .should('contain', 'Citadel Path Event');
+ });
+
+ it('should display multiple events from multi-event ICS', () => {
+ const dateStr = futureDateStr(15);
+ selectIcs(buildIcs([
+ makeVevent(`${dateStr}T100000`, `${dateStr}T110000`, 'Floating Event', 'tztest-multi-floating'),
+ makeVevent(`${dateStr}T120000Z`, `${dateStr}T130000Z`, 'UTC Event', 'tztest-multi-utc'),
+ makeVevent(`TZID=Europe/Oslo:${dateStr}T140000`, `TZID=Europe/Oslo:${dateStr}T150000`,
+ 'TZID Event', 'tztest-multi-tzid'),
+ ]));
+ cy.get('app-calendar-event-card').should('have.length', 3);
+ cy.get('app-calendar-event-card .upcomingEventCard')
+ .should('contain', 'Floating Event')
+ .and('contain', 'UTC Event')
+ .and('contain', 'TZID Event');
+ });
+
+ it('should fully import a floating time event', () => {
+ const dateStr = futureDateStr(15);
+ selectIcs(buildIcs([
+ makeVevent(`${dateStr}T140000`, `${dateStr}T150000`, 'Imported Floating Event', 'tztest-import-floating'),
+ ]));
+ cy.get('app-calendar-event-card .upcomingEventCard')
+ .should('contain', 'Imported Floating Event');
+ doImport();
+ });
+
+ it('should fully import a UTC event', () => {
+ const dateStr = futureDateStr(15);
+ selectIcs(buildIcs([
+ makeVevent(`${dateStr}T130000Z`, `${dateStr}T140000Z`, 'Imported UTC Event', 'tztest-import-utc'),
+ ]));
+ cy.get('app-calendar-event-card .upcomingEventCard')
+ .should('contain', 'Imported UTC Event');
+ doImport();
+ });
+
+ // --- Timezone bug reproduction tests ---
+
+ it('should display London TZID event at correct hour for London account', () => {
+ cy.request('/rest/e2e/setTimezone_Europe/London');
+ const dateStr = futureDateStr(3);
+
+ // Event at 12:00 London (BST = UTC+1 in summer) = 11:00 UTC
+ const ics = buildIcs([
+ makeVevent(
+ `TZID=Europe/London:${dateStr}T120000`,
+ `TZID=Europe/London:${dateStr}T130000`,
+ 'London Noon Meeting', 'tz-london-001'),
+ ], londonVtimezone);
+
+ cy.request({
+ method: 'POST',
+ url: '/rest/e2e/addEvent',
+ body: { id: 'mock cal/tz-london-001', ical: ics, calendar: 'mock cal' },
+ });
+
+ cy.visit('/calendar');
+ cy.get('.calendarListItem').should('have.length', 1);
+ navigateToEventMonth(dateStr);
+ cy.get('button.calendarMonthDayEvent').should('contain', 'London Noon Meeting');
+
+ const expected = expectedDisplayTime(dateStr, 11, 0);
+ cy.get('button.calendarMonthDayEvent')
+ .contains('London Noon Meeting')
+ .invoke('text')
+ .should('match', new RegExp(expected));
+
+ cy.request('/rest/e2e/resetTimezone');
+ });
+
+ it('should display floating time event at correct hour matching account tz', () => {
+ cy.request('/rest/e2e/setTimezone_Europe/London');
+ const dateStr = futureDateStr(3);
+
+ // Floating time 14:00 (no TZID) — interpreted as London BST = 13:00 UTC
+ const ics = buildIcs([
+ makeVevent(`${dateStr}T140000`, `${dateStr}T150000`, 'Floating 2pm Meeting', 'tz-floating-001'),
+ ]);
+
+ cy.request({
+ method: 'POST',
+ url: '/rest/e2e/addEvent',
+ body: { id: 'mock cal/tz-floating-001', ical: ics, calendar: 'mock cal' },
+ });
+
+ cy.visit('/calendar');
+ cy.get('.calendarListItem').should('have.length', 1);
+ navigateToEventMonth(dateStr);
+ cy.get('button.calendarMonthDayEvent').should('contain', 'Floating 2pm Meeting');
+
+ const expected = expectedDisplayTime(dateStr, 13, 0);
+ cy.get('button.calendarMonthDayEvent')
+ .contains('Floating 2pm Meeting')
+ .invoke('text')
+ .should('match', new RegExp(expected));
+
+ cy.request('/rest/e2e/resetTimezone');
+ });
+
+ it('should show different displayed hour after timezone change', () => {
+ const dateStr = futureDateStr(3);
+
+ // Floating time 12:00. Account timezone determines interpretation:
+ // Oslo CEST = 12:00 local = 10:00 UTC, London BST = 12:00 local = 11:00 UTC.
+ // These produce different local hours in any browser timezone.
+ const ics = buildIcs([
+ makeVevent(`${dateStr}T120000`, `${dateStr}T130000`, 'Floating Noon', 'tz-changeme'),
+ ]);
+
+ cy.request({
+ method: 'POST',
+ url: '/rest/e2e/addEvent',
+ body: { id: 'mock cal/tz-changeme', ical: ics, calendar: 'mock cal' },
+ });
+
+ // View with default Oslo timezone
+ cy.visit('/calendar');
+ cy.get('.calendarListItem').should('have.length', 1);
+ navigateToEventMonth(dateStr);
+
+ cy.get('button.calendarMonthDayEvent')
+ .contains('Floating Noon')
+ .invoke('text')
+ .then(osloText => {
+ const osloHour = osloText.match(/(\d+):\d+/);
+
+ // Switch to London timezone
+ cy.request('/rest/e2e/setTimezone_Europe/London');
+ cy.visit('/calendar');
+ cy.get('.calendarListItem').should('have.length', 1);
+ navigateToEventMonth(dateStr);
+
+ cy.get('button.calendarMonthDayEvent')
+ .contains('Floating Noon')
+ .invoke('text')
+ .then(londonText => {
+ const londonHour = londonText.match(/(\d+):\d+/);
+ expect(londonHour?.[1]).to.not.equal(osloHour?.[1],
+ 'Displayed hour should change after timezone change');
+ });
+ });
+
+ cy.request('/rest/e2e/resetTimezone');
+ });
+
+ it('should display 12pm Oslo event at correct hour in month view', () => {
+ const dateStr = futureDateStr(3);
+
+ // Event at 12:00 Oslo (CEST = UTC+2 in summer) = 10:00 UTC
+ const ics = buildIcs([
+ makeVevent(
+ `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T120000`,
+ `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T130000`,
+ 'Oslo Noon Event', 'tz-oslo-noon'),
+ ], osloVtimezone);
+
+ cy.request({
+ method: 'POST',
+ url: '/rest/e2e/addEvent',
+ body: { id: 'mock cal/tz-oslo-noon', ical: ics, calendar: 'mock cal' },
+ });
+
+ cy.visit('/calendar');
+ cy.get('.calendarListItem').should('have.length', 1);
+ navigateToEventMonth(dateStr);
+ cy.get('button.calendarMonthDayEvent').should('contain', 'Oslo Noon Event');
+
+ const expected = expectedDisplayTime(dateStr, 10, 0);
+ cy.get('button.calendarMonthDayEvent')
+ .contains('Oslo Noon Event')
+ .invoke('text')
+ .should('match', new RegExp(expected));
+ });
+
+ it('should display all-day event on correct day in month view', () => {
+ const dateStr = futureDateStr(3);
+ const nextDay = futureDateStr(4);
+
+ const ics = buildIcs([
+ makeVevent(`VALUE=DATE:${dateStr}`, `VALUE=DATE:${nextDay}`, 'All-Day Event', 'tz-allday'),
+ ]);
+
+ cy.request({
+ method: 'POST',
+ url: '/rest/e2e/addEvent',
+ body: { id: 'mock cal/tz-allday', ical: ics, calendar: 'mock cal' },
+ });
+
+ cy.visit('/calendar');
+ cy.get('.calendarListItem').should('have.length', 1);
+ navigateToEventMonth(dateStr);
+ cy.get('button.calendarMonthDayEvent').should('contain', 'All-Day Event');
+
+ const targetDay = dateStrDay(dateStr);
+ // Avoids ambiguity when the month view shows two cells with the same day number
+ cy.get('button.calendarMonthDayEvent')
+ .contains('All-Day Event')
+ .parents('.cal-cell-top')
+ .find('.cal-day-number')
+ .should(dayEl => {
+ expect(parseInt(dayEl.text().trim(), 10)).to.equal(targetDay);
+ });
+ });
+
+ it('should create all-day event via dialog on correct day', () => {
+ // Switch to month view and navigate to next month for a clean view
+ cy.contains('button.calendarToolbarButton', 'Month').click();
+ cy.get('#nextPeriodButton').click();
+ cy.wait(500);
+
+ // Pick the 15th of next month — find its add-event button
+ cy.get('.cal-cell-top').then(cells => {
+ const targetDay = 15;
+ let targetCell: JQuery = null;
+ cells.each((i, el) => {
+ const dayNum = parseInt(Cypress.$(el).find('.cal-day-number').text().trim(), 10);
+ // First occurrence of targetDay in the grid belongs to the displayed month
+ if (dayNum === targetDay && !targetCell) {
+ targetCell = Cypress.$(el);
+ }
+ });
+ cy.wrap(targetCell).find('.add-new-event').invoke('css', 'visibility', 'visible');
+ cy.wrap(targetCell).find('.add-new-event button').should('be.visible').click();
+ });
+
+ cy.get('mat-dialog-container').within(() => {
+ // Use invoke + trigger instead of type() to avoid XHR-triggered
+ // change detection overwriting the value mid-typing.
+ cy.get('input[matInput]').first()
+ .invoke('val', 'Created All-Day Event')
+ .trigger('input')
+ .should('have.value', 'Created All-Day Event');
+ cy.get('mat-checkbox').contains('All-day event').click();
+ cy.get('#eventSubmitButton').click();
+ });
+
+ cy.get('button.calendarMonthDayEvent')
+ .should('contain', 'Created All-Day Event');
+
+ cy.get('button.calendarMonthDayEvent')
+ .contains('Created All-Day Event')
+ .parents('.cal-cell-top')
+ .find('.cal-day-number')
+ .should(dayEl => {
+ expect(parseInt(dayEl.text().trim(), 10)).to.equal(15);
+ });
+ });
+
+ it('should display multi-day all-day event starting on correct day', () => {
+ const day1 = futureDateStr(3);
+ const day4 = futureDateStr(6);
+
+ const ics = buildIcs([
+ makeVevent(`VALUE=DATE:${day1}`, `VALUE=DATE:${day4}`, 'Multi-Day Event', 'tz-multiday'),
+ ]);
+
+ cy.request({
+ method: 'POST',
+ url: '/rest/e2e/addEvent',
+ body: { id: 'mock cal/tz-multiday', ical: ics, calendar: 'mock cal' },
+ });
+
+ cy.visit('/calendar');
+ cy.get('.calendarListItem').should('have.length', 1);
+ navigateToEventMonth(day1);
+ cy.get('button.calendarMonthDayEvent').should('contain', 'Multi-Day Event');
+
+ const startDay = dateStrDay(day1);
+ // Find the event first, then verify its parent cell's day number.
+ cy.get('button.calendarMonthDayEvent')
+ .contains('Multi-Day Event')
+ .parents('.cal-cell-top')
+ .find('.cal-day-number')
+ .should(dayEl => {
+ expect(parseInt(dayEl.text().trim(), 10)).to.equal(startDay);
+ });
+ });
+});
diff --git a/e2e/cypress/integration/signup.ts b/e2e/cypress/integration/signup.ts
new file mode 100644
index 000000000..9282cb200
--- /dev/null
+++ b/e2e/cypress/integration/signup.ts
@@ -0,0 +1,100 @@
+// --------- BEGIN RUNBOX LICENSE ---------
+// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com).
+//
+// This file is part of Runbox 7.
+//
+// Runbox 7 is free software: You can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the
+// Free Software Foundation, either version 3 of the License, or (at your
+// option) any later version.
+//
+// Runbox 7 is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Runbox 7. If not, see .
+// ---------- END RUNBOX LICENSE ----------
+
+///
+
+describe('Signup', () => {
+ beforeEach(() => {
+ cy.intercept('GET', '/signup?legacy=1&runbox7=1', {
+ statusCode: 200,
+ body: `
+
+
+
+
+
+ `,
+ headers: {
+ 'content-type': 'text/html',
+ },
+ }).as('legacySignup');
+
+ cy.intercept('GET', 'https://hcaptcha.com/1/api.js?render=explicit', {
+ statusCode: 200,
+ body: 'window.hcaptcha = { render: function() { return "test-widget"; } };',
+ headers: {
+ 'content-type': 'application/javascript',
+ },
+ }).as('hcaptchaScript');
+ });
+
+ it('should render the Angular signup page in the local mock-backed environment', () => {
+ cy.visit('/signup?runbox7=1');
+ cy.wait('@legacySignup');
+ cy.wait('@hcaptchaScript');
+
+ cy.location('pathname').should('eq', '/signup');
+ cy.location('search').should('contain', 'runbox7=1');
+ cy.contains('h1', 'Create a Runbox Account').should('exist');
+ cy.get('form.signup-form').should('have.attr', 'action', '/mail/signup');
+ cy.get('input[name="user"]').should('exist');
+ cy.get('input[name="first_name"]').should('exist');
+ cy.get('input[name="last_name"]').should('exist');
+ cy.get('input[name="password"]').should('exist');
+ cy.get('select[name="runboxDomain"]').find('option').should('have.length', 3);
+ cy.get('select[name="runboxDomain"]').find('option').then((options) => {
+ const domains = Array.from(options).map((option) => option.value);
+ expect(domains).to.include('runbox.com');
+ expect(domains).to.include('rbx.email');
+ });
+ cy.get('div.captcha-host').should('exist');
+ cy.contains('button.submit', 'Set up my Runbox account').should('exist');
+ });
+
+ it('should show the public trust and transparency content', () => {
+ cy.visit('/signup?runbox7=1');
+ cy.wait('@legacySignup');
+ cy.wait('@hcaptchaScript');
+
+ cy.get('header.signup-header .brand img').should('be.visible');
+ cy.get('header.signup-header .brand').should('not.contain', 'Runbox 7');
+
+ cy.contains('.hero-panel h2', 'Privacy by business model').should('exist');
+ cy.contains('.hero-panel h2', 'Hosted in Norway').should('exist');
+ cy.contains('.hero-panel h2', 'Sustainable and secure').should('exist');
+ cy.contains('.hero-panel h2', 'How the trial works').should('exist');
+
+ cy.contains('.hero-panel', 'customer email content is private').should('exist');
+ cy.contains('.form-section', 'default sender name recipients will see').should('exist');
+
+ cy.get('.info-chip').should('have.length.at.least', 3);
+ cy.contains('.field-label, .field small', 'Existing email address').should('exist');
+ cy.contains('.field-label, .field small', 'How did you hear about Runbox?').should('exist');
+
+ cy.contains('.form-actions button.submit', 'Set up my Runbox account').should('exist');
+ cy.contains('a', 'Use legacy signup page').should('not.exist');
+ });
+});
diff --git a/e2e/cypress/plugins/index.js b/e2e/cypress/plugins/index.js
index c1f7270f1..92e2bc043 100644
--- a/e2e/cypress/plugins/index.js
+++ b/e2e/cypress/plugins/index.js
@@ -19,4 +19,16 @@ module.exports = (on, config) => {
}
on('file:preprocessor', wp(options))
require('cypress-terminal-report/src/installLogsPrinter')(on);
+
+ // Force the browser into a specific timezone via TZ env var.
+ // Defaults to Europe/Oslo (CET/CEST) to expose bugs where
+ // account tz != browser tz. Override with env var CYPRESS_TZ.
+ on('before:browser:launch', (browser, launchOptions) => {
+ const tz = config.env.TZ || 'Europe/Oslo';
+ launchOptions.env = {
+ ...(launchOptions.env || {}),
+ TZ: tz,
+ };
+ return launchOptions;
+ });
}
diff --git a/e2e/cypress/support/ics-helpers.ts b/e2e/cypress/support/ics-helpers.ts
new file mode 100644
index 000000000..ea0c60953
--- /dev/null
+++ b/e2e/cypress/support/ics-helpers.ts
@@ -0,0 +1,117 @@
+// --------- BEGIN RUNBOX LICENSE ---------
+// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com).
+//
+// This file is part of Runbox 7.
+//
+// Runbox 7 is free software: You can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the
+// Free Software Foundation, either version 3 of the License, or (at your
+// option) any later version.
+//
+// Runbox 7 is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Runbox 7. If not, see .
+// ---------- END RUNBOX LICENSE ----------
+
+export function futureDateStr(daysFromNow: number): string {
+ const d = new Date();
+ d.setDate(d.getDate() + daysFromNow);
+ return d.toISOString().replace(/-/g, '').replace(/T.*/, '');
+}
+
+export function dateStrMonth(dateStr: string): number {
+ return parseInt(dateStr.substring(4, 6), 10);
+}
+
+export function dateStrYear(dateStr: string): number {
+ return parseInt(dateStr.substring(0, 4), 10);
+}
+
+export function dateStrDay(dateStr: string): number {
+ return parseInt(dateStr.substring(6, 8), 10);
+}
+
+export function buildIcs(veventBlocks: string[], vtimezone?: string): string {
+ const parts = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//Runbox//E2E Test//EN',
+ ];
+ if (vtimezone) { parts.push(vtimezone); }
+ parts.push(...veventBlocks, 'END:VCALENDAR');
+ return parts.join('\r\n');
+}
+
+export function dtLine(prefix: string, value: string): string {
+ return value.includes('=') ? `${prefix};${value}` : `${prefix}:${value}`;
+}
+
+export function makeVevent(dtstart: string, dtend: string, summary: string, uid: string, extra: string[] = []): string {
+ return [
+ 'BEGIN:VEVENT',
+ dtLine('DTSTART', dtstart),
+ dtLine('DTEND', dtend),
+ `SUMMARY:${summary}`,
+ `UID:${uid}`,
+ 'DTSTAMP:20260101T000000Z',
+ ...extra,
+ 'END:VEVENT',
+ ].join('\r\n');
+}
+
+export const osloVtimezone = [
+ 'BEGIN:VTIMEZONE',
+ 'TZID:/citadel.org/20210210_1/Europe/Oslo',
+ 'LAST-MODIFIED:20210210T123706Z',
+ 'X-LIC-LOCATION:Europe/Oslo',
+ 'BEGIN:STANDARD',
+ 'TZNAME:CET',
+ 'TZOFFSETFROM:+0200',
+ 'TZOFFSETTO:+0100',
+ 'DTSTART:19961027T030000',
+ 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
+ 'END:STANDARD',
+ 'BEGIN:DAYLIGHT',
+ 'TZNAME:CEST',
+ 'TZOFFSETFROM:+0100',
+ 'TZOFFSETTO:+0200',
+ 'DTSTART:19810329T020000',
+ 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
+ 'END:DAYLIGHT',
+ 'END:VTIMEZONE',
+].join('\r\n');
+
+/**
+ * Compute what the Angular date pipe would display for a given UTC time
+ * in the browser's current timezone. Returns 'HH:mm' format.
+ * dateStr: 'YYYYMMDD' format date string.
+ */
+export function expectedDisplayTime(dateStr: string, utcHour: number, utcMinute: number = 0): string {
+ const date = new Date(Date.UTC(dateStrYear(dateStr), dateStrMonth(dateStr) - 1, dateStrDay(dateStr), utcHour, utcMinute, 0));
+ return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
+}
+
+export const londonVtimezone = [
+ 'BEGIN:VTIMEZONE',
+ 'TZID:Europe/London',
+ 'X-LIC-LOCATION:Europe/London',
+ 'BEGIN:STANDARD',
+ 'TZNAME:GMT',
+ 'TZOFFSETFROM:+0100',
+ 'TZOFFSETTO:+0000',
+ 'DTSTART:19701025T020000',
+ 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
+ 'END:STANDARD',
+ 'BEGIN:DAYLIGHT',
+ 'TZNAME:BST',
+ 'TZOFFSETFROM:+0000',
+ 'TZOFFSETTO:+0100',
+ 'DTSTART:19810329T010000',
+ 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
+ 'END:DAYLIGHT',
+ 'END:VTIMEZONE',
+].join('\r\n');
diff --git a/e2e/mockserver/mockserver.ts b/e2e/mockserver/mockserver.ts
index 36ffae31f..422fd336b 100644
--- a/e2e/mockserver/mockserver.ts
+++ b/e2e/mockserver/mockserver.ts
@@ -22,23 +22,24 @@ import { createWriteStream } from 'fs';
import { mail_message_obj } from './emailresponse';
const logger = createWriteStream('mockserver.log');
-function log(line) {
+function log(line: string) {
logger.write(line + '\n');
}
export class MockServer {
- server: Server;
+ server!: Server;
loggedIn = true;
challenge2fa = false;
port = 15000;
- calendars = [
- { id: 'mock cal', displayname: 'Mock Calendar' },
+ calendars: { id: string; displayname: string; syncToken: string }[] = [
+ { id: 'mock cal', displayname: 'Mock Calendar', syncToken: 'e2e-sync-1' },
];
- events = [];
+ events: { id: string; ical: any; calendar: string }[] = [];
+ accountTimezone = 'Europe/Oslo';
folders = [
{
@@ -203,12 +204,37 @@ RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
END:VCALENDAR
+`;
+
+ vtimezone_london =
+`BEGIN:VCALENDAR
+PRODID:-//runbox//NONSGML Runbox calendar//EN
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:Europe/London
+X-LIC-LOCATION:Europe/London
+BEGIN:STANDARD
+TZNAME:GMT
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0000
+DTSTART:19701025T020000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+BEGIN:DAYLIGHT
+TZNAME:BST
+TZOFFSETFROM:+0000
+TZOFFSETTO:+0100
+DTSTART:19810329T010000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+END:VTIMEZONE
+END:VCALENDAR
`;
public start() {
log('Starting mock server');
this.server = createServer((request, response) => {
- const e2eFixture = request.url.match(/\/rest\/e2e\/(\w+)/);
+ const e2eFixture = request.url?.match(/\/rest\/e2e\/([^?]+)/);
if (e2eFixture) {
log(e2eFixture[1]);
const command = e2eFixture[1];
@@ -223,6 +249,34 @@ END:VCALENDAR
if (command === 'disable2fa') {
this.challenge2fa = false;
}
+ if (command === 'resetCalendarEvents') {
+ this.events = [];
+ this.bumpSyncToken();
+ }
+ if (command.startsWith('setTimezone_')) {
+ this.accountTimezone = command.replace('setTimezone_', '');
+ }
+ if (command === 'resetTimezone') {
+ this.accountTimezone = 'Europe/Oslo';
+ }
+ if (command === 'addEvent') {
+ // POST with JSON body: { id, ical, calendar }
+ let body = '';
+ request.on('readable', () => {
+ body += request.read() || '';
+ });
+ request.on('end', () => {
+ const parsed = JSON.parse(body);
+ this.events.push({
+ id: parsed.id || ('e2e-event-' + this.events.length),
+ ical: parsed.ical,
+ calendar: parsed.calendar || 'mock cal',
+ });
+ this.bumpSyncToken();
+ response.end(JSON.stringify({ status: 'success' }));
+ });
+ return;
+ }
response.end();
return;
}
@@ -232,7 +286,7 @@ END:VCALENDAR
return;
}
log(request.method + ' ' + request.url);
- let requesturl = request.url;
+ let requesturl = request.url || '';
if (requesturl.indexOf('/rest/v1/list/deleted_messages') === 0) {
requesturl = '/rest/v1/list/deleted_messages';
}
@@ -251,7 +305,7 @@ END:VCALENDAR
if (bulkemailendpiont) {
const ids = bulkemailendpiont[1].split(',').map(id => parseInt(id, 10));
- const messages = {};
+ const messages: { [key: number]: { json: any } } = {};
for (const id of ids) {
messages[id] = { json: this.getMessage(id).result };
}
@@ -295,6 +349,33 @@ END:VCALENDAR
));
return;
}
+ // ICS calendar import: PUT /rest/v1/calendar/ics/{calendar_id}
+ const icsImportMatch = requesturl.match(/^\/rest\/v1\/calendar\/ics\/(.+)$/);
+ if (icsImportMatch && request.method === 'PUT') {
+ let body = '';
+ request.on('readable', () => {
+ body += request.read() || '';
+ });
+ request.on('end', () => {
+ const parsed = JSON.parse(body);
+ const eventCount = (parsed.ical.match(/BEGIN:VEVENT/g) || []).length;
+ const calendarId = decodeURIComponent(icsImportMatch[1]);
+ const uidMatch = parsed.ical.match(/UID:(.*)/);
+ this.events.push({
+ id: calendarId + '/' + (uidMatch ? uidMatch[1].trim() : 'imported-' + this.events.length),
+ ical: parsed.ical,
+ calendar: calendarId,
+ });
+ response.end(JSON.stringify({
+ 'status': 'success',
+ 'result': {
+ 'events_imported': eventCount,
+ }
+ }));
+ });
+ return;
+ }
+
switch (requesturl) {
case '/ajax_mfa_authenticate':
setTimeout(() => {
@@ -449,7 +530,7 @@ END:VCALENDAR
'last_name': 'User',
'email_alternative': 'test@example.com',
'country': 'NO',
- 'timezone': 'Europe/Oslo',
+ 'timezone': this.accountTimezone,
'phone_number': '',
'company': '',
'email_alternative_status': 0
@@ -477,8 +558,11 @@ END:VCALENDAR
case '/_ics/Europe/Oslo.ics':
response.end(this.vtimezone_oslo);
break;
+ case '/_ics/Europe/London.ics':
+ response.end(this.vtimezone_london);
+ break;
default:
- if (request.url.indexOf('/rest') === 0) {
+ if (request.url && request.url.indexOf('/rest') === 0) {
response.end(JSON.stringify({ status: 'success' }));
} else {
response.end('');
@@ -548,7 +632,7 @@ END:VCALENDAR
},
'company': null, 'is_overwrite_subaccount_ip_rules': 0,
'currency': 'EUR',
- 'user_created': null, 'timezone': 'Europe/Oslo', 'uid': 221,
+ 'user_created': null, 'timezone': this.accountTimezone, 'uid': 221,
'sub_accounts': ['test%subaccount.com'], 'password_strength': 5,
'gender': null, 'has_sub_accounts': 1, 'need2pay': 'n',
'paid': 'n', 'country': null
@@ -908,7 +992,7 @@ END:VCALENDAR
};
}
- createFolder(request, response) {
+ createFolder(request: any, response: any) {
let body = '';
request.on('readable', () => {
body += request.read() || '';
@@ -997,6 +1081,12 @@ END:VCALENDAR
};
}
+ bumpSyncToken() {
+ const current = this.calendars[0].syncToken || '';
+ const num = parseInt(current.split('-').pop() || '0', 10) || 0;
+ this.calendars[0].syncToken = 'e2e-sync-' + (num + 1);
+ }
+
getCalendars() {
return {
'status': 'success',
@@ -1014,8 +1104,9 @@ END:VCALENDAR
});
request.on('end', () => {
const event = JSON.parse(body);
- event['id'] = 'mock-event-' + (this.events.length + 1);
+ event['id'] = (event['calendar'] || 'mock cal') + '/mock-event-' + (this.events.length + 1);
this.events.push(event);
+ this.bumpSyncToken();
response.end(JSON.stringify({
'status': 'success',
'result': {
diff --git a/package.json b/package.json
index 5a2cf1c08..2e38704ba 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"build": "node src/build/pre-build.js && ng build --configuration production --base-href=/app/ runbox7; RES=$?; node src/build/post-build.js; exit $RES",
"policy": "node policy-tests/run-all.js",
"test": "ng test",
+ "test:signup:firefox": "ng test --watch=false --browsers Firefox --include src/app/signup/signup.component.spec.ts",
"lint": "ng lint",
"e2e": "ng e2e",
"start-e2e-server": "start-test mockserver 15000 start-use-mockserver",
diff --git a/src/app/aliases/aliases.lister.spec.ts b/src/app/aliases/aliases.lister.spec.ts
index 8a3ffb893..3fef53f94 100644
--- a/src/app/aliases/aliases.lister.spec.ts
+++ b/src/app/aliases/aliases.lister.spec.ts
@@ -17,10 +17,16 @@
// along with Runbox 7. If not, see .
// ---------- END RUNBOX LICENSE ----------
-import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
import { AliasesListerComponent } from './aliases.lister';
-import { MatLegacyDialogModule } from '@angular/material/legacy-dialog';
+import { MatLegacyButtonModule } from '@angular/material/legacy-button';
+import { MatLegacyCardModule } from '@angular/material/legacy-card';
+import { MatLegacyDialog as MatDialog, MatLegacyDialogModule } from '@angular/material/legacy-dialog';
+import { MatLegacyInputModule } from '@angular/material/legacy-input';
import { MatLegacySnackBarModule } from '@angular/material/legacy-snack-bar';
+import { MatLegacySelectModule } from '@angular/material/legacy-select';
+import { MatExpansionModule } from '@angular/material/expansion';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { RunboxWebmailAPI } from '../rmmapi/rbwebmail';
import { RMM } from '../rmm';
@@ -34,6 +40,7 @@ import { HttpClient } from '@angular/common/http';
describe('AliasesListerComponent', () => {
let component: AliasesListerComponent;
let fixture: ComponentFixture;
+ let dialog: MatDialog;
const DEFAULT_EMAIL = 'a.kalou@shadowcat.co.uk';
const ALLOWED_DOMAINS = ['runbox.com', 'shadowcat.co.uk'];
@@ -47,10 +54,16 @@ describe('AliasesListerComponent', () => {
TestBed.configureTestingModule({
imports: [
CommonModule,
+ FormsModule,
HttpClientTestingModule,
+ MatLegacyButtonModule,
+ MatLegacyCardModule,
MatLegacyCommonModule,
+ MatLegacyInputModule,
+ MatLegacySelectModule,
MatLegacySnackBarModule,
MatLegacyDialogModule,
+ MatExpansionModule,
NoopAnimationsModule,
],
providers: [
@@ -94,6 +107,7 @@ describe('AliasesListerComponent', () => {
});
fixture = TestBed.createComponent(AliasesListerComponent);
component = fixture.componentInstance;
+ dialog = TestBed.inject(MatDialog);
});
it('loads aliases through RMM', () => {
@@ -126,7 +140,7 @@ describe('AliasesListerComponent', () => {
expect(forwards.length).toBe(component.aliases.length, 'all forwards should be shown');
});
- it('sets the default email to the current users email', fakeAsync(() => {
+ it('sets the default email to the current users email', () => {
expect(component.defaultEmail).toBe(DEFAULT_EMAIL);
// spawn a modal
@@ -143,22 +157,21 @@ describe('AliasesListerComponent', () => {
// FIXME: doesn't work, value isn't set, probably because of ngModel
// expect(forwardTo.value)
// .toBe(DEFAULT_EMAIL, "Forward to should default to the user's email");
- }));
+ });
- it('dialog loads allowed domains', () => {
+ it('dialog loads allowed domains', async () => {
// spawn a modal
component.create();
fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
const modal =
fixture.nativeElement.nextSibling.querySelector('app-aliases-edit');
expect(modal).toBeTruthy();
- const domain: HTMLSelectElement =
- modal.querySelector('mat-select[name=\'domain\']');
-
- ALLOWED_DOMAINS.forEach(allowed_domain => {
- expect(domain.textContent).toContain(allowed_domain);
- });
+ const dialogRef = dialog.openDialogs[0];
+ expect(dialogRef).toBeTruthy();
+ expect(dialogRef.componentInstance.allowedDomains).toEqual(ALLOWED_DOMAINS);
});
});
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index bced66edb..278d4b93f 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -94,6 +94,7 @@ window.addEventListener('dragover', (event) => event.preventDefault());
window.addEventListener('drop', (event) => event.preventDefault());
const routes: Routes = [
+ { path: 'signup', loadChildren: () => import('./signup/signup.module').then(m => m.SignupModule) },
{
path: '',
canActivateChild: [RMMAuthGuardService],
@@ -136,7 +137,8 @@ const routes: Routes = [
{ path: 'domainregistration', component: DomainRegisterRedirectComponent },
]
},
- { path: 'login', component: LoginComponent }
+ { path: 'login', component: LoginComponent },
+ { path: '**', redirectTo: '' }
];
@NgModule({
@@ -213,4 +215,3 @@ export class AppModule {
);
}
}
-
diff --git a/src/app/calendar-app/calendar-app.component.html b/src/app/calendar-app/calendar-app.component.html
index d539940ae..517b2d517 100644
--- a/src/app/calendar-app/calendar-app.component.html
+++ b/src/app/calendar-app/calendar-app.component.html
@@ -12,8 +12,8 @@
*ngFor="let event of (day.events.length > 4 ? day.events.slice(0, 3) : day.events)"
(click)="openEvent(event); $event.stopPropagation()"
>
-
- {{ event.start.getHours() }}:{{ ('0' + event.start.getMinutes()).slice(-2) }}
+
+ {{ event.start | date:'HH:mm' }}
{{ event.title }}
diff --git a/src/app/calendar-app/calendar-app.component.spec.ts b/src/app/calendar-app/calendar-app.component.spec.ts
index e2b29af55..1d145cadd 100644
--- a/src/app/calendar-app/calendar-app.component.spec.ts
+++ b/src/app/calendar-app/calendar-app.component.spec.ts
@@ -45,20 +45,28 @@ describe('CalendarAppComponent', () => {
let component: CalendarAppComponent;
let fixture: ComponentFixture;
+ // moment.toISOString() includes milliseconds (e.g. "2026-04-02T14:30:00.000Z"),
+ // which causes ICAL.js's fromDateTimeString to miss the 'Z' UTC designator
+ // (it checks index 19, but milliseconds push 'Z' to index 23).
+ // This helper produces ICAL-compatible ISO strings without milliseconds.
+ function toIcalISOString(m: moment.Moment): string {
+ return m.format('YYYY-MM-DDTHH:mm:ss') + 'Z';
+ }
+
// jCal spec: https://tools.ietf.org/html/rfc7265
const simpleEvents = [
{'id': 'test-calendar/event0',
'ical': new ICAL.Component(['vcalendar', [], [ [ 'vevent', [
- [ 'dtstart', {}, 'date-time', moment().toISOString() ],
- [ 'dtend', {}, 'date-time', moment().add(1, 'hour').toISOString() ],
+ [ 'dtstart', {}, 'date-time', toIcalISOString(moment()) ],
+ [ 'dtend', {}, 'date-time', toIcalISOString(moment().add(1, 'hour')) ],
[ 'summary', {}, 'text', 'Test Event #0' ],
]]]]).toString(),
'calendar': 'test-calendar',
},
{'id': 'test-calendar/event1',
'ical': new ICAL.Component(['vcalendar', [], [ [ 'vevent', [
- [ 'dtstart', {}, 'date-time', moment().date(15).add(1, 'month').add(1, 'day').add(2, 'hour').toISOString() ],
- [ 'dtend', {}, 'date-time', moment().date(15).add(1, 'month').add(1, 'day').add(3, 'hour').toISOString() ],
+ [ 'dtstart', {}, 'date-time', toIcalISOString(moment().date(15).add(1, 'month').add(1, 'day').add(2, 'hour')) ],
+ [ 'dtend', {}, 'date-time', toIcalISOString(moment().date(15).add(1, 'month').add(1, 'day').add(3, 'hour')) ],
[ 'summary', {}, 'text', 'Event #1, next month' ],
]]]]).toString(),
'calendar': 'test-calendar',
@@ -68,8 +76,8 @@ describe('CalendarAppComponent', () => {
const recurringEvents = [
{ 'id': 'test-calendar/recurring',
'ical': new ICAL.Component(['vcalendar', [], [ [ 'vevent', [
- [ 'dtstart', {}, 'date-time', moment().date(15).toISOString() ],
- [ 'dtend', {}, 'date-time', moment().add(1, 'hour').date(15).toISOString() ],
+ [ 'dtstart', {}, 'date-time', toIcalISOString(moment().date(15)) ],
+ [ 'dtend', {}, 'date-time', toIcalISOString(moment().add(1, 'hour').date(15)) ],
[ 'summary', {}, 'text', 'Weekly Event #0' ],
[ 'rrule', {}, 'recur', {'freq': 'WEEKLY'} ],
]]]]).toString(),
@@ -305,8 +313,15 @@ END:VCALENDAR
events = fixture.debugElement.nativeElement.querySelectorAll('button.calendarMonthDayEvent');
expect(component.shown_events.length).toBeGreaterThan(2, 'more events should be displayed now');
const first_occurence = component.shown_events[0].start;
- expect(first_occurence.getDate()).toBe(1, 'day matches');
- expect(first_occurence.getHours()).toBe(12, 'hour matches');
- expect(first_occurence.getMinutes()).toBe(34, 'minute matches');
+ // Event was created at 12:34 UTC using moment.utc().date(1).hour(12).minute(34).toISOString()
+ // but without a TZID in the iCal data. The calendar timezone is Europe/Stockholm.
+ // The floating time 12:34 is interpreted as Stockholm local time.
+ // Stockholm offset depends on DST: CET (UTC+1) in winter, CEST (UTC+2) in summer.
+ const eventLocalHour = 12;
+ const stockholmOffset = moment.tz('Europe/Stockholm').utcOffset(); // minutes
+ const expectedUtcHour = (eventLocalHour - stockholmOffset / 60 + 24) % 24;
+ expect(first_occurence.getUTCDate()).toBe(1);
+ expect(first_occurence.getUTCHours()).toBe(expectedUtcHour);
+ expect(first_occurence.getUTCMinutes()).toBe(34);
});
});
diff --git a/src/app/calendar-app/calendar.service.spec.ts b/src/app/calendar-app/calendar.service.spec.ts
index 6fbfe662e..bd6f64def 100644
--- a/src/app/calendar-app/calendar.service.spec.ts
+++ b/src/app/calendar-app/calendar.service.spec.ts
@@ -340,8 +340,10 @@ END:VCALENDAR
// Produces multiple CalendarEvents which refer to the same ICal.Event
expect(rbevents.length).toEqual(5, 'Recurring event contains 5 instances');
expect(rbevents[0].recurringFrequency).toEqual('DAILY', 'recurrence is DAILY');
- expect(rbevents[0].start).toEqual(new Date(2021, 3, 25, 15, 0, 0), 'event 1 start date is 3pm in Stockholm');
- expect(rbevents[1].start).toEqual(new Date(2021, 3, 26, 16, 0, 0), 'event 1 start date is 4pm in Stockholm');
+ // Event 1: 9am Eastern (EDT UTC-4) = 13:00 UTC
+ // Event 2: 10am Eastern (EDT UTC-4) = 14:00 UTC (exception with +1hr)
+ expect(rbevents[0].start).toEqual(moment.utc('2021-04-25T13:00:00').toDate(), 'event 1 start date is 9am Eastern = 13:00 UTC');
+ expect(rbevents[1].start).toEqual(moment.utc('2021-04-26T14:00:00').toDate(), 'event 2 start date is 10am Eastern = 14:00 UTC');
});
it('should be possible to import a static (non recurring) event', () => {
diff --git a/src/app/calendar-app/calendar.service.ts b/src/app/calendar-app/calendar.service.ts
index 9929a3b09..809c56081 100644
--- a/src/app/calendar-app/calendar.service.ts
+++ b/src/app/calendar-app/calendar.service.ts
@@ -332,7 +332,11 @@ export class CalendarService implements OnDestroy {
// from its rrule at some point - check to see if we already
// have the same uid
if (vevents.length === 0) {
- const ievent = new ICAL.Event(component.getFirstSubcomponent('vevent'));
+ const vevent = component.getFirstSubcomponent('vevent');
+ if (!vevent) {
+ return { id, event: null };
+ }
+ const ievent = new ICAL.Event(vevent);
const existingEvent = this.icalevents.find(
(entry) => entry['id'] === ievent.uid
);
@@ -342,7 +346,7 @@ export class CalendarService implements OnDestroy {
// we could save modified event, and delete this one
// or keep doing this and leave that for a different fix?
}
- return;
+ return { id, event: null };
}
if (keep) {
this.icalevents.push({ 'id': id, 'event': vevents[0] });
@@ -444,7 +448,7 @@ export class CalendarService implements OnDestroy {
// we need to copy it to a new calendar, and remove it from the old one.
this.addEvent(event).then(id => {
console.log('Event recreated as', id);
- this.deleteEvent(event._old_id);
+ this.deleteEvent(event._old_id || '');
});
} else {
// simple case: just modify the event in place
@@ -477,7 +481,7 @@ export class CalendarService implements OnDestroy {
// console.log('Found timezone data:', tzData);
// VCALENDAR with VTIMEZONE in it
const component = new ICAL.Component(ICAL.parse(tzData));
- let tz;
+ let tz: ICAL.Timezone | undefined;
if (component.getFirstSubcomponent('vtimezone')) {
for (const tzComponent of component.getAllSubcomponents('vtimezone')) {
// TZIDs in vzic are, eg: /citadel.org/20210210_1/Europe/London
@@ -492,7 +496,9 @@ export class CalendarService implements OnDestroy {
}
}
}
- this.userTimezoneLoaded.next(tz);
+ if (tz) {
+ this.userTimezoneLoaded.next(tz);
+ }
this.userTimezoneLoaded.complete();
});
}
@@ -578,7 +584,12 @@ export class CalendarService implements OnDestroy {
// NB calendar-app.component.spec.ts relis on this being
// multiple months
if (this.icalevents.length > 0) {
- this.events = this.generateEvents(start.toDate(), next_month.toDate(), this.icalevents);
+ // Preserve events that were pushed locally (e.g. via addEvent) but
+ // aren't in icalevents yet, so updateEventList doesn't drop them.
+ const localEvents = this.events.filter(
+ e => !this.icalevents.some(ie => ie.id === e.id));
+ this.events = this.generateEvents(start.toDate(), next_month.toDate(), this.icalevents)
+ .concat(localEvents);
this.eventSubject.next(this.events);
}
diff --git a/src/app/calendar-app/event-editor-dialog.component.ts b/src/app/calendar-app/event-editor-dialog.component.ts
index dd5899481..6457ad953 100644
--- a/src/app/calendar-app/event-editor-dialog.component.ts
+++ b/src/app/calendar-app/event-editor-dialog.component.ts
@@ -145,7 +145,7 @@ export class EventEditorDialogComponent {
const hasDay = setDays.find((entry) => entry['day'] === day.val);
if (hasDay) {
day['recurs_on'] = true;
- if (hasDay['numth'] > 0) {
+ if (parseInt(hasDay['numth'], 10) > 0) {
this.recur_by_monthyeardays.push(hasDay['numth']);
}
recurs_on_weekdays.push(day.val);
diff --git a/src/app/calendar-app/runbox-calendar-event.spec.ts b/src/app/calendar-app/runbox-calendar-event.spec.ts
index 7ce53cb4a..8dde2b4fa 100644
--- a/src/app/calendar-app/runbox-calendar-event.spec.ts
+++ b/src/app/calendar-app/runbox-calendar-event.spec.ts
@@ -1,3 +1,4 @@
+///
// --------- BEGIN RUNBOX LICENSE ---------
// Copyright (C) 2016-2018 Runbox Solutions AS (runbox.com).
//
@@ -33,7 +34,6 @@ describe('RunboxCalendarEvent', () => {
newEvent.title = 'New Event';
newEvent.location = 'Somewhere';
- // test things addEvent calls:
expect(newEvent.toIcal()).toMatch(/BEGIN:VEVENT/);
});
it('should be possible to create a new event, without times', () => {
@@ -55,14 +55,74 @@ describe('RunboxCalendarEvent', () => {
[]
);
- // test things addEvent calls:
expect(newEvent.toIcal()).toMatch(/BEGIN:VEVENT/);
- // check date not time:
const now = ICAL.Time.fromJSDate(moment().date(1).toDate());
expect(newEvent.toIcal()).not.toContain(now.toICALString());
now.isDate = true;
expect(newEvent.toIcal()).toContain(now.toICALString());
});
+ // All-day event display correctness with positive UTC offsets
+ // Bug: toJSDate() shifts midnight to previous day UTC (e.g. midnight CEST = 22:00 UTC prev day)
+ it('should display all-day event on correct day with positive UTC offset', () => {
+ ensureTimezone('Europe/Oslo', 1, 2);
+
+ const event = RunboxCalendarEvent.newEmpty('Europe/Oslo');
+ const startMoment = moment('2026-04-29T00:00:00').seconds(0).milliseconds(0);
+ const endMoment = moment('2026-04-30T00:00:00').seconds(0).milliseconds(0);
+
+ event.updateEvent(
+ startMoment, endMoment, true, 'test-cal',
+ RecurSaveType.ALL_OCCURENCES, 'All-day event', '', '',
+ false, '', 0, [], [], []
+ );
+
+ expect(event.allDay).toBe(true, 'event should be all-day');
+ expect(event.start.getDate()).toBe(29,
+ 'All-day event on April 29 should have start.getDate() = 29');
+ expect(event.start.getMonth()).toBe(3,
+ 'All-day event on April 29 should have start.getMonth() = 3 (April)');
+ });
+ it('should display multi-day all-day event on correct start and end days', () => {
+ ensureTimezone('Europe/Oslo', 1, 2);
+
+ const event = RunboxCalendarEvent.newEmpty('Europe/Oslo');
+ const startMoment = moment('2026-04-29T00:00:00').seconds(0).milliseconds(0);
+ const endMoment = moment('2026-05-01T00:00:00').seconds(0).milliseconds(0);
+
+ event.updateEvent(
+ startMoment, endMoment, true, 'test-cal',
+ RecurSaveType.ALL_OCCURENCES, 'Multi-day event', '', '',
+ false, '', 0, [], [], []
+ );
+
+ expect(event.start.getDate()).toBe(29,
+ 'Multi-day event start should be April 29');
+ expect(event.end.getDate()).toBe(30,
+ 'Multi-day event end should be April 30');
+ });
+ it('should display created all-day event start and end on correct day', () => {
+ ensureTimezone('Europe/Oslo', 1, 2);
+
+ const event = RunboxCalendarEvent.newEmpty('Europe/Oslo');
+ // Simulate dialog: start at noon, end at 2pm (+1 day added by dialog for exclusive DTEND)
+ const startMoment = moment('2026-05-05T12:00:00').seconds(0).milliseconds(0);
+ const endMoment = moment('2026-05-06T14:00:00').seconds(0).milliseconds(0);
+
+ event.updateEvent(
+ startMoment, endMoment, true, 'test-cal',
+ RecurSaveType.ALL_OCCURENCES, 'All-day event', '', '',
+ false, '', 0, [], [], []
+ );
+
+ expect(event.start.getDate()).toBe(5,
+ 'Created all-day event start day should be 5');
+ expect(event.end.getDate()).toBe(5,
+ 'Created all-day event end day should be 5 (inclusive)');
+ expect(event.start.getMonth()).toBe(4,
+ 'Created all-day event start month should be May (4)');
+ expect(event.end.getMonth()).toBe(4,
+ 'Created all-day event end month should be May (4)');
+ });
it('should be possible to add/edit/remove a WEEKLY recurrence rule', () => {
const sut = new RunboxCalendarEvent(
'testcal/testev', new ICAL.Event(new ICAL.Component(['vcalendar', [], [
@@ -71,8 +131,8 @@ describe('RunboxCalendarEvent', () => {
[ 'dtend', {}, 'date', moment().toISOString().split('T')[0] ],
[ 'summary', {}, 'text', 'One-time event' ],
] ]
- ]])), ICAL.Time.fromJSDate(new Date()), ICAL.Time.fromJSDate(new Date())
- , 'Europe/London' // user's timezone for display
+ ]])), ICAL.Time.fromJSDate(new Date()), ICAL.Time.fromJSDate(new Date()),
+ 'Europe/London'
);
sut.recurringFrequency = 'WEEKLY';
expect(sut.recurringFrequency).toBe('WEEKLY', 'recurrence seems to be set');
@@ -116,8 +176,8 @@ describe('RunboxCalendarEvent', () => {
[ 'dtend', {}, 'date', moment().toISOString().split('T')[0] ],
[ 'summary', {}, 'text', 'One-time event' ],
] ]
- ]])), ICAL.Time.fromJSDate(new Date()), ICAL.Time.fromJSDate(new Date())
- , 'Europe/London' // user's timezone for display
+ ]])), ICAL.Time.fromJSDate(new Date()), ICAL.Time.fromJSDate(new Date()),
+ 'Europe/London'
);
sut.recurringFrequency = 'MONTHLY';
sut.recurInterval = 1; // the default
@@ -199,11 +259,11 @@ describe('RunboxCalendarEvent', () => {
false,
sut.calendar,
RecurSaveType.THIS_ONLY,
- 'Moved weekly event', undefined, undefined,
+ 'Moved weekly event', '', '',
true,
sut.recurringFrequency,
sut.recurInterval,
- undefined, undefined, undefined, // and optional params..
+ [], [], [] // and optional params..
);
expect(sut.toIcal()).toContain('SUMMARY:Moved weekly event');
@@ -232,11 +292,11 @@ describe('RunboxCalendarEvent', () => {
false,
sut.calendar,
RecurSaveType.THIS_ONLY,
- 'Moved weekly event one hour', undefined, undefined,
+ 'Moved weekly event one hour', '', '',
true,
sut.recurringFrequency,
sut.recurInterval,
- undefined, undefined, undefined, // and optional params..
+ [], [], [],
);
expect(sut.toIcal()).toContain('SUMMARY:Moved weekly event one hour');
@@ -349,25 +409,30 @@ END:VCALENDAR`
);
console.log('sut tz :' + sut.timezone);
// verify timezone event has sane start/end dates
- // This should be in the user's tz (Europe/London in this test)
+ // Event is at 09:00 Berlin (CEST=UTC+2) = 07:00 UTC
+ // When converted to New York (EDT=UTC-4) = 03:00 local NY time
+ // toJSDate() returns the correct absolute UTC time
console.log('event start :' + sut.start.toISOString());
- // 3am New York
- expect(sut.start.toISOString()).toBe('2021-05-15T03:00:00.000Z');
- // Move this one an hour later
- // TZ?
- const future = moment('2021-05-15T04:00:00');
- const future_end = moment('2021-05-15T05:00:00');
+ // 07:00 UTC (which is 03:00 New York EDT)
+ expect(sut.start.toISOString()).toBe('2021-05-15T07:00:00.000Z');
+ // Move this one an hour later (09:00 -> 10:00 Berlin time)
+ // Use moment.tz to create a deterministic time in the event's timezone,
+ // simulating what the dialog would pass after user edits the time.
+ // momentToIcalTime now converts via UTC, so this produces the correct
+ // 10:00 Berlin regardless of the test machine's local timezone.
+ const future = moment.tz('2021-05-15T10:00:00', 'Europe/Berlin');
+ const future_end = moment.tz('2021-05-15T11:00:00', 'Europe/Berlin');
sut.updateEvent(
future,
future_end,
false,
sut.calendar,
RecurSaveType.THIS_ONLY,
- 'Moved daily event one hour', undefined, undefined,
+ 'Moved daily event one hour', '', '',
true,
sut.recurringFrequency,
sut.recurInterval,
- undefined, undefined, undefined, // and optional params..
+ [], [], [],
);
expect(sut.toIcal()).toContain('SUMMARY:Moved daily event one hour');
@@ -375,4 +440,532 @@ END:VCALENDAR`
expect(sut.toIcal()).toContain('RECURRENCE-ID;TZID=/freeassociation.sourceforge.net/Europe/Berlin:20210515T\r\n 090000');
expect(sut.toIcal()).toContain('DTSTART;TZID=/freeassociation.sourceforge.net/Europe/Berlin:20210515T100000');
});
+
+ it('should handle floating time events (no TZID) by interpreting in user timezone', () => {
+ // Setup: Register Europe/London and Europe/Berlin timezones
+ const londonTzData = `BEGIN:VTIMEZONE
+TZID:Europe/London
+X-LIC-LOCATION:Europe/London
+BEGIN:STANDARD
+TZNAME:GMT
+DTSTART:19701025T020000
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+BEGIN:DAYLIGHT
+TZNAME:BST
+DTSTART:19810329T010000
+TZOFFSETFROM:+0000
+TZOFFSETTO:+0100
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+END:VTIMEZONE`;
+
+ const berlinTzData = `BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+X-LIC-LOCATION:Europe/Berlin
+BEGIN:STANDARD
+TZNAME:CET
+DTSTART:19701025T020000
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+BEGIN:DAYLIGHT
+TZNAME:CEST
+DTSTART:19810329T010000
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+END:VTIMEZONE`;
+
+ // Register timezones
+ const londonComponent = new ICAL.Component(ICAL.parse(londonTzData));
+ const londonTz = new ICAL.Timezone({
+ tzid: londonComponent.getFirstPropertyValue('tzid'),
+ component: londonComponent
+ });
+ ICAL.TimezoneService.register(londonTz.tzid, londonTz);
+
+ const berlinComponent = new ICAL.Component(ICAL.parse(berlinTzData));
+ const berlinTz = new ICAL.Timezone({
+ tzid: berlinComponent.getFirstPropertyValue('tzid'),
+ component: berlinComponent
+ });
+ ICAL.TimezoneService.register(berlinTz.tzid, berlinTz);
+
+ // Floating time (no TZID) - interpreted in calendar's timezone
+ const floatingIcalEvent = new ICAL.Event(new ICAL.Component(['vevent', [
+ ['dtstart', {}, 'date-time', '2026-03-24T16:00:00'],
+ ['dtend', {}, 'date-time', '2026-03-24T17:00:00'],
+ ['summary', {}, 'text', 'Floating event at 4pm'],
+ ]]));
+ const floatingEvent = new RunboxCalendarEvent(
+ 'testcal/floating',
+ floatingIcalEvent,
+ floatingIcalEvent.startDate,
+ floatingIcalEvent.endDate,
+ 'Europe/London'
+ );
+
+ // 16:00 floating with calendar tz London (UTC+0) = 16:00 UTC
+ expect(floatingEvent.start.getUTCHours()).toBe(16, 'Floating time 16:00 London should be 16:00 UTC');
+
+ // Same floating time for Berlin user - interpreted as Berlin time (UTC+1) = 15:00 UTC
+ const floatingBerlinIcalEvent = new ICAL.Event(new ICAL.Component(['vevent', [
+ ['dtstart', {}, 'date-time', '2026-03-24T16:00:00'],
+ ['dtend', {}, 'date-time', '2026-03-24T17:00:00'],
+ ['summary', {}, 'text', 'Floating event at 4pm (Berlin user)'],
+ ]]));
+ const floatingEventBerlin = new RunboxCalendarEvent(
+ 'testcal/floating-berlin',
+ floatingBerlinIcalEvent,
+ floatingBerlinIcalEvent.startDate,
+ floatingBerlinIcalEvent.endDate,
+ 'Europe/Berlin'
+ );
+
+ expect(floatingEventBerlin.start.getUTCHours()).toBe(15, 'Floating time 16:00 Berlin should be 15:00 UTC');
+ });
+
+ // Helper to create and register a timezone from standard offsets
+ function ensureTimezone(tzid: string, stdOffset: number, dstOffset: number) {
+ if (ICAL.TimezoneService.has(tzid)) { return; }
+ const fmt = (n: number) => (n >= 0 ? '+' : '-') + String(Math.abs(n)).padStart(2, '0');
+ const std = fmt(stdOffset);
+ const dst = fmt(dstOffset);
+ const tzData = `BEGIN:VTIMEZONE
+TZID:${tzid}
+BEGIN:STANDARD
+DTSTART:19701025T020000
+TZOFFSETFROM:${dst}00
+TZOFFSETTO:${std}00
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19810329T010000
+TZOFFSETFROM:${std}00
+TZOFFSETTO:${dst}00
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+END:VTIMEZONE`;
+ const comp = new ICAL.Component(ICAL.parse(tzData));
+ const tz = new ICAL.Timezone({ tzid, component: comp });
+ ICAL.TimezoneService.register(tz.tzid, tz);
+ }
+
+ it('should correctly convert London TZID event to Berlin user timezone', () => {
+ // Simulates an issue: 4pm London event shown as 3pm (wrong) instead of 5pm (correct)
+ ensureTimezone('Europe/London', 0, 1); // GMT/BST
+ ensureTimezone('Europe/Berlin', 1, 2); // CET/CEST
+
+ // Create event with TZID=Europe/London at 4pm (March = GMT = UTC+0)
+ const vevent = new ICAL.Component(['vevent', [
+ ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'],
+ ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'],
+ ['summary', {}, 'text', 'Meeting at 4pm London'],
+ ]]);
+ const dtstartProp = vevent.getFirstProperty('dtstart');
+
+ const londonEvent = new RunboxCalendarEvent(
+ 'testcal/lon',
+ new ICAL.Event(vevent),
+ ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp),
+ ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp),
+ 'Europe/London'
+ );
+ expect(londonEvent.start.getUTCHours()).toBe(16, '4pm London = 16:00 UTC');
+
+ const berlinUserEvent = new RunboxCalendarEvent(
+ 'testcal/ber',
+ new ICAL.Event(vevent),
+ ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp),
+ ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp),
+ 'Europe/Berlin'
+ );
+ // 4pm London (GMT+0) = 4pm UTC = 5pm Berlin (CET+1) when displayed
+ // The UTC time must remain 16:00Z - angular-calendar displays in browser's local tz
+ expect(berlinUserEvent.start.getUTCHours()).toBe(16, 'Same event = same UTC time (16:00Z)');
+ });
+
+ it('should handle event with TZID but timezone NOT registered', () => {
+ // Bug: Event has TZID=Europe/London but London isn't in TimezoneService
+ // Fix: Preserve local time values instead of misinterpreting as floating
+ ensureTimezone('Europe/Berlin', 1, 2);
+
+ const vevent = new ICAL.Component(['vevent', [
+ ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'],
+ ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'],
+ ['summary', {}, 'text', 'Meeting at 4pm London'],
+ ]]);
+ const dtstartProp = vevent.getFirstProperty('dtstart');
+ const dtstart = ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp);
+ const dtend = ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp);
+
+ const event = new RunboxCalendarEvent(
+ 'testcal/bug',
+ new ICAL.Event(vevent),
+ dtstart,
+ dtend,
+ 'Europe/Berlin'
+ );
+
+ expect(event.start.getUTCHours()).toBe(16, '4pm London should be 16:00 UTC even if London not registered');
+ });
+
+ it('should display 4pm London event as 5pm for Berlin user', () => {
+ // Bug: 4pm London showed as 3pm Berlin instead of 5pm
+ ensureTimezone('Europe/London', 0, 1);
+ ensureTimezone('Europe/Berlin', 1, 2);
+
+ const vevent = new ICAL.Component(['vevent', [
+ ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'],
+ ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'],
+ ['summary', {}, 'text', 'Meeting at 4pm London'],
+ ]]);
+ const dtstartProp = vevent.getFirstProperty('dtstart');
+ const dtstart = ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp);
+ const dtend = ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp);
+
+ const event = new RunboxCalendarEvent(
+ 'testcal/berlin-user',
+ new ICAL.Event(vevent),
+ dtstart,
+ dtend,
+ 'Europe/Berlin'
+ );
+
+ // Month view: event.start.getUTCHours()
+ expect(event.start.getUTCHours()).toBe(16, '4pm London = 16:00 UTC');
+ // Event card: event.dtstart (moment with timezone)
+ expect(event.dtstart.hour()).toBe(17, '4pm London displays as 5pm (17:00) in Berlin');
+ });
+
+ it('should handle TZID path mismatch between event and user timezone', () => {
+ // User's tz may be registered with full path (e.g., /citadel.org/.../Europe/Berlin)
+ // while event uses simple TZID (e.g., Europe/London)
+ ensureTimezone('/citadel.org/20210210_1/Europe/Berlin', 1, 2);
+ ensureTimezone('Europe/Berlin', 1, 2);
+ ensureTimezone('Europe/London', 0, 1);
+
+ const vevent = new ICAL.Component(['vevent', [
+ ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'],
+ ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'],
+ ['summary', {}, 'text', 'Meeting at 4pm London'],
+ ]]);
+ const dtstartProp = vevent.getFirstProperty('dtstart');
+ const dtstart = ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp);
+
+ const event = new RunboxCalendarEvent(
+ 'testcal/tzpath',
+ new ICAL.Event(vevent),
+ dtstart,
+ ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp),
+ '/citadel.org/20210210_1/Europe/Berlin'
+ );
+
+ expect(event.start.getUTCHours()).toBe(16, '4pm London should be 16:00 UTC');
+
+ const eventSimple = new RunboxCalendarEvent(
+ 'testcal/tzsimple',
+ new ICAL.Event(vevent),
+ ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp),
+ ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp),
+ 'Europe/Berlin'
+ );
+ expect(eventSimple.start.getUTCHours()).toBe(16, 'Simple tz name should also work');
+ });
+
+ it('should display all-day event on correct day with citadel-path timezone', () => {
+ // Bug: All-day events with citadel-path TZID display on previous day
+ // because toJSDate() shifts midnight CEST to 22:00 UTC previous day
+ ensureTimezone('/citadel.org/20210210_1/Europe/Oslo', 1, 2);
+
+ const icalData = `BEGIN:VCALENDAR
+BEGIN:VTIMEZONE
+TZID:/citadel.org/20210210_1/Europe/Oslo
+BEGIN:STANDARD
+DTSTART:19701025T020000
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19810329T010000
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+UID:allday-citadel-test
+DTSTART;VALUE=DATE:20260429
+DTEND;VALUE=DATE:20260430
+SUMMARY:Citadel All-Day Event
+END:VEVENT
+END:VCALENDAR`;
+
+ const jcal = ICAL.parse(icalData);
+ const ical = new ICAL.Component(jcal);
+
+ for (const tzComponent of ical.getAllSubcomponents('vtimezone')) {
+ const tz = new ICAL.Timezone({
+ tzid: tzComponent.getFirstPropertyValue('tzid'),
+ component: tzComponent,
+ });
+ if (!ICAL.TimezoneService.has(tz.tzid)) {
+ ICAL.TimezoneService.register(tz.tzid, tz);
+ }
+ }
+
+ const vevent = ical.getFirstSubcomponent('vevent');
+ const event = new RunboxCalendarEvent(
+ 'testcal/allday-citadel',
+ new ICAL.Event(vevent),
+ ICAL.Time.fromDateString('2026-04-29'),
+ ICAL.Time.fromDateString('2026-04-30'),
+ '/citadel.org/20210210_1/Europe/Oslo'
+ );
+
+ expect(event.allDay).toBe(true, 'event should be all-day');
+ expect(event.start.getDate()).toBe(29,
+ 'All-day event on April 29 should display on day 29 even with citadel-path TZ');
+ });
+
+ it('should preserve time when event timezone is not registered', () => {
+ // Bug: When event TZID is not in TimezoneService, time shifts incorrectly
+ ensureTimezone('Europe/Berlin', 1, 2);
+ // Europe/Oslo is NOT registered
+
+ const vevent = new ICAL.Component(['vevent', [
+ ['dtstart', { tzid: 'Europe/Oslo' }, 'date-time', '2026-04-29T12:00:00'],
+ ['dtend', { tzid: 'Europe/Oslo' }, 'date-time', '2026-04-29T13:00:00'],
+ ['summary', {}, 'text', 'Oslo Noon (unresolved TZ)'],
+ ]]);
+ const dtstartProp = vevent.getFirstProperty('dtstart');
+ const dtstart = ICAL.Time.fromDateTimeString('2026-04-29T12:00:00', dtstartProp);
+ const dtend = ICAL.Time.fromDateTimeString('2026-04-29T13:00:00', dtstartProp);
+
+ const event = new RunboxCalendarEvent(
+ 'testcal/unresolved-tz',
+ new ICAL.Event(vevent),
+ dtstart,
+ dtend,
+ 'Europe/Berlin'
+ );
+
+ // When TZID is unresolvable, fallback should preserve the local time as UTC
+ // 12:00 Oslo (unresolved) → 12:00 UTC (fallback, not 10:00 UTC which would be correct)
+ expect(event.start.getUTCHours()).toBe(12,
+ 'Unresolved TZ should preserve local time as UTC');
+ });
+
+ // Reproduction tests for staging feedback (PR #1779)
+
+ it('should round-trip entered time correctly when creating event via updateEvent', () => {
+ // Bug 1 (staging feedback): User in CET browser, account tz = UK (London)
+ // User enters 12:00, event shows as 13:00 when reopening edit dialog.
+ //
+ // The model round-trip is actually correct: the ICAL data stores 12:00 London
+ // and dtstart.hour() returns 12. The bug is in EventEditorDialogComponent which
+ // uses bare Date objects (displayed in browser local time).
+ ensureTimezone('Europe/London', 0, 1);
+
+ const event = RunboxCalendarEvent.newEmpty('Europe/London');
+ const startMoment = moment('2026-04-14T12:00:00').seconds(0).milliseconds(0);
+ const endMoment = startMoment.clone().add(1, 'hour');
+
+ event.updateEvent(
+ startMoment, endMoment, false, 'test-cal',
+ RecurSaveType.ALL_OCCURENCES, 'Test Event', '', '',
+ false, '', 0, [], [], []
+ );
+
+ // dtstart (timezone-aware moment) should show 12:00 in London
+ expect(event.dtstart.hour()).toBe(12,
+ 'dtstart should show 12:00 in account timezone (London)');
+ // April = BST (UTC+1), so 12:00 London = 11:00 UTC
+ expect(event.start.toISOString()).toBe('2026-04-14T11:00:00.000Z',
+ 'start Date should be 11:00 UTC (12:00 BST)');
+ });
+
+ it('should round-trip 12pm timed event with Oslo/citadel-path timezone', () => {
+ // Production TZID is a citadel path — moment-timezone can't resolve it
+ ensureTimezone('/citadel.org/20210210_1/Europe/Oslo', 1, 2);
+
+ const event = RunboxCalendarEvent.newEmpty('/citadel.org/20210210_1/Europe/Oslo');
+ const startMoment = moment('2026-04-29T12:00:00').seconds(0).milliseconds(0);
+ const endMoment = startMoment.clone().add(1, 'hour');
+
+ event.updateEvent(
+ startMoment, endMoment, false, 'test-cal',
+ RecurSaveType.ALL_OCCURENCES, 'Oslo Noon Event', '', '',
+ false, '', 0, [], [], []
+ );
+
+ // April 29 = CEST (UTC+2), so 12:00 CEST = 10:00 UTC
+ expect(event.dtstart.hour()).toBe(12,
+ 'dtstart should show 12:00 in account timezone (Oslo)');
+ expect(event.start.toISOString()).toBe('2026-04-29T10:00:00.000Z',
+ 'start Date should be 10:00 UTC (12:00 CEST)');
+ });
+
+ it('should store exception at user-entered time when event tz differs from account tz', () => {
+ // Bug 2 (staging feedback): Recurring event at 09:00 Berlin, account tz = London.
+ // User edits one occurrence from 09:00 to 10:00 (shown in browser local time).
+ // momentToIcalTime tags the time with account tz (London), then converts to
+ // event tz (Berlin) — this double-conversion shifts the time by the London↔Berlin offset.
+ ensureTimezone('Europe/London', 0, 1);
+ ensureTimezone('/freeassociation.sourceforge.net/Europe/Berlin', 1, 2);
+
+ const jcal = ICAL.parse(
+`BEGIN:VCALENDAR
+CALSCALE:GREGORIAN
+PRODID:-//Test//Test//EN
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:/freeassociation.sourceforge.net/Europe/Berlin
+X-LIC-LOCATION:Europe/Berlin
+BEGIN:DAYLIGHT
+TZNAME:CEST
+DTSTART:19810328T020000
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
+END:DAYLIGHT
+BEGIN:STANDARD
+TZNAME:CET
+DTSTART:19961031T030000
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+UID:bug2-recurring-test
+DTSTAMP:20210511T111559Z
+DTSTART;TZID=/freeassociation.sourceforge.net/Europe/Berlin:
+ 20210514T090000
+DTEND;TZID=/freeassociation.sourceforge.net/Europe/Berlin:
+ 20210514T100000
+SUMMARY:Daily Berlin 9am
+RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5
+END:VEVENT
+END:VCALENDAR`
+ );
+ const ical = new ICAL.Component(jcal);
+ for (const tzComponent of ical.getAllSubcomponents('vtimezone')) {
+ const tz = new ICAL.Timezone({
+ tzid: tzComponent.getFirstPropertyValue('tzid'),
+ component: tzComponent,
+ });
+ if (!ICAL.TimezoneService.has(tz.tzid)) {
+ ICAL.TimezoneService.register(tz.tzid, tz);
+ }
+ }
+ const vevent = ical.getFirstSubcomponent('vevent');
+ const dtstartProp = vevent.getFirstProperty('dtstart');
+
+ // Day 2 instance (May 15 at 09:00 Berlin)
+ const sut = new RunboxCalendarEvent(
+ 'testcal/bug2',
+ new ICAL.Event(vevent),
+ ICAL.Time.fromDateTimeString('2021-05-15T09:00:00', dtstartProp),
+ ICAL.Time.fromDateTimeString('2021-05-15T10:00:00', dtstartProp),
+ 'Europe/London' // account tz = London (differs from event tz = Berlin)
+ );
+
+ // Verify original: 09:00 Berlin (CEST=UTC+2) = 07:00 UTC
+ expect(sut.start.toISOString()).toBe('2021-05-15T07:00:00.000Z',
+ 'Original: 09:00 Berlin CEST = 07:00 UTC');
+
+ // User edits this occurrence from 09:00 to 10:00 in dialog
+ // Use moment.tz to create a deterministic time in the event's timezone,
+ // simulating what the dialog would pass after user edits the time.
+ // momentToIcalTime now converts via UTC, so this produces the correct
+ // 10:00 Berlin regardless of the test machine's local timezone.
+ const newStart = moment.tz('2021-05-15T10:00:00', 'Europe/Berlin').seconds(0).milliseconds(0);
+ const newEnd = moment.tz('2021-05-15T11:00:00', 'Europe/Berlin').seconds(0).milliseconds(0);
+ sut.updateEvent(
+ newStart, newEnd, false, sut.calendar,
+ RecurSaveType.THIS_ONLY,
+ 'Moved to 10am', '', '',
+ true, sut.recurringFrequency, sut.recurInterval,
+ [], [], []
+ );
+
+ // The exception should exist in the ICAL data
+ expect(sut.toIcal()).toContain('RECURRENCE-ID');
+ expect(sut.toIcal()).toContain('Moved to 10am');
+
+ // The exception DTSTART should be 10:00 Berlin (user entered 10:00)
+ // NOT 11:00 Berlin (which would result from London→Berlin double-conversion)
+ // 10:00 Berlin CEST (UTC+2) = 08:00 UTC
+ expect(sut.toIcal()).toContain('DTSTART;TZID=/freeassociation.sourceforge.net/Europe/Berlin:20210515T100000',
+ 'Exception should be at 10:00 Berlin (user-entered time)');
+ });
+
+ it('should reflect updated display timezone when timezone property changes', () => {
+ // Bug 3 (staging feedback): After changing account timezone, event times
+ // don't update. The timezone property on existing RunboxCalendarEvent instances
+ // is set once at construction and never refreshed.
+ ensureTimezone('Europe/London', 0, 1);
+ ensureTimezone('Europe/Berlin', 1, 2);
+
+ // Event stored as 16:00 London (March = GMT = UTC+0)
+ const vevent = new ICAL.Component(['vevent', [
+ ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'],
+ ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'],
+ ['summary', {}, 'text', '4pm London'],
+ ]]);
+ const dtstartProp = vevent.getFirstProperty('dtstart');
+ const event = new RunboxCalendarEvent(
+ 'test/tzchange',
+ new ICAL.Event(vevent),
+ ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp),
+ ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp),
+ 'Europe/London'
+ );
+
+ // Initially: 4pm London (GMT = UTC+0 in March) = 16:00 UTC
+ expect(event.start.getUTCHours()).toBe(16);
+ expect(event.dtstart.hour()).toBe(16, '4pm London shows as 16:00 in London');
+
+ // User changes account timezone to Berlin
+ event.timezone = 'Europe/Berlin';
+
+ // dtstart moment should now show Berlin time
+ // 16:00 UTC = 17:00 CET (UTC+1 in March)
+ expect(event.dtstart.hour()).toBe(17,
+ 'After tz change to Berlin, 4pm London should display as 5pm (17:00)');
+
+ // start Date remains the same UTC time (Date is always UTC)
+ expect(event.start.getUTCHours()).toBe(16,
+ 'start Date UTC time should not change (16:00 UTC)');
+ });
+
+ it('should display UTC event at correct hour for negative-offset account', () => {
+ // Exercises the via-UTC path in momentToIcalTime with a negative offset (America/New_York)
+ ensureTimezone('America/New_York', -5, -4); // EST / EDT
+
+ const event = RunboxCalendarEvent.newEmpty('America/New_York');
+ // July 15 = EDT (UTC-4), so 13:00 EDT = 17:00 UTC
+ const startMoment = moment('2026-07-15T13:00:00').seconds(0).milliseconds(0);
+ const endMoment = moment('2026-07-15T14:00:00').seconds(0).milliseconds(0);
+
+ event.updateEvent(
+ startMoment, endMoment, false, 'test-cal',
+ RecurSaveType.ALL_OCCURENCES, 'NY Afternoon Event', '', '',
+ false, '', 0, [], [], []
+ );
+
+ // 13:00 EDT (UTC-4) = 17:00 UTC
+ expect(event.start.toISOString()).toBe('2026-07-15T17:00:00.000Z',
+ '1pm New York EDT should be 17:00 UTC');
+ // dtstart moment should show 13:00 in New York
+ expect(event.dtstart.hour()).toBe(13,
+ 'dtstart should show 13:00 in account timezone (New York)');
+ });
});
diff --git a/src/app/calendar-app/runbox-calendar-event.ts b/src/app/calendar-app/runbox-calendar-event.ts
index 81da09446..43009785d 100644
--- a/src/app/calendar-app/runbox-calendar-event.ts
+++ b/src/app/calendar-app/runbox-calendar-event.ts
@@ -49,20 +49,20 @@ export class RunboxCalendarEvent implements CalendarEvent {
// start and end are for display pursposes only,
// and will be different from dtstart/dtend in
// recurring events
- _calendar: string;
+ _calendar!: string;
_old_id?: string;
ical: ICAL.Component;
event: ICAL.Event;
// *display* start/end - for reccurrences will not match the ICAL.Event
- private _dtstart: ICAL.Time;
- private _dtend: ICAL.Time;
+ private _dtstart!: ICAL.Time;
+ private _dtend!: ICAL.Time;
// store user's selection of allDay setting while updating the event
// required cos neither Moment nor Date have a "date only" functionality
// ICAL.Time does tho!
- private _allDay: boolean;
+ private _allDay!: boolean;
get calendar(): string {
return this._calendar;
@@ -114,7 +114,7 @@ export class RunboxCalendarEvent implements CalendarEvent {
set dtstart(value: moment.Moment) {
// check this before we update:
if (this._dtstart.toJSDate().toString() === this.recurStart.toString()) {
- this._dtstart = this.momentToIcalTime(value, this.event.startDate ? this.event.startDate.zone : null);
+ this._dtstart = this.momentToIcalTime(value, this.event.startDate ? this.event.startDate.zone : ICAL.Timezone.utcTimezone);
this.event.startDate = this._dtstart;
}
}
@@ -132,7 +132,7 @@ export class RunboxCalendarEvent implements CalendarEvent {
// one with a time? (as a side effect that is)
set dtend(value: moment.Moment) {
if (value && this._dtstart.toJSDate().toString() === this.recurStart.toString()) {
- this._dtend = this.momentToIcalTime(value, this.event.endDate ? this.event.endDate.zone : undefined);
+ this._dtend = this.momentToIcalTime(value, this.event.endDate ? this.event.endDate.zone : ICAL.Timezone.utcTimezone);
this.event.endDate = this._dtend;
}
}
@@ -140,39 +140,112 @@ export class RunboxCalendarEvent implements CalendarEvent {
// angular-calendar compatibility
get start(): Date {
- // This needs to be converted *from* tz the ical data is in
- // *to* the tz the user's calendar display is in (this.timezone?)
- let user_dtstart = this._dtstart;
- // can't convert items with no tz set, so assume default (utc)
- if (this._dtstart.zone) {
- // console.log('start: convert from: ' + user_dtstart.zone.tzid);
- // console.log('offset: ' + user_dtstart.zone.utcOffset(user_dtstart));
- // console.log('start: convert to : ' + this.timezone);
- // console.log('have timezone? : ' + ICAL.TimezoneService.has(this.timezone));
- // console.log('offset: ' + ICAL.TimezoneService.get(this.timezone).utcOffset(user_dtstart));
- user_dtstart = this._dtstart.convertToZone(ICAL.TimezoneService.get(this.timezone));
- }
-
- return new Date(user_dtstart.toString());
+ return this.convertIcalTimeToDate(this._dtstart, 'dtstart');
}
get end(): Date {
if (!this._dtend) {
return undefined;
}
-
- let shownEnd = this._dtend.clone();
// ICAL event DTEND is exclusive, angular-calendar is inclusive
if (this.allDay) {
- shownEnd.addDuration(new ICAL.Duration({'isNegative': true, 'days': 1}));
- } else {
- shownEnd.addDuration(new ICAL.Duration({'isNegative': true, 'seconds': 1}));
+ // Subtract 1 day at the Date level to avoid ICAL.js clone/normalize subtleties.
+ // Uses same noon-UTC pattern as convertIcalTimeToDate — keep in sync.
+ return new Date(Date.UTC(
+ this._dtend.year,
+ this._dtend.month - 1,
+ this._dtend.day - 1,
+ 12, 0, 0
+ ));
}
- if (shownEnd.zone) {
- shownEnd = shownEnd.convertToZone(ICAL.TimezoneService.get(this.timezone));
+ const shownEnd = this._dtend.clone();
+ shownEnd.addDuration(new ICAL.Duration({'isNegative': true, 'seconds': 1}));
+ return this.convertIcalTimeToDate(shownEnd, 'dtend');
+ }
+
+ /**
+ * Convert an ICAL.Time to a JavaScript Date with proper timezone handling.
+ *
+ * Handles three cases:
+ * 1. Proper timezone (has VTIMEZONE data or UTC) → convert to user's display timezone
+ * 2. True floating time (no TZID in property) → interpret in calendar's timezone
+ * 3. Unresolved TZID (has TZID but not found) → preserve local time values as UTC
+ */
+ private convertIcalTimeToDate(time: ICAL.Time, propName: 'dtstart' | 'dtend'): Date {
+ if (time.isDate) {
+ // All-day dates must display on the same calendar date regardless of timezone.
+ // Use noon UTC so getDate() returns the correct day in every timezone.
+ return new Date(Date.UTC(time.year, time.month - 1, time.day, 12, 0, 0));
+ }
+
+ const zone = time.zone;
+ // Check for proper timezone with VTIMEZONE data or UTC
+ const hasProperTimezone = zone &&
+ typeof zone === 'object' &&
+ zone.tzid &&
+ zone.tzid !== 'floating' &&
+ (zone.component || zone.tzid === 'UTC');
+
+ if (hasProperTimezone) {
+ const targetTz = this.getAccountTimezone();
+ const converted = targetTz ? time.convertToZone(targetTz) : time;
+ return converted.toJSDate();
+ }
+
+ // Check if the property had a TZID parameter to differentiate floating vs unresolved
+ const prop = this.event.component.getFirstProperty(propName);
+ const hasTzidParam = prop && prop.getParameter('tzid');
+
+ if (!hasTzidParam) {
+ // True floating time - interpret in calendar's timezone
+ // First try ICAL.TimezoneService (for non-standard paths with VTIMEZONE data)
+ const calendarTz = this.getAccountTimezone();
+
+ if (calendarTz) {
+ // Create time directly in calendar's timezone, then convert to UTC via toJSDate()
+ const localTime = new ICAL.Time({
+ year: time.year,
+ month: time.month,
+ day: time.day,
+ hour: time.hour,
+ minute: time.minute,
+ second: time.second
+ }, calendarTz);
+ return localTime.toJSDate();
+ }
+
+ // Try moment-timezone for standard IANA timezones
+ const momentZone = this.timezone ? moment.tz.zone(this.timezone) : null;
+ if (momentZone) {
+ const m = moment.tz([
+ time.year,
+ time.month - 1,
+ time.day,
+ time.hour,
+ time.minute,
+ time.second,
+ 0
+ ], this.timezone || 'UTC');
+ return m.toDate();
+ }
+
+ // Fallback: no calendar timezone available, preserve local time as UTC
+ return this.icalTimeToUTCDate(time);
}
- return new Date(shownEnd.toString());
+ // Unresolved TZID or no calendar timezone - preserve local time values
+ return this.icalTimeToUTCDate(time);
+ }
+
+ private icalTimeToUTCDate(time: ICAL.Time): Date {
+ return new Date(Date.UTC(
+ time.year,
+ time.month - 1,
+ time.day,
+ time.hour,
+ time.minute,
+ time.second
+ ));
}
set allDay(value) {
@@ -211,15 +284,14 @@ export class RunboxCalendarEvent implements CalendarEvent {
// DAILY, WEEKLY, MONTHLY etc
get recurringFrequency(): string {
- const recur = this.event.component.getFirstPropertyValue('rrule');
- return recur ? recur.freq : '';
+ return this.getRecur()?.freq || '';
}
// Only set if the new value is different from the old one
// Prevents us accidentally overwriting imported RRULE details
// -> dont display the "recurring" select box on exception events?
set recurringFrequency(frequency: string) {
- const recur = this.event.component.getFirstPropertyValue('rrule');
+ const recur = this.getRecur();
if (recur) {
recur.freq = frequency;
this.event.component.updatePropertyWithValue('rrule', recur);
@@ -230,44 +302,49 @@ export class RunboxCalendarEvent implements CalendarEvent {
// How often does this repeat (every X freqs)
get recurInterval(): number {
- const recur = this.event.component.getFirstPropertyValue('rrule');
- return recur ? recur.interval : 1;
+ return this.getRecur()?.interval || 1;
}
set recurInterval(interval: number) {
- const recur = this.event.component.getFirstPropertyValue('rrule');
- recur.interval = interval;
- this.event.component.updatePropertyWithValue('rrule', recur);
+ const recur = this.getRecur();
+ if (recur) {
+ recur.interval = interval;
+ this.event.component.updatePropertyWithValue('rrule', recur);
+ }
}
// An UNTIL date, a COUNT or null = unset/repeats forever
- get recurEnds(): Date | number {
- const recur = this.event.component.getFirstPropertyValue('rrule');
- if (recur && recur.until) {
- return recur.until.toJSDate();
- }
- if (recur && recur.count) {
- return recur.count;
+ get recurEnds(): Date | number | null {
+ const recur = this.getRecur();
+ if (recur) {
+ if (recur.until) {
+ return recur.until.toJSDate();
+ }
+ if (recur.count) {
+ return recur.count;
+ }
}
return null;
}
set recurEnds(end: Date | number) {
- const recur = this.event.component.getFirstPropertyValue('rrule');
- recur.until = null;
- recur.count = null;
- if (typeof end === 'number' && end != null) {
- recur.count = end;
- }
- if (end instanceof Date && end != null) {
- // Must be a date (cant do typeof === 'Date' !?
- const zone = this.event.startDate.zone;
- const icaltime = ICAL.Time.fromJSDate(end);
- icaltime.zone = zone;
- recur.until = icaltime;
+ const recur = this.getRecur();
+ if (recur) {
+ recur.until = null;
+ recur.count = null;
+ if (typeof end === 'number') {
+ recur.count = end;
+ }
+ if (end instanceof Date) {
+ // Must be a date (cant do typeof === 'Date' !?
+ const zone = this.event.startDate.zone;
+ const icaltime = ICAL.Time.fromJSDate(end);
+ icaltime.zone = zone;
+ recur.until = icaltime;
+ }
+ // else, everything null, repeats forever.
+ this.event.component.updatePropertyWithValue('rrule', recur);
}
- // else, everything null, repeats forever.
- this.event.component.updatePropertyWithValue('rrule', recur);
}
// get "part"s, eg byday, bymonthday, bymonth, byyearday, byweekno
@@ -275,72 +352,80 @@ export class RunboxCalendarEvent implements CalendarEvent {
// One or more days of the week this rule could run on (or none)
// SU, MO etc (or can convert to days of week using .icalDayToNumericDay
// if a monthly BYDAY, could be "\+?1SU" or "-2TU" etc
- get recursByDay(): string[] {
- const recur = this.event.component.getFirstPropertyValue('rrule');
+ get recursByDay(): { day: string; numth: string }[] {
+ const recur = this.getRecur();
if (!recur) {
return [];
}
const bydays = recur.getComponent('byday');
const rgx = /^([+-]?\d+)?(\w{2})$/;
- const bydays_mapped = bydays.map((day) => {
+ const bydays_mapped = bydays.map((day: string) => {
const match = day.match(rgx);
- const numth = match[1] ? match[1] : '0';
- return { 'day': match[2], 'numth': numth };
+ const numth = match && match[1] ? match[1] : '0';
+ return { 'day': match && match[2] ? match[2] : '', 'numth': numth };
});
return bydays_mapped;
}
// replace entire list of day(s)
set recursByDay(value: string[]) {
- const recur = this.event.component.getFirstPropertyValue('rrule');
- recur.setComponent('byday', value);
- this.event.component.updatePropertyWithValue('rrule', recur);
+ const recur = this.getRecur();
+ if (recur) {
+ recur.setComponent('byday', value);
+ this.event.component.updatePropertyWithValue('rrule', recur);
+ }
}
get recursByMonthDay(): string[] {
- const recur = this.event.component.getFirstPropertyValue('rrule');
+ const recur = this.getRecur();
if (!recur) {
return [];
}
const bymonthdays = recur.getComponent('bymonthday');
- return bymonthdays.map((day) => day.toString());
+ return bymonthdays.map((day: string) => day.toString());
}
set recursByMonthDay(value: string[]) {
- const recur = this.event.component.getFirstPropertyValue('rrule');
- recur.setComponent('bymonthday', value);
- this.event.component.updatePropertyWithValue('rrule', recur);
+ const recur = this.getRecur();
+ if (recur) {
+ recur.setComponent('bymonthday', value);
+ this.event.component.updatePropertyWithValue('rrule', recur);
+ }
}
get recursByYearDay(): string[] {
- const recur = this.event.component.getFirstPropertyValue('rrule');
+ const recur = this.getRecur();
if (!recur) {
return [];
}
const byyeardays = recur.getComponent('byyearday');
- return byyeardays.map((day) => day.toString());
+ return byyeardays.map((day: string) => day.toString());
}
set recursByYearDay(value: string[]) {
- const recur = this.event.component.getFirstPropertyValue('rrule');
- recur.setComponent('byyearday', value);
- this.event.component.updatePropertyWithValue('rrule', recur);
+ const recur = this.getRecur();
+ if (recur) {
+ recur.setComponent('byyearday', value);
+ this.event.component.updatePropertyWithValue('rrule', recur);
+ }
}
// Jan-Dec numbered 1-12
get recursByMonth(): string[] {
- const recur = this.event.component.getFirstPropertyValue('rrule');
+ const recur = this.getRecur();
if (!recur) {
return [];
}
const bymonth = recur.getComponent('bymonth');
- return bymonth.map((month) => month.toString());
+ return bymonth.map((month: string) => month.toString());
}
set recursByMonth(value: string[]) {
- const recur = this.event.component.getFirstPropertyValue('rrule');
- recur.setComponent('bymonth', value);
- this.event.component.updatePropertyWithValue('rrule', recur);
+ const recur = this.getRecur();
+ if (recur) {
+ recur.setComponent('bymonth', value);
+ this.event.component.updatePropertyWithValue('rrule', recur);
+ }
}
get isException(): boolean {
@@ -398,30 +483,34 @@ export class RunboxCalendarEvent implements CalendarEvent {
thisandfuture: boolean,
title: string,
description: string,
- location: string): boolean {
+ location: string): boolean | undefined {
if (this.isException) {
// This shouldnt be possible
console.log('Refusing to create an exception of an exception');
- return;
+ return false;
}
// clone existing one
const new_exception = new ICAL.Event(ICAL.Component.fromString(this.event.toString()));
new_exception.component.removeProperty('rrule');
const recurrence_id = origdate;
- const new_start = this.momentToIcalTime(startdate, this.event.startDate.zone);
- let new_end;
+ const new_start = this.momentToIcalTime(startdate, this.event.startDate.zone || ICAL.Timezone.utcTimezone);
+ let new_end: ICAL.Time | undefined;
if (enddate) {
- new_end = this.momentToIcalTime(enddate, this.event.startDate.zone);
+ new_end = this.momentToIcalTime(enddate, this.event.startDate.zone || ICAL.Timezone.utcTimezone);
}
if (this._dtstart.isDate) {
recurrence_id.isDate = true;
new_start.isDate = true;
- new_end.isDate = true;
+ if (new_end) {
+ new_end.isDate = true;
+ }
}
new_exception.recurrenceId = recurrence_id;
if (thisandfuture) {
const rId = new_exception.component.getFirstProperty('recurrence-id');
- rId.setParameter('range', 'THISANDFUTURE');
+ if (rId) {
+ rId.setParameter('range', 'THISANDFUTURE');
+ }
}
new_exception.startDate = new_start;
if (new_end) {
@@ -543,28 +632,28 @@ export class RunboxCalendarEvent implements CalendarEvent {
get_overview(): EventOverview[] {
const events = [];
- const seen = {};
+ const seen: { [key: string]: boolean } = {};
- for (let e of this.ical.getAllSubcomponents('vevent')) {
- e = new ICAL.Event(e);
+ for (const component of this.ical.getAllSubcomponents('vevent')) {
+ const event = new ICAL.Event(component);
// Skip duplicate uids (if defined),
// to eliminate possible special cases in recurring events.
- if (e.uid && seen[e.uid]) {
+ if (event.uid && seen[event.uid]) {
continue;
} else {
- seen[e.uid] = true;
+ seen[event.uid] = true;
}
- const rrule = e.component.getFirstPropertyValue('rrule');
+ const rrule = event.component.getFirstPropertyValue('rrule');
events.push(new EventOverview(
- e.summary,
- this.icalTimeToMoment(e.startDate),
- e.endDate ? this.icalTimeToMoment(e.endDate) : undefined,
- rrule ? rrule.freq : undefined,
- e.location,
- e.description,
+ event.summary,
+ this.icalTimeToMoment(event.startDate),
+ event.endDate ? this.icalTimeToMoment(event.endDate) : undefined,
+ (rrule instanceof ICAL.Recur) ? rrule.freq : undefined,
+ event.location,
+ event.description,
));
}
@@ -584,14 +673,58 @@ export class RunboxCalendarEvent implements CalendarEvent {
return moment(time.toString());
} else {
// Assemble a moment with the UTC Date() and the user's timezone
- let my_timezone = time.zone && time.zone.component ? time.zone.component.getFirstPropertyValue('x-lic-location') : null;
- my_timezone = my_timezone || this.timezone || moment.tz.guess();
+ let my_timezone: string | null = time.zone && time.zone.component ? time.zone.component.getFirstPropertyValue('x-lic-location') as string : null;
+ my_timezone = my_timezone && moment.tz.zone(my_timezone)
+ ? my_timezone
+ : this.resolveTimezoneName();
const m = moment(time.toJSDate()).tz(my_timezone);
return m;
}
}
- private momentToIcalTime(input: moment.Moment, zone: ICAL.Timezone): ICAL.Time {
+ /** Resolve a usable IANA timezone name for moment-timezone. */
+ private resolveTimezoneName(): string {
+ if (this.timezone && moment.tz.zone(this.timezone)) {
+ return this.timezone;
+ }
+ // Extract IANA from path-style TZIDs: /citadel.org/.../Europe/Oslo → Europe/Oslo
+ if (this.timezone && this.timezone.includes('/')) {
+ const iana = this.timezone.split('/').slice(-2).join('/');
+ if (moment.tz.zone(iana)) {
+ return iana;
+ }
+ }
+ return moment.tz.guess();
+ }
+
+ /** Safely get the RRULE as an ICAL.Recur, or null if missing/not a Recur instance. */
+ private getRecur(): ICAL.Recur | null {
+ const recur = this.event.component.getFirstPropertyValue('rrule');
+ return (recur && recur instanceof ICAL.Recur) ? recur : null;
+ }
+
+ /** Get the account timezone from ICAL.TimezoneService, or null if unset/unregistered. */
+ private getAccountTimezone(): ICAL.Timezone | null {
+ return this.timezone ? ICAL.TimezoneService.get(this.timezone) : null;
+ }
+
+ private momentToIcalTime(input: moment.Moment, zone: ICAL.Timezone | null | undefined): ICAL.Time {
+ if (this._allDay) {
+ // All-day events: use local date parts to preserve the calendar date
+ // regardless of the UTC offset. Midnight local May 1 must store day=1,
+ // not day=30 (which would result from extracting UTC date parts).
+ const d = input.toDate();
+ const ical_time = ICAL.Time.fromJSDate(d);
+ ical_time.isDate = true;
+ const accountTz = this.getAccountTimezone();
+ if (accountTz) {
+ ical_time.zone = accountTz;
+ if (!this.ical.getFirstSubcomponent('vtimezone')) {
+ this.ical.addSubcomponent(accountTz.component);
+ }
+ }
+ return ical_time;
+ }
// No supplied tz = new, or original didnt have one:
// (Is it legit to have dates with tzs and without in same ical?)
if (!zone || zone.tzid === 'floating') {
@@ -607,18 +740,20 @@ export class RunboxCalendarEvent implements CalendarEvent {
ical_time.isDate = this._allDay;
return ical_time;
}
- // input is date in user timezone, convert to target timezone
- const ical_tztime = ICAL.Time.fromJSDate(input.toDate());
- if (ICAL.TimezoneService.has(this.timezone)) {
- ical_tztime.zone = ICAL.TimezoneService.get(this.timezone);
- ical_tztime.isDate = this._allDay;
- return ical_tztime.convertToZone(zone);
- } else {
- // Hmm this (should?) only get hit if zone wasnt loaded
- // eg in tests!?
- ical_tztime.zone = zone;
- ical_tztime.isDate = this._allDay;
- return ical_tztime;
- }
+ // input is date in browser-local time, convert to target timezone via UTC.
+ // Using UTC as intermediate avoids double-conversion when browser tz
+ // differs from account tz (the moment's UTC representation is always correct).
+ const d = input.toDate();
+ const ical_tztime = new ICAL.Time({
+ year: d.getUTCFullYear(),
+ month: d.getUTCMonth() + 1,
+ day: d.getUTCDate(),
+ hour: d.getUTCHours(),
+ minute: d.getUTCMinutes(),
+ second: d.getUTCSeconds()
+ });
+ ical_tztime.zone = ICAL.Timezone.utcTimezone;
+ ical_tztime.isDate = this._allDay;
+ return ical_tztime.convertToZone(zone);
}
}
diff --git a/src/app/common/preferences.service.spec.ts b/src/app/common/preferences.service.spec.ts
index de5eac85d..49d0de4c6 100644
--- a/src/app/common/preferences.service.spec.ts
+++ b/src/app/common/preferences.service.spec.ts
@@ -19,12 +19,12 @@
import { PreferencesService, DefaultPrefGroups, PreferencesResult } from './preferences.service';
import { ScreenSize } from '../mobile-query.service';
-import { of, Subject, firstValueFrom, Observable } from 'rxjs';
+import { of, Subject, ReplaySubject, firstValueFrom, Observable } from 'rxjs';
import { take } from 'rxjs/operators';
class MockStorageService {
private store = new Map();
- me: Observable = of({ uid: 42 });
+ uid: Observable = of(42);
get(key: string): Promise {
return Promise.resolve(this.store.get(key));
@@ -241,9 +241,9 @@ describe('PreferencesService', () => {
describe('Integration with StorageService uid AsyncSubject', () => {
it('should handle operations that depend on uid being loaded', async () => {
- const uidSubject = new Subject<{ uid: number }>();
+ const uidSubject = new ReplaySubject(1);
const uidStorage = {
- me: uidSubject.asObservable(),
+ uid: uidSubject.asObservable(),
get: (key: string) => mockStorage.get(key),
set: (key: string, value: any) => mockStorage.set(key, value)
} as any;
@@ -254,7 +254,7 @@ describe('PreferencesService', () => {
testService.set(DefaultPrefGroups.Desktop, 'uidTestKey', 'uidTestValue');
// Now emit uid
- uidSubject.next({ uid: 999 });
+ uidSubject.next(999);
uidSubject.complete();
await new Promise(resolve => setTimeout(resolve, 50));
diff --git a/src/app/compose/compose.component.scss b/src/app/compose/compose.component.scss
index 3b28d9c44..99540e515 100644
--- a/src/app/compose/compose.component.scss
+++ b/src/app/compose/compose.component.scss
@@ -1,6 +1,6 @@
-@import '../../../node_modules/@angular/material/theming';
+@use '@angular/material' as mat;
-$rmm-default-accent: mat-palette($mat-blue, 100, 50, 200);
+$rmm-default-accent: mat.define-palette(mat.$blue-palette, 100, 50, 200);
#messageTextArea {
box-sizing: border-box;
diff --git a/src/app/help/help.component.spec.ts b/src/app/help/help.component.spec.ts
index 78805b537..a8de86db8 100644
--- a/src/app/help/help.component.spec.ts
+++ b/src/app/help/help.component.spec.ts
@@ -18,6 +18,7 @@
// ---------- END RUNBOX LICENSE ----------
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
import { HelpComponent } from './help.component';
@@ -27,7 +28,8 @@ describe('HelpComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
- declarations: [ HelpComponent ]
+ declarations: [ HelpComponent ],
+ schemas: [ NO_ERRORS_SCHEMA ]
})
.compileComponents();
});
diff --git a/src/app/login/login.component.html b/src/app/login/login.component.html
index c9d3d2425..bbc047927 100644
--- a/src/app/login/login.component.html
+++ b/src/app/login/login.component.html
@@ -21,7 +21,7 @@
diff --git a/src/app/mailviewer/singlemailviewer.component.spec.ts b/src/app/mailviewer/singlemailviewer.component.spec.ts
index d4bc44f93..13a60cda5 100644
--- a/src/app/mailviewer/singlemailviewer.component.spec.ts
+++ b/src/app/mailviewer/singlemailviewer.component.spec.ts
@@ -30,6 +30,7 @@ import { MatExpansionModule } from '@angular/material/expansion';
import { MatGridListModule } from '@angular/material/grid-list';
import { MatIcon, MatIconModule } from '@angular/material/icon';
import { MatIconTestingModule } from '@angular/material/icon/testing';
+import { MatLegacyListModule as MatListModule } from '@angular/material/legacy-list';
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { MatLegacyRadioModule as MatRadioModule } from '@angular/material/legacy-radio';
import { MatToolbarModule } from '@angular/material/toolbar';
@@ -105,6 +106,7 @@ describe('SingleMailViewerComponent', () => {
ResizerModule,
MatIconModule,
MatIconTestingModule,
+ MatListModule,
MatGridListModule,
MatToolbarModule,
MatTooltipModule,
diff --git a/src/app/signup/signup.component.html b/src/app/signup/signup.component.html
new file mode 100644
index 000000000..ca0bcd9f9
--- /dev/null
+++ b/src/app/signup/signup.component.html
@@ -0,0 +1,339 @@
+
+
+
+
+
+
+
+
+
Sustainable and Private Email Hosting
+
Create a Runbox Account
+
+ Sustainable and secure email, your own domain options, and a 30-day free trial with no credit card required.
+
+
+ Your trial starts immediately. Before it ends, you can choose whether to upgrade and keep using the account.
+ Runbox is a paid email service, not an ad-funded one, and customer email content is private.
+
+
+
+
+
+
Sustainable and secure
+
Runbox combines modern webmail, custom domains, standard email access, strong spam filtering, and an emphasis on responsible operations.
+
+
+
Privacy by business model
+
Runbox is funded by subscriptions, not advertising, so your email content is not mined to sell attention.
+
+
+
Hosted in Norway
+
Your service is operated from Norway by an independent email provider with a long-standing focus on privacy and user control.
+
+
+
How the trial works
+
Your mailbox is created immediately. Before the trial ends, you can decide whether to upgrade and continue using the account.
+
+
+
+
+
+
+
+
+
diff --git a/src/app/signup/signup.component.scss b/src/app/signup/signup.component.scss
new file mode 100644
index 000000000..fdd78b9b6
--- /dev/null
+++ b/src/app/signup/signup.component.scss
@@ -0,0 +1,825 @@
+/* --------- BEGIN RUNBOX LICENSE ---------
+Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com).
+
+This file is part of Runbox 7.
+
+Runbox 7 is free software: You can redistribute it and/or modify it
+under the terms of the GNU General Public License as published by the
+Free Software Foundation, either version 3 of the License, or (at your
+option) any later version.
+
+Runbox 7 is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with Runbox 7. If not, see .
+---------- END RUNBOX LICENSE ---------- */
+
+:host {
+ display: block;
+ min-height: 100vh;
+ color: #0f2740;
+ background:
+ radial-gradient(circle at top left, rgba(143, 198, 255, 0.34), transparent 34%),
+ radial-gradient(circle at bottom right, rgba(0, 88, 153, 0.18), transparent 28%),
+ linear-gradient(180deg, #f5f9fc 0%, #dfeaf3 100%);
+}
+
+.signup-shell {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.signup-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem 1.5rem;
+ background-image: linear-gradient(145deg, #0068b7, #003156);
+ color: #fff;
+ box-shadow: 0 8px 20px rgba(0, 25, 46, 0.12);
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ color: inherit;
+ text-decoration: none;
+}
+
+.brand img {
+ height: 34px;
+ width: auto;
+}
+
+.header-tagline {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 1rem;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+
+.footer-links a:hover {
+ text-decoration: underline;
+}
+
+.signup-main {
+ width: min(1380px, calc(100% - 2rem));
+ margin: 0 auto;
+ padding: 1.5rem 0 2rem;
+ display: grid;
+ grid-template-columns: minmax(280px, 420px) minmax(0, 1fr);
+ gap: 1.5rem;
+ align-items: start;
+ flex: 1 0 auto;
+}
+
+.hero-panel,
+.form-panel {
+ border: 1px solid rgba(112, 145, 176, 0.28);
+ border-radius: 8px;
+ overflow: hidden;
+ box-shadow: 0 18px 48px rgba(9, 33, 58, 0.1);
+}
+
+.hero-panel {
+ display: grid;
+ background: linear-gradient(180deg, #0068b7 0%, #003156 100%);
+ color: #fff;
+}
+
+.hero-copy {
+ padding: 2rem 1.75rem 1.5rem;
+}
+
+.eyebrow {
+ margin: 0 0 0.75rem;
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: rgba(255, 255, 255, 0.72);
+}
+
+.hero-copy h1 {
+ margin: 0;
+ font-size: clamp(2rem, 3vw, 3.2rem);
+ line-height: 1.02;
+}
+
+.hero-text {
+ margin: 1rem 0 0;
+ font-size: 1.04rem;
+ line-height: 1.6;
+ color: rgba(255, 255, 255, 0.88);
+}
+
+.hero-subtext {
+ margin: 1rem 0 0;
+ max-width: 40rem;
+ font-size: 0.96rem;
+ line-height: 1.6;
+ color: rgba(255, 255, 255, 0.78);
+}
+
+.hero-notes {
+ display: grid;
+ gap: 0;
+ background: transparent;
+}
+
+.note {
+ padding: 1.15rem 1.75rem;
+ background: transparent;
+ border-top: 1px solid rgba(255, 255, 255, 0.14);
+}
+
+.note h2 {
+ margin: 0 0 0.4rem;
+ font-size: 1rem;
+}
+
+.note p {
+ margin: 0;
+ line-height: 1.5;
+ color: rgba(255, 255, 255, 0.78);
+}
+
+.form-panel {
+ background: rgba(255, 255, 255, 0.92);
+ backdrop-filter: blur(4px);
+}
+
+.form-heading {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 0;
+ align-items: center;
+ padding: 1.4rem 1.5rem;
+ border-bottom: 1px solid #d9e4ef;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(240, 246, 252, 0.92));
+}
+
+.form-heading h2 {
+ margin: 0;
+ font-size: 1.7rem;
+}
+
+.form-heading p {
+ margin: 0.35rem 0 0;
+ color: #486175;
+ line-height: 1.5;
+}
+
+.signup-form {
+ padding: 1.4rem 1.5rem 1.6rem;
+}
+
+.form-section + .form-section {
+ margin-top: 1.35rem;
+}
+
+.form-section {
+ padding-top: 1.35rem;
+ border-top: 1px solid #dde7f0;
+}
+
+.form-section:first-child {
+ padding-top: 0;
+ border-top: 0;
+}
+
+.section-head {
+ margin-bottom: 0.9rem;
+}
+
+.section-head h3 {
+ margin: 0;
+ font-size: 1.08rem;
+}
+
+.section-head p {
+ margin: 0.28rem 0 0;
+ color: #5a7187;
+ line-height: 1.5;
+}
+
+.choice-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.85rem;
+}
+
+.choice-card {
+ display: grid;
+ gap: 0.32rem;
+ padding: 1rem 1rem 1rem 2.8rem;
+ position: relative;
+ border: 1px solid #bfd0e1;
+ border-radius: 8px;
+ background: #f8fbfe;
+ cursor: pointer;
+}
+
+.choice-card input[type='radio'] {
+ position: absolute;
+ top: 1.08rem;
+ left: 1rem;
+ margin: 0;
+}
+
+.choice-card.active {
+ border-color: #0068b7;
+ background: linear-gradient(180deg, #eef7ff 0%, #f8fbff 100%);
+ box-shadow: inset 0 0 0 1px rgba(0, 104, 183, 0.18);
+}
+
+.choice-title {
+ font-weight: 700;
+}
+
+.choice-copy {
+ color: #587085;
+ line-height: 1.45;
+ font-weight: 400;
+}
+
+.field-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.9rem 1rem;
+}
+
+.field-grid.single,
+.field.field-wide {
+ grid-column: 1 / -1;
+}
+
+.field {
+ display: grid;
+ gap: 0.38rem;
+ align-content: start;
+}
+
+.field > span {
+ font-weight: 700;
+}
+
+.field-label {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ max-width: 100%;
+}
+
+.field-label-text {
+ display: inline;
+}
+
+.field-help-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ width: fit-content;
+ max-width: 100%;
+}
+
+.info-trigger {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ width: fit-content;
+ max-width: 100%;
+}
+
+.field > small {
+ color: #637b90;
+ font-size: 0.82rem;
+}
+
+.field.is-invalid > span,
+.checkbox-row.is-invalid span {
+ color: #a12020;
+}
+
+.info-chip {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.15rem;
+ height: 1.15rem;
+ padding: 0;
+ border: 0;
+ border-radius: 999px;
+ background: #d9ebf8;
+ color: #005897;
+ font: inherit;
+ font-size: 0.76rem;
+ font-weight: 700;
+ line-height: 1;
+ cursor: help;
+}
+
+.info-popover {
+ position: absolute;
+ left: 0;
+ top: calc(100% + 0.55rem);
+ z-index: 5;
+ width: 18rem;
+ max-width: min(18rem, calc(100vw - 5rem));
+ padding: 0.7rem 0.8rem;
+ border-radius: 8px;
+ background: #0f2740;
+ color: #fff;
+ font-size: 0.82rem;
+ font-weight: 400;
+ line-height: 1.5;
+ text-align: left;
+ box-shadow: 0 16px 32px rgba(9, 33, 58, 0.22);
+ opacity: 0;
+ pointer-events: none;
+ transform: translate(0, 0.2rem);
+ transition: opacity 120ms ease, transform 120ms ease;
+}
+
+.info-trigger-end .info-popover {
+ left: auto;
+ right: 0;
+}
+
+.info-popover::before {
+ content: '';
+ position: absolute;
+ left: 0.95rem;
+ top: -0.35rem;
+ width: 0.7rem;
+ height: 0.7rem;
+ background: #0f2740;
+ transform: rotate(45deg);
+}
+
+.info-trigger-end .info-popover::before {
+ left: auto;
+ right: 0.95rem;
+}
+
+.info-trigger:hover .info-popover,
+.info-trigger:focus-within .info-popover {
+ opacity: 1;
+ transform: translate(0, 0);
+}
+
+.info-chip:focus-visible {
+ outline: 2px solid #0068b7;
+ outline-offset: 2px;
+}
+
+.field input,
+.field select {
+ width: 100%;
+ min-width: 0;
+ padding: 0.78rem 0.9rem;
+ border: 1px solid #9eb5ca;
+ border-radius: 8px;
+ background: #fff;
+ font: inherit;
+ color: #0f2740;
+ box-sizing: border-box;
+}
+
+.field input:focus,
+.field select:focus {
+ outline: 0;
+ border-color: #0068b7;
+ box-shadow: 0 0 0 3px rgba(0, 104, 183, 0.12);
+}
+
+.field.is-invalid input,
+.field.is-invalid select,
+.checkbox-row.is-invalid input {
+ border-color: #d04848;
+ background: #fff7f7;
+}
+
+.field.is-invalid input:focus,
+.field.is-invalid select:focus,
+.checkbox-row.is-invalid input:focus {
+ border-color: #c23030;
+ box-shadow: 0 0 0 3px rgba(210, 72, 72, 0.14);
+}
+
+.strength-meter {
+ display: grid;
+ grid-template-columns: auto minmax(120px, 220px) auto;
+ align-items: center;
+ gap: 0.8rem;
+ margin-top: 0.9rem;
+}
+
+.strength-meter span,
+.strength-meter strong {
+ white-space: nowrap;
+}
+
+.strength-meter progress {
+ width: 100%;
+ height: 10px;
+}
+
+.captcha-box {
+ padding: 1rem;
+ border-radius: 8px;
+ background: #f6fafd;
+ border: 1px solid #d6e2ec;
+ overflow-x: auto;
+}
+
+.captcha-host {
+ min-height: 78px;
+}
+
+.captcha-host.is-hidden {
+ display: none;
+}
+
+.captcha-host:empty::before {
+ content: 'Loading CAPTCHA...';
+ display: inline-block;
+ color: #587085;
+ font-size: 0.92rem;
+}
+
+.captcha-missing {
+ margin: 0;
+ padding: 0.8rem 0.9rem;
+ border: 1px solid #d6b087;
+ background: #fff3e4;
+ border-radius: 8px;
+ line-height: 1.5;
+}
+
+.policy-block {
+ background: linear-gradient(180deg, #f8fbfe 0%, #f2f7fb 100%);
+ padding: 1.25rem;
+ border: 1px solid #dce6ef;
+ border-radius: 8px;
+}
+
+.policy-block .section-head {
+ margin-bottom: 1rem;
+}
+
+.news-choice {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.9rem 1.2rem;
+}
+
+.news-choice > span {
+ font-weight: 700;
+}
+
+.inline-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.inline-options label,
+.checkbox-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.65rem;
+}
+
+.inline-options label {
+ font-weight: 600;
+}
+
+.inline-options input,
+.checkbox-row input {
+ margin: 0.15rem 0 0;
+ flex: 0 0 auto;
+}
+
+.checkbox-row {
+ margin-top: 1rem;
+ line-height: 1.55;
+}
+
+.form-error {
+ margin: 1.1rem 0 0;
+ padding: 0.85rem 1rem;
+ border-radius: 8px;
+ border: 1px solid #d79a9a;
+ background: #fff2f2;
+ color: #8b1d1d;
+ line-height: 1.5;
+ font-weight: 600;
+}
+
+.field-error {
+ display: block;
+ color: #a12020;
+ font-size: 0.82rem;
+ line-height: 1.4;
+}
+
+.captcha-error {
+ margin-top: 0.75rem;
+}
+
+.form-actions {
+ display: flex;
+ align-items: center;
+ margin-top: 1.5rem;
+}
+
+.submit {
+ border: 0;
+ border-radius: 4px;
+ min-width: 132px;
+ min-height: 42px;
+ background: #01579b;
+ color: #fff;
+ padding: 0 1.7rem;
+ font: inherit;
+ font-weight: 700;
+ font-size: 1rem;
+ cursor: pointer;
+ line-height: 36px;
+ box-shadow:
+ 0 3px 1px -2px rgba(0, 0, 0, 0.2),
+ 0 2px 2px 0 rgba(0, 0, 0, 0.14),
+ 0 1px 5px 0 rgba(0, 0, 0, 0.12);
+ transition: background-color 120ms ease, box-shadow 120ms ease;
+}
+
+.submit:hover {
+ background: #0068b7;
+ box-shadow:
+ 0 4px 5px -2px rgba(0, 0, 0, 0.2),
+ 0 7px 10px 1px rgba(0, 0, 0, 0.14),
+ 0 2px 16px 1px rgba(0, 0, 0, 0.12);
+}
+
+.submit.is-submitting {
+ opacity: 0.84;
+ cursor: wait;
+}
+
+.honeypot {
+ position: absolute;
+ left: -10000px;
+ top: auto;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+}
+
+.signup-footer {
+ padding: 1.2rem 1.25rem 1.8rem;
+ background-image: linear-gradient(170deg, #014f89, #001e35);
+ color: rgba(255, 255, 255, 0.82);
+ text-align: center;
+}
+
+.footer-links {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+ gap: 1rem 1.2rem;
+ margin-bottom: 0.75rem;
+}
+
+.footer-links a {
+ color: rgba(255, 255, 255, 0.86);
+ text-decoration: none;
+}
+
+.signup-footer p {
+ margin: 0;
+ font-size: 0.92rem;
+}
+
+@media (max-width: 1100px) {
+ .signup-main {
+ grid-template-columns: 1fr;
+ }
+
+ .hero-panel {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media (max-width: 900px) {
+ .signup-header {
+ padding: 0.9rem 1.15rem;
+ }
+
+ .header-tagline {
+ font-size: 0.94rem;
+ }
+
+ .signup-main {
+ width: min(100% - 1.5rem, 1380px);
+ gap: 1.1rem;
+ }
+
+ .hero-copy,
+ .note,
+ .signup-form,
+ .form-heading {
+ padding-left: 1.2rem;
+ padding-right: 1.2rem;
+ }
+
+ .form-heading {
+ grid-template-columns: 1fr;
+ }
+
+ .choice-grid,
+ .field-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .strength-meter {
+ grid-template-columns: 1fr;
+ justify-items: start;
+ }
+
+ .captcha-box {
+ padding: 0.9rem;
+ }
+}
+
+@media (max-width: 720px) {
+ .signup-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.55rem;
+ }
+
+ .signup-main {
+ width: min(100% - 1rem, 1380px);
+ padding-top: 0.75rem;
+ padding-bottom: 1.2rem;
+ gap: 1rem;
+ }
+
+ .hero-panel {
+ grid-template-columns: 1fr;
+ }
+
+ .hero-copy,
+ .note,
+ .signup-form,
+ .form-heading {
+ padding-left: 1rem;
+ padding-right: 1rem;
+ }
+
+ .news-choice {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .form-actions {
+ align-items: stretch;
+ }
+
+ .submit {
+ width: 100%;
+ min-height: 46px;
+ }
+
+ .info-popover {
+ width: 16rem;
+ max-width: min(16rem, calc(100vw - 3rem));
+ }
+
+ .info-popover::before {
+ left: 0.9rem;
+ }
+
+ .info-trigger-end .info-popover::before {
+ left: auto;
+ right: 0.9rem;
+ }
+}
+
+@media (max-width: 560px) {
+ .signup-header {
+ padding: 0.85rem 1rem 0.9rem;
+ }
+
+ .brand img {
+ height: 28px;
+ }
+
+ .header-tagline {
+ font-size: 0.88rem;
+ line-height: 1.4;
+ }
+
+ .signup-footer p,
+ .footer-links a {
+ font-size: 0.9rem;
+ }
+
+ .hero-copy h1 {
+ font-size: clamp(1.8rem, 9vw, 2.4rem);
+ line-height: 1.08;
+ }
+
+ .hero-text,
+ .hero-subtext,
+ .note p,
+ .section-head p {
+ font-size: 0.95rem;
+ }
+
+ .signup-form,
+ .form-heading,
+ .hero-copy,
+ .note {
+ padding-left: 0.9rem;
+ padding-right: 0.9rem;
+ }
+
+ .field input,
+ .field select {
+ padding: 0.72rem 0.82rem;
+ font-size: 16px;
+ }
+
+ .policy-block {
+ padding: 1rem;
+ }
+
+ .inline-options {
+ gap: 0.8rem;
+ }
+
+ .captcha-box {
+ padding: 0.75rem;
+ }
+
+ .submit {
+ padding: 0 1.2rem;
+ }
+
+ .signup-footer {
+ padding: 1rem 0.9rem 1.4rem;
+ }
+}
+
+@media (max-width: 420px) {
+ .signup-main {
+ width: calc(100% - 0.75rem);
+ gap: 0.85rem;
+ }
+
+ .hero-copy h1 {
+ font-size: 1.85rem;
+ }
+
+ .hero-text,
+ .hero-subtext,
+ .note p,
+ .form-heading p,
+ .section-head p {
+ font-size: 0.92rem;
+ }
+
+ .form-heading {
+ padding-top: 1.15rem;
+ padding-bottom: 1.15rem;
+ }
+
+ .signup-form {
+ padding-top: 1.1rem;
+ padding-bottom: 1.25rem;
+ }
+
+ .note {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+ }
+
+ .policy-block {
+ padding: 1rem;
+ }
+
+ .footer-links {
+ gap: 0.75rem 1rem;
+ }
+}
diff --git a/src/app/signup/signup.component.spec.ts b/src/app/signup/signup.component.spec.ts
new file mode 100644
index 000000000..b96936326
--- /dev/null
+++ b/src/app/signup/signup.component.spec.ts
@@ -0,0 +1,237 @@
+// --------- BEGIN RUNBOX LICENSE ---------
+// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com).
+//
+// This file is part of Runbox 7.
+//
+// Runbox 7 is free software: You can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the
+// Free Software Foundation, either version 3 of the License, or (at your
+// option) any later version.
+//
+// Runbox 7 is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Runbox 7. If not, see .
+// ---------- END RUNBOX LICENSE ----------
+
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule, NgForm, NgModel } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { ActivatedRoute, convertToParamMap } from '@angular/router';
+import { BehaviorSubject } from 'rxjs';
+import { SignupComponent } from './signup.component';
+
+describe('SignupComponent', () => {
+ let component: SignupComponent;
+ let fixture: ComponentFixture;
+ let httpMock: HttpTestingController;
+ let queryParamMap$: BehaviorSubject>;
+
+ beforeEach(async () => {
+ queryParamMap$ = new BehaviorSubject(convertToParamMap({ runbox7: '1' }));
+
+ await TestBed.configureTestingModule({
+ imports: [FormsModule, HttpClientTestingModule],
+ declarations: [SignupComponent],
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ queryParamMap: queryParamMap$.asObservable(),
+ },
+ },
+ ],
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SignupComponent);
+ component = fixture.componentInstance;
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ function stubCaptchaInitialization(loadResult = false): jasmine.Spy {
+ return spyOn(component, 'loadHCaptchaScript').and.resolveTo(loadResult);
+ }
+
+ function flushLegacyMetadata(html?: string): void {
+ const request = httpMock.expectOne('/signup?legacy=1&runbox7=1');
+ expect(request.request.method).toBe('GET');
+ request.flush(html || `
+
+
+
+
+
+ `);
+ }
+
+ async function initComponent(html?: string, loadResult = false): Promise {
+ stubCaptchaInitialization(loadResult);
+ fixture.detectChanges();
+ flushLegacyMetadata(html);
+ await fixture.whenStable();
+ fixture.detectChanges();
+ }
+
+ function getForm(): NgForm {
+ return fixture.debugElement.query(By.css('form')).injector.get(NgForm);
+ }
+
+ function setInputValue(selector: string, value: string): HTMLInputElement {
+ const input = fixture.nativeElement.querySelector(selector) as HTMLInputElement;
+ input.value = value;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ return input;
+ }
+
+ function setCheckboxValue(selector: string, checked: boolean): HTMLInputElement {
+ const input = fixture.nativeElement.querySelector(selector) as HTMLInputElement;
+ input.checked = checked;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ return input;
+ }
+
+ async function fillRequiredFields(): Promise {
+ setInputValue('input[name="first_name"]', 'Joe');
+ setInputValue('input[name="last_name"]', 'Bond');
+ setInputValue('input[name="user"]', 'joebond');
+ setInputValue('input[name="password"]', 'S3cret!Pass');
+ setInputValue('input[name="email_alternative"]', 'joe@example.com');
+ setCheckboxValue('input[name="tos_accepted"]', true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ }
+
+ it('loads signup metadata from the legacy signup page', async () => {
+ await initComponent();
+
+ expect(component.signupAction).toBe('/mail/signup');
+ expect(component.hCaptchaSiteKey).toBe('test-site-key');
+ expect(component.runboxDomains).toEqual(['runbox.com', 'runbox.no', 'rbx.email']);
+ expect(component.runboxDomain).toBe('runbox.com');
+ });
+
+ it('applies query parameters during initialization', async () => {
+ queryParamMap$.next(convertToParamMap({
+ accountType: 'business',
+ domainType: 'user',
+ account_number: '12345',
+ runbox7: '7',
+ }));
+
+ await initComponent();
+
+ expect(component.accountType).toBe('business');
+ expect(component.domainType).toBe('user');
+ expect(component.accountNumber).toBe('12345');
+ expect(component.runbox7).toBe('7');
+ });
+
+ it('keeps safe defaults if legacy metadata cannot be fetched', async () => {
+ stubCaptchaInitialization(false);
+ fixture.detectChanges();
+
+ const request = httpMock.expectOne('/signup?legacy=1&runbox7=1');
+ request.flush('backend unavailable', { status: 500, statusText: 'Server Error' });
+
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(component.signupAction).toBe('/signup');
+ expect(component.runboxDomains).toEqual(['runbox.com', 'runbox.no']);
+ expect(component.hCaptchaSiteKey).toBe('');
+ expect(component.hCaptchaError).toContain('CAPTCHA is temporarily unavailable');
+ });
+
+ it('shows field-level validation feedback after submit', async () => {
+ await initComponent();
+
+ const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement;
+ const focusSpy = spyOn(component, 'focusFirstInvalidField');
+
+ formElement.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const fieldErrors = Array.from(fixture.nativeElement.querySelectorAll('.field-error'))
+ .map((el: HTMLElement) => el.textContent?.trim());
+
+ expect(component.submitError).toBe('Complete the required fields before continuing.');
+ expect(focusSpy).toHaveBeenCalledWith(formElement);
+ expect(fieldErrors).toContain('Enter your first name.');
+ expect(fieldErrors).toContain('Enter your last name.');
+ expect(fieldErrors).toContain('Choose a username for your mailbox.');
+ expect(fieldErrors).toContain('Enter a password for your account.');
+ expect(fieldErrors).toContain('Enter an email address for recovery and account notices.');
+ expect(fieldErrors).toContain('You must accept the terms to create an account.');
+ });
+
+ it('shows a field-level validation error for an invalid custom domain', async () => {
+ await initComponent();
+
+ component.domainType = 'user';
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const domainControl = fixture.debugElement.query(By.css('input[name="userdomain"]')).injector.get(NgModel);
+ setInputValue('input[name="userdomain"]', 'invalid domain');
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(component.showFieldError(domainControl, getForm())).toBeTrue();
+ expect(fixture.nativeElement.textContent).toContain('Enter a valid domain such as example.com.');
+ });
+
+ it('blocks submit if captcha is missing even when required fields are valid', async () => {
+ await initComponent();
+ await fillRequiredFields();
+
+ const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement;
+ const submitSpy = spyOn(formElement, 'submit');
+
+ component.onSubmit(getForm(), formElement);
+ fixture.detectChanges();
+
+ expect(component.showCaptchaValidationError).toBeTrue();
+ expect(component.submitError).toBe('Complete the CAPTCHA verification before submitting.');
+ expect(submitSpy).not.toHaveBeenCalled();
+ });
+
+ it('submits the native form when validation and captcha both pass', async () => {
+ await initComponent();
+ await fillRequiredFields();
+
+ const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement;
+ const captchaResponse = document.createElement('textarea');
+ captchaResponse.name = 'h-captcha-response';
+ captchaResponse.value = 'captcha-token';
+ formElement.appendChild(captchaResponse);
+ const submitSpy = spyOn(formElement, 'submit');
+
+ component.onSubmit(getForm(), formElement);
+
+ expect(component.submitInProgress).toBeTrue();
+ expect(submitSpy).toHaveBeenCalled();
+ expect(component.submitError).toBe('');
+ expect(component.showCaptchaValidationError).toBeFalse();
+ });
+});
diff --git a/src/app/signup/signup.component.ts b/src/app/signup/signup.component.ts
new file mode 100644
index 000000000..91cf00c6f
--- /dev/null
+++ b/src/app/signup/signup.component.ts
@@ -0,0 +1,320 @@
+// --------- BEGIN RUNBOX LICENSE ---------
+// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com).
+//
+// This file is part of Runbox 7.
+//
+// Runbox 7 is free software: You can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the
+// Free Software Foundation, either version 3 of the License, or (at your
+// option) any later version.
+//
+// Runbox 7 is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Runbox 7. If not, see .
+// ---------- END RUNBOX LICENSE ----------
+
+import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { NgForm, NgModel } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { environment } from '../../environments/environment';
+
+type AccountType = 'person' | 'business';
+type DomainType = 'runbox' | 'user';
+type SignupEnvironment = typeof environment & { SIGNUP_HCAPTCHA_SITE_KEY?: string };
+
+@Component({
+ selector: 'app-signup',
+ templateUrl: './signup.component.html',
+ styleUrls: ['./signup.component.scss'],
+})
+export class SignupComponent implements OnInit, AfterViewInit, OnDestroy {
+ @ViewChild('captchaContainer') captchaContainer?: ElementRef;
+
+ accountType: AccountType = 'person';
+ domainType: DomainType = 'runbox';
+
+ user = '';
+ userDomain = '';
+ runboxDomain = 'runbox.com';
+ firstName = '';
+ lastName = '';
+ company = '';
+ password = '';
+ emailAlternative = '';
+ phoneNumberCellular = '';
+ referrer = '';
+ sendNewsOffers = '';
+ tosAccepted = false;
+ passwordStrength = 0;
+ accountNumber = '';
+ runbox7 = '1';
+ timezone = 'UTC';
+ signupAction = '/signup';
+
+ runboxDomains = ['runbox.com', 'runbox.no'];
+ readonly referrers = [
+ 'Advertisement',
+ 'Friend or family',
+ 'News media',
+ 'Review website',
+ 'Search engine',
+ 'Social media',
+ 'Other',
+ ];
+
+ hCaptchaSiteKey = (environment as SignupEnvironment).SIGNUP_HCAPTCHA_SITE_KEY || '';
+ hCaptchaError = '';
+ submitError = '';
+ submitInProgress = false;
+ showCaptchaValidationError = false;
+
+ private hCaptchaWidgetId: string | null = null;
+ private hCaptchaReady = false;
+ private nativeSubmitting = false;
+ private pendingCaptchaRender = false;
+
+ constructor(
+ private route: ActivatedRoute,
+ private http: HttpClient,
+ ) {}
+
+ ngOnInit(): void {
+ document.body.classList.add('signup-page');
+ document.getElementById('main')?.classList.add('signup-page-shell');
+
+ const host = window?.location?.hostname || '';
+ if (host.endsWith('.no')) {
+ this.runboxDomain = 'runbox.no';
+ }
+ const resolvedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ if (resolvedTz) {
+ this.timezone = resolvedTz;
+ }
+ this.route.queryParamMap.subscribe((params) => {
+ const accountType = params.get('accountType');
+ if (accountType === 'business' || accountType === 'person') {
+ this.accountType = accountType;
+ }
+ const domainType = params.get('domainType');
+ if (domainType === 'user' || domainType === 'runbox') {
+ this.domainType = domainType;
+ }
+ this.accountNumber = params.get('account_number') || params.get('accountNumber') || '';
+ this.runbox7 = params.get('runbox7') || '1';
+ });
+
+ void this.initializeHCaptcha();
+ }
+
+ ngAfterViewInit(): void {
+ if (this.pendingCaptchaRender) {
+ this.renderHCaptcha();
+ }
+ }
+
+ ngOnDestroy(): void {
+ document.body.classList.remove('signup-page');
+ document.getElementById('main')?.classList.remove('signup-page-shell');
+ }
+
+ onPasswordChange(): void {
+ let score = 0;
+ if (this.password.length >= 8) {
+ score++;
+ }
+ if (/[a-z]/.test(this.password) && /[A-Z]/.test(this.password)) {
+ score++;
+ }
+ if (/\d/.test(this.password)) {
+ score++;
+ }
+ if (/[^A-Za-z0-9]/.test(this.password)) {
+ score++;
+ }
+ this.passwordStrength = score;
+ }
+
+ onSubmit(form: NgForm, formElement: HTMLFormElement): void {
+ this.submitError = '';
+ this.showCaptchaValidationError = false;
+
+ if (this.nativeSubmitting) {
+ return;
+ }
+
+ if (!form.valid) {
+ this.submitError = 'Complete the required fields before continuing.';
+ this.focusFirstInvalidField(formElement);
+ return;
+ }
+
+ if (!this.hCaptchaSiteKey) {
+ this.submitError = 'CAPTCHA is unavailable right now. Use the legacy signup page or try again shortly.';
+ return;
+ }
+
+ if (!this.hasCaptchaResponse(formElement)) {
+ this.showCaptchaValidationError = true;
+ this.submitError = 'Complete the CAPTCHA verification before submitting.';
+ this.captchaContainer?.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ return;
+ }
+
+ this.submitInProgress = true;
+ this.nativeSubmitting = true;
+ formElement.submit();
+ }
+
+ showFieldError(control?: NgModel | null, form?: NgForm): boolean {
+ if (!control) {
+ return false;
+ }
+
+ return control.invalid && (control.touched || control.dirty || Boolean(form?.submitted));
+ }
+
+ private async initializeHCaptcha(): Promise {
+ await this.loadLegacySignupMetadata();
+
+ if (!this.hCaptchaSiteKey) {
+ this.hCaptchaError = 'CAPTCHA is temporarily unavailable. Please use the legacy signup page below.';
+ return;
+ }
+
+ const captchaLoaded = await this.loadHCaptchaScript();
+ if (!captchaLoaded) {
+ return;
+ }
+
+ this.hCaptchaReady = true;
+ this.renderHCaptcha();
+ }
+
+ private loadLegacySignupMetadata(): Promise {
+ return new Promise((resolve) => {
+ this.http
+ .get('/signup?legacy=1&runbox7=1', { responseType: 'text' })
+ .subscribe({
+ next: (html) => {
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ const legacyWidget = doc.querySelector('.h-captcha');
+ const legacyForm = doc.querySelector('form[name="signup"], form[action*="signup"]');
+ const legacyDomains = Array.from(
+ doc.querySelectorAll('select[name="runboxDomain"] option'),
+ )
+ .map((option) => option.value.trim())
+ .filter((domain, index, domains) => Boolean(domain) && domains.indexOf(domain) === index);
+
+ this.hCaptchaSiteKey = legacyWidget?.getAttribute('data-sitekey') || this.hCaptchaSiteKey;
+ this.signupAction = legacyForm?.getAttribute('action') || this.signupAction;
+ if (legacyDomains.length > 0) {
+ this.runboxDomains = legacyDomains;
+ if (!this.runboxDomains.includes(this.runboxDomain)) {
+ this.runboxDomain = this.runboxDomains[0];
+ }
+ }
+ resolve();
+ },
+ error: () => resolve(),
+ });
+ });
+ }
+
+ private loadHCaptchaScript(): Promise {
+ return new Promise((resolve, reject) => {
+ const existingScript = document.querySelector('script[data-runbox-hcaptcha="1"]');
+ if (existingScript) {
+ if ((window as WindowWithHCaptcha).hcaptcha) {
+ resolve(true);
+ return;
+ }
+
+ const pollForHCaptcha = window.setInterval(() => {
+ if ((window as WindowWithHCaptcha).hcaptcha) {
+ window.clearInterval(pollForHCaptcha);
+ resolve(true);
+ }
+ }, 100);
+
+ existingScript.addEventListener('load', () => {
+ window.clearInterval(pollForHCaptcha);
+ resolve(true);
+ }, { once: true });
+ existingScript.addEventListener('error', () => reject(new Error('Failed to load hCaptcha.')), { once: true });
+ return;
+ }
+
+ const script = document.createElement('script');
+ script.src = 'https://hcaptcha.com/1/api.js?render=explicit';
+ script.async = true;
+ script.defer = true;
+ script.setAttribute('data-runbox-hcaptcha', '1');
+ script.addEventListener('load', () => resolve(true), { once: true });
+ script.addEventListener('error', () => reject(new Error('Failed to load hCaptcha.')), { once: true });
+ document.body.appendChild(script);
+ }).catch((): boolean => {
+ this.hCaptchaError = 'CAPTCHA could not be loaded. Please use the legacy signup page below.';
+ return false;
+ });
+ }
+
+ private renderHCaptcha(): void {
+ if (!this.hCaptchaReady || !this.hCaptchaSiteKey) {
+ return;
+ }
+
+ const container = this.captchaContainer?.nativeElement;
+ const hcaptcha = (window as WindowWithHCaptcha).hcaptcha;
+ if (!container || !hcaptcha) {
+ this.pendingCaptchaRender = true;
+ window.setTimeout(() => this.renderHCaptcha(), 0);
+ return;
+ }
+
+ this.pendingCaptchaRender = false;
+
+ if (this.hCaptchaWidgetId !== null) {
+ return;
+ }
+
+ this.hCaptchaWidgetId = hcaptcha.render(container, {
+ sitekey: this.hCaptchaSiteKey,
+ callback: () => {
+ this.hCaptchaError = '';
+ this.submitError = '';
+ },
+ 'expired-callback': () => {
+ this.hCaptchaError = 'CAPTCHA expired. Complete it again before submitting.';
+ },
+ 'error-callback': () => {
+ this.hCaptchaError = 'CAPTCHA failed to load correctly. Try again or use the legacy signup page.';
+ },
+ });
+ }
+
+ private hasCaptchaResponse(formElement: HTMLFormElement): boolean {
+ const response = formElement.querySelector('textarea[name="h-captcha-response"], input[name="h-captcha-response"]');
+ return Boolean(response?.value?.trim());
+ }
+
+ private focusFirstInvalidField(formElement: HTMLFormElement): void {
+ const firstInvalidField = formElement.querySelector(
+ 'input.ng-invalid, select.ng-invalid, textarea.ng-invalid, input:invalid, select:invalid, textarea:invalid',
+ );
+ firstInvalidField?.focus();
+ firstInvalidField?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }
+}
+
+interface HCaptchaApi {
+ render(container: string | HTMLElement, params: Record): string;
+}
+
+interface WindowWithHCaptcha extends Window {
+ hcaptcha?: HCaptchaApi;
+}
diff --git a/src/app/signup/signup.module.ts b/src/app/signup/signup.module.ts
new file mode 100644
index 000000000..925d2b950
--- /dev/null
+++ b/src/app/signup/signup.module.ts
@@ -0,0 +1,36 @@
+// --------- BEGIN RUNBOX LICENSE ---------
+// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com).
+//
+// This file is part of Runbox 7.
+//
+// Runbox 7 is free software: You can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the
+// Free Software Foundation, either version 3 of the License, or (at your
+// option) any later version.
+//
+// Runbox 7 is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Runbox 7. If not, see .
+// ---------- END RUNBOX LICENSE ----------
+
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { NgModule } from '@angular/core';
+import { RouterModule } from '@angular/router';
+import { SignupComponent } from './signup.component';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ FormsModule,
+ RouterModule.forChild([
+ { path: '', component: SignupComponent },
+ ]),
+ ],
+ declarations: [SignupComponent],
+})
+export class SignupModule {}
diff --git a/src/app/start/startdesk.component.scss b/src/app/start/startdesk.component.scss
index fa1e78299..10716c024 100644
--- a/src/app/start/startdesk.component.scss
+++ b/src/app/start/startdesk.component.scss
@@ -1,5 +1,5 @@
-@import '../../../node_modules/@angular/material/theming';
-$rmm-default-highlight: mat-palette($mat-light-blue, 100, 50, 200);
+@use '@angular/material' as mat;
+$rmm-default-highlight: mat.define-palette(mat.$light-blue-palette, 100, 50, 200);
#startdesk {
position: absolute;
diff --git a/src/app/websocketsearch/websocketsearch.service.spec.ts b/src/app/websocketsearch/websocketsearch.service.spec.ts
index 4d718eaa3..00d1188b9 100644
--- a/src/app/websocketsearch/websocketsearch.service.spec.ts
+++ b/src/app/websocketsearch/websocketsearch.service.spec.ts
@@ -262,6 +262,7 @@ describe('WebSocketSearchService', () => {
describe('Error handling', () => {
it('should handle WebSocket errors', (done) => {
+ const consoleLogSpy = spyOn(console, 'log');
service.open();
mockWebSocket.simulateOpen();
@@ -271,6 +272,7 @@ describe('WebSocketSearchService', () => {
mockWebSocket.simulateError(new Error('WebSocket error'));
setTimeout(() => {
+ expect(consoleLogSpy).toHaveBeenCalledWith('websocket error', jasmine.any(Error));
expect(service.searchInProgress).toBe(false);
done();
}, 10);
diff --git a/src/app/welcome/welcomedesk.component.spec.ts b/src/app/welcome/welcomedesk.component.spec.ts
index c99110caa..d2c2eca62 100644
--- a/src/app/welcome/welcomedesk.component.spec.ts
+++ b/src/app/welcome/welcomedesk.component.spec.ts
@@ -18,9 +18,13 @@
// ---------- END RUNBOX LICENSE ----------
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MatIconModule } from '@angular/material/icon';
+import { MatIconTestingModule } from '@angular/material/icon/testing';
+import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { RunboxWebmailAPI } from '../rmmapi/rbwebmail';
import { WelcomeDeskComponent } from './welcomedesk.component';
-import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
+import { ActivatedRoute, convertToParamMap } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
describe('WelcomeDeskComponent', () => {
@@ -31,6 +35,10 @@ describe('WelcomeDeskComponent', () => {
await TestBed.configureTestingModule({
declarations: [ WelcomeDeskComponent ],
imports: [
+ MatCardModule,
+ MatIconModule,
+ MatIconTestingModule,
+ RouterTestingModule,
],
providers:
[
diff --git a/src/build/gen-env.js b/src/build/gen-env.js
index 8db3a977b..ffc3ceeea 100644
--- a/src/build/gen-env.js
+++ b/src/build/gen-env.js
@@ -13,6 +13,7 @@ process.env.BUILD_TIMESTAMP ??= new Date().toJSON()
const env = [
['BUILD_TIMESTAMP', assertString],
['SENTRY_DSN', tryCatch(assertString, orNull)],
+ ['SIGNUP_HCAPTCHA_SITE_KEY', tryCatch(assertString, orEmptyString)],
]
function assertString(input) {
@@ -37,6 +38,10 @@ function orNull () {
return null
}
+function orEmptyString () {
+ return ''
+}
+
fs.writeFileSync('src/environments/env.ts', `
/* eslint-disable @typescript-eslint/quotes */
diff --git a/src/styles.scss b/src/styles.scss
index ed7a85188..553d71bee 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -404,6 +404,28 @@ mat-grid-tile.tableTitle {
justify-content: center;
}
+body.signup-page {
+ height: auto;
+ min-height: 100%;
+ overflow-y: auto;
+ overscroll-behavior: auto;
+}
+
+#main.signup-page-shell {
+ position: static;
+ width: 100%;
+ height: auto;
+ min-height: 100vh;
+ overflow: visible;
+ display: block;
+}
+
+body.signup-page app-rmm {
+ display: block !important;
+ width: 100% !important;
+ min-height: 100vh;
+}
+
/* Snackbar */
.mat-snack-bar-container {