feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)

Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
This commit is contained in:
Z User committed 2026-10-27 22:50:50 +00:00
1 parent 7acc0c5451
commit e45b68bb8b
1089 files changed
+260614

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
---
name: social-carousel
description: 小红书选辑 / anthology 横滚社交模板 — 8 张竖卡连续 auto-scroll snap loop,每张几何裁切 mask(拱 / 椭 / 半穹顶 / 斜)+ 宋体标题 + 一个 SVG 装饰符号 + 角部期号元数据。用于摄影系列 / 艺术指导 / mood reel / 品牌瞬间集 (6-10 帧)。触发:"做个 anthology 选辑 / 横滚画廊 / mood reel / 摄影系列展 / 第 0X 辑封面集"。NOT 教程类(用 digital-eguide),NOT < 4 张系列,NOT 文字密集卡(横滚速度藏文字)。
visibility: public
mode: template
carrier: fixed-image
scenario: social-content
pattern_source: scroll-gallery.html
source_priority: skill-first
triggers:
- "做个 anthology 选辑"
- "横滚画廊"
- "mood reel"
- "摄影系列展"
- "第 0X 辑封面集"
- "auto-scroll 卡集"
related_patterns: social-card-image-led, digital-eguide
---
# Social Carousel Template
Use this template when the artifact is a **curated multi-card anthology** — 6–10 images that share an editorial identity and want to be experienced in rhythm, not as a list.
Distinct from `digital-eguide/social-carousel.html` (12-frame field-notes guide with 50/50 photo + metadata). This one is **gallery-paced**, no per-card metadata table, sculptural geometric masks.
## Use When
- The user wants to publish a **series / anthology / 选辑 / collection** — photography, art direction, branding moments, mood reel.
- Each card carries one image + a short serif title + a volume number (第 0X 辑).
- The viewing context is browsing / pause / browse — not step-by-step instruction.
- The output is a fixed-image set (HTML-to-image) or an embedded auto-scrolling strip.
## Avoid When
- The content is a tutorial or numbered steps — use `digital-eguide/social-carousel.html` (it has progress + per-step metadata) or `social-card-swiss/`.
- The series is < 4 entries — make individual cards instead.
- The content is text-heavy (quotes, body copy) — gallery rhythm hides text.
- The platform expects discrete swipeable posts (XHS multi-image post) — this is one scrolling strip, not 9 separate posts.
## Required Reading
Read this `SKILL.md` first. Do **not** read `scroll-gallery.html` by default.
Read `scroll-gallery.html` only when you need: the geometric-mask shapes (arch / oval / half-dome / diagonal), the auto-scroll easing values, the seamless-loop double-render trick, or the hover-slowdown behavior. Extract patterns; do not copy the placeholder Songti titles, photo URLs, or class names wholesale.
## Design Strategy
Commit to the anthology spine before designing cards:
- one cover frame role per card (full-bleed, arched, oval, diagonal, masked half-dome)
- a single rotating ornamental SVG mark style (don't mix flower + grid + arrow)
- consistent corner metadata (volume + tag + year, picked once)
- one title font (Songti SC), one meta font (PingFang SC), no third voice
If the cards don't share a visual system, the auto-scroll exposes the inconsistency.
## Layout Bias
Prefer:
- 2:3 or 7:9 portrait per card; mix sparingly, never randomly
- restrained warm palette (creams / terracottas / sage) over white or near-black
- one geometric mask per card, rotated through the set
- ornamental marks small and consistent, in a single accent ink
- continuous slow auto-scroll, pause on hover, infinite loop
Avoid:
- emoji or 3D plastic stickers in corner metadata
- mixing portrait + landscape card aspects randomly
- adding step numbers (this is not a tutorial)
- decorative gradients behind images — the mask is the decoration
- 黑金 / neon palettes — the template's identity is editorial / warm
## Platform / Size
A horizontal strip of vertical cards (~ 360×540 each). Designed for embedding on a long page or capturing as a wide image. If the user wants discrete XHS posts, export each card individually.
@@ -0,0 +1,431 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>专辑选辑</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
html, body {
height: 100vh; height: 100svh;
overflow: hidden;
background: #0a0a0a;
color: #f4f4f4;
/* 中文字体放在前面,避免破折号/引号被英文字体抢走(破折号杀手)。lang="zh-CN" 配合激活字体 locl */
font-family: "PingFang SC", "MiSans", "HarmonyOS Sans SC", "Hiragino Sans GB", "Microsoft YaHei UI", -apple-system, BlinkMacSystemFont, sans-serif;
overscroll-behavior: none;
touch-action: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* 中文断行兜底:按词断而非按字硬断 */
word-break: keep-all;
overflow-wrap: break-word;
line-break: strict;
}
::selection { background: #f4f4f4; color: #0a0a0a; }
.stage {
position: fixed;
inset: 0;
display: flex;
align-items: center;
overflow: hidden;
}
.lane {
position: relative;
width: 100%;
height: 70vh;
overflow: hidden;
cursor: grab;
}
.lane.is-dragging { cursor: grabbing; }
.track {
position: absolute;
top: 0;
left: 0;
height: 100%;
display: flex;
align-items: center;
gap: 28px;
padding: 0 40px;
will-change: transform;
}
.card {
position: relative;
flex: 0 0 auto;
width: 360px;
height: 540px;
background: #fafaf7;
color: #111;
border-radius: 6px;
overflow: hidden;
user-select: none;
}
.card.tall { width: 320px; height: 540px; }
.card.wide { width: 420px; height: 540px; }
.media {
position: absolute;
inset: 0;
overflow: hidden;
}
.media img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
-webkit-user-drag: none;
}
/* ===== 几何切割层 ===== */
/* 拱形:底部贴边的拱门遮罩 */
.geo-arch {
position: absolute;
left: 24px; right: 24px; bottom: 0;
height: 360px;
overflow: hidden;
border-top-left-radius: 999px;
border-top-right-radius: 999px;
}
.geo-arch img { width: 100%; height: 100%; object-fit: cover; }
/* 椭圆框 */
.geo-oval {
position: absolute;
left: 28px; right: 28px;
top: 60px;
height: 380px;
overflow: hidden;
border-radius: 50%;
}
.geo-oval img { width: 100%; height: 100%; object-fit: cover; }
/* 圆形剪裁 */
.geo-circle {
position: absolute;
top: 32px; left: 50%;
transform: translateX(-50%);
width: 240px; height: 240px;
border-radius: 50%;
overflow: hidden;
}
.geo-circle img { width: 100%; height: 100%; object-fit: cover; }
/* 半圆贴边(顶部圆形) */
.geo-halftop {
position: absolute;
left: 0; right: 0; top: 0;
height: 60%;
clip-path: ellipse(75% 100% at 50% 100%);
overflow: hidden;
}
.geo-halftop img { width: 100%; height: 100%; object-fit: cover; }
/* 对角切 */
.geo-diag {
position: absolute;
inset: 0;
clip-path: polygon(0 0, 100% 0, 100% 65%, 0 100%);
overflow: hidden;
}
.geo-diag img { width: 100%; height: 100%; object-fit: cover; }
/* 满版渐变照片背景 */
.geo-full { position: absolute; inset: 0; overflow: hidden; }
.geo-full img { width: 100%; height: 100%; object-fit: cover; }
/* 双圆叠加装饰 */
.ring {
position: absolute;
border: 1.5px solid currentColor;
border-radius: 50%;
pointer-events: none;
}
/* ===== 卡片文字 ===== */
.title {
position: absolute;
left: 24px; right: 24px;
bottom: 60px;
/* 中文宋体栈(编辑/文学气质)。中文优先,避免标点回落到 Latin 字体 */
font-family: "Songti SC", "Source Han Serif SC", "Noto Serif CJK SC", "STSong", "SimSun", "Times New Roman", "Georgia", serif;
font-weight: 500;
font-size: 30px;
line-height: 1.22;
letter-spacing: 0;
color: #111;
}
.title.light { color: #fafaf7; }
.title.center {
left: 50%;
right: auto;
bottom: 50%;
transform: translate(-50%, 50%);
text-align: center;
width: 80%;
}
.title.top {
top: 64px;
bottom: auto;
}
.mark {
position: absolute;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center;
}
.mark svg { width: 100%; height: 100%; }
.mark.light { color: #fafaf7; }
.meta {
position: absolute;
left: 24px;
top: 24px;
/* 中文不用合成斜体;改用 sans + 适度字距营造编辑部感 */
font-family: "PingFang SC", "MiSans", "HarmonyOS Sans SC", "Hiragino Sans GB", -apple-system, "Helvetica Neue", sans-serif;
font-style: normal;
font-size: 11px;
letter-spacing: 0.08em;
color: rgba(17,17,17,.55);
font-weight: 500;
}
.meta.light { color: rgba(250,250,247,.7); }
.meta.right { left: auto; right: 24px; }
@media (max-width: 900px) {
.card { width: 280px; height: 440px; }
.card.tall { width: 240px; height: 440px; }
.card.wide { width: 320px; height: 440px; }
.title { font-size: 24px; bottom: 50px; }
.geo-arch { height: 280px; }
.geo-oval { height: 300px; top: 50px; }
.geo-circle { width: 180px; height: 180px; }
}
</style>
</head>
<body>
<main class="stage">
<div class="lane" id="lane">
<div class="track" id="track">
<!-- 卡片由 JS 注入,便于轮播循环 -->
</div>
</div>
</main>
<script>
(() => {
// ===== 卡片数据:标题 / 图片 / 几何样式 =====
// 图片来自 Unsplash 公开 CDN(可直连)
const U = (id, w=900) => `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${w}&q=80`;
const CARDS = [
{
kind: 'arch',
bg: '#f3efe6',
img: U('1506905925346-21bda4d32df4'), // 山峦
title: '成为\n十倍速设计师',
meta: '第 01 辑',
titleClass: '',
},
{
kind: 'full',
bg: '#e9c3a0',
img: U('1554034483-04fda0d3507b'), // 暖橘渐变
title: '为躁动心智\n打造的\n随取工坊',
meta: '第 02 辑',
titleClass: 'light center',
markLight: true,
},
{
kind: 'oval',
bg: '#ffffff',
img: U('1465146344425-f00d5f5c8f07'), // 草地花
title: '向一株草,\n学习耐心。',
meta: '第 03 辑',
titleClass: 'top',
titleAlign: 'right',
},
{
kind: 'circle',
bg: '#eef0ea',
img: U('1470071459604-3b5ec3a7fe05'), // 雾山
title: '慢生活\n手帐',
meta: '第 04 辑',
titleClass: '',
},
{
kind: 'halftop',
bg: '#f6f1ea',
img: U('1487958449943-2429e8be8625'), // 建筑
title: '都市札记 ——\n2026。',
meta: '第 05 辑',
titleClass: '',
},
{
kind: 'full',
bg: '#c9d4d0',
img: U('1502134249126-9f3755a50d78'), // 暮色山景
title: '多做事,\n少说话。',
meta: '第 06 辑',
titleClass: 'light',
markLight: true,
},
{
kind: 'diag',
bg: '#efe7dc',
img: U('1518837695005-2083093ee35b'), // 海洋
title: '漂流\n状态。',
meta: '第 07 辑',
titleClass: '',
},
{
kind: 'arch',
bg: '#ece6db',
img: U('1469474968028-56623f02e42e'), // 风景
title: '一份\n安静工作\n田野指南',
meta: '第 08 辑',
titleClass: '',
},
];
// ===== 渲染卡片 =====
const SHAPES = {
arch: `<div class="geo-arch"><img src="{IMG}" alt=""></div>`,
oval: `<div class="geo-oval"><img src="{IMG}" alt=""></div>`,
circle: `<div class="geo-circle"><img src="{IMG}" alt=""></div>`,
halftop:`<div class="geo-halftop"><img src="{IMG}" alt=""></div>`,
diag: `<div class="geo-diag"><img src="{IMG}" alt=""></div>`,
full: `<div class="geo-full"><img src="{IMG}" alt=""></div>`,
};
const markSVG = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4">
<circle cx="12" cy="12" r="10"/>
<path d="M7 12c3-5 7-5 10 0M7 12c3 5 7 5 10 0"/>
</svg>`;
function buildCard(c) {
const widthCls = c.kind === 'full' ? 'tall' : (c.kind === 'oval' ? 'wide' : '');
const shape = SHAPES[c.kind].replace('{IMG}', c.img);
const titleHTML = c.title.split('\n').map(l => `<div>${l}</div>`).join('');
const metaPos = c.titleAlign === 'right' ? 'right' : '';
const metaCls = c.markLight ? 'light' : '';
const titleStyle = c.titleAlign === 'right' ? 'text-align:right;' : '';
return `
<article class="card ${widthCls}" style="background:${c.bg}">
${shape}
<div class="meta ${metaCls} ${metaPos}">${c.meta} · 编辑专栏</div>
<h2 class="title ${c.titleClass}" style="${titleStyle}">${titleHTML}</h2>
<div class="mark ${c.markLight ? 'light' : ''}">${markSVG}</div>
</article>
`;
}
const track = document.getElementById('track');
// 双倍渲染以实现无缝循环
const html = CARDS.map(buildCard).join('');
track.innerHTML = html + html;
// ===== 横向轮播:自动滚 + 拖拽 =====
const lane = document.getElementById('lane');
let x = 0; // 当前位移
let vx = -0.45; // 自动速度(px/帧)
let halfWidth = 0; // 一组卡片宽度(包含 gap 与左 padding)
let isDown = false;
let lastX = 0;
let dragVX = 0;
let paused = false;
function measure() {
// 一组卡片的总宽:第二组第一张距 track 左边的距离 = 一组宽度
const cards = track.children;
if (cards.length < CARDS.length + 1) return;
const first = cards[0];
const secondGroupFirst = cards[CARDS.length];
halfWidth = secondGroupFirst.offsetLeft - first.offsetLeft;
}
function tick() {
if (!isDown && !paused) {
x += vx;
}
// 无缝循环
if (halfWidth > 0) {
if (x <= -halfWidth) x += halfWidth;
if (x > 0) x -= halfWidth;
}
track.style.transform = `translate3d(${x}px,0,0)`;
requestAnimationFrame(tick);
}
// 拖拽
lane.addEventListener('pointerdown', e => {
isDown = true;
lastX = e.clientX;
dragVX = 0;
lane.classList.add('is-dragging');
lane.setPointerCapture(e.pointerId);
});
lane.addEventListener('pointermove', e => {
if (!isDown) return;
const dx = e.clientX - lastX;
lastX = e.clientX;
x += dx;
dragVX = dx;
});
function endDrag(e){
if (!isDown) return;
isDown = false;
lane.classList.remove('is-dragging');
// 惯性:把最后一次拖拽速度带入自动速度,再缓慢回到默认
const target = -0.45;
let v = Math.max(-12, Math.min(12, dragVX));
const ease = () => {
v = v * 0.92 + target * 0.08;
vx = v;
if (Math.abs(v - target) > 0.02) requestAnimationFrame(ease);
else vx = target;
};
ease();
}
lane.addEventListener('pointerup', endDrag);
lane.addEventListener('pointercancel', endDrag);
lane.addEventListener('pointerleave', endDrag);
// 滚轮横向滚动(鼠标横滑或竖滚都映射到 x)
lane.addEventListener('wheel', e => {
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
x -= dx * 0.6;
e.preventDefault();
}, { passive:false });
// 悬停轻微减速
lane.addEventListener('mouseenter', () => { paused = false; vx = -0.2; });
lane.addEventListener('mouseleave', () => { vx = -0.45; });
// 等图片加载好再测量
const imgs = track.querySelectorAll('img');
let loaded = 0;
const onReady = () => { if (++loaded >= imgs.length) measure(); };
imgs.forEach(img => {
if (img.complete) onReady();
else { img.addEventListener('load', onReady); img.addEventListener('error', onReady); }
});
// 兜底,1.2s 后强制测量一次
setTimeout(measure, 1200);
window.addEventListener('resize', measure);
requestAnimationFrame(tick);
})();
</script>
</body>
</html>