Daily Lemlist Task Reminders in 1:1 Teams Chats with n8n¶
At Dolist, the sales team runs its prospecting sequences in Lemlist. Every sequence generates manual tasks (a call, a LinkedIn message, a profile visit) and Lemlist displays them perfectly... in Lemlist. The problem is that nobody lives in Lemlist all day. Here's how a small n8n workflow was enough to land these tasks where attention already is: Teams.
The problem: tasks that exist but nobody looks at¶
Every prospecting sequence generates manual tasks: a call to make, a LinkedIn message to send, a profile visit to do before following up. Lemlist displays these tasks perfectly... in Lemlist. The problem is that nobody lives in Lemlist all day. The sales team's real working tool is Teams: that's where they get their notifications, where they talk to their manager, where they start their day.
Observed result on the ground: tasks piling up, spilling over several days, and only getting spotted during a weekly check-in, well past their due date. This isn't a problem of individual discipline, it's a channel problem: the information exists, but it isn't where attention already is.
Why not just use Lemlist's native reminder¶
Lemlist's native email notifications don't cover pending tasks at all: they're about campaign health (a mailbox disconnecting, a high bounce rate, a campaign finishing), not action reminders. The only native channel actually tied to tasks is a Slack integration (via webhook), which has three concrete limitations:
- The wrong tool. The team works in Teams, not Slack. Wiring up that integration would have added yet another tool to watch instead of centralizing the information where reps already are.
- Partial coverage. That integration only notifies for tasks created automatically by lead activity (an email/LinkedIn reply, a link click), not "Manual task" or "Call" tasks placed directly in a sequence. A good chunk of the team's real tasks (calls, LinkedIn profile visits) would therefore never trigger a notification through that channel.
- No consolidated view. Even for the tasks it does cover, the notification fires task by task, as they come in. The goal here was a single morning entry point: a consolidated list of everything due today and tomorrow, grouped by person, with just enough formatting to prioritize at a glance.
What we wanted to build¶
A simple workflow that runs on its own every working morning and:
- Queries the Lemlist API to fetch tasks due today or tomorrow.
- Filters out noise (already-handled tasks, system tasks like out-of-office detection).
- Groups tasks by rep and builds a readable message, with an icon per action type.
- Sends that message into the right rep's 1:1 Teams conversation, never into a shared channel.
No dashboard, no interface: just a message that lands in the right place, at the right time, with the right content.
Workflow architecture¶
flowchart TD
A["Schedule Trigger\n0 7 * * 1-5 (working days, 7am Paris)"] --> B["HTTP Request\nGET api.lemlist.com/api/tasks\nfilter dueDate = today -> tomorrow"]
B --> C["Code: group by rep\nfilter + group + format the message"]
C --> D{"Switch on Lemlist userId"}
D --> E["Teams 1:1 Rep A"]
D --> F["Teams 1:1 Rep B"]
D --> G["Teams 1:1 Rep C"]
D --> H["Teams 1:1 Rep D"]
Five building blocks, no exotic conditional branching: a trigger, an API call, a code node for the business logic, a router, and four outputs. The complexity isn't in the number of nodes, it's concentrated in the code node that does all the filtering and formatting work.
Building it, node by node¶
1. The trigger: working days, 7am¶
A standard Schedule Trigger, using a cron expression rather than a simple interval, to exclude weekends without an extra node:
Workflow timezone set to Europe/Paris. The reminder lands before the workday starts, never on Saturday or Sunday.
2. Fetching due tasks via the Lemlist API¶
An HTTP Request node queries GET https://api.lemlist.com/api/tasks directly, with a filter passed as a query parameter:
[{"id": "dueDate", "from": "{{ $now.format('yyyy-MM-dd') }}", "to": "{{ $now.plus({ days: 1 }).format('yyyy-MM-dd') }}"}]
Two choices worth noting here. First, the window covers today and tomorrow, not just today: the idea is to let a rep get ahead on tomorrow's workload the evening before... except the workflow runs in the morning, so in practice this window mainly serves to avoid missing any task that straddles the date change (timezone, task created late). Second, fine-grained filtering (status, task types to exclude) is deliberately not done here: the Lemlist API exposes a date filter, not a status or content filter, so all the business logic gets pushed into the next node.
3. The code node: filter, group, format¶
This is the heart of the workflow. A Code node (JavaScript) that does four things in sequence.
a) Normalize the API response. The exact shape of the Lemlist response (raw array, or an object wrapping results/tasks/data/items) isn't guaranteed depending on the endpoint or API version. A small toArray() function protects against this instead of assuming a fixed shape:
function toArray(x) {
if (Array.isArray(x)) return x;
if (x && typeof x === 'object') {
for (const key of ['results', 'tasks', 'data', 'items']) {
if (Array.isArray(x[key])) return x[key];
}
}
return [];
}
b) Filter out noise. Three combined conditions: the status must be empty or pending (so not already handled), the title must not be "Out of office detected" (a system task Lemlist generates automatically, with no action value for the rep), and the due date must fall today or tomorrow.
c) Group by rep. Every Lemlist task carries a userId. A simple accumulator object groups tasks by owner:
const byUser = {};
for (const task of tasks) {
const uid = task.userId || 'non_assigne';
if (!byUser[uid]) byUser[uid] = [];
byUser[uid].push(task);
}
d) Build the message. Each Lemlist task type gets a dedicated icon (📝 manual, 📞 call, 🔗 LinkedIn invite, 💬 LinkedIn or WhatsApp message, 👀 profile visit, ✉️ email, 📱 SMS), and the final message lists the first ten tasks tagged "today" or "tomorrow", then summarizes the rest (...and N more) if the person has more than ten.
4. Routing to the right rep¶
A Switch node with four rules, one per Lemlist userId, each wired to its own output. This is a hardcoded match between a Lemlist ID and a workflow branch, not a dynamic resolution.
5. Sending the message as a 1:1 Teams message¶
Four Microsoft Teams nodes (chatMessage resource), each targeting the exact 1:1 conversation ID between the rep and the technical account running the workflow. The message sent is the one built in step 3, injected via an n8n expression referencing the code node.
The canvas mockup (and why it isn't a real screenshot)¶
The idea was to illustrate this article with an actual screenshot of the n8n canvas, personal data blurred out. In practice, the environment this article was written from blocked the n8n interface from loading (network assets filtered on the browser side): no real screenshot available this time. Rather than fake a visual, here's a faithful reconstruction of the actual node layout on the canvas, with rep names anonymized:
flowchart LR
subgraph Canvas["n8n Canvas - overview"]
direction LR
T["Every morning 7am\n(working days)"] --> R["Lemlist -\nDue tasks"]
R --> G["Group by\nrep"]
G --> S["Switch -\nWhich rep"]
S --> N1["Teams - Send\nto Rep A"]
S --> N2["Teams - Send\nto Rep B"]
S --> N3["Teams - Send\nto Rep C"]
S --> N4["Teams - Send\nto Rep D"]
end
The node labels above are translations of the workflow's actual node names: in the live n8n instance they're written in French (Tous les matins 7h (jours ouvres), Lemlist - Taches dues, Grouper par commercial, Switch - Quel commercial); only the four rep names on the Switch's outputs have been replaced.
What works well¶
- The right channel. A 1:1 Teams message gets a far higher attention rate than an email notification, without needing to build a complex integration.
- A short tolerance window. Grouping only "today + tomorrow" avoids the endless-list effect that discourages reading, while still leaving a bit of lead time.
- Defensive normalization of the API response. The
toArray()avoided at least one class of silent failure should Lemlist ever change the shape of its response. - Filtering out system noise. Excluding
"Out of office detected"was added after seeing this kind of task clutter up an early test message.
Limitations and pitfalls identified¶
Unmapped reps fall into a void
The code node assigns 'non_assigne' as the key when a task has no userId, but the downstream Switch only knows four specific userId values. Any task belonging to a fifth rep, or to an account with no userId, matches none of the four rules and has no output configured for that case: the message is silently lost, with no visible error in the execution history. Adding a new rep means editing the Switch and adding a Teams node, not just a config line.
No direct link to the task. The message lists tasks by title and type, but doesn't include a clickable link to the corresponding Lemlist record. The rep has to reopen Lemlist manually to act, which eats into some of the friction reduction the project was meant to deliver.
A small style inconsistency between branches. Three of the four Teams nodes explicitly reference the source node ($('Grouper par commercial').item.json.message), while the "Rep B" node uses the implicit reference to the current item ($json.message) and additionally hardcodes contentType: html, which the other three don't specify. The end result is identical at runtime (each Switch branch only ever receives a single item), but it's the kind of divergence that turns into a real bug if the workflow is ever modified to merge or duplicate branches.
No deduplication between two runs. A task seen "for tomorrow" one day will be seen again "for today" the next morning: that's intentional (a progressive reminder), but it means a task not handled in time does not trigger a stronger follow-up; it simply drops out of the window once its date has passed.
What I'd do differently¶
Replace the static Switch with a dynamic resolution of the Teams channel from a lookup table (an n8n Data Table or a small external database), with an explicit fallback branch for unrecognized userId values, rather than total silence. Also add a direct link to each task in the message, if the task URL is exposed by the Lemlist API (to be verified), to remove the manual lookup step. Finally, harmonize the four Teams nodes so they all use the same expression syntax, a maintenance hygiene issue more than a functional bug.
FAQ¶
Why Teams and not Slack? Because it's the internal messaging tool the sales team actually uses. The logic would carry over as-is to Slack by swapping the four Teams nodes for Slack nodes targeting DMs.
Why group "today + tomorrow" rather than just "today"? To absorb small timezone or task-creation-time offsets, and give a bit of visibility into tomorrow without changing when the workflow runs.
Does the workflow handle overdue tasks (past due date)?
No. The filter only covers the [today, tomorrow] window. A task whose due date has passed without being handled simply drops out of the reminder's scope.
What happens when a new rep joins the team?
You have to manually edit the Switch node to add their Lemlist userId, and create a new Teams node pointing to their 1:1 conversation. It's not automatic; see the limitations section.
Conclusion¶
This workflow doesn't do anything spectacular: it moves information that already existed from a tool where it was ignored to a channel where it gets seen. That's often where the real value of a RevOps automation lies: not in technical sophistication, but in picking the right channel at the right time. The limitations identified here, especially the fixed mapping between rep and Teams conversation, are typical of a deliberately simple first pass: good enough for a four-person team, worth revisiting before any scale-up.
The same routing pattern (one Switch per recipient, one Teams node per branch) was later reused for a different need: see Generating LinkedIn Posts with AI via n8n.