## messenger-style email with full backend integration Implements M8SH Mail — a complete messenger UI for email built on top of the Gitea fork's integrated email server. Features: - All 17 endpoints (`/api/v1/mail/*`) — thread list with cursor pagination, message send/forward/delete, pin/unread, block list, group rename/members, attachment serving with Range support - RFC 5322 parsing with multipart support, HTML detection/correction, reply quote stripping (Gmail/Outlook patterns), plain-text preview extraction via `golang.org/x/net/html` - M8SH HTTP fast-path for same-domain, SMTP MX fallback for external recipients, DKIM signing, `From:` display name from Gitea Full Name - Internal-domain users (`@m8sh.su`) auto-activated and get Chats tab; external users get activation email via integrated mail server (MX → SMTP) - Vue 3 SPA with ThreadList (infinite scroll, context menu, delete confirmation), ChatPane (message bubbles, HTML cards, audio player, in-thread search, scroll-up pagination), avatar resolution pipeline (Gitea → Gravatar → favicon.ico → favicon.png → default) - Plain text only — no JSON/ForwardedContent trees, fully RFC 5322 compatible - Music player, video player, image viewer are integrated into chat UI and they make content easily downloadable Reviewed-on: #12
300 lines
7.3 KiB
CSS
300 lines
7.3 KiB
CSS
/* Copyright 2026 The M8SH Authors. */
|
|
/* SPDX-License-Identifier: GPL-3.0 */
|
|
|
|
/* The mail page fills the viewport below the navbar and above the footer.
|
|
The body is a flex column: [navbar wrapper .full.height] then [footer].
|
|
.full.height has flex-grow:1, so it already fills everything except the footer.
|
|
We make it a flex container so the mail page can flex:1 within it, sitting below the navbar. */
|
|
|
|
/* When the mail page is active, make .full.height a column flex container so
|
|
the navbar stays fixed at top and the mail page fills the remaining height. */
|
|
.full.height:has(.mail-page) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
padding-bottom: 0; /* mail manages its own bottom spacing */
|
|
}
|
|
|
|
.page-content.mail-page {
|
|
flex: 1;
|
|
min-height: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.page-content.mail-page > #mail-app {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* On mobile, hide the footer so the mail app uses the full viewport height. */
|
|
@media (max-width: 639.98px) {
|
|
.full.height:has(.mail-page) ~ .page-footer {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
/* In-thread search highlight. Global (not scoped) because it must reach
|
|
<mark> nodes produced via v-html inside bubbles and html cards. */
|
|
#mail-app mark.search-hit {
|
|
background: var(--color-highlight-bg);
|
|
color: var(--color-text);
|
|
border-radius: 2px;
|
|
padding: 0 1px;
|
|
}
|
|
|
|
#mail-app mark.search-hit.current {
|
|
outline: 1px solid var(--color-highlight-fg);
|
|
}
|
|
|
|
/* Thread context menu. It is teleported to <body> (outside the Vue scoped
|
|
component subtree), so its styles live here, unscoped. */
|
|
body > .ctx-menu {
|
|
position: fixed;
|
|
z-index: 1002;
|
|
min-width: 140px;
|
|
background: var(--color-menu);
|
|
border: 0.5px solid var(--color-secondary-alpha-30);
|
|
border-radius: var(--border-radius-medium);
|
|
padding: 4px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
body > .ctx-menu .ctx-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--color-text);
|
|
font-size: 13px;
|
|
padding: 7px 10px;
|
|
border-radius: var(--border-radius);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
min-height: 36px;
|
|
}
|
|
|
|
body > .ctx-menu .ctx-item:hover {
|
|
background: var(--color-hover);
|
|
}
|
|
|
|
body > .ctx-menu .ctx-item.danger:hover {
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
/* Delete conversation confirmation dialog (teleported, so unscoped). */
|
|
body > .confirm-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 1003;
|
|
background: var(--color-overlay-backdrop);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 16px;
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-modal {
|
|
width: 100%;
|
|
max-width: 360px;
|
|
background: var(--color-menu);
|
|
border: 0.5px solid var(--color-secondary-alpha-30);
|
|
border-radius: var(--border-radius-medium);
|
|
padding: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-icon {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: var(--border-radius-full);
|
|
background: var(--color-red-badge-bg);
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-title {
|
|
font-size: 15px;
|
|
font-weight: var(--font-weight-medium);
|
|
color: var(--color-text);
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-desc {
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
color: var(--color-text-light-2);
|
|
text-align: center;
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
width: 100%;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-cancel {
|
|
flex: 1;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 0.5px solid var(--color-secondary-alpha-30);
|
|
background: var(--color-body);
|
|
color: var(--color-text);
|
|
font-size: 13px;
|
|
padding: 8px 12px;
|
|
border-radius: var(--border-radius-medium);
|
|
cursor: pointer;
|
|
min-height: 38px;
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-cancel:hover {
|
|
background: var(--color-hover);
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-delete {
|
|
flex: 1;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
border: none;
|
|
background: var(--color-danger);
|
|
color: var(--color-white);
|
|
font-size: 13px;
|
|
padding: 8px 12px;
|
|
border-radius: var(--border-radius-medium);
|
|
cursor: pointer;
|
|
min-height: 38px;
|
|
}
|
|
|
|
body > .confirm-overlay .confirm-delete:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
/* Block toggle row: a right-aligned chevron that flips up when expanded. */
|
|
body > .ctx-menu .ctx-toggle .ctx-chev {
|
|
margin-left: auto;
|
|
color: var(--color-text-light-2);
|
|
transition: transform 0.12s ease;
|
|
}
|
|
body > .ctx-menu .ctx-toggle .ctx-chev.up {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
/* Expandable Block panel: its option buttons go edge-to-edge on hover so the
|
|
whole row is the target, not just the centered label. */
|
|
body > .ctx-menu .ctx-expand {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
padding-top: 2px;
|
|
border-top: 0.5px solid var(--color-secondary-alpha-30);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
body > .ctx-menu .ctx-expand .ctx-opt {
|
|
width: 100%;
|
|
border-radius: 0;
|
|
padding-left: 10px; /* flush with the other menu items' text, no icon column */
|
|
}
|
|
body > .ctx-menu .ctx-expand .ctx-opt:hover {
|
|
width: 100%;
|
|
background: var(--color-hover);
|
|
}
|
|
|
|
/* Delete confirmation label inside context menu */
|
|
body > .ctx-menu .ctx-confirm-label {
|
|
padding: 6px 12px 2px;
|
|
font-size: 11px;
|
|
color: var(--color-text-light-2);
|
|
}
|
|
|
|
/* Rename group dialog input */
|
|
body > .confirm-overlay .rename-input {
|
|
width: 100%;
|
|
font-size: 14px;
|
|
font-family: var(--fonts-regular);
|
|
color: var(--color-text);
|
|
background: var(--color-secondary-bg);
|
|
border: 0.5px solid var(--color-secondary-alpha-30);
|
|
border-radius: var(--border-radius-medium);
|
|
padding: 8px 10px;
|
|
outline: none;
|
|
}
|
|
body > .confirm-overlay .rename-input:focus {
|
|
border-color: var(--color-primary);
|
|
}
|
|
body > .confirm-overlay .rename-modal {
|
|
align-items: stretch;
|
|
}
|
|
|
|
/* Mobile overflow menu: transparent full-screen click catcher with the menu
|
|
positioned in the top-right corner, just like the context menu (no darkening). */
|
|
body > .overflow-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 1002;
|
|
}
|
|
|
|
body > .overflow-backdrop .overflow-menu {
|
|
position: absolute;
|
|
top: 56px;
|
|
right: 8px;
|
|
min-width: 160px;
|
|
background: var(--color-menu);
|
|
border: 0.5px solid var(--color-secondary-alpha-30);
|
|
border-radius: var(--border-radius-medium);
|
|
padding: 4px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
body > .overflow-backdrop .ctx-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--color-text);
|
|
font-size: 13px;
|
|
padding: 7px 10px;
|
|
border-radius: var(--border-radius);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
min-height: 36px;
|
|
}
|
|
|
|
body > .overflow-backdrop .ctx-item:hover {
|
|
background: var(--color-hover);
|
|
}
|
|
|
|
body > .overflow-backdrop .ctx-item.danger:hover {
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
|
|
/* Audio player buttons: instant hover, no delay (overrides all inherited transitions) */
|
|
#mail-app .audio-player a.ap-btn,
|
|
#mail-app .audio-player button.ap-btn,
|
|
#mail-app .audio-player a.ap-download,
|
|
#mail-app .audio-player .ap-download,
|
|
#mail-app .audio-player .ap-close,
|
|
#mail-app .audio-player .ap-btn,
|
|
#mail-app .audio-player .ap-btn:hover {
|
|
transition: none !important;
|
|
transition-property: none !important;
|
|
transition-duration: 0s !important;
|
|
transition-delay: 0s !important;
|
|
}
|