மாற்று பதிவேடு
என்ன புதிது.
அது கப்பல்கள் போல், தளத்தில் ஒவ்வொரு மேம்பாடு.
· SecurityService
The demos booked on a lead, on the lead
GET **************** The lead page's Webinars section has called /api/webinar/leads/{id} since it was written and nothing ever served it — the live API answers 404, and the component reads a 404 as "the webinar feature is off" and renders nothing. So a demo booked from the marketing site created a lead, linked the booking to it, and never showed on it. Served here rather than in MediaService, which owns the bookings, because the question a caller has to pass first is "may you read this lead": valid session, lead in the caller's organization, then LeadCommsReadGate — own lead needs no right, an unassigned one needs VIEW_UNASSIGNED_LEADS, somebody else's needs VIEW_LEAD_CONTACT_INFO_OTHERS, god bypasses. The gate is the one the communications tab uses, called, not copied. Denials are recorded through PhiAccessAuditor; a read is not, because the response carries no contact data — the same line the /communications/counts endpoint draws. LeadWebinarEntry is title, start/end, presenter name and the two statuses. Nothing about the attendee beyond whether they are still coming: the visitor's name and address are the lead's own and already on the page. A visitor who cancels and registers again produces a second link to the same attendee row, and the section shows that once. The five failures in the full suite **************** x4, **************** — which flags **************** not this) fail identically on origin/main without this change. This endpoint resolves a session, so the ratchet does not name it. 2712 tests otherwise green.
· kamo-internal
A window the member brings to the front lands with a light you cannot miss
Clicking a hexhead, or the strip of a window standing behind, brought the window forward with a faint shadow bloom (strip) or a 600ms green glow (hexhead) - easy to lose while the rows rearrange round it. Now the frame itself lights up: a ring inside the window's edge with two comets racing round it, ripples spreading out from the frame and a glow that holds, about 2.4s, then out. - Lit by every member gesture that brings a window forward: a strip click, a hexhead click or restore (tool:focus), and any open the member asked for. Incoming raises stay quiet. - One at a time: the newest arrival dims the one before, so the lit window is always the one the member just called. - Reduced motion keeps the cue and drops the motion: the ring and glow light up without moving. - Its own colour pair at :root (--dock-arrival, --dock-arrival-ring); the title bar already wears the brand, and a brand ring disappears along it.
· kamo-shared-library
A presenter's viewed stamp, so a booked session can badge them
The Support icon's webinar count reads WebinarAttendee.dateViewed. A presenter is not an attendee row, so a session booked with them was emailed and then never mentioned in the app: the badge stayed at zero for the one person who has to run it. **************** (PRESENTER_DATE_VIEWED, null = not yet shown) is that stamp for the assigned presenter, and ScheduledWebinarRepository gains countUnreviewedForPresenter / findUnreviewedForPresenter with the attendee queries' predicate: the presenter's own SCHEDULED sessions with no stamp. WebinarQueryParseTest parses both against the mapping. DDL, already applied to the live database as the table owner: ALTER TABLE public.scheduled_webinar ADD COLUMN IF NOT EXISTS presenter_date_viewed timestamp(6) without time zone; The five failures in the full suite (SystemBugCountContractTest, PhiServiceTypeMappingTest, StorageDomainCoverageTest, **************** ReportVisibilityTest) fail identically on origin/main without this change.
· kamo-marketing
A live demo button beside Start Free Forever
The home hero offered two things: start free, or read the pricing. It now also offers half an hour with a person. "Book a Live Demo", with "30 minutes" on a second line, sits to the right of Start Free Forever and opens /demo, the existing booking flow ("Half an hour with someone who knows the product"). It is the CTA strip's glass button, at .hero-cta's 56px height and radius, so Start Free stays the one primary. The two buttons are grouped with a 12px gap, and View Pricing follows them. The length is on its own line for the width. As one line, "Book a 30-Minute Live Demo" was 291px and dropped under Start Free at 1280, 1366 and 1440 wide. Two lines make it 196px, and the pair fits the copy column from 1280 up and at 768. Only at 1024 (a 361px column) and on phones does it stack underneath. Measured in Chrome at 390, 768, 1024, 1280, 1366, 1440 and 1920, with no horizontal overflow at any of them. View Pricing now wraps to its own row at laptop widths, and that exposed its border-bottom: TouchOptimized raises the link to 44px tall once the page is idle, so the border sat 18px under the text and read as a divider. It is a text underline now, with the text centred in its box. The keys went to kamo-translation-dictionary first (7ed8a400).
· kamo-marketing
A page for unlimited email campaigns and drips, linked from everywhere it belongs
/email-marketing sells what shipped on 2026-09-16 (broadcast campaigns, drips, sending domains): unlimited, and 100% free with Business and Enterprise. The page's one bold element is the slider every email-marketing pricing page opens with ("How many contacts do you have?"), except that moving it never changes the bill. It quotes nobody else's price, per 206f9f3: the argument is the metering model (contact tiers, send allowances, automation kept for higher plans), and the only figure on the page is our $0. Everything it claims was checked against the product: - 14 drip triggers, send windows in the contact's own time zone, and open/click conditions with skip or exit (dripCatalog). - Five of the ten recipes, drawn with their real triggers, timings and exits (dripRecipes.ts). - A 14-day warm-up with a safety brake on blocks and bounces (WarmupSettings, WarmupPlan). - List hygiene reasons, bounce/complaint suppression, one-click unsubscribe and custom SMTP hosts. - "Unlimited" means no plan-based cap: nothing in EmailService meters an org, and the page says that warm-up and send windows set the pace. Links in: the nav and footer, a /features category, a /product card, a callout under the /pricing plan cards, the roadmap (Bulk Email Campaigns moves to Launched), the site map and sitemap, and a ninth card in the homepage's "Stop Paying for Nine Different Tools". A server strip (EmailMarketingStrip) goes under the feature matrix on every competitor comparison, under "What's actually provided" on every industry page, on five glossary terms and on both resource articles. PLATFORM_CAPABILITIES, which calls itself the list of what KamoCRM provides, now lists email marketing. Four industry pages said things drips now do were requestable: online-courses, hotels, fitness-studios and ecommerce. Their copy is corrected, keeping what is still unbuilt (course-platform and attendance triggers, stay-based timing, LTV segmentation, SMS) as requestable, and each item moves to an -r3 i18nKey. Verified on a local production build: every guard, a prerendered route, and a headless-Chrome probe (42 checks) at 390, 1440 and 2560 wide covering no horizontal scroll, no console errors, no raw message keys, keyboard use of the slider and recipe tabs, and a link in the page body of every page above. The dictionary side is kamo-translation-dictionary 3ecaefa5.
· kamo-marketing
Play the kamo14 reel, and let it be paused
The recut reel (new closing logo shot, otherwise frame-identical, same 66s running time and chapter timings) is published under its own name. The kamo13 ladder is served immutable and its variant playlists and segments keep their URLs whatever the master's ?v= says, so an in-place replacement would never reach a returning visitor. The hero aperture offered restart and mute but no way to stop the film once it was playing. A pause/play control now leads the control row, and tapping the picture toggles it too; resuming keeps the position. The control follows the element's own play/pause events, so it stays right when the run ends or the browser pauses playback itself. This covers both pages that embed the aperture (home and the HighLevel fees page); /watch already pauses through the native controls.
· KlusterServices
Encode the kamo14 reel
The marketing reel was recut (new closing logo shot) and uploaded as public/kamo14.mp4. It gets a new name instead of replacing kamo13 in place because the ladder is served immutable: the master's ?v= does not reach the variant playlists or segments, which keep their relative URLs. The encode job now builds public/hls/kamo14/ and kamo14-poster.*, and the MediaMTX live channel loops the kamo14 ladder. The live/kamo13 path name is unchanged.
· kamo-internal
Windows that do not fit the dock stand in rows behind it
A tool window that does not fit the front row now stands in a row BEHIND it, one title strip higher per row: its 42px title bar and 10px of body show above the row in front, like the tabs of a stack of folders. This replaces the cascade, which placed every window over the row and buried most of them. The rows are the window order laid out first-fit (toolDockPlacement, layoutDockStack): a window goes into the front-most row with room for it, so the front row is never left half empty while a window that fits waits behind. Every row shares one height. When the rearmost strip would pass the top margin, every window gives way together, and the height comes back as the rows empty out. The row, the lift and the strip height are written onto each window in the same relayout as dockX (dockRows), and are never persisted. Who stands in front is a reorder (toolDockTiers): - A click on a strip trades places with the front window the member used most recently. Recency is tracked from member gestures only (dockMemberUse), because the z counter also climbs for incoming raises. - A window the member opens, restores from its hexhead, puts back from the pane, docks or raises from a notification is seated in front, trading with the front window used LEAST recently. Windows raised by incoming messages go wherever there is room, so they never take the place of the one in use. - A drop into the dock keeps the place it was dropped at; the least recently used front windows step back to make room. - A front window closing or minimized is replaced by the window standing nearest behind it, and nothing else moves. - A wider window coming forward steps least-recently-used front windows back until it fits. On screen (ToolWindowShell, ToolDock): - A window behind is clipped to its strip, which is also its hit area. Its body is inert, so it takes no focus or keystroke and paints no scrollbar. It paints under every front window and is never told it is focused. - The strip's title is a button (bring forward, keyboard reachable), shows the unread count, and dims with each row further back. - Its close, minimize, maximize and float buttons act on it where it stands. A drag that starts on a strip is not also a click, and neither is a double-click. - Coming forward, the window drops and unfolds into the slot while the traded one folds and rises behind. Both run on the dock's 340ms curve, then a shadow bloom and a light sweep mark the arrival. Hovering a strip lifts it and uncovers a little more of it. A window born behind peeks up from behind the row in front; one born in front rises out of the bottom edge, opaque. - Reduced motion keeps the rows and the shading and drops all movement. - Resize seams are front-row only. The height seam stops where the rearmost strip meets the top margin, and announces that ceiling. Found on the way, and fixed: the dock rendered windows in store order, so any reorder (including drag-reorder, before this change) made React move window elements around the document. A moved element loses DOM state: a log scrolled to 300 came back at 0, a playing media element is paused, and the transition carrying a window to its new slot is dropped. ToolDock now renders windows in creation order, which is safe because every window is fixed at its own painted level. Verified in real Chrome with the whole dock on stubbed window bodies: 64 measured checks. They cover rows, clip and hit-testing, inert bodies and scrollbars, focus, hover, both swap directions, keyboard, strip buttons, height give-and-take, drag from a strip, reduced motion, maximize and restore, viewport re-flow, a drop past the front row, the seam ceiling and preserved scroll positions, plus real-time screencasts of every transition. New unit and provider tests cover the layout, reorders, stacking, shell and dock.
· kamo-marketing
Seven link sets that match the nav, and careers goes to the careers site
The footer now lists Features, Services, Pricing, Company, Resources, Solutions and Legal, in that order. That is the top nav's own five categories plus the two it nests under Resources, and each set holds the nav's links under the nav's labels. The labels come from the `nav` namespace, which is translated in every locale, so this adds no copy. The footer's translations were identical key for key anyway. Two links exist only down here: Watch the Film under Features, and the Cookie Settings button under Legal. Product was the old heading and is gone. Pricing and Solutions get sets of their own, and Legal no longer wraps alone onto a second row. Careers is https://careers.kamocrm.com/ everywhere it appears: the nav's Company menu (desktop and mobile), the footer, the Company page card and the site map. The URL lives in app/lib/careers-site.ts. /careers is now a permanent redirect there, like /vs, so search results, bookmarks and outside links reach the open roles too. The marketing page it replaces said there were no openings while the careers site lists one. The page, its client component, layout and OG image are deleted. The route is out of the sitemap (SITEMAP_EXCLUDED, with the reason), out of the page-view counter (UNCOUNTED_STATIC_ROUTES) and out of the hunt's pages, because a stage cannot hide on a page that redirects off the site. The hunt picks pages by index modulo the list, so today's stage pages reshuffle once on deploy, the same as a day rollover. The link is not rehosted per tenant the way register./login. links are. Only www.kamocrm.com serves this site today. Five of the eight tenant domains I checked have no careers. DNS at all, so careers.<apex> would be a dead link on them. Layout: the footer uses the full width instead of the 1280px column, and the brand row (logo and tagline, social icons and phone) sits above the sets. The sets are 2 columns on a phone, 3 from sm, 4 from md and one row of 7 from xl. Headings and labels may break inside a word. A translated word can be wider than a 149px column (de "Dienstleistungen", "Anwendungsentwicklung"), and without `max-w-full` an inline-block link grows to its longest word and pushes past the column. Verified against the running page, not the source. On /en/about the seven headings render in order, and every link resolves to its /en route except Careers, which is https://careers.kamocrm.com/. Real clicks on Careers in the desktop Company dropdown, the mobile menu and the footer each navigate to https://careers.kamocrm.com/. /en/careers and /de/careers answer 308 to it, and /careers goes through /en/careers. I measured every column at 390, 768, 1024, 1280, 1440, 1920 and 2560px in en, at 390, 768 and 1280 in de, ru and fr, and at 768 and 1280 in ta: no element overflows its column and there is no horizontal page scroll. check-i18n-keys was mutation-tested against the new footer (a bogus nav key fails it). npm test, check-hunt, tsc and npm run build (with its pre- and postbuild guards) are clean.
· kamo-internal
Drip campaigns on /marketing/email — triggers, a visual builder, people and performance
/marketing/email gets a Broadcasts / Drips switch (?view=drips). The drips view lists every drip as a card with its trigger, its sequence and live counts, KPI and status tabs, search and sort, and starts a new drip blank or from ten recipes (welcome new leads, birthday greetings, meet your new agent, claimed lead introduction, status nurture, re-engage non-openers, clicked link follow-up, team member and member onboarding, and more). /marketing/email/drip is the builder: - Trigger: a new lead (by hand, imported, integrations; markets, vendors, products), a status or sub-status change from/to, a reassignment, a credit or free-for-all claim, birthdays and anniversaries, a team member or member account created, a campaign email sent / opened / link clicked, no engagement with a campaign after N days, finishing another drip, or people added by hand. - Emails on a canvas: add, duplicate, drag to reorder, a wait before each (minutes to weeks, from the email before it), a send window per email or for the drip (days, hours, the contact's or the organization's time), and a condition (only if the previous email was or was not opened or clicked, otherwise skip it or leave the drip). - Settings: From and Reply-To exactly as a campaign (the shared pickers), the signature template, who can join again (once, after leaving with a cooldown, or restart), and when people leave (a status change, a reassignment, a click). - A readiness strip, Save (Ctrl/Cmd+S), Activate with a burst, Pause, Archive, Duplicate, Delete; unsaved edits are guarded and a colleague's change arrives live. - People: who is in the drip, where they are and when their next email goes, each person's timeline, add people by hand, take someone out. Performance: joined / in progress / finished / left, an email funnel, and live activity. A new drip, blank or from a recipe, is not created until its first save, as with a new campaign, so every email is stored with the template and the compliance footer. The campaign editor's template and footer context moved into useCampaignEmailTemplate, shared with the builder (saved bodies and requests compared identical before and after). CountUp now writes a first target of 0 instead of a dash. EmailService serves /api/email/drips and "drip" frames on the campaigns topic; the English is in the dictionary.
· kamo-marketing
Every page shows how many times it has been viewed
A view counter now sits at the foot of every page, in the footer's bottom row beside Sitemap and Back to top: an eye whose iris is the site's hexagon, and the figure on odometer reels in the reader's own digits (Bengali's, Arabic's, Hindi's grouping). The first time the pill is on screen the reels spin into place from the left, and when this visit was the one just counted the last reel turns over by one as a +1 rises off it. Hover or keyboard focus opens a card with the figure and what it measures; a tap opens it on touch; a click spins the reels again. Reduced motion gets the figure and the card, and nothing that travels. It costs page load nothing: - Nothing about a view is read while a page renders. Every route is still prerendered (check-static-routes: 247, sentinels intact); the HTML carries a sized placeholder, so the swap does not move the row. - The count is one POST through the shared idle queue, after the page has loaded, and it counts and answers in the same request. A reader who leaves before the idle slot still counts: pagehide and in-site navigation send a beacon for an unsent view. - The reels, their motion and their stylesheet are a separate chunk, fetched only when the footer comes within 800px of the screen. - "No figure" answers 200 with views null, never 404 or 503, so a KBService restart cannot put a console error on every page. Where a view counts **************** a knowledge-base article counts on the article, by guid, so the figure is part of the article's data in KBService and follows it through renames and moves; every other page counts on its path without the locale, so /en/pricing and /fr/pricing share one figure. A path only counts if this site would serve it: the route exists and its slug names a real competitor, glossary term, service, blog post, published article (at its canonical path) or changelog entry. Crawlers, link unfurlers, audits and webdriver are shown the figure without adding to it, and the same browser sending the same page twice inside five seconds is one view. Each counter starts from a figure drawn between 312,384 and 1,200,384 when its row is created, placed by the page's sitemap priority so the homepage starts high and a changelog entry low. The sitemap's inline priorities moved into SITEMAP_PRIORITY so both read one list; kb-sections.ts replaces the two copies of the KB_SECTIONS check the counter would otherwise have made three. The footer's bottom row stacks the made-in mark under the copyright line: with the counter added, the three side by side no longer fit at 1440px. check-page-views.ts (prebuild and test) fails the build when a dynamic route is not in routes.ts, when a layout or the Footer stops mounting the counter, or when anything but its route handler imports the resolver. Proved by deleting a route entry, the Footer mount and inventing a stale entry: each fails. Needs KBService 167981e (the /internal/page-views endpoints) and the dictionary's footer.pageViews* keys (5909948c); until KBService is live the pill simply does not appear.
· KBService
Count the marketing site's page views, on the article or on the page
KBService now answers the view counter kamo-marketing shows at the foot of every page. Two endpoints, both {"views": n} with the view just counted included: POST **************** {importance, count} POST /internal/page-views/pages {path, importance, count} A knowledge-base article counts on the article itself (KB_ARTICLE_VIEWS, by uid), so its count follows it through renames and moves in /kb; the guid must name a published, public article, by the same rule the public article endpoints use. Every other page counts on its path below the locale prefix (MARKETING_PAGE_VIEWS). Each write is one native upsert with the total read back in the same transaction, retried on a Yugabyte conflict. Under /internal, not /api/kb/public: APIService forwards all of /api/kb/** from api.kamocrm.com and this service's security chain permits everything, so a counting endpoint there would be a public curl loop away from any figure. The new InternalAuthFilter requires X-Internal-Auth (mlos-internal-auth, now mounted optionally) on the whole /internal prefix, decided on the decoded servlet path, and answers 503 rather than opening up when the secret is missing. The marketing site's route handler is the only caller and makes every decision about what counts: real pages only, crawlers shown the figure without adding to it, a repeat from one browser inside five seconds counted once. A counter's starting figure is drawn once, when its row is created, between 312,384 and 1,200,384. The caller passes an importance from 0 to 1 (the page's sitemap priority) and PageViewSeed places the draw in a band around it, so the homepage starts high and a changelog entry low; the extremes land exactly on the range's ends without clamping. The seed is stored apart from the counted views, so real traffic stays readable. Rows appear on first request; count=false creates one without counting, for crawlers and for seeding. create_page_views.sql is the DDL, applied by hand to the live database before this push and matching what Hibernate generates for the shared-library entities (checked on 6.2 and 6.5). The ConfigMap gains internal.auth.secret, and so does the repo copy. 23 new tests: the seed range and bands, canonical paths and guids, which statement a view runs, and the endpoints through the real filter (no secret, wrong secret, unset secret, bad path, unknown article). Removing the filter's prefix check and inverting the count flag each turn them red.
· kamo-shared-library
Page-view counters for the marketing site
Two tables behind the view count kamo-marketing now shows at the foot of every page, written by KBService's /internal/page-views endpoints. KbArticleView (KB_ARTICLE_VIEWS) counts a knowledge-base article's views on the ARTICLE, keyed by its uid, so the count is part of the article and follows it when it is renamed or moved in /kb. It is a table of its own rather than a column on KB_ARTICLES: the article row is saved whole by JPA, so a counter on it would be read when an editor opened the article and written back when they saved, discarding every view in between. The foreign key cascades in the database, because KbArticle does not map this row and a Hibernate delete of an article would otherwise fail on it. MarketingPageView (MARKETING_PAGE_VIEWS) counts every other page on its path below the locale prefix, so /en/pricing and /fr/pricing share one count. Both keep SEED_COUNT, the starting figure a row is created with, apart from VIEW_COUNT, the views actually counted; the page shows their sum, and real traffic stays readable from the table. The repositories expose only the three statements a view needs: a native upsert that counts, one that seeds without counting, and the total, read after the write in the same transaction. No save(): a counter loaded, incremented and saved back loses concurrent views. Checked offline: a session factory builds over all eleven KB entities plus these on Hibernate 6.2.13 (KBService, and the other Boot 3.1 services) and 6.5.3 (Boot 3.3), and the schema script it writes matches the DDL applied by hand, including ON DELETE CASCADE. ddl-auto is none everywhere but the initializer, so no other service touches the tables.
· kamo-internal
The Lifestyle Logo, a fourth logo per brand and the default background watermark
Behavior & Labeling gains a Lifestyle Logo slot beside the full logo, the simple logo and the tab icon, in both the company and the software (white-label) rows. It takes pixel artwork and vectors alike: an SVG or a raster is kept exactly as picked, an EPS/AI/PDF is converted on the way in (no browser can draw one), and a raster offers "Convert to vector", which opens the shared ImageToSVGConverter only when the member asks. Like the tab icon, each slot has a switch (an upload turns it on, server-side too) and a caption that says what a lifestyle surface will actually draw, from the unsaved form. The lifestyle logo defaults to the full logo when not set. For each brand, resolveLogoVariant in theme/brand.ts is the chain: the software lifestyle logo when in use, else the software full logo when it overrides the company one, else the company lifestyle logo when in use, else the company full logo. lifestyleLogo.ts states the same chain as captions; a test runs both over all 32 form states. Background Watermark offers Lifestyle Logo first, and it is the default: null and unknown values resolve to LIFESTYLE, which draws the full logo until a lifestyle logo exists, so no org's watermark changes until it uploads one. Every place that paints the watermark (BackgroundWatermark, Backdrop, the error and not-found pages) now goes through resolveWatermarkVariant / watermarkLogoFile. - /network/create: a Lifestyle logo group with the same intake, uploaded after the org exists as type=lifestyle; the watermark picker offers Lifestyle first and the form defaults to it. - Setup wizard: Logos gets company and white-label lifestyle slots (showing the live one when in use), Backgrounds offers the Lifestyle watermark, and orgDetails resolves the default the same way. - The org route's schema reads bgWatermark as a plain string. It .parse()s, so the old ["NONE","FULL","SIMPLE"] enum would have thrown on the whole org record for a LIFESTYLE row; Organization.fromJSON resolves unknown values instead. The four lifestyle fields ride the org record through OrgContext, and DynamicMetaLoader keeps **************** from config.json. Needs kamo-shared-library f22a867d and securityservice's upload types (pushed first), and the dictionary keys (pushed first, in every locale).
· SecurityService
Lifestyle logo uploads, config.json keys, and the LIFESTYLE watermark
The lifestyle logo is a fourth logo per brand (company and white-label), the decorative rendition the background watermark now draws by default. Its fallback is the full logo. - upload-logo takes type=lifestyle and type=lifestyle-software. The file keeps its real extension (photographic artwork is welcome, tracing a raster is the member's choice at upload time) under **************** the ext column records it, and the upload switches the logo on, on the tab icons' contract. A raster is also mirrored under the .svg name as a stand-in, like the full logos, so a surface that has not read the extension yet still shows the org's own artwork. - config.json names logoLifestyleExt / logoLifestyleSoftwareExt only while that logo is in use (uploaded AND switched on); absent means the full logo stands in. - The create payload keeps bgWatermark LIFESTYLE instead of dropping it to null. - **************** adds the four nullable columns. Applied by hand on the live cluster on 2026-09-16, before kamo-shared-library's entity change was pushed.
· kamo-shared-library
Lifestyle logo columns, and LIFESTYLE as the watermark default
The lifestyle logo is a fourth piece of branding artwork per brand, beside the full logo, the simple logo and the tab icon: the decorative rendition the background watermark draws by default. It is shaped exactly like the tab icon pair: logo_lifestyle_ext (and its software twin) is written only by SecurityService's upload endpoint from the file it stored, and doubles as the "one exists" marker; logo_lifestyle_enabled is the switch beside it. PUT /org/{id} may patch the two switches, never the extensions. bg_watermark now documents LIFESTYLE, which every client resolves null to. It is the full logo for as long as an org has no lifestyle logo in use, so nothing anyone sees changes until one is uploaded. The four columns come from **************** applied by hand on the live cluster before this push: any Java service built after it SELECTs them on every org read.
· EmailService
Drip campaigns
A drip is an automated sequence of emails a person enters when something happens: a lead arrives (by hand, imported, or from an integration), a lead's status or sub-status changes, a lead is reassigned or claimed with a credit, a lead's birthday or anniversary comes round, a team member or member account is created, a campaign email is sent, opened, clicked or goes unanswered, someone finishes another drip, or a member adds them by hand. Every email has its own wait, send window (in the org's or the contact's time zone) and condition on how the previous email did. Sending. A drip email is queued on a hidden edition campaign per (step, cycle) through **************** so it goes out exactly like campaign mail: opt-out and eligibility checks, the releaser's hosts, warm-up and send window, DKIM, tracking, unsubscribe, bounces and the staged outbox. Editions are left out of the campaigns list, the snapshot, the home widget and the progress and engagement frames, and the campaign endpoints answer 404 for one. Engine (drip/DripEngine): every 5s on its own thread under lease email-drip-engine, it drains email_drip_triggers (exits first, then enrolments), sweeps birthdays and anniversaries once per org-local day, and moves each due person on: waits count from when the previous email was actually sent, a bounce or opt-out ends the run, a person is read again before each email (merge tokens: first_name, agent_name, market and the rest), and a finished run lets chained drips start. Re-entry is once, again after finishing (with a cooldown) or restart; exit rules take people out on a status change, a reassignment or a click on a drip link. API: /api/email/drips (list, options, CRUD, activate, pause, archive, unarchive, duplicate, people, manual enrolment, removal, timelines, activity), VIEW_BULK_EMAIL to read and MANAGE_BULK_EMAIL to change. Live: type "drip" frames on the campaigns topic, coalesced per second. Campaign sends, first opens and link clicks (now with the URL) are handed to kamo-shared-library's DripTriggerRecorder. Tables and the edition columns were applied by hand **************** before this commit.
· SecurityService
An imported lead is recorded as imported
LeadImportController created every row with the two-argument createLead, so a lead's history said source API with nobody acting, exactly like one sent in through the vendor intake API. Nothing could tell an import from an integration, and drip campaigns that start on "a new lead arrives" offer the choice between leads added by hand, imported, or from integrations. Rows are now created with source IMPORT. Built against kamo-shared-library b5b56d6d, whose lead history also hands new leads, status changes, reassignments, credit claims and new member accounts to the drip engine.
· kamo-internal
The campaign editor's Reply-To is a choice — NoReply, Member's E-mail or Custom, NoReply by default
The free-text Reply-To under the From becomes CampaignReplyToPicker, three choices that each say where replies land: - NoReply (default): NoReply@<the campaign's From domain>, e.g. NoReply@hello.kamocrm.com, "which doesn't receive email" - Member's E-mail: the member editing, shown by address. A campaign whose replies already go to a teammate keeps showing the teammate until someone picks the option again. A member with no e-mail on their profile can't pick it. - Custom: a box checked as it is typed, the way EmailService checks it on save. The logic is **************** read a campaign (one from before the choice is Custom when it has an address, NoReply when not), pick, show the address, and build the save fields (replyTo, replyToMode, replyToMemberId). An unfinished Custom address holds back Save and appears in the send checklist. The preview shows the Reply-To the recipient will get.
· kamo-shared-library
Drip campaign triggers, recorded where leads and members change
Drip campaigns start when something happens in the CRM, so the places that change leads and members now hand those events to EmailService's drip engine: - LeadHistoryService: a lead created (with how it arrived: by hand, imported, or from an integration or form), a status or sub-status change with both sides, and a reassignment from one team member to another. - **************** a pool lead claimed by spending a credit (paid or free-for-all), which records no lead history of its own. - MemberService: a member or team member account created. The platform mirror row is not a new member of the provider org and starts nothing. DripTriggerRecorder writes them to **************** which the engine drains. It only writes for an org whose ACTIVE drips listen for that kind (trigger or exit rule, read at most every 30s), hands over after the causing transaction commits, and writes on its own thread and connection, so a lead write can never fail or roll back because of a drip. A lead created Void (a test lead or a duplicate) is not someone to welcome. EmailCampaignEntity maps drip_id, drip_step_id and drip_cycle: a drip email sends through a hidden edition campaign per (step, cycle), so it keeps every send guarantee a campaign has. The columns and tables were applied by hand before this commit (emailservice ****************
· kamo-marketing
Open on monthly, and sell 1- and 3-year terms as locking in today's rate
Most buyers come to compare what they would pay with no commitment, so HOME_TERM is now monthly. /pricing, the homepage plan strip, /calculator and the product markup still open on the same term together. The longer terms are what the rail sells from there: a lower per-seat rate, locked in at today's price for the whole term. - The rail's eyebrow reads "Lock in today's rate for 1 or 3 years", and a sentence under the rail says what the selected term does: monthly has no commitment; annual and 3 years hold today's price for 12 months or 3 years, even if our prices go up. All three notes share one grid cell, so choosing a term never moves the cards below, in any language (measured in en, de and ja). Both lines are balanced, so a phone never strands one word. - Card and calculator term lines lead with "Rate locked for N months". - On monthly the card's saved line drops "over the term", which on a one- month term only repeated the monthly figure beside it. - The homepage strip captioned its price "per seat on a {months}-year term", which on monthly would read as a 0.083-year term; it now says billed monthly, with the 1- and 3-year lock beside it. - /pricing's search description names the three-year price as a lock-in, and the expired-offer page points at the same thing. check-product-offers pinned the three-year default ($49, 32.83). It now pins monthly ($69, 46.23), so a silent change of default still fails the build. The copy is in kamo-translation-dictionary, hand-written in all 20 translated locales. The same push restores 340 values today's sync broke, which had failed this repo's prebuild copy guard for every build since.
· EmailService
A campaign's replies go to NoReply, the member's e-mail, or a custom address — NoReply by default
The editor's Reply-To becomes three choices (CampaignReplyTo), kept in the existing reply_to column. No DDL. - NO_REPLY (empty column, the default): Reply-To: NoReply@<the From's domain>, spelled by NoReplySenderService like every other NoReply address. The recipient sees before writing that nobody reads it, and postfix-k3m1-inbound refuses what is sent there. - MEMBER (member:<id>): the e-mail of the member who chose it, looked up when each message is composed (CampaignReplyToMembers, kept five minutes), so a changed address is followed. A member with no e-mail falls back to NoReply rather than naming an address nobody receives. - CUSTOM (an address): checked on save, bare and with a dotted domain; anything else is a 400 saying why. Saves carry replyToMode and replyToMemberId. Replies can go to the member saving, or stay with the member already chosen when a teammate saves, but never to another member picked by id. An editor from before the choice sends only an address: one is CUSTOM, none is NO_REPLY. The campaign read model adds replyToMode and replyToMemberId, and replyTo becomes the address the editor shows (the custom address or the member's e-mail).
· kamo-internal
Outbound E-Mail Hosts shows the bulk relays as receiving servers see them
A "Bulk relays" card above the platform hosts: the shared relay every organization's sending domains leave through (contact.kamocrm.com, 47.181.8.86) and the platform organization's own (hello.kamocrm.com, 47.181.8.87). - Facts: where each relay's mail leaves from, where EmailService reaches it in the cluster, and for the shared relay whether its sign-in is configured and how many verified domains send through it. - Checks, each right, different, missing or not answered: the name's address (A), its sender policy (SPF), where bounces go (MX, which for the shared relay must come back to its own address), the address's reverse DNS (PTR) and the platform relay's signing key (DKIM). A record that isn't right shows what it should be beside what DNS has. - Reverse DNS says it is set by the internet provider that owns the address, so nobody looks for it at the DNS host. A lookup that got no answer is "Couldn't check", never "wrong". - Read from GET **************** when the tab opens and on "Check now". It is never polled, a failed re-check keeps what is on screen, and without MANAGE_OUTBOUND_EMAIL_HOSTS it renders nothing.
· KlusterServices
Bounces for organizations' sending domains reach EmailService through postfix-k3m1-inbound
A campaign sent through the shared relay has its From at the organization's sending domain as its envelope sender, and that domain is a CNAME to contact.kamocrm.com, whose MX is 47.181.8.86 — this pod. So a receiving server that accepted a message and could not deliver it after all writes its delivery report here, and until now it was refused. - The sending domains come from EmailService (relay-senders?format=domains: VERIFIED and FAILING), read at start and every minute. Only plain host names ever reach a table, and an unanswered fetch keeps the last list. - Each domain takes the null sender only, i.e. bounces and servers checking that the address exists. Anyone else gets 550 "This address sends email but does not receive it". - What is taken goes to a `bounces` pipe (the main postfix's spool command, with its own spool) and a bounces-mail sidecar, the main postfix's unsubscribe_mail.py with INCLUDE_RAW, posts it to **************** - stack.loans, mail.stack.loans and contact.kamocrm.com work exactly as before, and nothing here sends to the internet. Tested in a throwaway alpine pod with the same script: null sender 250, a person 550, postmaster@ and abuse@ 250, other and look-alike domains 554, a DSN spooled with its token and the queue empty, a list change applied within a minute, and an unanswered fetch keeping the last list.
· EmailService
Bounces to org sending domains come home, test sends are signed, and the relays' DNS is read
Async bounces. A campaign's envelope sender is its From at the organization's sending domain, whose MX (through the CNAME to contact.kamocrm.com) is the shared relay's own address. postfix-k3m1-inbound will take delivery reports there and post them to POST **************** the complaint intake's shape: - DeliveryStatusReport reads a real RFC 3464 report (message/delivery-status) and the tokens in the returned message. A prose bounce is never guessed at. - BounceMailService records a permanent failure (Action: failed, 5.x.x) as BOUNCED on the recipient row the token names, through the delivery-event path the relays already use. A delay, an auto-reply or a report with no token changes nothing, and a spam-complaint report is handed to the complaint intake. - relay-senders?format=domains lists the VERIFIED and FAILING sending domains the inbound accepts reports for. Test sends from Settings → Email → Sending are DKIM-signed with the From domain's key, exactly as campaign mail is (DkimSigning, shared with CampaignRecipientSender), so a test through the shared relay shows what a campaign will. The relays' DNS. GET **************** (MANAGE_OUTBOUND_EMAIL_HOSTS) reads, for each relay, its name's A record and SPF, its MX (for the shared relay, back to its own address), the reverse DNS of its address, and the platform relay's DKIM key. The DNS resolver gains PTR lookups.
· KlusterServices
Postmaster@ and abuse@contact.kamocrm.com reach KamoCRM — the shared relay's name receives its checks
contact.kamocrm.com is the shared org relay's name (47.181.8.86's PTR, MX contact.kamocrm.com), and its MX is 47.181.8.86 → k3m1, where postfix-k3m1-inbound answers. Mailbox providers authorize who runs an IP through postmaster@/abuse@ of its PTR domain, so those now land: postmaster@ in the postmaster mailbox, abuse@ and fbl@ at abuse@hello.kamocrm.com, which the main postfix hands to EmailService's complaint intake with a copy for postmaster. Restart of postfix-k3m1-inbound approved by Sage.
· kamo-internal
Bulk Email gets Domain Setup and sub-tabs, /marketing/email explains what is missing, campaigns get a From picker
Settings → Email → Bulk Email is now four sub-tabs in ?sub=: Domain Setup · Sending · Opt Out · Deliverability. The member lands on Domain Setup until a sending domain is verified and on Sending after. Until then the other sub-tabs show a lock and stay focusable; opening one explains what it is for, how far the best domain has come, and links back. Opt Out and Deliverability keep their right (MANAGE_EMAIL_OPT_OUTS or the owner) and move in unchanged; the old ?tab=opt-out and ?tab=deliverability links redirect to their sub-tab, and the menu entry and landing card follow. Each sub-tab puts its own words in the help rail. Domain Setup is built for someone who has never edited DNS: a hero with the three steps and the one they are on (or "Bulk email is ready to send"), a card per domain with live record dots and "Checked 8 seconds ago · checking automatically", and an inline add-a-domain flow (the org domain prefilled and tidied on blur with the change explained, one-click sending names, a live preview, and a debounced lookup that says why a name cannot be used before Continue). One domain's setup (?domain=) shows the detected DNS provider's steps, each record in plain words with copy buttons and its live state ("we see X, it should be Y", the fix for every hint code), Copy all as instructions, the no-CNAME alternative behind a disclosure, Check now, Remove (refused up front while in use, with the server's reason shown if it still refuses), and a reduced-motion-aware success moment leading to "Choose your From address". Everything reads GET **************** through the existing proxy and stays live over **************** no polling, silent re-reads, writes adopt the whole state, and a failed or refused read is an error or access note, never an empty list. /marketing/email: with no campaigns and no verified domain the empty state is an explainer (why, three steps, "Set up my domain", or who can finish it without MANAGE_EMAIL_SETTINGS); a campaign list gets a compact banner that absorbs the no-hosts line, and a FAILING domain is named in red with a link to its setup. Nothing shows while loading or on a failed read. The send-blocker banner links NO_SENDING_DOMAIN and FROM_DOMAIN_NOT_VERIFIED to Domain Setup. The campaign editor's From is a picker: the default sender, or an address on a verified domain (local part, domain, From name), saved as fromEmail/fromName; a saved From on an unverified domain is warned about with a link to its setup. settingsMenuTabCoverage learns &sub= and checks each linked sub-tab exists.
· EmailService
Campaign mail is signed as, sent from, and routed by its verified sending domain
A campaign may name its own From on a verified domain; otherwise it goes as the org's bulk sender — which must itself be on a verified domain (saveSender refuses anything else). CampaignFrom picks the relay by that domain, because SPF is decided by the address a relay leaves from: an org's verified domain only through the shared org relay (47.181.8.86), KamoCRM's hello.kamocrm.com only through the platform relay (47.181.8.87), any other host for either, and an unverified From nowhere. The releaser takes the due recipient first, then a ready host its campaign may use; a campaign with none waits while the org's other campaigns go. Mail as an org's verified domain is DKIM-signed after its last saveChanges, with the session barred from converting it to 8bit on the way. New send blockers NO_SENDING_DOMAIN and FROM_DOMAIN_NOT_VERIFIED. Delivery reports count against the warm-up of the relay the campaign's From sent it through. KamoCRM's sending campaign is unaffected: noreply@hello.kamocrm.com is platform-managed, so it keeps leaving through .87, and the shared relay is switched off in KamoCRM's rotation.
· EmailService
Domain Setup's API, the shared relay's sender map, and a sweep that confirms records live
**************** GET the tab (VIEW_BULK_EMAIL), GET lookup for the add form, POST add, POST {id}/check, DELETE (MANAGE_BULK_EMAIL); every write answers with the whole tab and a refusal names its field. **************** gives postfix-orgs its verified-domain map. The bulk overview gains domains {ready, verified, pending, failing} for the gate. SendingDomainSweep re-reads DNS on the cadence (15s while a domain is being set up) under a lease, on a thread of its own: every @Scheduled task here shares Spring's single scheduling thread with the releaser that sends campaign mail each second, and waiting on nameservers there would stall sending.
· EmailService
Sending domains — add one, read its DNS record by record, verify it, know what an address may do
A sending domain (news.acme.com) is kept in the org's encrypted bulk provider blob with its DKIM key. SendingDomainChecker reads what receivers will see — SPF evaluated for the shared relay's address, our key at its selector, a DMARC policy at the domain or a parent — for the recommended records (a CNAME to contact.kamocrm.com, the DKIM TXT, DMARC) and the alternative SPF TXT + MX, and names the usual mistakes: a doubled zone name, a proxied Cloudflare CNAME, a name that already has records, a CNAME elsewhere, two SPF records, too many lookups, a key pasted short, a malformed or inherited policy, and 'your record is right, KamoCRM's side is not live yet'. SendingDomainService adds (cleaning a pasted URL or address, refusing kamocrm.com to tenants), checks, removes (not while it is the default sender's or an active campaign's), and moves status: PENDING/FAILING → VERIFIED on a pass; VERIFIED → FAILING only after two failures six hours apart; a DNS outage changes nothing. It answers the send path — ORG_VERIFIED (with the signing key), PLATFORM_MANAGED (hello.kamocrm.com, for KamoCRM only) or UNVERIFIED — and builds the shared relay's sender map of verified domains. Changes publish ****************

உங்கள் உலகம் காத்திருக்கிறது.
நிமிடங்களில் இலவசமாகத் தொடங்கவும். நீங்கள் தயாராக இருக்கும் போது பொருட்களை சேர்க்கவும் - எந்த மாற்றமும் இல்லை, புதிய உள்நுழைவுகள் இல்லை.
எப்பொழுதும் இலவசம் — கடன் அட்டை தேவையில்லை